问题描述
我正在学习一些 lambda 演算,我很好奇的一件事是如何在指令中实际应用完全抽象的函数。让我们以下面的例子为例,我允许使用小的自然数(作为给定)和 TRUE
FALSE
定义。
例如,让我们使用以下代码,其计算结果应为 5
:
# using python for the example but anything would suffice
TRUE = lambda x: lambda y: x # T = λab.a
FALSE = lambda x: lambda y: y # F = λab.b
TRUE(5)(2) # T('5')('2')
如何在类似 lambda 演算的指令中实现它来评估它?我想到的一件事是“un-lambda-ify”它,所以它只是出现如下:
// uncurried
int TRUE(int x,int y) {
return x;
}
int FALSE(int x,int y) {
return y;
}
TRUE(2,5);
可能会得出以下结论:
SYS_EXIT = 60
.globl _start
TRUE: # faking a C calling convention
mov %edi,%eax
ret
_start:
mov $5,%edi
mov $2,%esi
call TRUE
mov %eax,%edi
mov $SYS_EXIT,%eax
syscall
这是如何完成的,或者对函数式/类似 lambda 的语言如何编译为指令的更深入的解释是什么?
解决方法
基于 lambda 演算的函数式语言有很多评估和编译策略。为简单起见,我假设您在谈论如何将您的示例视为真正严格的函数式语言(我们有应用顺序调用按值应用程序和整数文字的合适编码)。
如果我们考虑您提供的程序:
(λx.λy.x) 5 2
第一步通常是执行某种独特的重命名,我们确保词法范围是明显的(如您所知,λx.λx.x
是 alpha 等价于 λy.λx.x
- 尽管后者更多clear) 并且潜在的代码运动优化不会意外导致范围冲突。上面的程序已经有唯一的名称,所以我将在此处避免使用它。
第二步是执行某种转换为正常形式的表示(其中计算产生的中间值绑定到给定唯一名称的变量)。对于严格的函数式语言,通常需要在 ANF (A Normal Form) 和 CPS (Continuation Passing Style) 之间做出决定。为简单起见,我将使用 ANF 的一个变体(如果您有兴趣,可以在 Andrew Appel 的“Compiling with Continuations”中找到对 CPS 编译思想的彻底处理)。
这是这种转变的潜在结果:
let f x =
let g y = x in g
in
let x0 = f 5 in
let x1 = x0 2 in
x1
如您所见,以前的匿名函数(lambda 抽象)现在由命名函数表示,两个应用程序的中间结果现在绑定到变量。在这种形式中可以进行很多转换,但这个答案更侧重于引入这种表示以实现结构清晰,而不是我们可以应用的任何优化。
下一步是进行闭包转换。此步骤解决了使用高阶函数编译代码时出现的问题,这些函数的主体在其自身的词法范围之外捕获值。由于我们尚未执行非柯里化转换,因此我们的代码 (f 5
) 中存在部分应用程序。这必须返回一个函数,该函数在应用时返回我们提供给 x
的 f
(无论作用域如何)。为了在不生成运行时代码的情况下解决这个问题,编译器开发人员选择使用“闭包”数据结构来表示这些高阶函数
(之所以这样称呼,是因为它通过为自由变量提供值来关闭函数)。
转换本身使函数采用补充参数(针对它们的环境)并返回高阶函数以返回闭包数据结构(在我们的例子中,我们将使用“扁平”闭包:一对由一个代码指针和一个指向将在内存中由记录表示的环境的指针)。最重要的是,所有调用点都必须适应解构返回的闭包才能应用它。
这听起来很多,但这里有一些伪代码来演示这种天真的转换:
let f(x,e) =
let g(y,e') = e'.x in (g,{x})
in
let x0 = f(5,{}) in
let g_ptr = x0.0 in
let g_env = x0.1 in
let x1 = g_ptr(2,g_env) in
x1
在这里,我使用了大括号语法来表示环境的构造(所以 {}
是一个空环境 - 这种朴素转换留下的人工制品 - 而 {x}
表示堆分配的记录存储 x
的值)。对语法 (g,{x})
堆分配一对(作为结构),其中第一个组件是 g
的代码指针,第二个组件是指向 {{1} 的堆分配记录的指针}. {x}
和 .0
投影表示访问这些组件。您可能还注意到,我采用了多参数应用程序语法 .1
- 这不应与我用于对的语法混淆。
闭包转换后,程序关闭,因此,我们可以安全地执行所谓的“提升”转换,将嵌套函数提升到全局范围内(因此它们看起来更像 C 类函数,更容易compile - 很多编译过程是一种线性化)。
提升的结果可能如下所示:
f(x,y,...)
从这里开始,通常编译器可能会使用某种具有聚合和指针类型概念的三地址代码 IR(以便于处理闭包转换引入的辅助结构)。完全有可能将上述表示降低到 LLVM(使用一些廉价的 let g(y,e') = e'.x
let f(x,e) = (g,{x})
let entry() =
let x0 = f(5,{}) in
let g_ptr = x0.0 in
let g_env = x0.1 in
let x1 = g_ptr(2,g_env) in
x1
转换技巧 - 请注意,每个函数都是方便的 2 参数,因此我们不需要保留任何类型信息来生成 { {1}})。作为练习,您可能希望将上述内容转换为 C 代码(这也很简单)。但是,由于您的回答需要一些组装,因此这是一个非常幼稚(泄漏)的实现:
i64*
现在我将讨论一些重要的、实用的、我在整个答案中都忽略的细节:
- 不能总是推导出闭包的“范围”(或生命周期),因此,默认情况下,我们选择在堆上分配它们。如果我们可以推断出闭包不会向上逃逸(称为“逃逸分析”的过程用于发现这些情况),我们可以更经济地分配这些闭包(例如在堆栈)。我们想要优化的情况是闭包只会向下转义(换句话说,高阶函数只向下传递调用堆栈,但永远不会向上转义 - 在调用链的结果中 -或被分配到比闭馆创建地点还长的其他地点)。
- 将闭包的环境表示为记录非常重要,因为我们将依靠垃圾收集来收集它们。这对我们选择表示未装箱文字值(例如上面示例中的
call
和.data fmt: .asciz "result = %lld\n" .text .globl main g: mov 0(%rsi),%rax # get and return x from environment ret f: sub $24,%rsp mov %rdi,(%rsp) # preserve x mov $8,%edi # allocate space for {x} call malloc@plt mov (%rsp),%rcx # load and store x into environment mov %rcx,0(%rax) mov %rax,8(%rsp) # preserve environment mov $16,%edi # allocate closure pair (2 pointers) call malloc@plt lea g(%rip),%rcx mov %rcx,0(%rax) # store g's code pointer as first component mov 8(%rsp),8(%rax) # store g's environment,{x},as second component add $24,%rsp ret main: push %rbx mov $5,%edi xor %esi,%esi # {} = null,for simplicity call f # f(5,{}) mov 0(%rax),%rbx # extract code pointer mov 8(%rax),%rsi # extract environment mov $2,%edi call *%rbx # g(2,{x}) # print the result mov %rax,%rsi lea fmt(%rip),%rdi xor %eax,%eax call printf@plt xor %eax,%eax pop %rbx ret
)以及指向堆分配的事物(例如闭包环境,它们本身存储整数文字和指向闭包对的指针)。 OCaml 之类的语言选择这样做的方式是执行最低有效位标记(通过利用指针的对齐方式,我们可以通过检查最低位来区分未装箱的 63 位整数和 64 位指针 - 然而,这确实如此,意味着所有算术都必须适用于左移 1 位并递增 1 的值;因此5
在编译的 OCaml 程序中看起来像2
)。除了这种区别之外,垃圾收集器必须遍历数据结构以找到指针,因此我们需要一个相当同类的结构来有效地做到这一点,而无需编译布局信息(因此记录具有严格的对齐要求并存储 64 位值 - 您可以看到OCaml here 使用的同构“块”布局图)。
我希望这个答案能够让人们了解通常如何处理以 lambda 演算为根源的严格的函数式语言。
总结一下,步骤基本上是:
- 独特的重命名(可以同时进行范围检查)
- 选择性或整个程序转换为 ANF 或 CPS(涉及创建新名称)
- ANF 或 CPS 特定的优化
- 闭包转换
- 吊装
- 进一步降低为更像机器的三(或两个)地址代码 IR,为转换引入的辅助结构的处理提供更明确的细节
- 将该 IR 降低为特定于目标的汇编语言
在编译严格的函数式语言时,这绝对不是全部。例如,如果我们将我们的语言扩展为具有命名的、相互递归的函数,那么最好将闭包共享工作到我们的闭包转换转换中(并消除不需要闭包的情况)。
,这个问题对于 Stack Overflow 来说真的太大了。
但一种回答方法是说,如果您可以机械地将 λ-演算的某些变体翻译成某种其他语言,那么您可以减少如何编译 λ-演算的问题询问您如何将底层语言转换为某些物理机器的机器语言:换句话说,您如何编译该底层语言。这应该会初步暗示为什么这个问题太大了。
幸运的是,λ-演算非常简单,因此翻译成基础语言也很容易,尤其是如果您选择具有一流函数和宏的基础语言:您可以简单地将 λ-演算编译成一个该语言的一小部分。底层语言可能会对数字、字符串和各种其他类型的事物进行操作,但您的编译器不会针对其中任何一种:它只会将 λ 演算中的函数转换为底层语言中的函数,然后依靠该语言来编译它们。如果底层语言没有一流的功能,你就得更加努力,所以我会选择一个。
这里有一个具体的例子。我有一种叫做“oa”的玩具语言(或者实际上是“oa/normal”,它是一个无类型的、正常顺序的 λ 演算。它用传统 Lisp 表示的一个轻微变体来表示函数:(λ x y)
是 λx .y. 函数应用是 (x y)
。
oa然后大致如下变成Scheme(实际上是Racket)。
首先,它使用两个操作将正常顺序语义转换为 Scheme 的应用顺序语义:
-
(hold x)
延迟了x
的评估——它只是 Scheme 的delay
的一个版本,它存在所以我可以检测它来发现错误。像delay
一样,hold
不是函数:它是一个宏。如果没有宏,则翻译过程必须生成hold
扩展为的表达式。 -
(release* x)
将强制由hold
创建的持有对象,并一直这样做,直到它获得的对象不是持有对象。release*
等价于迭代的force
,它不断强制直到事物不再是承诺。与hold
不同,release*
是一个函数,但与hold
一样,如果您想让转换的输出更大更难阅读,它可以简单地扩展为内联代码。
那么这里是几个 λ 演算表达式如何变成 Scheme 表达式的。在这里,我将使用 λ
表示“这是一个 oa/λ-calculus-world 的东西”,而 lambda
表示“这是一个 Scheme 世界的东西”。
(define true (λ x (λ y x)))
(define false (λ x (λ y y)))
变成
(define true (lambda (x) (lambda (y) x)))
(define false (lambda (x) (lambda (y) y)))
(define cond (λ p (λ a (λ b ((p a) b)))))
变成
(define cond (lambda (p)
(lambda (a)
(lambda (b)
(((release* p) a) b)))))
请注意,它在即将调用它包装的任何内容时释放 p
:因为该语言中发生的唯一操作是函数调用,这是唯一需要强制承诺的地方。
现在
(((cond p) a) b)
变成
(((cond
(hold p))
(hold a))
(hold b))
所以在这里您可以看到所有参数都保存在函数调用中,这为您提供了正常顺序的语义。
实际上,将 oa 变成 Scheme 的规则只有两个,oa 中的每个构造一个。
-
(λ x y)
->(lambda (x) y)
-
(x y)
->((release* x) (hold y))
所以很明显,您可以机械地将 λ 演算变成另一种语言。如果其他语言具有宏和一阶函数之类的东西,这会有所帮助,但是如果您愿意努力工作,则可以将其变成其他任何东西。上述将 oa 变成 Scheme 的方法的一个好处是,oa 函数只是化装中的 Scheme 函数(或者,实际上,Scheme 函数是化装中的 oa 函数,或者它们可能交换了彼此的衣服或其他东西)。
那么我们将问题简化为一个更简单的问题:如何编译 Scheme?
嗯,首先要问一个大问题:对于 Stack Overflow 的答案来说太大了。有很多 Scheme 编译器,尽管它们可能具有共同的功能,但它们绝不相同。而且,由于其中很多都相当多毛,实际上查看它们生成的代码通常不会提供很多信息(oa 中 (λ x x)
的反汇编似乎有 154 行)。
然而,至少有两个关于编译 Scheme 的特别有趣的参考资料。
第一个(或几乎第一个)Scheme 编译器被称为“Rabbit”,由 Guy Steele 编写。我不知道 Rabbit 本身是否仍然可用,但是 Steele's thesis on it is 和它的文本版本 here 表面上看起来更易读但有问题。
编译的Scheme方言Rabbit是现代Scheme的一个相当遥远的祖先:我认为论文中对它进行了足够的描述以了解它是如何工作的。
Rabbit 编译为 MACLISP 而非机器语言。那么现在还有一个问题:如何编译 MACLISP?但事实上,它编译为 MACLISP 的一个极其受限的子集,我认为很容易看出如何将其转换为机器代码。
第二个有趣的参考是向导书:Structure and Interpretation of Computer Programs。 SICP 的 Chapter 5 是关于注册机及其编译的。它定义了一个简单的寄存器机,并为它定义了 Scheme(或 Scheme 的一个子集)的编译器的实现。 Figure 5.17,第 597 和 597 页包含了书中定义的寄存器机器的明显递归 factorial
函数的编译输出。
最后:SICP 的那一章长达 120 页:这就是为什么这对 Stack Overflow 来说是一个太大的问题。
作为附录,我修正了 oa/normal 中我认为的白痴问题,这使得编译输出更易于处理。使用 Racket 的 disassemble 包(用一些胶水将其附加到 oa/normal/pure
语言,并使用 Racket/CS):
> (disassemble (λ x x))
0: 4883fd01 (cmp rbp #x1)
4: 7507 (jnz (+ rip #x7)) ; => d
6: 4c89c5 (mov rbp r8)
9: 41ff6500 (jmp (mem64+ r13 #x0))
d: e96ecc35f8 (jmp (+ rip #x-7ca3392)) ; #<code doargerr> ; <=
12: 0f1f8000000000 (data)
我认为甚至有可能弄清楚这是做什么的:前两条指令是检查单个参数,如果没有,则跳转到错误处理程序。第三条指令是(我假设)当函数返回时将参数移动到它需要的任何位置(我猜是 rbp
,并且参数大概是在 r8
中),然后它跳转到下一个需要去的地方。对比一下
> (disassemble (λ x 1))
0: 4883fd01 (cmp rbp #x1)
4: 750b (jnz (+ rip #xb)) ; => 11
6: 48c7c508000000 (mov rbp #x8)
d: 41ff6500 (jmp (mem64+ r13 #x0))
11: e96acc0cc7 (jmp (+ rip #x-38f33396)) ; #<code doargerr> ; <=
16: 0f1f8000000000 (data)
这是相同的,但 (mov rbp #x8)
正在将固定编号 1
移动到我假设的 rbp
中,这已以通常的方式移动,因为我猜系统使用了低标签。
显然,事情从那里迅速变得更加棘手。