写给 CSer 的 lambda 演算入门

大概是面向 \(\text{CSer}\) 的入门,虽然我觉得网上很多教程难懂的主要原因是不喜欢把所有括号都打出来,然后在描述的时候滥用柯里化(虽然这是对的)。

\(\lambda\) 演算可以理解成一种(好用?的)编程语言。\(\lambda\) 演算的思想就是设计一个可计算的函数,可以看成像 HaskellErlang 之类函数式编程的先祖。

这里还是描述一下函数式编程,在函数式编程中,所有东西都是函数,比如常量 \(1\),被认为是一个总是返回 \(1\) 的常函数。同时,函数和数学上一样是不可变的(毕竟就是数学家做出来的东西)。

语法

函数的定义

\(\lambda\) 演算作为一种很早的很基本的,而且更本没有考虑可读性的语言,其语法规则是简单但几乎不可读的。

首先,标准的 \(\lambda\) 演算是没有函数名的,这也是为什么 c++ 等语言用 \(\lambda\) 表达式来描述匿名函数。当你想调用一个函数的时候,你必须把其重新定义一遍。不过这样实在有点愚蠢,这里我们还是定义函数名,可以理解成语法糖。这里记录一下最简单的 \(\lambda\) 函数:\(I(x)=\lambda x.x\),返回自身的恒等函数。

\(\lambda\) 演算中定义函数非常简单:\(\lambda \text{参数}.\text{函数体}\)。最开始的 \(\lambda\) 是一个标识符,相当于告诉你我要定义一个函数了。在 \(\lambda\) 演算中,为了可读性,我们允许用 \(()\) 标记运算优先级。

最标准的 \(\lambda\) 演算同样只允许单元函数,比如我们计算 \(a+b\) 就应该写,\((\lambda x.(\lambda y.x+y)b)a\)。当然,加法可以有更基本的定义,不过我们暂时不考虑这个问题。

理解一下这个函数的运行逻辑,就是先读入 \(a\),然后作为第一个参数,把 \(x=a\) 当作常量参数传入 \(\lambda y.x+y\) 中,再读入 \(y=b\)

这样写起来会多写很多函数,为了简便,我们可以简写为 \((\lambda xy.x+y)ab\),约定先读入最左侧的参数。有时候,为了区分到底是 \(a(b)\) 作为输入,还是 \(a\)\(b\) 作为两个参数输入,我们还使用 \(,\) 分隔,也就是 \((\lambda xy.x+y)(a,b)\) 用于计算 \(a+b\)

变量

刚刚的演示中我们已经看到了 \(\lambda\) 演算中的所有变量类型:自由变量和约束变量。

自由变量就是上文的 \(ab\) 这样的变量,它本身不是一个函数的参数,想取什么都可以,这样的变量应该被理解成一般编程语言中的输入。(代码中的常量本质上也是输入,你可以理解成现代的语言给了语法糖,让你不用每次调用都输入)

约束变量就是 \(\lambda x.x\) 这样的函数中的参数,就是形参。

\(\#\) 作为一种古老的语言,没有语法安全检测是正常的,你需要自己保证自己的代码不会 \(\color{yellow}\text{CE}\)

选择结构

实现选择,其实很简单,比如一段很简单的 python 代码

if condition:
	F
else:
	G

那我们一下子就知道了,如果 condition 为真,就运行 F,否则就运行 G。这个其实在 \(\lambda\) 演算中也很好实现。一个容易出现的错误想法是我们的函数接受 \(3\) 个参数,一个布尔变量和两个函数,但是这样你选择不了,会陷入循环论证。

此时正确的做法是回忆一下开头所说:\(\lambda\) 演算中所有东西都是函数。那么布尔变量也应该是函数,这下就容易了:

\[\begin{align*} T&=True=\lambda(f,g).f\\ F&=False=\lambda(f,g).g \end{align*} \]

此时就很好定义 if 了:\(if(a,b,c)=\big(\lambda(a,b,c).a(b,c)\big)\),自然地可以理解成 if a then b else c。当然,这就是什么都没有做,重点是上面对布尔变量的定义。

逻辑运算

都会 if 了,逻辑运算就很简单了,这里只是写一下定义(进行了一定的化简,你用一堆 if 拼出来自然也是可以的),方便后面使用。

\[\begin{align*} x\land y&=\lambda(x,y).x(y,F)\\ x\lor y&=\lambda(x,y).x(T,y)\\ \neg x&=\lambda x.x(F,T) \end{align*} \]

函数迭代与自然数

\(\lambda\) 演算中,函数迭代是不简单的,因为我们还没有定义自然数,自然也不能写循环,事实上,函数式编程中没有循环,只有递归(也就是函数迭代)。

那么,为了解决这个问题,像之前说的一样,自然数也是函数,那我们把函数迭代的迭代次数本身当成自然数不就好了。

具体一点:

\[\begin{align*} 0&=\lambda fx.x\\ 1&=\lambda fx.f(x)\\ 2&=\lambda fx.f(f(x))\\ &\ldots \end{align*} \]

这里的 \(=\) 应该理解成宏替换,实际上用 \(\equiv\) 会更数学一点。(一个有意思的点是 \(0=F\)\(\lambda\) 演算中是自然成立的)

需要理解的一点是,既然我们用函数定义了自然数,那最后我们就不一定要填满所有的参数,知道这个函数表示什么数就可以了。

比如 \(\lambda\) 演算编写的程序:\(\lambda fx.f(f(f(x)))\),我们并不需要真的输入 \(f\)\(x\),我们已经知道这就是一个常函数,返回值为 \(3\) 了。

另外需要注意的一点是,我们目前还没有定义数组或数对,所以你写 \(\lambda\) 演算式 \(33\),我们的理解应该是复合函数,直接复合的结果就是 \(3^3\),于是乘方就不用写了。

是否为 0

考虑之前对布尔值的定义,我们可以观察到一个性质:\(F=\lambda xy.y\),这时我们要记住,这其实是一个缩写,本质是 \(F=\lambda x.(\lambda y.y)\),那么如果只给一个参数,就有 \(F(a)=\lambda y.y=I\),同时这也告诉我们,\(FF=I\)

这是一个很好的性质,可以被类比成一种开关,于是我们可以写下一个函数判断一个量是否为 \(0\)

\[Zero(x)=\lambda x.\Big(\big(x(F)\big)(\neg)\Big)(F) \]

这里的 \(\neg\) 本质上也是一个函数,我们写 \(x(F)\) 的意思是,如果 \(x>1\),那么就会迭代出 \(I\),如果 \(x=1\),那么 \(F(\neg)\) 也等于 \(I\),然后 \(I(F)=F\),但是如果 \(x=0\),那么 \(x(F)=I\),于是结果就是 \(\neg F=T\)

后继与加法

如果你看了分析基础 I,就应该知道比加法更基本一点的运算是后继运算,也就是 \(suc(x)=x+1\)。(这里的 \(suc\) 没有使用正体 \(\text{suc}\),是因为想强调在 \(\lambda\) 演算中数和函数没有本质区别,所有东西都是函数)

其实有了后继就有加法了,因为我们可以定义 \(add(x,y)=\lambda xy.\big(x(suc)\big)(y)\),因为 \(suc\) 是自加 \(1\),那迭代 \(x\) 次就是自加 \(x\)

然后我们知道数字本质是用 \(f\) 重复的次数定义,那求后继多套一个不就完了,再想一下细节,为了 \(f\) 表示同一个函数,\(suc\) 其实应该接受 \(3\) 个参数,也就是

\[suc(x)=suc(x,f,a)=\big(\lambda (x,f,a).f(x(f,a))\big) \]

就像定义自然数时一样,虽然我们有 \(3\) 个参数,但是后 \(2\) 个大多数时候根本不需要,我们只是形式上保留了这样的内容。

后面我们就可以开心得写 \(x+y\) 这样的内容了。

数对,前驱与减法

\(\lambda\) 演算天然支持存储单个元素,但这样就像只有一个寄存器的电脑一样让人难受。

那最简单的构造数对的方法就是把两个数放在一起,于是我们可以想到和之前构造自然数一样,我们保留一个形式化的参数,就有了

\[mp(a,b)=makePair(a,b)=\lambda (a,b,f).f(a,b) \]

这样带来的一个额外好处是,对于数对 \(p\)\(p(T)\) 相当于 p.first\(p(F)\) 相当于 p.second

然后你发现其实 \(b\) 是个什么东西都可以,于是一个 \(\text{naive}\) 的想法就是可以这样定义数组,然后还真行~。这样得到的列表大概是 \((a,(b,(c,\ldots)))\)。然后数组还有一些实现细节,之后再说。

现在我们回归之前的目标,推导出基本的二元运算,现在我们推导减法,首先还是推导前驱。

我们知道,前驱运算很简单,就是 \(pre(x)=x-1\)

但是减法的定义是有点困难的,这里有一个妙妙做法就是考虑每次存数对 \((n+1,n)\),然后当 \(n+1=x\) 的时候,取 pair 中的另外一个元素就得到了 \(n\)。(毕竟是数学模型,能做就行,至少暂时不要考虑复杂度)

定义数对的后继是很容易的,\(psuc(p)=mp\Big(suc\big(p(T)\big),p(T)\Big)\)。然后和加法一样,我们直接迭代 \(n\) 次就好了,于是可以有

\[pre(x)=\lambda x.\Big(\big(x(psuc)\big)(mp(0,0))\Big)(F) \]

可以注意到,此时有 \(pre(0)=0\),这个当成 \(\text{ub}\) 就好了,但是有时候很好用。

然后,\(\lambda xy.\big(y(pre)\big)(x)\) 是可以当成无符号整数的 \(x-y\) 用的,但是如果 \(y>x\) 得到的结果还是 \(0\)

乘法

在之前乘方的基础上稍微改改就行了,直接放结果:

\[x\times y=\lambda xya.x(y(a)) \]

比较算符

首先,我们可以定义 \(x\le y\),这个很简单,就是

\[x\le y=\lambda xy.Zero(x-y) \]

然后别的几个都可以拼出来,比如 \(x=y\Leftrightarrow x\le y\land y\le x\)

Recursion

Combinator

没有循环的高(nan)贵(xie)语言。

\(\text{naive}\) 的想法肯定是,我直接写不就完了,比如 \(f(a,b,x)=if(a,b,f(a,b,x))\)

但问题就在于,这是不能通过编译的。因为 \(f\) 本身是个 \(\lambda\) 函数,然后你展开的话就会发现有无穷多层。

所以常用的做法是把 \(f\) 也当成参数,从外面传入进来。

那外面这个辅助函数最简单地,至少要可以生成无限次的递归,至于怎么终止则要看内部的函数,这个任务不应该被交给辅助函数。形式化地,记外面的辅助函数为 \(H\),那么应该有

\[H(f)=f\big(H(f)\big) \]

然后你展开就会发现这意味着

\[H(f)=f(f(f\ldots))=f_\infty \]

求解这样的 \(H\) 目前看上去有点困难,但是我们可以考虑简单一点的情况,只调用自己一次,那这样的函数是很简单的(也就是想得到 \(g(f(x))=f(f(x))\)),只需要考虑 \(\omega(x)=\lambda x.x(x)\) 即可。

这个时候,聪明的同学可以注意到一件事,如果我考虑 \(\omega(\omega)\) 会发生什么事呢,代入之后就会发现两个 \(x\) 都会被替换成 \(\omega\),于是 \(\omega\omega=\omega\omega\),这个东西有个名字叫 \(\Omega\) 组合子,因为是两个 \(\omega\) 组合起来的算子。

然后 \(\Omega\) 稍微改造一下其实就可以实现递归了。怎么想到的呢?就是 \(\omega\) 每次嵌套什么都没有做,但是如果我传入一个函数 \(f\),每次 \(\omega\) 嵌套的时候都在外面套一层 \(f\),就可以实现无穷层的递归了。后面填一点细节就是这样:

外面传入两个参数,一个是递归函数的核 \(f\)(我也不知道是不是叫这个名字),一个是 \(x\)(也就是原本 \(\omega\) 用来递归的东西),具体就是

\[\omega(f,x)=f(x(f,x)) \]

之前说过,这里的参数就是函数

\[\omega(f,\omega)=f(\omega(f,\omega)) \]

这样的函数定义无疑是合理的,而且外面会发现如果记 \(x=\omega(f,\omega)\),就有 \(f(x)=x\)。于是 \(\omega(f,\omega)\) 又叫做 \(f\) 的不动点。

你会发现上面我们做的,其实就是提出了一种构造任意函数不动点的机械方法,这样的方法(中用到的辅助函数)就被称为组合子。

\(\lambda\) 演算写下来 \(\omega\) 的定义:

\[\begin{align*} \omega&=\lambda f.\big(\lambda x.f(x(f,x))\big)\\ &=\lambda f.\big(\lambda x.f(xfx)\big)\\ \end{align*} \]

这时我们想要一个更好的封装,也就是 \(C(r)=\omega(r,\omega)=\omega r\omega\)。这样的 \(C\) 是容易得到的:

\[\begin{align*} C(r)&=\lambda r.\omega r\omega\\ &=\lambda r.\Big(\lambda f.\big(\lambda x.f(xfx)\big)\Big)r\Big(\lambda f.\big(\lambda x.f(xfx)\big)\Big)\\ &=\lambda r.\big(\lambda x.r(xrx)\big)\Big(\lambda f.\big(\lambda x.f(xfx)\big)\Big) \end{align*} \]

通过交换 \(x,r\) 的位置我们可以得到更简单的格式(我一开始出了点问题,没有想到这里,看了后面就知道是什么问题了):

\[C(r)=(\lambda x.\lambda r.xrx)\Big(\lambda f.\big(\lambda x.f(xfx)\big)\Big) \]

其实相当于把 \(\omega(f,\omega)\) 换成了 \(\omega(\omega,f)\),同时稍微修改一下 \(\omega\) 的定义变为 \(\omega(x,f)=f(x(f,x))\) 就可以了。

这个组合子可以参考John Tromp 的一篇论文(第 10 页中的 \(\mathbf{Y}^{\prime\prime}\)),我没细看论文,不知道推法一不一样。

An Example of Combinator

比如一个典中典的问题是求解自然数的阶乘,那在 \(\lambda\) 演算中我们是没有循环的,只能写递归了。

首先,我们考虑简单的递归函数:

def fact(n):
	if n == 0:
		return 1
	else:
		return n * fact(n - 1)

为了便于理解,我们首先在代码层面把这个函数拆分为递归部分 fact 和计算部分 calc

def fact1(n, calc):
	if n == 0:
		return 1
	else:
		return n * calc(n - 1, calc)

我们发现 fact1 本身就可以作为计算阶乘的函数,于是就有 \(n!\) 等于 fact1(n, fact1)。(这里很多网上的资料会化成 fact1=f=>n=>f(f)(n),虽然是对的,但是我还是不习惯柯里化的写法,感觉有点毒瘤)

那转化成 \(\lambda\) 演算就应该是 \(fact1(n,x)=if\big(Zero(n),1,n*x(n-1,x)\big)\)。然后 \(fact1(n, fact1)\) 就是我们想求的,可以发现这就是 \(\omega f\omega\) 的形式,于是 \(C(fact1)\) 就是 \(fact\)\(\big(C(fact1)\big)(n)=n!\)

A Way to Find Combinators

\(f^\infty=f(f^\infty)\),也就是 \(f\) 的无穷层嵌套得到的函数。只要我们有办法表示出 \(f^\infty\) 就可以了,或者说,\(f\) 的任意不动点就是 \(f^\infty\)

从之前的方法,我们可以意识到,直接求解 \(f^\infty\) 是困难的,但是我们可以用一个辅助函数 \(g\) 的有限次嵌套来构造出无限循环。

比如我们假设 \(f^\infty=g(g)=gg\),那么就有 \(f(gg)=gg\),这样我们可以立马知道 \(g(x)=\lambda x.f(xx)\)。然后我们想要得到这样的算子 \(Y\),于是 \(Y(f)=g(g)\)。(当然,严谨一点,我们应该把 \(f\) 也当成参数传给 \(g\)

这样得到的组合子就是 \(Y=\lambda g.\big(\lambda x.g(xx)\big)\big(\lambda x.g(xx)\big)\),即著名的 \(Y\) 组合子。(本来也想一开始就讲这个的,但是不知道为什么我推了个别的出来)

就算是同样的设法也可以得到不同的组合子,比如和上面同样的设法,但是你交换一下 \(g\) 里面的参数,也就是定义为 \(g(x,f)\),那么不动点就是 \(g(g,f)\),可以得到 \(g=\lambda x.(\lambda f.f(xxf))\)

这时得到的组合子被称为 \(\text{Turing Combinator}\),即 \(T(f)=g(g,f)=\big(\lambda x.x(x)\big)\Big(\lambda y.\big(\lambda f.f(yyf)\big)\Big)\)

实际上因为我们总是至少要两个参数,一个函数体 \(f\),一个辅助函数 \(g\),所以交换 \(g\) 中的参数再手动加一层总是可以得到一个新的递归函数,实际上,我们可以定义一个函数 \(S=\lambda x.\lambda y.y(xy)\) 。比如 \(T\) 就是 \(Y S\)。所以其实之前我只是得到了 \(C S\),而不是 \(C\)

之前我们得到的组合子的构造方法是 \(f^\infty=g(f(g))\),实际上什么构造方法都可以,比如 \(f^\infty=g(f(g(g)))\) 也可以,当然,这里你就会觉得柯里化是合理的,因为每次嵌套,你都需要给 \(g\) 增加一个参数,更详细的内容可以参考Y组合子的一个启发式推导中的第五部分。

Array

要是我们可以用 \(\lambda\) 演算定义数列了,那么我们就有足够的信心相信它不弱于图灵机了。

定义数列(列表)可以用极其粗暴的方式,每次要在 \(L\) 末位插入一个元素 \(x\) 时,直接返回 \(mp(L, x)\) 就行了。

然后,出于习惯,我们约定空列表 \(nil=F\)

这时我们想一想,可以用 \(Head(L)=L(T)\) 得到 \(L\) 中的第一个元素,用 \(Tail(L)=L(F)\) 得到 \(L\) 中的第二个元素,也就是删去了第一个元素的列表。

Is Empty

判断一个列表是不是空是很常用的功能,然后我们发现之前说过(或者你可以回去看定义):

\(\lambda\) 演算中,\(0\)\(F\) 天然是一个东西。

于是之前的函数 \(Zero\) 就是 \(Null\)

Length

我们想要做的另外一个事情是判定给定列表的长度。首先容易写出正常的代码:

def len(lst, calc):
	if Null(lst):
		return 0
	else:
		return 1+calc(Tail(lst), calc)

\(\lambda\) 演算写就是 \(len=\lambda lst.\lambda f.If\big(Null(lst),0,suc\text{ }calc(Tail(lst),calc)\big)\),然后 \(Len=Y(len)\),其中 \(Y\) 你随便选个喜欢的组合子都可以。

Kth Element

一个列表,我们当然想取出其中第 \(k\) 个元素,其实差不多,存一个计数器,到 \(0\) 的时候就返回就可以了。

\[get=\lambda lst.\lambda k.\lambda f.If\Big(Zero(k),lst(T),f\big(lst(F),pre\text{ }k,f\big)\Big) \]

然后用 \(Get=Y(get)\) 就行了。

后记

咕咕,咕咕咕,咕——

posted @ 2024-12-25 16:35  嘉年华_efX  阅读(194)  评论(0)    收藏  举报