λ演算简介

433 阅读10分钟

摘要

作为程序员,你平时会不会有如下的问题:如果我们只给编程语言保留最核心最基本的规则,应该保留哪些?本文通过介绍λ演算尝试回答该问题,阐述了λ演算的语法跟计算规则,以及如何建立平时所用的编程语言当中的常见数据结构、控制流以及函数递归等机制。

一、语法与基本规则

λ演算,英文为:lambda calculus,最早Church在20世纪30年代提出。当时的背景是为了解决函数可计算的本质问题,后来根据Church–Turing thesis,证明了λ演算与图灵机是等价的。

1.1 语法规则

λ演算的语法规则极其简单,只有三种形式,定义如下:

E = x			variable
    | λx. E		function creation(abstraction)
    | E E 		function application

E这里称为λ表达式(后续简称为表达式),语法上有三种规则:

  1. x:一个变量本身是一个表达式。
  2. 创建带一个参数为x,函数体为E的函数,也叫抽象(abstraction)。
  3. 对左半部分的E函数使用右半部分的参数E进行应用(application)。

几个符合语法规则的表达式如下:

x				变量x
λx. x				参数为x,函数体为x的函数
λx. λy. y			参数为x,函数体为返回另外一个函数,其中另外一个函数的参数为y,函数体为y
(λx. x) y			对函数λx. x进行应用,其中实参为y

一些书写约定:

函数抽象中的函数体尽量向右延伸,例如:

λx.x λy.x y z 应该理解为 λx. (x (λy. ((x y) z)))

函数应用遵循左结合,例如:

x y z 应该理解为(x y) z

1.2 计算规则

λ演算“只有"一种(实际上是三种)计算规则,称为β化简(beta-reduction):

对应用(application)的左半部分中出现的bound变量使用右半部分表达式进行替换。

什么是bound变量❓

对于变量x来说,如果它出现在抽象(abstraction)λx. t的主体部分t中,则称为bound变量。反之则是free变量。例子:

x			x是free变量
λx. x 			x是bound变量
λy. x			x是free变量
λx. λy. x y		在λy. x y中x是free变量,而在λx. λy. x y x是bound变量

注意这里binder的符号是可以重命名而不影响结果的。例如λx. x = λy. y。这也叫符号重命名(alpha renaming)。

λ演算还有一种规则是:如果两个函数的输入输出都完全一样,则可以进行替换,这称为eta化简(η reduction)。

一些计算例子:

(λx. x) y 
	-> y
  
(λx. λy. x x y) n1 n2 
	-> (λy. n1 n1 y) n2 
  ->  n1 n1 n2

这种计算何时终止呢?

计算到没有应用(application)时。例如:

(λx. λy. λz. x y z) n1 n2
  -> (λy. λz. n1 y z) n2
  -> λz. n1 n2 z
  
(λx. λy. y) n
  -> λy. y				没有应用了,计算终止

柯里化(curring):

λ演算当中的应用跟我们平时说的函数调用是一个概念,但是它只接受一个参数,那么多个参数函数调用是怎么处理的呢?答案就是柯里化(curring):n个参数单一函数可以表示为n个单参数的函数,每次函数调用的返回值都是另外一个函数,并且返回的函数当中"存储"了被调用的参数。例如:

λx y. y x = λx. (λy. y x)		

1.3 求值顺序

1). 及早求值(Call by Value):

  • 从最外层开始,但是只有应用(application)的右半部分是值之后才能求值(evaluation)。
  • 什么是值?已经求值完毕,并且无法再被应用。
  • 当最外层有多个应用可以求值时,不关注顺序。
(λy. (λx. x) y)((λu. u) (λv. v))
  -> (λy. (λx. x) y)(λv. v)           λv. v代入到λu. u当中
  -> (λx. x) (λv. v)                  λv. v代入到λy. (λx. x) y当中 
  -> λv. v

2). 惰性求值(Call by name):

  • 从最外层开始,右半部分可以不是值。
(λy. (λx. x) y)((λu. u) (λv. v))
  -> (λx. x)((λu. u)(λv. v))          ((λu. u) (λv. v))代入到(λy. (λx. x) y)中
  -> (λu. u)(λv. v)
  -> λv. v

3). 其他(从左到右,从外到里)。

这些求值顺序根据Church-Rosser定理,结果是等价的。

二、λ演算作为编程语言

作为编程语言,一般都要有最基础的数据结构与控制流,λ演算并没有定义这些,那我们怎么进行编程呢?答案是可以将某些值(还记得值的定义么?)编码成为常用的数据结构,再针对数据结构挑选一些函数成为控制流或者编程语言基本函数。

2.1 Bool的编码与控制流

Bool中有两个值:true跟false,我们可以如下定义它们:

true 	= λx. λy. x
false = λx. λy. y

true跟false如定义编码为二元函数,区别只是返回值:true的返回值是第一个参数x,第二个参数直接丢弃了,false是反过来的。

有了值定义,接下来定义test函数用来表示控制流:

test = λb. λx. λy. b x y

其中b为true或者false,x是b为true时的返回值,y是b为false的返回值。我们分别代入true跟false进行应用(application)看下结果是否符合预期:

test true n1 n2 
  -> (λb. λx. λy. b x y) true n1 n2 
  -> (λb. λx. λy. b x y) (λu. λv. u) n1 n2			
  -> (λx. λy. (λu. λv. u) x y) n1 n2
  -> (λx. λy. x) n1 n2
  -> n1																	Bingo!得到n1
  
test false n1 n2
  -> (λb. λx. λy. b x y) false n1 n2
  -> false n1 n2 												简化了替换
  -> (λx. λy. y) n1 n2
  -> n2																	Bingo!得到n2

2.2 Number的编码

程序当中最常见的数据类型应该是整数类型,这里介绍自然数在λ演算当中的编码。

我们将0,1,2...的自然数按照如下值进行编码定义:

0 = λf. λs. s
1 = λf. λs. f s
2 = λf. λs. f (f s)
....

可以这样理解这些数字的定义,首先我们有一个初始状态s,并且任一数字都是两个参数的函数,其中第一个参数f是一个函数,第二个参数是初始状态s,数字n代表使用f对s进行n次应用(application)。

有了数字的定义,我们还要定义一些操作来表示它们的关系,首先最重要的就是后继函数succ:

succ = λn. λf. λs. f (n f s)

尝试对一些数字进行后继计算:

succ 0
  -> (λn. λf. λs. f (n f s)) 0
  -> λf. λs. f (0 f s)
  -> λf. λs. f ((λu. λv. v) f s)
  -> λf. λs. f s
  -> 1
  
succ 1
  -> (λn. λf. λs. f (n f s)) 1
  -> λf. λs. f (1 f s)
  -> λf. λs. f ((λu. λv. u v) f s)
  -> λf. λs. f (f s)
  -> 2
  
succ n
  -> (λv. λf. λs. f (v f s)) n
  -> λf. λs. f (n f s)
  -> λf. λs. f (λu. λv. u...u (u v)) f s
  -> λf. λs. f (f...f (f s))							一共有n+1个f
  -> n+1

那么当我们有了后继函数之后 ,就可以定义加法了add了:

add = λn1. λn2. n1 succ n2

这个定义从直观上就很好理解了,让我们计算一下add n1 n2:

add n1 n2
  -> (λu. λv. u succ v) n1 n2
  -> n1 succ n2
  -> (λf. λs. f...f (f s)) (succ n2) 
  -> succ...succ (succ n2)							省略号前后一共有n1个succ
  -> succ...succ (succ (succ...succ 0))	括号内的省略号前后一共有n2个succ,一共有n1+n2个succ
  -> n1+n2															根据succ n -> n+1

同样可以找到一个乘法函数mult的定义如下:

mult = λn1. λn2. n1 (add n2) 0

可以很直观的对乘法进行解释:参数n1根据number定义,是一个两个参数:f跟s的函数,函数体是返回对s应用n1次f的结果;在mult当中,f是add n2,s是0,也就是应用n1次(add n2 0),结果自然是n1n2。*

推导过程如下:

mult n1 n2 
  -> n1 (add n2) 0
  -> (add n2)...(add n2) (add n2 0)		包括写明跟省略的,总共有n1个add n2
  -> (add n2)...(add n2) (add n2 n2) 	省略号中减少一个add n2
  -> (add n2)...(add n2) (add n2 2*n2)
  -> n1*n2

2.3 Pair的编码

Pair是编程当中常用的数据结构,表示一对值,通过特定的函数能把这对值当中的某一个取出来。Pair扩展之后还能形成不限定值个数的Tuple结构,如果将Tuple中的位置上的值取一个具体的名字,就形成了struct的概念。所以Pair非常重要,可以如下定义:

pair   = λf. λs. λb. b f s
first  = λp. p true
second = λp. p false	

让我们做一些推导:

first pair u v
  -> first (λf. λs. λb. b f s)(u v)
  -> first (λb. b u v)
  -> (λb. b u v) true
  -> true u v
  -> u

second pair u v
  -> second (λf. λs. λb. b f s)(u v)
  -> second (λb. b u v)
  -> (λb. b u v) false
  -> false u v
  -> v

可以看到,first跟second函数正好符合我们对其的认知,分别将pair中第一个值跟第二个值取出来返回。

2.3 递归

递归函数是程序的重要结构,否则我们书写源代码就会陷入无穷无尽的无意义重复当中。让我们看看在λ演算当中如何表示递归函数。

首先我们称一个无法再进行β规约的表达式为是规范形式(normal form),也就是值(value)。其中有一些特别的表达式,不管怎么样,都无法求值为规范形式,这种表达被称为发散的表达式。例如:

omega = (λx. x x) (λx. x x)

我们发现对omega进行求值得到的依然是自身,所以它无法形成规范形式。

这样的表达式有一种特别有用的形式,叫做不动组合子(fix-point combinator):

fix = λf. (λx. f (λy. x x y)) (λx. f (λy. x x y))

所谓的不动组合子会符合如下性质(证明略):

fix f = f (fix f)		∀f

那么如果我们要写一个递归函数,也就是f -> (函数体中包含f),那么可以使用fix对f进行重复。技术方法是令g = λf. (函数体中包含f),然后令h = fix g,那么h就是递归的函数。举个例子,假设要实现递归版本的阶乘函数factorial,它想达到的形式如下:

factorial = λn. test (equal n 0) 1 (mult n fac1 n-1)) 
		    其中fac1 = λn. test (equal n 0) 1 (mult n fac2 n-2)
                    其中fac2 = ...

其中equal函数用于判断两个number参数是否相等,如果相等返回true,否则返回false;prev用来返回number参数的前驱,假设参数是n,返回值就是n-1。

为了能自动生成factorial函数的所有函数体,可利用上述的技术,先定义g,再利用fix函数对g进行“复制(replicated)”:

g = λfct. λn. test (equal n 0) 1 (mult n fct (prev n))
factorial = h = fix g

让我们尝试来推导factorial n,看看产生的值是否符合我们预期:

factorial n
  -> fix g n
  -> g (fix g) n
  -> test (equal n 0) 1 mult n (fix g) (prev n)
  -> mult n (fix g) n-1
  -> mult n g (fix g) n-1
  -> mult n (test (equal n-1 0) 1 mult n-1 (fix g) (prev n-1))
  -> mult n (mult n-1 (fix g) n-2)
  ...
  -> mult n n-1...(test equal 0 1 (mult 0 (fix g) (prev 0))
  -> mult n n-1...1
  -> n!

至此,我们拥有了程序编码需要的一些基础数据结构,复合数据类型,基本函数,递归函数,分支控制流,那完整实现程序只是对这些结构的反复应用而已了。

参考资料

[1]. 编程语言的设计原理. 胡振江,赵海燕,熊英飞.