这里用科里化的思想比较好理解。因为函数 + 可以看做接受两个参数然后返回其和的函数,那么传入一个参数 N 后会自然地得到另一个函数 + N,这个函数接受一个参数,返回结果是对这个参数加上 N。所以把函数 + N 作为 M 的第一个参数,0 作为第二个参数,意思是 “重复应用这个对参数加 N 的函数 M 次到 0 上”,即 “对 N 连加 M 次”。
也可以不用 + 的定义,写作:
* = 𝜆M.𝜆N.𝜆s.𝜆z.M (N s) z
列表 (List)
我们可以用𝜆演算的函数 fold (或 reduce) 来表示列表。如 [x, y, z],可以表示为一个函数,这个函数接受两个参数 c 和 n,返回 c x (c y (c z n)))。
我们定义函数 cons,接受一个元素 h 和一个列表 t (即一个 fold 函数),返回一个和 t 类似的列表,只是把 h 加在了 t 的最前面:
𝜆 演算 (Lambda Calculus)
0. 背景
函数
函数在数学中的含义:
例如,我们假设一个函数
f
定义了这样一种关系:输入集合是
{1, 2, 3}
,输出集合是{A, B, C}
。如果输入1
,函数总是返回A
。相反,这样不是一个合法函数:
这不满足函数的定义中唯一一项的要求。
这样是合法函数吗:
是合法的。不同的输入可以返回相同的输出。
我们知道,在函数式编程中有个概念叫 纯函数。是说,
第一点和数学上函数的定义是一致的。 带副作用的函数中,输出可能依赖于全局变量或函数外的变量,或者从I/O获取数据,都有可能导致同样的输入返回不同的输出。这不满足数学上函数的定义。
图灵完备 (Turing completeness)
世界上存在各种各样的编程语言,有各种各样的特性。很多语言特性的存在其实只是为了方便 (语法糖):
多参数函数
解决办法:科里化 (Currying) 或元组 (Tuple)
循环
解决办法:递归 (Recursion)
那么,什么样的语言特性是 真正 需要的?换句话说,没有这个特性,这个语言就不叫语言呢?
我们用过很多语言,比如汇编,C, Objective-C, Java, Swift, Kotlin, PHP, HTML/CSS 等。 有两个比较流行的观点:
上个世纪30年代艾伦·图灵思考,能不能造一个机器,模拟计算人类所能进行的任何计算过程?这就是 图灵机 。然后图灵又提出了 通用图灵机 的概念,即接受任意一个图灵机的编码,然后模拟其运作。现代电子计算机 (冯·诺伊曼结构) 其实就是这样一种通用图灵机,它能接受一段描述其他图灵机的程序,并运行程序实现该程序所描述的算法。
汇编,C,Java 等编程语言都是 图灵完备 的。而他们之间也是 图灵等价 的,即他们之间可以相互模拟,他们的计算能力与通用图灵机的计算能力相同。
能不能有一个语言,特性越少越好,但仍满足图灵完备?换句话说,可以模拟通用图灵机,亦可以模拟所有的图灵完备的编程语言?
𝜆演算就是这样一种语言。
1. 𝜆演算简介
𝜆 演算(英语:lambda calculus,𝜆-calculus)是一套用于研究函数定义、函数应用和递归的形式系统。它由阿隆佐·邱奇 (Alonzo Church) 和他的学生斯蒂芬·科尔·克莱尼 (Stephen Cole Kleene) 在20世纪30年代引入。邱奇运用𝜆演算在1936年给出判定性问题(Entscheidungsproblem)的一个否定的答案。这种演算可以用来清晰地定义什么是一个可计算函数。关于两个 𝜆 演算表达式是否等价的命题无法通过一个“通用的算法”来解决,这是不可判定性能够证明的头一个问题,甚至还在停机问题之先。𝜆 演算对函数式编程语言有巨大的影响,比如 Lisp 语言、ML 语言和 Haskell 语言。 𝜆 演算可以被称为最小的通用程序设计语言。它包括一条变换规则(变量替换)和一条函数定义方式,𝜆 演算之通用在于,任何一个可计算函数都能用这种形式来表达和求值。因而,它是等价于图灵机的。尽管如此,𝜆 演算强调的是变换规则的运用,而非实现它们的具体机器。可以认为这是一种更接近软件而非硬件的方式。
2. 𝜆 演算的定义
𝜆 演算只有三种基本成员,或称为 项 (terms): 表达式 (expressions),变量 (variables),抽象 (abstraction)。
变量仅仅是一个名字,用来指代抽象可能的输入。
抽象是纯函数,也是数学定义上的函数。以下我们都把抽象称为函数。函数包括一个头部 (head) 和一个体 (body),如:
头部中的变量为函数的参数 (parameter),与函数体中相同的变量绑定。意思是:当我们应用 (apply) 函数到一个参数上去,函数体中每一个变量的值都为这个参数的值。
总结起来,𝜆 演算中所有东西都是函数。函数的应用
e e′
中,e
是被应用的函数, 参数e′
也是函数。变量x
不过是函数中的变量。自由变量 (Free variables)
𝜆x.e
这个句法构造 (syntactic construct) 把变量x
绑定 (binds) 在了表达式e
。𝜆 表达式中的变量,如果不是自由的 (free) 那么它就是被约束的 (bound)。如果一个变量属于如下递归定义的
FV(e)
集合,那么它就是自由的:举例:
β-规约 (Beta reduction)
𝜆 演算最纯粹的形式中,不存在内置的常量和操作,包括数字,算术运算,条件,记录,循环,I/O等。当我们谈论“计算”时我们在谈论 应用 函数到参数上 (参数也是函数)。 计算中的每一步都是这样的,左边是一个函数,右边是参数,把左边函数体中的绑定变量替换 (substitute) 为右边的参数,然后去掉头部。这个过程叫 β-规约。
比如有函数:
我们使用 数字 来做β-规约 (通常的数字概念,在 𝜆 演算中是不存在的。但是后面我们会找到一种方法来推导出数字的概念)。我们应用这个函数到数字
2
,即把函数体中每一个绑定变量都替换为2
,然后去掉函数头:这个函数叫 恒等函数 (identity function)。
我们也可以应用我们的恒等函数到另一个函数:
这里我们引入一个新的语法,
[𝑥 ∶= 𝑧]
,来表示所有的x
会被替换为z
。(在这里,z
为函数(𝜆𝑦.𝑦)
)。做β-规约如下:(𝜆x. e’) e
这种形式的项是 可规约表达式 (reducible expression,丘齐称其为 redex)。我们可以对其做β-规约。一个项如果不能再被β-规约,我们称它为 规范型 (normal form)。而有一些项永远都不能被求值为规范型,被称为 发散的 (diverge)。
静态作用域和α-转换 (Static scoping & Alpha Conversion)
𝜆演算使用静态作用域。
我们考虑这样一个表达式:
第二个
x
是第一个绑定,最右边的x
是第二个绑定。第一个绑定中的x
被第二个绑定给 遮蔽 (shadow) 了。对函数
𝜆𝑥.𝑥
来说,变量x
没有语义上的意义。项之间存在一种等价形式叫 α-等价 (Alpha equivalence),如:都是相同的函数。称他们是α-等价的。
把一个𝜆项转换成另一种α-等价的项,叫做 α-转换。如把
𝜆𝑥.𝑥
α-转换成𝜆𝑑.𝑑
,写作[𝑥/𝑑]
。又如:
α-转换的本质是对处于同一个绑定内的相同名字的变量做集体换名。如:
替换 (Substitution)
β-规约的核心是替换,我们递归定义替换过程:
解释一下第 4 点,在
𝜆x.e’
中,x
是参数,根据静态作用域的概念,这是一个 局部变量 (local variable),和其外层的函数中的x
是不一样的。举例:问题:第 5 点是
𝜆y.(e’[x:=e])
吗?不是。变量捕捉 (Variable capture)
当我们把
y
替换进去时,我们不想让它被内层的y
的绑定捕捉 (captured),这样破坏了静态作用域。如:解决办法:使用α-转换重命名内层的绑定。
完成替换的定义
重回替换定义的第 5 点:
我们想避免把
e
中的自由出现 (free occurrences) 的y
被绑定。解决方法就是α-转换:
y
替换为变量w
,w
既没有出现在e’
中,也没有出现在e
中。w
被称为新鲜的 (free)。e’
中所有的y
都替换为w
。e’
中所有的x
替换为e
。形式化地描述:
求值策略
𝜆演算有多种求值策略,策略定义了项中哪一个 redex 可以在下一步被规约。我们列举几个:
任意 redex 可以在任意时间被规约。在任意一步我们都可以选取任意 redux 来规约。举例,有这样的项
或者我们可以写的更具可读性
id (id (𝜆z. id z))
。这个项包含3个redex:我们可以按任意顺序规约,比如先最里面的redex,然后是中间的,最后是最外层的:
最左边的,最外层的redex总是最先被规约。按照这种顺序,上面的例子:
传名调用比上面两个更受限,不允许在函数内部做规约。如:
这里把
𝜆z. id z
当做一个规范型 (即不能再被规约)。传名调用有很多种变体,而 Haskell 使用了一种更优化的变体叫 传需求调用(call by need),即不用每次都去对参数求值,而是把参数第一次求值时的值记下来,然后以后的调用都直接替换为这个值。
这是大多数语言采用的求值策略,是只有最外侧的redex会被规约,一个redex要被规约前,他右边的值必须已经被规约到一个值(value) (值无法再继续被规约,在𝜆演算中即规范型的项,而在其他语言中,可能是数字,布尔值,字符串,元组,字典,列表等)。举例:
我们称传值调用是 严格的(strict),意味着函数的参数总是被求值,不管是否会不会被函数体用到。相反,传名调用和传需求调用是 非严格的(non-strict),也称作 惰性的(lazy),只有在参数被用到时才会去求值。
多参数 (Multiple arguments)
每一个 lambda 函数只能绑定一个参数,也只能接受一个参数。如果要实现多参数呢?可以用多个嵌套的函数头来实现。当应用多参数函数时,首先第一个 (最左边) 函数头被去掉,然后是第二个函数头,依次下去。这个方法最早是Moses Schönfinkel(俄罗斯的逻辑学家和数学家,在哥廷根大学做研究,发明了组合逻辑) 和戈特洛布·弗雷格 (Gottlob Frege,德国的逻辑学家,数学家和哲学家,数理分析和分析哲学的奠基人) 于 20 世纪 20 年代发现的,然后被哈斯凯尔·科里 (Haskell Curry) 重新发现。通常称为 科里化 (Currying)。
举例:
是两个嵌套的函数的简写:
当应用第一个参数时会绑定
x
,然后消除外层的函数,得到𝜆𝑦.𝑥𝑦
,其中x
为外层函数参数绑定的值。3. 邱奇编码 ([Church encoding])
𝜆 演算是图灵完备的,意味着,我们可以用 𝜆 演算来编码任意计算,包括:
布尔值
对基本的布尔值我们可以编码成:
即
true
是一个接受两个参数的函数,返回第一个参数,false
也是一个接受两个参数的函数,返回第二个参数。那么我们想编码条件判断
if
表达式,可以这样例如:
习题:
对于取反操作
not
我们可以编码成:可以解释为:
举例:
对于与操作
and
我们可以编码成:可以解释为:
对于或操作
or
我们可以编码成:可以解释为:
序对
我们可以把序对 a, b 编码成:
例子:
习题:
自然数 (Natural Numbers, 或称丘奇数 Church Numerals)
我们可以把非负数编码如下:
有的丘奇数都是带有两个参数的函数,对于任何数
n
,它的丘奇数是将其第一个参数应用到第二个参数上n
次的函数。一个很好的理解办法是将
z
作为是对于零值的命名,而s
作为后继函数的名称。因此,0
是一个仅返回零值的函数,1
是将后继函数运用到零值 1 次的函数;2
则是将后继函数应用到零值的后继上的函数,以此类推。后继 (Successor)
n
为丘齐数,我们可以把s
和z
作为参数传给n
,然后显式地对结果再应用 1 次s
,最终得到一个函数,函数中s
应用到z
的次数比n
多 1 次,这正是丘齐数n+1
,即n
的后继。举例求
0
的后继:IsZero?
加法 (Addition)
写成 4 个参数的形式:
加法是接受两个丘齐数
M
和N
,返回另一个丘齐数,这个丘齐数是这样一个函数,接受参数s
和z
,把s
应用到z
n举例:
乘法 (Multiplication)
乘法的定义为,加法的连续运算,即同一数的若干次连加,所以可以利用我们定义好的
+
来定义乘法:写成函数的形式:
这里用科里化的思想比较好理解。因为函数
+
可以看做接受两个参数然后返回其和的函数,那么传入一个参数N
后会自然地得到另一个函数+ N
,这个函数接受一个参数,返回结果是对这个参数加上N
。所以把函数+ N
作为M
的第一个参数,0
作为第二个参数,意思是 “重复应用这个对参数加N
的函数M
次到0
上”,即 “对N
连加M
次”。也可以不用
+
的定义,写作:列表 (List)
我们可以用𝜆演算的函数
fold
(或reduce
) 来表示列表。如[x, y, z]
,可以表示为一个函数,这个函数接受两个参数c
和n
,返回c x (c y (c z n)))
。我们定义函数
cons
,接受一个元素h
和一个列表t
(即一个fold
函数),返回一个和t
类似的列表,只是把h
加在了t
的最前面:我们回顾
true
和false
的定义:我们定义空列表
nil
:定义函数
isnil
,接受一个参数为列表,判断列表是否为空:定义函数
head
,接受一个参数为列表,取列表的第 1 项,如果列表为空则返回nil
:举例,有列表
l = cons 1 (cons 2 nil)
:稍微总结
个人觉得丘齐编码最酷的地方在于,只用函数这一个东西就能定义出各种计算的对象,如序对,布尔值,自然数。或者进一步说,这些对象其实就是计算。 如布尔值
true
的定义为函数𝜆x.𝜆y.x
,解释出来就是,一个接受 2 个参数返回第 1 个参数的函数。相应地,布尔值false
的定义是一个接受 2 个参数返回第 2 个参数的函数。由于布尔值是函数,而函数可以被应用,那么可以直接应用函数true
和false
来实现常用的条件计算,即if ... then ... else ...
。 类似序对,自然数及其上的运算都是类似的实现。循环和递归 (Looping & Recursion)
如果一个 lambda 项中没有自由变量,我们说这个项是封闭的 (closed)。一个封闭的项也称为 组合子。如恒等函数
𝜆x.x
。我们之前提过,有一些项永远都不能被求值为规范型,即会一直求值下去,被称为发散的,比如我们定义组合子
D = 𝜆x. x x
:D D
是一个无限循环。对于一个图灵完备的语言来说,循环必不可少。在函数式语言中一般用递归来实现循环。如阶乘函数的定义:
但是在𝜆演算中,我们只有匿名函数。我们没有办法通过引用一个函数的名字来递归调用它。那么怎么办呢?
不动点组合子 (The Fixpoint Combinator)
不动点组合子也叫 Y 组合子,定义如下:
有:
可以看出不动点组合子是发散的。
Y F
叫做F
的不动点 (fixed point)。因此有
我们可以用不动点组合子实现递归调用。
例如阶乘函数定义:
fact
的第二个参数是整数,第一个参数是在函数体中调用的函数。那么有:
4. 参考
Lambda相关
Haskell Programming from first principles, Chapter 1: http://haskellbook.com/
Types and Programming Languages, Chapter 5: https://www.cis.upenn.edu/~bcpierce/tapl/
Lambda教程: https://www.cs.umd.edu/class/fall2016/cmsc330/lectures/15-lambda.pdf
我的最爱Lambda演算: http://cgnail.github.io/academic/lambda-1/
reinventing-the-ycombinator: https://www.slideshare.net/yinwang0/reinventing-the-ycombinator
重新发明 Y 组合子 JavaScript(ES6) 版: http://picasso250.github.io/2015/03/31/reinvent-y.html
其他
函数副作用: https://zh.wikipedia.org/wiki/%E5%87%BD%E6%95%B0%E5%89%AF%E4%BD%9C%E7%94%A8
纯函数: https://zh.wikipedia.org/wiki/%E7%BA%AF%E5%87%BD%E6%95%B0
图灵机: https://zh.wikipedia.org/wiki/%E5%9B%BE%E7%81%B5%E6%9C%BA
图灵可计算函数: https://zh.wikipedia.org/wiki/%E5%8F%AF%E8%AE%A1%E7%AE%97%E5%87%BD%E6%95%B0
求值策略: https://zh.wikipedia.org/wiki/%E6%B1%82%E5%80%BC%E7%AD%96%E7%95%A5
𝜆演算: https://zh.wikipedia.org/wiki/%CE%9B%E6%BC%94%E7%AE%97
决定性问题: https://zh.wikipedia.org/wiki/%E6%B1%BA%E5%AE%9A%E6%80%A7%E5%95%8F%E9%A1%8C
函数: https://zh.wikipedia.org/wiki/%E5%87%BD%E6%95%B0
浅谈静态作用域和动态作用域: http://www.cnblogs.com/lienhua34/archive/2012/03/10/2388872.html
Church encoding: https://zh.wikipedia.org/wiki/%E9%82%B1%E5%A5%87%E6%95%B0