在编译器中实现闭包

10

我正在尝试设计一个将基本编译器转换为伪汇编代码的程序。然而,我无法弄清楚如何实现闭包。似乎我需要将特定的寄存器值与每个"子程序"关联起来。我考虑过使用堆栈,但再次看起来不足够。除了关联数组似乎没有其他更好的选择,但是在汇编中怎么实现呢?

我选择的示例是以下CoffeeScript代码:

((x) -> (y) -> x(y))((x) -> x)(2)

这是我一直在尝试的通用结构,这是我编译的伪汇编代码示例。

'((label lam1)   ;; (x) -> x
  (cp resp arg)
  (ret)

  (label lam2)   ;; (y) -> x(y)
  (jmp x)

  (label lam3)   ;; (x) -> [(y) -> ...]
  (cp x arg)     ;; this is the assignment intended for a closure
  (cp resp lam2) ;; this is a returned lambda, needing the closure
  (ret)

  (label main)
  (cp arg lam1)
  (call lam3)
  (set arg 2)
  (call resp))) 

这个方法是有效的;但是,它仅仅把值设置在x名称下,然后返回一个lambda函数。在执行lambda函数之前,x的值可能会被轻松地污染。

在《计算机程序的构造和解释》中,实现的描述如下,但我认为在汇编中似乎做不到。我不知道他们还能使用什么其他策略。

过程对象将通过在运行时结合当前环境(定义点的环境)与已编译过程的入口点(一个新生成的标签)来构建。

总结一下,如何将寄存器值与“子例程”相关联?堆栈是否足够?


2
你学过《计算机程序的构造和解释》吗?(http://www.amazon.com/dp/0262510871) - Barmar
1
@Barmar SICP说:“过程对象将在运行时通过将当前环境(定义点处的环境)与编译过程的入口点(新生成的标签)组合而构建。” 这对我来说似乎不切实际。整个环境如何在运行时缓存? - matt3141
3
顺便说一下,你的问题被称为“Funarg问题”。你可能会发现这份文档有趣。(PDF):http://dspace.mit.edu/bitstream/handle/1721.1/5854/AIM-199.pdf - Sylwester
@Sylwester 谢谢!对于纯函数来说,这事情变得相当容易了。 - matt3141
1
http://en.wikipedia.org/wiki/Lisp_In_Small_Pieces - Basile Starynkevitch
显示剩余3条评论
1个回答

16

堆栈可能不够用...考虑一个更简单的情况,其中它们可行

function bar(f) {
    alert(f());
}

function foo(x) {
    bar(function(){ return x; });
}

foo(42);
在上述情况下,理论上闭包中的x可以存在于foo的堆栈帧中,因为该闭包不会超过其创建者foo的生存期。然而,稍作修改:
function bar(f) {
    to_call_later.push(f);
}

闭包将被存储,并且在 foo 已终止并其激活记录的堆栈空间已被回收时可能被调用。显然,x 不能在该堆栈区域中,因为它必须幸存。

因此存在两个问题:

  1. 闭包必须有一些存储(环境)。当您考虑两次调用 foo 并传递两个不同的值时,这是显然的,应为 x 创建两个独立的存储空间。如果闭包只是代码,则除非每次调用 foo 时都要生成不同的代码,否则不可能实现此目标。

  2. 此存储空间必须至少与闭包本身一样长,而不仅仅是创建闭包的人。

请注意,如果要读取/写入封闭变量,则需要额外的间接层,例如:

function bar(f) {
    alert(f());
}

function foo(x) {
    var c1 = function() { return ++x; };
    var c2 = function() { return x *= 2; };
    bar(c1);
    bar(c2);
}

foo(42);  // displays 42+1=43 and 43*2=86 (not 42*2=84!)
换句话说,你可以有几个不同的闭包共享相同的环境。
因此,x不能在foo激活记录的堆栈中,也不能在闭包对象本身中。闭包对象必须有指向x所在位置的指针。
在x86上实现这个的一个可能的解决方案是:
- 使用垃圾回收或引用计数内存管理系统。堆栈远远不足以处理闭包。 - 每个闭包都是一个对象,有两个字段: 指向代码的指针和指向封闭变量(“环境”)的指针数组。 - 在执行代码时,有一个堆栈esp,例如esi指向闭包对象本身(因此(esi)是代码的地址,(esi+4)是第一个封闭变量的地址,(esi+8)是第二个封闭变量的地址,依此类推)。 - 每个变量是一个独立的堆分配对象,只要还有闭包指向它就可以存在。
当然,这是一个非常粗略的方法。例如,SBCL更聪明,未被捕获的变量只在堆栈和/或寄存器中分配。这需要对闭包的使用进行分析。
编辑
假设您只考虑纯函数设置(换句话说,函数/闭包的返回值仅取决于传递的参数,并且闭包状态不能发生变化),那么事情可以简化一些。
您可以将闭包对象中包含捕获的值而不是捕获的变量,并同时使闭包本身成为可复制对象,然后只需要在理论上使用堆栈 (除了有一个问题,即闭包的大小取决于需要捕获多少状态),因此至少对我来说,很难想象在这种情况下使用基于堆栈的合理参数传递和返回值协议。
通过使闭包成为固定大小的对象来消除变量大小问题,您可以看到这个C程序如何只使用堆栈实现闭包(请注意,没有malloc调用)。
#include <stdio.h>

typedef struct TClosure {
    int (*code)(struct TClosure *env, int);
    int state;
} Closure;

int call(Closure *c, int x) {
    return c->code(c, x);
}

int adder_code(Closure *env, int x) {
    return env->state + x;
}

int multiplier_code(Closure *env, int x) {
    return env->state * x;
}

Closure make_closure(int op, int k) {
    Closure c;
    c.state = k;
    c.code = (op == '+' ? adder_code : multiplier_code);
    return c;
}

int main(int argc, const char *argv[]) {
    Closure c1 = make_closure('+', 10);
    Closure c2 = make_closure('*', 3);
    printf("c1(3) = %i, c2(3) = %i\n",
           call(&c1, 3), call(&c2, 3));
    return 0;
}

Closure 结构体可以进行传递、返回和存储,因为环境是只读的,所以你不会遇到生命周期问题,因为不可变数据可以被复制而不影响语义。

一个 C 编译器可以使用这种方法创建只能按值捕获变量的闭包,这也是 C++11 lambda 提供的功能(你也可以按引用捕获,但程序员需要确保捕获变量的生命周期足够长)。


谢谢,我对“生命周期”问题有直觉感觉,但无法表达清楚。这澄清了事情。您认为这在纯函数中变得不那么重要了吗? - matt3141
@matt3141:对于纯函数(即闭包的返回值取决于参数和捕获状态,但其中的状态是不可变的),事情可以简化,理论上只需要一个堆栈。请参见编辑... - 6502
太好了!这个编辑正是我需要的,但之前的内容也为将来提供了有用的信息。我的目标是制作一个纯函数式语言的编译器,在其中我将编写一个解释器,在解释器中我可以允许更好的闭包。我认为这就是为什么良好的闭包实现大多在解释型语言中的原因。 - matt3141
@mat3141:Lisp 有很好的闭包实现,其 SBCL 实现是完全编译的(即使对于 REPL 也是如此)。因此,您可以在编译器中实现闭包。顺便说一句,MELT 编译成 C 并具有良好的(带垃圾回收的)闭包。 - Basile Starynkevitch

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接