肉丁土豆园地

静谧编程花园 - The Secret Garden
如何用 Lambda 演算写出 Lambda 演算的解释器

那么我们都知道, lambda 演算是图灵完备的,图灵完备必能自举。
我就来试着搞搞玩玩。

什么是 lambda 演算

如果学过的话可以跳过这章。
我就简略介绍一下,实在想了解就自己搜一下吧。

lambda 演算就是一种很奇特的语言,只有三种语言结构:

  1. \(a\)

    这是一个叫 \(a\) 的值;

  2. \(λa.b\)

    这是一个参数为 \(a\) ,返回值为 \(b\) 的函数;

  3. \((f\ x)\)

    这是把 \(x\) 作为 \(f\) 的参数来应用。
    有时不加括号也可以,但是为了防止你(还有我)看不懂,本文会一直加的,顺便你也可以锻炼一下看括号的能力。

把这三种语言结构用 EBNF 表达可能有助于你理解:

LambdaExpr
    = value
    | Call
    | Func
    ;
value
    = A - Z or a - z ;
Call
    = "(" , LambdaExpr , LambdaExpr , ")" ;
Func
    = "λ" , value , "." , LambdaExpr ;

于是就可以写出很复杂的话,比如

\[(λx.(x\ x)\ λa.λb.b) \]

这段话是什么意思? lambda 演算还有两条法则:

  1. \(\alpha\) 转换

    这个是说,如果有一个函数 \(λa.λb.(a\ b)\) ,那它也完全可以改名为 \(λx.λy.(x\ y)\) ,可以用来防止变量重名。

    顺便一提,这个函数不仅可以看作一个返回 \(λb.(a\ b)\) 的函数,还可以看作一个具有两个参数,返回 \((a\ b)\) 的函数。

  2. \(\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} \]

    同样的, \(((f\ a)\ b)\) 也可以看作是一下子给 \(f\) 喂了 \(a, b\) 两个参数,所以本文章会简写为 \((f\ a\ b)\)

如果你理解了,那就算是掌握了 lambda 演算。回过来看上面那句复杂的话,我们来算算它。

\[\begin{aligned} &(λx.(x\ x)\ λa.λb.b) \\ =&(λa.λb.b\ λa.λb.b) \\ =&λb.b \\ \end{aligned} \]

基本思想

我们知道什么是 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 演算,比如

\[(λx.(x\ x)\ λa.λb.b) \]

就可以转写为

call
    func x
        call
            x
            x
    func a
        func b
            b

呃,这算是简化吗?嘛,不论如何,对于我们要写的解释器来说,这应该是简化的很好了。

符号表

我现在要告诉你一个天大的坏消息:英文字母只有 \(26\) 个!我们之前一直在用字母表示参数和值,难道 \(26\) 个用完了我们就只能 aa ab 这样玩了吗?

不难发现

  • 一个完整的 lambda 表达式里,每个字母都是一个已知函数的参数(否则叫做自由标识符,说明表达式不完整);
  • 每个函数只有一个参数。

也就是说我们指出字母对应的函数就可以表示字母。用指令 arg(n) 来指代当前往上第 n 层的函数就可以了。

还是刚才的表达式,现在写成

call
    func            // 这是函数 a
        call
            arg(0)  // 使用往上数第 0 层函数也就是 a 的变量
            arg(0)  // 程序员都从 0 开始数
    func
        func        // 这是函数 b
            arg(0)  // 使用往上数第 0 层也就是 b 的变量

非常好了,现在我们不需要担心任何变量混淆的问题,而且语法被进一步简化了,我们的解释器也能更容易读懂程序。

用 EBNF 表达这个语法的话:

LambdaExpr
    = Arg
    | Call
    | Func
    ;
Call
    = "call" , LambdaExpr , LambdaExpr ;
Func
    = "func" , LambdaExpr ;
Arg
    = "arg(" , number , ")" ;

虽然我们的语法越来越不适合人类阅读,但是它很适合实现解释器。

数组

之后我们知道指令是一个一个的,顺序还不能乱。
那我们就需要一种类似数组的东西。

在思考了很久(也不是很久)之后,我发现数组可以这样表达:

\[λF.(F\ a\ b\ c) \]

在此之后约定结构性质的标识符使用大写,数据性质的用小写

既然决定用这个做数组了,那我们开始研究它怎么操作。

添加数组元素

还记得 lambda 演算就是简单的字符替换吗?那把上面示例的 \(F\) 替换为 \((F\ x)\) 岂不是就在头上插入了一个元素?

\[\begin{aligned} &(λF.(F\ a\ b\ c)\ (F\ x)) \\ =&(F\ x\ a\ b\ c) \\ \end{aligned} \]

所以

\[pushedHead = λl.λx.λF.(l\ (F\ x)) \]

其中参数 \(l\) 是需要被插入的数组, \(x\) 就是新的元素, \(λF.\) 则是返回的数组的开头。

这是往 \(Head\)\(push\) ,那如何往 \(Tail\) 里?

\[\begin{aligned} &(λF.(F\ a\ b\ c)\ F\ x) \\ =&\left(F\ a\ b\ c\ x\right) \\ \end{aligned} \\ \]

\[pushedTail = λl.λx.λF.(l\ F\ x) \]

上面这两个例子如果看不懂,可以琢磨琢磨。如果你琢磨懂了,那你的 lambda 演算就小成了!

删除数组元素

把第一个元素当作参数接收后直接扔了,就可以删除头部元素。

\[deletedHead = λl.λF.(l\ λn.F) \]

\(deletedTail\) 呢?

非常不幸,这时,我发现没法简单 \(deletedTail\) 了。
想要扔掉最后代入 \(F\) 的参数,必须要知道前面有多少参数。

唉,天才如我也不得不承认,那些编程语言里对数组的实现总是由数据和长度两部分组成,看来不是没有道理的。

邱奇数

想要表示长度就需要数字,如何在 lambda 演算的世界里表示数字呢?伟大的逻辑学还是数学我也不知道家邱奇就提出过一种表示方法。

\[\begin{aligned} 0 &= λF.λX.X \\ 1 &= λF.λX.(F\ X) \\ 2 &= λF.λX.(F\ (F\ X)) \\ 3 &= λF.λX.(F\ (F\ (F\ X))) \\ \end{aligned} \]

看起来一个代表 \(n\) 的邱奇数其实就是把 \(F\) 应用 \(n\) 次。

好,既然他说这可以用来表示数,那来推导一下相关的操作。

加法

两个数相加就是把其中一个邱奇数放到另一个邱奇数的 \(X\) 里。

\[plus=λa.λb.λF.λX.(a\ F\ (b\ F\ X)) \]

后继函数就是 \(+1\) 函数。

\[\begin{aligned} succ&=(plus\ 1) \\ &=(plus\ λF.λX.(F\ X)) \\ &=λb.λF.λX.(λF.λX.(F\ X)\ F\ (b\ F\ X)) \\ &=λb.λF.λX.(λX.(F\ X)\ (b\ F\ X)) \\ &=λb.λF.λX.(F\ (b\ F\ X)) \\ \end{aligned} \]

上面这两个例子如果你能看懂,你就可以说自己会 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)\) 被应用回来了——这正好是我们所需要的数字!

离成功就这么点距离了,再给它头尾一处理,

\[\begin{aligned} &(λX.λa.(a\ (F\ (F\ \cdots(F\ (X\ F))\cdots)))\ λn.X\ λn.n) \\ =&(λa.(a\ (F\ (F\ \cdots(F\ X)\cdots)))\ λn.n) \\ =&(F\ (F\ \cdots(F\ X)\cdots)) \\ \end{aligned} \]

就得到了 \(n-1\)

\[pred=λn.λF.λX.(n\ λp.λa.(a\ (p\ F))\ λn.X\ λn.n) \]

\(a-b\) ,实际上就是对 \(a\) 应用 \(b\)\(-1\) ,很容易就能写出来:

\[minus=λa.λb.(b\ pred\ a) \]

如果你没注意到: \((n\ f\ x)\) 利用了邱奇数的定义,就表示对 \(x\) 应用了 \(n\)\(f\)

我们可以发现, \((pred\ 0)=0\) ,而我们的 \(minus\) 是根据 \(pred\) 定义的,也就是说如果 \(a \leq b\) ,那么 \((minus\ a\ b)=0\)

这么看,只要我们可以判断一个数是不是 \(0\) ,那就可以实现 \(>,\geq,<,\leq,=,\neq\) 全部 \(6\) 种比较方法了。但是等等,在实现之前,别忘了我们还有一个问题没有解决:比较的结果怎么表示?

布尔值

邱奇同样发明了一种真和假的表示法。他说:

\[\begin{aligned} true&=\lambda a.\lambda b.a \\ false&=\lambda a.\lambda b.b \\ \end{aligned} \]

\(true\) 就是从两个东西里选前一个出来;对应地, \(false\) 就是两个东西选后一个。

貌似有道理。布尔值是可以进行与或非运算的,那继续来把这三个推导出来。

这个是最简单的,让操作数在 \(false\)\(true\) 里面选就行。

\[not=\lambda a.(a\ false\ true) \]

如果 \(a=true\) ,那它会选择前者,也就是 \(false\) ;反之 \(a=false\) ,就会选择 \(true\) ,就达到了取反的效果。

两个操作数都为假时,或的结果为假。参考上面的 \(not\) ,可以设计出来:

\[or=\lambda a.\lambda b.(a\ true\ b) \]

如果 \(a=true\) ,那么结果一定是 \(true\) ,否则就像逻辑短路一样,用 \(b\) 的值来作为 \(or\) 的结果。非常有道理。

两个操作数都为真时,与的结果为真。这里我们可以直接照抄上面的 \(or\)

\[and=\lambda a.\lambda b.(a\ b\ false) \]

其他逻辑运算

有了与或非,我们就可以把与非、或非、异或、与或全部都做出来了,虽然我们用不到。

\[\begin{aligned} nor&=\lambda a.\lambda b.(not\ (or\ a\ b)) \\ nand&=\lambda a.\lambda b.(not\ (and\ a\ b)) \\ xor&=\lambda a.\lambda b.(and\ (or\ a\ b)\ (nand\ a\ b)) \\ xnor&=\lambda a.\lambda b.(or\ (nor\ a\ b)\ (and\ a\ b)) \\ \end{aligned} \]

数的比较

之后我们就可以来考虑如何判断一个邱奇数是不是 \(0\) 也就是 \(\lambda F.\lambda X.X\) 了。

根据邱奇数的定义,如果 \(F\) 一次也没有被应用过,那这个数就是 \(0\) ,否则只要 \(F\) 被应用到 \(X\) 上过,那就不是 \(0\)

\[isZero=\lambda n.(n\ \lambda x.false\ true) \]

非常容易就可以写出来,你读一读应该也懂了。

这样我们就可以把所有比较都得到了。

\[\begin{aligned} \leq&=\lambda a.\lambda b.(is0\ (minus\ a\ b)) \\ =&=\lambda a.\lambda b.(and\ (\leq\ a\ b)\ (\leq\ b\ a)) \\ \neq&=\lambda a.\lambda b.(not\ (=\ a\ b)) \\ >&=\lambda a.\lambda b.(not\ (\leq\ a\ b)) \\ \geq&=\lambda a.\lambda b.(and\ (>\ a\ b)\ (=\ a\ b)) \\ <&=\lambda a.\lambda b.(not\ (\geq\ a\ b)) \\ \end{aligned} \]

虽然我们用不到这些,但是我们对邱奇数更了解了……!

更强的数组

\(2\) 元素元组

总之我们继续回来思考数组。

数组的数据和长度需要分开存放,那我们可以用元组。

\[λT.(T\ a\ b) \]

其实也就是长度为 \(2\) 的数组,只不过这回我们能够确定它长度就是 \(2\) 而不会是其他什么东西。

想要愉快地使用这个元组,我们再给两个东西改个名字:

\[\begin{aligned} former &= true \\ latter &= false \\ \end{aligned} \]

这样如果一个元组叫 \(t\) ,那 \((t\ former)\) 可以拿到前一个元素, \((t\ latter)\) 可以拿到后一个。不错。

新的数组结构

现在数组变成了一个 \(2\) 元素元组,前一个元素 \(l_i\) 是之前定义的数组,后一个是数组的长度。
比如一个三元素数组 \(l\) 长这样:

\[\begin{aligned} l_i &= λF.(F\ a\ b\ c)\\ 3 &= λF.λX.(F\ (F\ (F\ X)))\\ l &= λT.((T\ l_i)\ 3) \\ \end{aligned} \]

太复杂了,就跟我们把一坨复杂的邱奇数简写为阿拉伯数字一样,以后这种数组也都来简写吧。

\[l=[a,b,c] \]

元组就这么简写

\[λT.(T\ a\ b)=\left(\begin{matrix} a \\ b \end{matrix}\right) \]

添加数组元素

修改了数组的定义,我们来重新写添加的逻辑:

\[\begin{aligned} pushedHead &= λl.λx.\left(\begin{matrix} λF.(l\ former\ (F\ x)) \\ (succ\ (l\ latter)) \end{matrix}\right) \\ pushedTail &= λl.λx.\left(\begin{matrix} λF.(l\ former\ F\ x) \\ (succ\ (l\ latter)) \end{matrix}\right) \\ \end{aligned} \]

删除头部元素

\[deletedHead = λl.\left(\begin{matrix} λF.(l\ former\ λn.F) \\ (pred\ (l\ latter)) \end{matrix}\right) \]

获取元素

其实 \(deletedTail\) 也是一种 \(deletedHead\) ,只需要思考如何把数组颠倒就可以了。
一个容易想到的方法是把这个数组的元素从头上一个一个拿出来,再依次从头上放到另一个数组里。

不过我们还不知道如何从头上拿元素,来推导一下。

你还记得我们在推导 \(pred\) 的时候令 \(F = λp.λa.p\) 可以得到一个有 \(n+1\) 个参数并返回第 \(1\) 个参数的函数吗。数组就是对一个函数应用好几个参数,这个就可以用到了。

\[\begin{aligned} f &= λn.(n\ λp.λa.p)\\ head &= λl.(l\ former\ (f\ (pred\ (l\ latter)))) \\ \end{aligned} \]

于是我们使用 \(head\) 就可以得到一个数组 \(l\) 最头上的元素了。

更进一步,我们可以先把头上的 \(n\) 个东西都用 \(deletedHead\) 删了,再 \(head\) ,就能获得第 \(n+1\) 个元素了。
之所以是 \(n+1\) 个元素是因为数组下标从 \(0\) 开始数。

\[indexed = λl.λn.(head\ (n\ deletedHead\ l)) \]

然后 \(tail\) 也能来了。

\[tail = λl.(indexed\ l\ (pred\ (l\ latter))) \]

加油,我们的数组就剩几个关键难题了!

颠倒数组

所以我们说可以依次从这个拿出,再放入那个,来颠倒数组。

如果数组长 \(n\) ,那这个操作 \(f\) 需要重复执行 \(n\) 次,就跟以前一样,我们可以用一个表示 \(n\) 的邱奇数来干这个事。
不过使用邱奇数有一个要求: \(f\) 的第一个参数应该和它自己的返回值长得差不多——毕竟下一次应用它时,它的第一个参数就是这一次应用它的返回值。
所以即使我们在同时操作 \(2\) 个数组,我们也只能给 \(f\) 传入 \(1\) 个参数……继续用元组吧。

\[\begin{aligned} r_0 &= λl.\left(\begin{matrix} [\ ] \\ l \end{matrix}\right) \\ f &= λr.\left(\begin{matrix} ( pushedHead\ (r\ former)\ ( head\ (r\ latter) ) ) \\ ( deletedHead\ (r\ latter) ) \end{matrix}\right) \\ reversed &= λl.(l\ latter\ f\ (r_0\ l)\ former) \end{aligned} \]

\(r\) 这个元组的初始状态是 \(r_0\) ,它的第一个元素是新的数组,第二个元素是原数组,通过 \(f\) 一次次地迁移元素,执行数组的长度也就是 \((l\ latter)\) 次,最终得到一个新的颠倒后的数组。

删除尾部元素

终于!历尽千辛万苦,我们终于取得了伟大的成功!

\[deletedTail = λl.(reversed\ (deletedHead\ (reversed\ l))) \]

非常优美的式子。但是别高兴的太早,我们这才刚刚弄完数组的定义。

解释器

我们数组有了,循环也有了,赶紧来构建我们的解释器吧!

基本想法就是,我们之前已经把 lambda 演算变成了一个只需要 call func arg(n) 三种指令的非常简单的语言,那我们只要逐字阅读这种语言,遇到特定指令就构建特定结构,不就得了。

想法很美好,我们赶紧来。

框架

根据我们搞数组的经验,我们可以把程序代码也放在一个数组里。

\[program = [a, b, \cdots, x, y] \]

只要知道长度也就是 \((program\ latter)\) ,我们就可以利用邱奇数的特点,实现一个循环。

嗯不错!之后我们来思考解释器的样子。

我们希望的效果是读到特定的指令就构建特定的结构,那这就意味着我们需要写一个 switch 语句,根据指令判断执行的操作……?不,我们现在是在 lambda 的世界里,更好的方法是让指令本身就是对应操作的函数,来直接操作之前构建出的结构,并把新的结构给下一条指令。

所以,如 \([a, b, c, d]\) 这样的程序,实际运行的顺序应该是像 \((d\ (c\ (b\ (a\ r_{init}))))\) 这样。每条指令最后都应该只剩一个构建到一半的结构——别忘了一开始还要给一个 \(r_{init}\) ,作为结构的初始值。

模仿我们数组的 \(reversed\) ,以上这些信息已经足够我们把基本框架搞出来了。

\[\begin{aligned} t_0 &= \lambda p. \left(\begin{matrix} r_{init} \\ p \end{matrix}\right) \\ f &=\lambda t. \left(\begin{matrix} (head\ (t\ latter)\ (t\ former)) \\ (deletedHead\ (t\ latter)) \end{matrix}\right) \\ interpreted &= \lambda p.(p\ latter\ f\ (t_0\ p)\ former) \end{aligned} \]

\(t\) 初始的状态是 \(t_0\) ,第一个元素是构建中的结构,第二个元素是剩余的程序,每运行一次 \(f\) ,就把剩余程序中的第一个指令应用到构建中的结构上,运行程序的长度次,结束。

我们如果想要让解释器解释一段程序,只需要

\[(interpreted\ program) \]

指令本身的结构

接下来我们来想想怎么实现指令。

那么再来讨论最简单的 call 指令。比如想构建 \(((a\ b)\ c)\) ,翻译为指令是

call
    call
        a
        b
    c

也就是 \([call, call, a, b, c]\) 。那么在我的想象中,应该可以达到这种效果

\[\begin{aligned} \left[call\right] &= \left\{\begin{matrix} \lambda A.\lambda B. \\ (A\ B) \end{matrix}\right. \\ [call, call] &= \left\{\begin{matrix} \lambda C.\lambda D.\lambda B. \\ ((C\ D)\ B) \end{matrix}\right. \\ [call, call, a] &= \left\{\begin{matrix} \lambda D.\lambda B. \\ ((a\ D)\ B) \end{matrix}\right. \\ [call, call, a, b] &= \left\{\begin{matrix} \lambda B. \\ ((a\ b)\ B) \end{matrix}\right. \\ [call, call, a, b, c] &= \left\{\begin{matrix} \\ ((a\ b)\ c) \end{matrix}\right. \\ \end{aligned} \]

不知道你能不能懂我的意思, \(\{\) 的上面表示未构建的位置,下面表示已构建的结果。

那么 call 指令长什么样就比较明显了:

\[call=\lambda p.\lambda F.\lambda X.(p\ (F\ X)) \]

\(p\) 即表示之前构建的结果, \(F, X\) 表示一个 call 会产生两个新的未构建的位置,并通过 \((p\ (F\ X))\) 的形式将上一次结果中的一个未构建的位置替换为调用。

好的,看起来还不错!我们可以沿着这个基本结构完善我们的指令。

符号表

根据我们刚才想到的结构,也可以类比想到 func 指令应该差不多长这样:

\[func=\lambda p.\lambda E.(p\ \lambda a.E) \]

……对吗?

不,那我们产生的未构建位置 \(E\) 咋获得参数 \(a\) 呢?
很明显我们需要一种能够捕捉构建出的函数的参数的方法,可以想到一个简单的方法是这样直接把 \(a\) 喂给 \(E\)

\[func=\lambda p.\lambda E.(p\ \lambda A.\lambda a.(E\ a)) \]

这里我们多给 \(E\) 喂了个 \(a\) 。同时别忘了前一个结构 \(p\) 也会给我们喂一个,所以我们还要加一个参数 \(A\) 来接收前一个结构的 \(a\) ……但是这只能拿一个参数,如果有嵌套的话 \(E\) 怎么才能拿到更外层的函数的参数呢?

相信你到这里已经有头绪了,那不要喂 \(a\) ,而是喂一个包含各个函数参数的数组不就行了!这个数组就可以叫符号表,就是我们设计指令时用的那个概念。

\[func=\lambda p.\lambda E.(p\ \lambda A.\lambda a.(E\ (pushedHead\ A\ a))) \]

这里, \(A\) 变成前一个结构 \(p\) 喂给我们的符号表。在给这个符号表的头上添加参数 \(a\) (越远的参数在符号表里排得就越往后)之后,我们再作为新的符号表把它喂给 \(E\)

非常完美,我们的 \(call\) 也得同步进行改造,把之前结构喂给我们的符号表原封不动地传给两个新的未构建位置就行。

\[call=\lambda p.\lambda F.\lambda X.(p\ \lambda A.(F\ A\ (X\ A))) \]

就差 arg(n) 指令了,非常好理解,就是取符号表里的第 n 个符号。

\[arg=\lambda n.\lambda p.(p\ \lambda A.(indexed\ A\ n)) \]

这样子 \((arg\ 3)\) 就表示 arg(3) 这个指令。

初始值

迫不及待了,感觉我们全都弄完了——除了 \(r_{init}\)

我们所有的三个指令都会把一个未构建位置改成自己的结构,并接收一个符号表。照这么说,初始的时候应该只有且只有一个未构建位置,也就是整个表达式。而符号表也应该是空的——或者技术上之后可以放一些全局变量。

\[r_{init}=\lambda E.(E\ []) \]

组装

终于!我们来实验一下。再拿我们开始用的那个例子

\[(λx.(x\ x)\ λa.λb.b) \]

这句话翻译为我们的程序就是:

\[program=[ call, func, call, (arg\ 0), (arg\ 0), func, func, (arg\ 0) ] \]

我们只要一个 \((interpreted\ program)\) 就能……

其实你发现了,我们到现在一直在用纸和笔手算 lambda 演算,也没法验证。就算我们的解释器真的跑通了,我们也并没有感觉非常爽,怎么办?

真正的解释器

有一种方法是上网搜“ lambda 演算规约器”,然后找到类似这个网站的地方。

但是说实话,有点无趣。总之,我开发了一个用 JS 来进行 lambda 演算的库,让你可以用普通的如同 x => x(x) 的 JS 语法来进行 lambda 演算,并且以多种方式输出!

在 JS 下,我们开发的那个解释器是这个样子:

import { Lambda, stdLambdaifier } from 'lambda-calculus-with-js';
import {
    tuple,
    head,
    latter,
    former,
    deletedHead,
    pushedTail,
    indexed,
    getNumber,
    getList,
} from 'lambda-calculus-with-js/defines';

const f: Lambda = t => tuple(
    head(t(latter))(t(former))
)(
    deletedHead(t(latter))
);
const r0: Lambda = l => tuple(x => x(l0))(l);
/**解释函数 */
export const interpreted: Lambda = l => l(latter)(f)(r0(l))(former);
/**调用指令 */
export const macroCall: Lambda = p => F => X => p(A => F(A)(X(A)));
/**函数指令 */
export const macroFunc: Lambda = p => E => p(A => a => E(pushedHead(A)(a)));
/**得到外面第 `n` 层函数的参数的指令 */
export const macroArg: Lambda = n => p => p(A => indexed(A)(n));

于是我们就可以来快乐地解释程序了!

const program = getList([
	macroCall,
	macroFunc,
	macroCall,
	macroArg(getNumber(0)),
	macroArg(getNumber(0)),
	macroFunc,
	macroFunc,
	macroArg(getNumber(0)),
]);

stdLambdaifier.log(interpreted(program));
// λa.a

完美!

posted on 2025-07-15 04:21  肉丁土豆表  阅读(229)  评论(0)    收藏  举报