如何用lambda演算写出lambda演算的解释器
那么我们都知道, lambda 演算是图灵完备的,图灵完备必能自举。
我就来试着搞搞玩玩。
什么是 lambda 演算
如果学过的话可以跳过这章。
我就简略介绍一下,实在想了解就自己搜一下吧。
lambda 演算就是一种很奇特的语言,只有三种语言结构:
-
\(a\)
这是一个叫 \(a\) 的值;
-
\(λa.b\)
这是一个参数为 \(a\) ,返回值为 \(b\) 的函数;
-
\((f\ x)\)
这是把 \(x\) 作为 \(f\) 的参数来应用。
有时不加括号也可以,但是为了防止你(还有我)看不懂,本文会一直加的,顺便你也可以锻炼一下看括号的能力。
于是就可以写出很复杂的话,比如
这段话是什么意思? lambda 演算还有两条法则:
-
\(\alpha\) 转换
这个是说,如果有一个函数 \(λa.λb.(a\ b)\) ,那它也完全可以改名为 \(λx.λy.(x\ y)\) ,可以用来防止变量重名。
顺便一提,这个函数不仅可以看作一个返回 \(λb.(a\ b)\) 的函数,还可以看作一个具有两个参数,返回 \((a\ b)\) 的函数。
-
\(\beta\) 规约
这个是说,函数被应用其实就是替换函数体里的所有参数,就像简单的字符串替换一样。比如这个函数
\[f=λx.(x\ (x\ x)) \]应用它,有
\[\begin{aligned} (f\ b) &= (b\ (b\ b)) \\ (f\ n) &= (n\ (n\ n)) \\ (f\ (x\ y)) &= ((x\ y)\ ((x\ y)\ (x\ y))) \\ (f\ λa.a) &= (λa.a\ (λa.a\ λa.a)) \\ &=(λa.a\ λa.a) \\ &=λa.a \\ \end{aligned} \]
如果你理解了,那就算是掌握了 lambda 演算。回过来看上面那句复杂的话,我们来算算它。
基本思想
我们知道什么是 lambda 演算了,如何使用它构建一个它自己的解释器呢?
首先,我们需要寻找一种方法把我们的 lambda 程序以 lambda 表达式形式的指令进行储存,这样才能喂给解释器。
之后我们发现 lambda 演算执行的关键是 \(\beta\) 规约——对 lambda 表达式使用 \(\beta\) 规约比较复杂,我们可以让解释器通过阅读程序,在内部构建一个与程序相同的 lambda 表达式,再来让运行我们解释器的解释器对其使用 \(\beta\) 规约。
最后我们需要把结果输出。
指令设计
首先可以考虑 lambda 表达式怎么用 lambda 表达式来表示。
……思考一会就可以发现难度太高了,那先来想想如何简单表示 lambda 算式吧。
简化
非常显而易见的是 \(λ\) 这个字母太难打出来了,而且 \(λa.b\) 里面有个点也莫名其妙,不能用空格吗?所以第一步就是用 func a b 来表示一个参数是 a ,返回值是 b 的函数。
再进一步,发现 \((a\ b)\) 里的括号也太多余了,因为括号这种东西只有在中缀表达式里才需要吧, lambda 演算里哪有需要写在什么中间的东西(可能是空格需要在中间)?不如直接写成 call a b 。
太好了,火眼金睛的我们成功简化了 lambda 演算,比如
就可以转写为
call
func x
call
x
x
func a
func b
b
呃,这算是简化吗?嘛,不论如何,对于我们要写的解释器来说,这应该是简化的很好了。
符号表
我现在要告诉你一个天大的坏消息:英文字母只有 \(26\) 个!我们之前一直在用字母表示参数和值,难道 \(26\) 个用完了我们就只能 aa ab 这样玩了吗?
不难发现
- 一个完整的 lambda 表达式里,每个字母都是一个已知函数的参数(否则叫做自由标识符,说明表达式不完整);
- 每个函数只有一个参数。
也就是说我们指出字母对应的函数就可以表示字母。用数字 \(n\) 来指代当前往上第 \(n\) 层的函数就可以了。
还是刚才的表达式,现在写成
call
func // 这是第 0 层(程序员都从 0 开始数)
call
0 // 使用第 0 层函数的变量
0 // 这个也一样
func // 从这开始,是另一个第 0 层函数
func // 这是第 1 层函数
1 // 使用第 1 层的变量
非常好了,现在我们不需要担心任何变量混淆的问题,而且语法被进一步简化了,我们的解释器也能更容易读懂程序。
用 EBNF 表达这个语法的话:
LambdaExpr
= number
| Call
| Func
Call
= "call", LambdaExpr, LambdaExpr
Func
= "func", LambdaExpr
虽然我们的语法越来越不适合人类阅读,但是它很适合实现解释器。
\(Y\) 组合子
前期工作做完了,我们开始来想象我们解释器的主体。
从头思考,我们想到我们的解释器是不断在读取用户的程序指令——那最好是用个循环结构。
然而你也知道,在 lambda 演算里没有什么 for while 之类的,想要实现循环需要递归,我们就需要一个叫 \(Y\) 组合子的东西。
同 lambda 演算的介绍一样,你了解过就可以跳过这个章节。
我们先从神奇的 lambda 世界回来,别管什么组合子了,考虑一下现实的东西。
斐波那契数列是递归,用普通编程语言可以写成
card(dad_yc)
card(JS+λ)
const feb = n => n <= 2 ? 1 : feb(n - 1) + feb(n - 2);card(JS)
function feb(n) { if (n <= 2) { return 1; } else { return feb(n - 1) + feb(n - 2); } }card(Python+λ)
feb = lambda n: ( 1 if n <= 2 else feb(n - 1) + feb(n - 2) )card(Python)
def feb(n): if n <= 2: return 1 else: return feb(n - 1) + feb(n - 2)
我们通过 feb 这个名字,在函数内部拿到了函数自己。
现在给你一个挑战:如果 feb 没有名字,怎么样才能在它的内部拿到自己并调用?
一个很好的思路是在调用自己的时候把自己也作为一个参数传进去,这样下一次的自己就可以调用自己了。
至于第一次调用自己的时候怎么办?再套个函数包装一下就行。
card(dad_yc)
card(JS+λ)
const febWithSelf = (self, n) => n <= 2 ? 1 : self(self, n - 1) + self(self, n - 2); const feb = n => febWithSelf(febWithSelf, n);card(JS)
function febWithSelf(self, n) { if (n <= 2) { return 1; } else { return self(self, n - 1) + self(self, n - 2); } } function feb(n) { return febWithSelf(febWithSelf, n); }card(Python+λ)
feb_with_self = lambda self, n: ( 1 if n <= 2 else self(self, n - 1) + self(self, n - 2) ) feb = lambda n: feb_with_self(feb_with_self, n)card(Python)
def feb_with_self(self, n): if n <= 2: return 1 else: return self(self, n - 1) + self(self, n - 2) def feb(n): return feb_with_self(feb_with_self, n)
其实我偷偷告诉你, f(a, b) 和 f(a)(b) 是一个意思。所以我们改写程序为
card(dad_yc)
card(JS+λ)
const febWithSelf = self => n => n <= 2 ? 1 : self(self)(n - 1) + self(self)(n - 2); const feb = febWithSelf(febWithSelf);card(JS)
function febWithSelf(self) { return function (n) { if (n <= 2) { return 1; } else { return self(self)(n - 1) + self(self)(n - 2); } } } const feb = febWithSelf(febWithSelf);card(Python+λ)
feb_with_self = lambda self: lambda n: ( 1 if n <= 2 else self(self)(n - 1) + self(self)(n - 2) ) feb = feb_with_self(feb_with_self)card(Python)
def feb_with_self(self): def do_feb(n): if n <= 2: return 1 else: return self(self)(n - 1) + self(self)(n - 2) return do_feb feb = feb_with_self(feb_with_self)
发现一堆 self(self) 太冗长了有没有,提取 self(self) 为 f 。
card(dad_yc)
card(JS+λ)
const febOnly = f => n => n <= 2 ? 1 : f(n - 1) + f(n - 2); const febWithSelf = self => n => febOnly(self(self))(n); const feb = febWithSelf(febWithSelf);card(JS)
function febOnly(f) { return function (n) { if (n <= 2) { return 1; } else { return f(n - 1) + f(n - 2); } } } function febWithSelf(self) { return function (n) { return febOnly(self(self))(n); } } const feb = febWithSelf(febWithSelf);card(Python+λ)
feb_only = lambda f: lambda n: ( 1 if n <= 2 else f(n - 1) + f(n - 2) ) feb_with_self = lambda self: lambda n: ( feb_only(self(self))(n) ) feb = feb_with_self(feb_with_self)card(Python)
def feb_only(f): def calc(n): if n <= 2: return 1 else: return f(n - 1) + f(n - 2) return calc def feb_with_self(self): def do_feb(n): return feb_only(self(self))(n) return do_feb feb = feb_with_self(feb_with_self)
不错,你会发现 febOnly 被提取出来了,那也就是说我们的 factorialOnly hanoiOnly 之类各种递归函数看起来都可以用这个方法?
我们可以继续提取,先来在 feb 里展开 febWithSelf 的定义,
card(dad_yc)
card(JS+λ)
const febOnly = f => n => n <= 2 ? 1 : f(n - 1) + f(n - 2); const feb = (self => n => febOnly(self(self))(n)) (self => n => febOnly(self(self))(n));card(JS)
function febOnly(f) { return function (n) { if (n <= 2) { return 1; } else { return f(n - 1) + f(n - 2); } } } const feb = (function (self) { return function (n) { return febOnly(self(self))(n); } })(function (self) { return function (n) { return febOnly(self(self))(n); } });card(Python+λ)
feb_only = lambda f: lambda n: ( 1 if n <= 2 else f(n - 1) + f(n - 2) ) feb = ( lambda self: lambda n: ( feb_only(self(self))(n) ) )( lambda self: lambda n: ( feb_only(self(self))(n) ) )
这样子就方便把 febOnly 提取为参数了。来:
card(dad_yc)
card(JS+λ)
const febOnly = f => n => n <= 2 ? 1 : f(n - 1) + f(n - 2); const yCombinator = someOnly => (self => n => someOnly(self(self))(n)) (self => n => someOnly(self(self))(n)); const feb = yCombinator(febOnly);card(JS)
function febOnly(f) { return function (n) { if (n <= 2) { return 1; } else { return f(n - 1) + f(n - 2); } } } function yCombinator(someOnly) { return (function (self) { return function (n) { return someOnly(self(self))(n); } })(function (self) { return function (n) { return someOnly(self(self))(n); } }); } const feb = yCombinator(febOnly);card(Python+λ)
feb_only = lambda f: lambda n: ( 1 if n <= 2 else f(n - 1) + f(n - 2) ) y_combinator = lambda some_only: ( lambda self: lambda n: ( some_only(self(self))(n) ) )( lambda self: lambda n: ( some_only(self(self))(n) ) ) feb = y_combinator(feb_only)card(Python)
这里 Python 可以略过上一步直接提取。
def feb_only(f): def calc(n): if n <= 2: return 1 else: return f(n - 1) + f(n - 2) return calc def y_combinator(some_only): def some_with_self(self): def do_some(n): return some_only(self(self))(n) return do_some return some_with_self(some_with_self) feb = y_combinator(feb_only)
非常好,我们于是就找到了这个 yCombinator ,大名鼎鼎的 \(Y\) 组合子! lambda 演算里写作:
不,其实这是 \(Z\) 组合子。下面这个简写版才是 \(Y\) 组合子,没法翻译到普通编程语言里:
……你反应过来了吗?
总之,我是说,仔细观察观察上面的 yCombinator ,你给它传进去任何一个函数 someOnly ,这 someOnly 的第一个参数都能拿到它自己对不对!
你可能觉得这是废话,呵呵,其实这就叫函数的“不动点”。记住这个高大上的名字,能够用这个词装逼可能是你看本文最大的收获。
数组
之后我们知道指令是一个一个的,顺序还不能乱。
那我们就需要一种类似数组的东西。
在思考了很久(也不是很久)之后,我发现数组可以这样表达:
在此之后约定结构性质的标识符使用大写,数据性质的用小写
既然决定用这个做数组了,那我们开始研究它怎么操作。
添加数组元素
还记得 lambda 演算就是简单的字符替换吗?那把式子中心的 \(F\) 替换为 \((F\ x)\) 岂不是就在头上插入了一个元素?
所以
其中参数 \(l\) 是需要被插入的数组, \(x\) 就是新的元素, \(λF.\) 则是返回的数组的开头。
这是往 \(Head\) 里 \(push\) ,那如何往 \(Tail\) 里?
上面这两个例子如果看不懂,可以琢磨琢磨。如果你琢磨懂了,那你的 lambda 演算就小成了!
删除数组元素
把第一个元素当作参数接收后直接扔了,就可以删除头部元素。
那 \(deletedTail\) 呢?
非常不幸,这时,我发现没法简单 \(deletedTail\) 了。
想要扔掉最后代入 \(F\) 的参数,必须要知道前面有多少参数。
唉,天才如我也不得不承认,那些编程语言里对数组的实现总是由数据和长度两部分组成,看来不是没有道理的。
邱奇数
想要表示长度就需要数字,如何在 lambda 演算的世界里表示数字呢?伟大的逻辑学还是数学我也不知道家邱奇就提出过一种表示方法。
看起来一个代表 \(n\) 的邱奇数其实就是把 \(F\) 应用 \(n\) 次。
好,既然他说这可以用来表示数,那来推导一下相关的操作。
加法
两个数相加就是把其中一个邱奇数放到另一个邱奇数的 \(X\) 里。
后继函数就是 \(+1\) 函数。
上面这两个例子如果你能看懂,你就可以说自己会 lambda 演算了!看不懂的话也没关系,只要有一颗想看懂的心就行。
减法
减法确实一时想不出来,我们先来思考 \(-1\) 。
观察邱奇数,我们可以发现邱奇数其实就表示 \(F\) 被应用了 \(n\) 次,除了第 \(1\) 次是 \(X\) ,其余每次应用时 \(F\) 都会收到上次 \(F\) 处理过的东西。
所以如果 \(F = λp.p\) ——其中 \(p\) 就是上一次处理的东西——这就是把上一次的东西不断传下去。最后会得到一个 \(λX.X\) 。
再思考,如果 \(F = λp.λa.p\) ,就是每次都在上次 \(p\) 的前面增加一个 \(λa.\),最后会得到 \(λX.λa.λa.\cdotsλa.X\) ,一共 \(n\) 个 \(λa.\) 。
以此类推, \(F = λp.λa.(a\ p)\) 就可以得到 \(λX.λa.(a\ λa.(a\ \cdots λa.(a\ X)\cdots))\) 。
神奇的一步就来了,如果再改进,使 \(F = λp.λa.(a\ (p\ F))\) 来把每个上次的 \(λa.(a\ \cdots)\) 像 \((λa.(a\ \cdots)\ F)=(F\ \cdots)\) 这样给应用掉,变回原来的样子,那由于第一次应用 \(F\) 时收到的 \(X\) 没有被处理过,不需要被应用掉,但依然被这次给应用了,就产生了 \((X\ F)\) ;而最后一次应用 \(F\) 时,不会再有下一次,所以它产生的 \(λa.(a\ \cdots)\) 就被留了下来,最终结果是 \(λX.λa.(a\ (F\ (F\ \cdots(F\ (X\ F))\cdots)))\) ,只有 \(n-1\) 个 \((F\ \cdots)\) 被应用回来了——这正好是我们所需要的数字!
离成功就这么点距离了,再给它头尾一处理,
就得到了 \(n-1\) 。
而 \(a-b\) ,实际上就是对 \(a\) 应用 \(b\) 次 \(-1\) ,很容易就能写出来:
如果你没注意到: \(((n\ f)\ x)\) 利用了邱奇数的定义,就表示对 \(x\) 应用了 \(n\) 次 \(f\) 。
我们可以发现, \((pred\ 0)=0\) ,而我们的 \(minus\) 是根据 \(pred\) 定义的,也就是说如果 \(a \leq b\) ,那么 \(((minus\ a)\ b)=0\) 。
判断是否为 \(0\)
所以说,只要我们可以判断一个数是不是 \(0\) ,那就可以通过 \(minus\) 来比较 \(a\)
我们用这些就够了。
更强的数组
\(2\) 元素元组
我们继续来思考数组。
数组的数据和长度需要分开存放,那我们可以用元组。
其实也就是长度为 \(2\) 的数组,只不过这回我们能够确定它长度就是 \(2\) 而不会是其他什么东西。
想要愉快地使用这个元组,我们再引入两个函数:
这样如果一个元组叫 \(t\) ,那 \((t\ former)\) 可以拿到前一个元素, \((t\ latter)\) 可以拿到后一个。不错。
包含两个东西的数组
现在数组变成了一个 \(2\) 元素元组,前一个元素 \(l_i\) 是之前定义的数组,后一个是数组的长度。
比如一个三元素数组 \(l\) 长这样:
太复杂了,就跟我们把一坨复杂的邱奇数简写为阿拉伯数字一样,以后这种数组也都来简写吧。
元组就这么简写
添加数组元素
修改了数组的定义,我们来重新写添加的逻辑:
删除头部元素
获取元素
其实 \(deletedTail\) 也是一种 \(deletedHead\) ,只需要思考如何把数组颠倒就可以了。
一个容易想到的方法是把这个数组的元素从头上一个一个拿出来,再依次从头上放到另一个数组里。
不过我们还不知道如何从头上拿元素,来推导一下。
你还记得我们在推导 \(pred\) 的时候令 \(F = λp.λa.p\) 可以得到一个有 \(n+1\) 个参数并返回第 \(1\) 个参数的函数吗。数组就是对一个函数应用好几个参数,这个就可以用到了。
更进一步,我们可以先把头上的 \(n\) 个东西都用 \(deleteHead\) 删了,再 \(getHead\) ,就能获得第 \(n+1\) 个元素了。
之所以是 \(n+1\) 个元素是因为数组下标从 \(0\) 开始数。
然后 \(tail\) 也能来了。
加油,我们的数组就剩几个关键难题了!
颠倒数组
所以我们说可以依次从这个拿出,再放入那个,来颠倒数组。
如果数组长 \(n\) ,那这个操作 \(f\) 需要重复执行 \(n\) 次,就跟以前一样,我们可以用一个表示 \(n\) 的邱奇数来干这个事。
不过使用邱奇数有一个要求: \(f\) 的第一个参数应该和它自己的返回值长得差不多——毕竟下一次应用它时,它的第一个参数就是这一次应用它的返回值。
所以即使我们在同时操作 \(2\) 个数组,我们也只能给 \(f\) 传入 \(1\) 个参数……继续用元组吧。
删除尾部元素
终于!历尽千辛万苦,我们终于取得了伟大的成功!
非常优美的式子。但是别高兴的太早,我们这才刚刚弄完数组的定义。
解释器主循环
我们数组有了,循环也有了,赶紧来考虑我们到底怎么读程序吧!
基本想法就是,我们之前已经把 lambda 演算变成了一个只需要 call 和 func 指令还有数字的非常简单的语言,那我们只要逐字阅读这种语言,遇到特定指令就构建特定结构,不就得了。
想法很美好,我们赶紧来。
程序如何存储
根据我们搞数组的经验,我们可以把程序代码也放在一个数组里。
由于我们的指令设计的不错,基本可以顺序阅读,不需要在解析的过程中回过头来看,所以我们可以不要长度信息,直接用元组。
那么我们怎么一口一口吃掉这个元组呢?因为不定长度,我们必须得用 \(Y\) 组合子了,每次递归我们都产生一个新函数来继续吃下一个元素。
博客园原文链接:https://www.cnblogs.com/QiFande/p/18984935,转载请注明。
如果你对本篇文章感兴趣,不如来看看肉丁土豆表的其他文章,说不定也有你喜欢的。
浙公网安备 33010602011771号