C++的词法闭包
Thomas M. Breuel[1]
翻译:吴晖
摘要
我们描述C++编程语言的一个允许函数定义嵌套,并提供具有动态生命期的词法闭包(lexical closures)的扩展。
对于这个扩展,我们的主要动力是它允许程序员,像成员函数那么简单地,为集合类(collection class)定义迭代器。这样的迭代器接受函数指针或闭包作为参数;提供词法闭包使表达状态(例如,累加器)自然且容易。这个技术在像Scheme,T或Smalltalk-80的编程语言里是司空见惯的,并且在面向对象编程语言中,可能是提供通用迭代结构最简洁与自然的方式。嵌套函数定义的能力还鼓励一个模块化的编程风格。
我们想要以这个方式扩展C++语言,无需为闭包引入新的数据类型,而且不影响不使用这个特性的程序的效率。为了做到这一点,我们建议在构建一个闭包时,产生一小段代码载入静态链指针,并跳转到函数体。一个闭包是指向这小段代码的指针。这个技巧允许像处理一个指向不引用任何非局部、非全局变量的普通C++函数的指针那样处理闭包。
我们讨论与现存的作用域规则、语法、分配策略、可移植性及效率的相容性问题。
我们希望能在C++程序中嵌套函数定义。在本节中,我们将讨论为什么嵌套函数定义的能力是期望的若干原因。几乎所有其它现代程序语言,比如Scheme,T,Smalltalk-80,Common Lisp,Pascal,Modula-2及Ada,都提供这个功能。
为了展示这个语言特性的功用,我们必须在语法上达成一致。为了在另一个函数内表示一个函数的定义,我们将简单地移动它的定义到那个函数里,但在其它方面与全局域的函数相同。例如,下面的代码片段在function1(int)内定义了function2(int):
function1(int x) {
// ...
function2(inty) {
//...
}
// ...
}
除非function2自己声明标识符x,在function2中提到的x将引用function1的实参。
为了允许在内部词法层次相互递归的函数的定义,提供某个方式来声明没有定义在全局层次的函数是必要的。我们建议通过向函数声明添加关键字auto作为前缀来实现。目前,在一个函数声明之前使用关键字auto是非法的。因此,这种扩展是兼容的。
嵌套函数定义的能力鼓励程序的模块化设计。它允许程序员把仅由一个函数使用的函数及变量保留在该函数局部。在C里,这样的模块化仅在文件层次是可能的:一个函数标识符的作用域可以通过把它声明为static,限制在一个文件中。一个在函数间共享的变量标识符的作用域必定包含至少一个编译单元,因为它必须在全局层次声明。C++支持限定某种函数及变量标识符的作用域,通过使这些函数及变量成为一个类的成员。
不过,引入一个新的类只是为了限定一个标识符的作用域通常是不自然的。例如,考虑堆排序算法【Wir79】。它包含一个反复调用sift函数向堆插入元素的排序函数。在C或C++中,我们将如下表示:
sift(int* v,int x,int l,int r) {
//insert x into the heap
//formed by elements l...r of v
}
// sort an array of integers v
// v is n elements long
heapsort(int* v,int n) {
// codethat calls sift
}
然而这不是令人满意的,因为函数sift不太可能用在程序的其它地方。函数sift应该仅在函数heapsort内可见。另外,我们希望在sift里引用变量v,而无需把它传递为一个参数。嵌套允许我们重写为:
// sort an array of integers v
// v is n elements long
heapsort(int* v,int n) {
sift(intx,int l,int r) {
//insert x into the heap
// formed by elements l...r of v
}
// codethat calls sift
}
特别注意在函数sift中标识符v的使用援引词法上封装函数heapsort的实参v[2]。
作为另一个例子,假定我们有函数integrate求一个给定函数在两个边界间的积分,并且我们希望对一个参数化函数族求积分。同样,最自然的表达方式如下:
integrate_all(int n,double* as,double *bs,double*cs,double eps) {
doubleintegrate(double low,double high,
doubleepsilon,double (*f)(double));
double a,b,c;
doublef(double x) {
returna*x*x+b*x+c;
}
for(inti=0;i<n;i++) {
a=as[i];b=bs[i]; c=cs[i];
printf("a: %g, b: %g, c: %g, i:%g\n",
a,b,c,integrate(0.0,1.0,eps,f));
}
}
嵌套函数定义与引用被封闭块声明的变量的能力,连同整个集合类上的迭代器,是特别有用的。考虑下面简单的集合类:
class BagOfInts {
public:
// addan int to the collection
voidadd(int);
// testwhether an int
// isin the collection
intmember(int);
//apply a function to every
// element in the collection
voidwalk(void (*)(int));
};
例如,要在一个包中打印所有的元素,我们在标准C++里这样做:
printit(int x) {
printf("integer in bag:%d\n",x);
}
main() {
BagOfIntsaBag;
...
aBag.walk(printit);
}
注意我们被迫在远离它实际使用的地方定义函数printit。我们为什么使printit成为摆在首位函数的原因,不是因为它是某个进程有用的抽象,也不是因为我们将在几个地方使用它。只是因为成员函数walk要求一个函数指针作为参数。更为恼人的是,在标准C++里,作为迭代器参数传递的一个函数仅能对静态变量或全局变量起作用。如果我们希望使用迭代器walk汇总一个包里的所有元素,我们必须使用一个全局变量:
int xxx_counter;
int xxx_count(int x) {
xxx_counter+=x;
}
fizzle() {
BagOfIntsaBag;
...
xxx_counter=0;
aBag.walk(xxx_count);
intsum=xxx_counter;
...
}
如果允许我们嵌套函数定义,我们可以把这简单地表示为:
fizzle() {
BagOfIntsaBag;
...
intsum=0;
count(intx) {
sum+=x;
}
aBag.walk(count);
...
}
现在,所有的标识符就被声明及定义在使用它们的地方。没有出现在全局作用域的标识符是不应该在全局作用域可见的[3]。并且计数器没有浪费全局数据空间;用于计数器的空间仅当函数fizzle活动时才存在。
为用户定义类编写迭代器的这个做法,在Smalltalk与类Lisp语言中,实际上非常常见。例如,在Smalltalk-80【GR83】,里,我们可以如下表达函数fizzle:
fizzle
| sum |
...
sum<- 0.
aBagdo: [x | sum <- sum + x].
...
而在CommonLisp【Ste84】里,我们可以写作:
(define fizzle ()
(let((aBag ...)
(sum 0))
...
(mapnil #'(lambda (x) (incf sum x)))
...))
读者可能会问使用标准C++构造是否有替换的做法、我们很快将描述其中之一。不过让我们首先提醒自己,在任何语言中我们要求什么样的迭代器构造。
一个迭代器基本上是接受一段代码,反复调用它,并改变这段代码环境中某些绑定的值的构造。根据C及C++内置的迭代器,我们习惯上应该能做到以下。
这些属性是内置迭代器非常有用的特性,对于用户定义操作符放弃它们之一将是十分遗憾的。
实现这些目标的一个方式是引入一个新的类连同一个宏,就像如下:
class BagOfIntsStepper {
public:
intmore();
intnext();
};
#define iterateBagOfInts(bag,var) \
for(BagOfIntsStepper stepper=bag.makeStepper(); \
stepper.more();\
var=stepper.next())
这个构造不会破坏上述任何的限制。不过,它有几个坏处。它要求我们为表达迭代的目的引入一个新的类,要求每个迭代步骤对成员函数的两个调用,并且要求我们定义一个宏。不过最重要的,我们不喜欢它在于当我们希望使用相同的基类[4]迭代不同的收集类时,它难以扩展。例如,我们可能还有一个类SetOfInts。如果我们使用walk形式的迭代结构,我们可以简单地使BagOfInts与SetOfInts成为类CollectionOfInts的子类,并把walk声明为虚拟函数。使用一个步进(stepper)类实现相同的效果要远没有那么直观,并且可能要求每个迭代步骤两个虚拟函数调用的额外开销。
为了在C++中实现嵌套与语法闭合,我们必须引入一个静态链,为词法上闭合的函数(术语参考【AU79】与【Wir77】)把每个活动记录链接到当前活动记录。当我们调用一个函数时,我们不仅必须知道它的地址,我们还要为词法上闭合的函数传递指向当前活动记录的一个指针。不过,对于这个规则有两个例外。
在全局域定义的函数的活动记录中,不需要保留一个静态链指针的空间,因为其词法环境已知是全局环境,并且一个标识符是否援引一个全局变量,在编译时刻是清楚的。
另外,一个定义在全局域的函数不需要向词法闭合环境传递一个指针,因为这个词法闭合环境是全局环境,它是唯一且具有一个已知的地址。
这两个例外连同C++仅允许在全局域定义函数这个事实,使得在C++里实现词法作用域,而不需要使用一个表露,一个静态链,或向环境传递指针,成为可能。
向没有定义在全局域的函数的活动记录添加静态链的空间是不重要的(可以简单地认为它们是额外的自动变量)。因为编译器总是知道一个活动记录对应哪个词法层次,对于在最外层词法层次函数的活动记录不同于在其它词法层次函数的活动记录,这不是问题。
呈现一个重要问题的事实是,不仅是代码的一个指针,在一个内部词法层次的一个函数的调用额外要求指向词法上封装函数的正确活动记录的一个指针。
如果我们希望能自由地向在内部词法层次的函数传递指针,这个信息必须与指向该函数代码的指针一起传递,因为这个信息不能以任何其它方式推导。
只要我们没有把函数用作其它函数的参数,或把函数赋给变量,编译器可以不动声色地确保向被调用者传递正确词法环境的信息。不过,一旦我们尝试把函数传递为数据,我们遇到一个问题。在C++中,当定义及使用一个函数指针时,仅需要为一个指针保留并传递足够的空间。然而,一个闭包,即带有一个环境的函数,通常要求两个指针,就像我们刚看到的。
改变一个C++函数指针的表示为两个指针大小,而不是一个指针,是不可接受的。这将意味着,例如,一个“函数指针”不能被赋给类型void*的一个变量,而不丢失信息。
一个替代方法可能是引入一个新数据类型,“闭包(closure)”,它必须用来表达到函数及其环境的引用,如果这些函数不是定义在全局域。使用从函数指针到闭包的隐含类型转换将允许我们混入可以用在闭包位置的函数指针。不过,闭包不能传递给期望函数指针的函数。另外,因为本质上相同概念的两个数据类型,语言变得不必要地混乱。
显然,这些做法都不可接受。幸运的是,有一个简单且高效的解决方案。事实上,我们的解决方案完全与C++、C兼容。特别是:
产生一个闭包的代码片段通过产生一小段向某个已知寄存器载入静态链,并跳转到该函数(下面列表的0043-0046行)的代码来实现。在一个函数的开头,这个寄存器的内容被移入新构建的活动记录(0076行)静态链的域。在一个类似于68000汇编语言[5]的汇编语言里,这将看起来像这样:
0000 ;;; source code:
0001 ;;;
0002 ;;; function1() {
0003 ;;; function2() {
0004 ;;; ...
0005 ;;; }
0006 ;;; void (*x)();
0007 ;;; x=function2;
0008 ;;; ...
0009 ;;; }
0010
0011 ;;; machineregisters:
0012 ;;; FP: currentframe
0013 ;;; SL: a registerthat is used to hold the static link pointer
0014 ;;; temporarily
0015
0016 ;;; instruction toload the SL register with constant
0017 INST_LOADSL equ0x
0018
0019 ;;; instruction fordirect jump
0020 INST_JUMP equ0x
0021
0022 function1:
0023 ;;; allocate space for the followingvariables:
0024 ;;; void* $dl -- dynamic link chain
0025 ;;; void* $sl -- static link chain
0026 ;;; int $stub[4] -- space for machine code forclosure
0027 ;;; int (*x)() -- a function pointer
0028 ;;; also sets up the dynamic link chain
0029
0030 $dl equ -4
0031 $sl equ -8
0032 $stub equ -24
0033 function1.x equ -28
0034
0035 link FP,28
0036
0037 ;;; set up the static link chain
0038
0039 move SL,$sl(FP)
0040
0041 ;;; create closure for function2
0042
0043 move #INST_LOADSL,$stub(FP)
0044 move FP,$stub+4(FP)
0045 move #INST_JUMP,$stub+8(FP)
0046 moveea function2,$stub+12(FP)
0047
0048 ;;; x=function2
0049
0050 moveea stub(FP),function1.x(FP)
0051 ...
0052
0053 ;;; code using x
0054
0055 move function1.x(FP),R1
0056 call(R1)
0057 ...
0058
0059 ;;;finish up
0060
0061 unlinkFP
0062 return
0063
0064 function2:
0065 ;;;function entry, as above
0066
0067 link4
0068
0069 $dl equ -4
0070 $sl equ -8
0071
0072 ;;; set up the static link chain
0073 ;;; register SL was set up by the stub
0074 ;;; code in function1
0075
0076 moveSL,$sl
0077 ...
0078
0079 unlinkFP
0080 return
代码数组$stub的生命期与function1活动记录的生命期相同。这是合理的,因为这个由$stub隐含定义的静态链,一旦function1退出就成为无效的[6]。
这是基本的想法。有几个编译时优化的可能,其中一些我们已经提到。如果function1是定义在全局域的, 例如,它不需要为其活动记录中的静态链保留空间。如果function2没有引用任何在function1活动记录中的变量,编译器可以把function1的活动记录留在传function2的静态链之外。
如果function2没有引用任何非局部,非全局变量,其定义实际上可以移到全局域(除了其名字的作用域),并且在function1内不需要产生一个静态链域,也不需要产生一个代码存根。如果function2仅在function1内使用,并且没有闭包传递,产生存根代码的代码可以消除,因为编译器可以产生在调用function2之前内联载入SL的代码[7]。
为了了解这个方案的效率如何,我们必须把它与替代实现比较。两个最直接的实现是把一个闭包表示为带有两个成员的结构体,一个代码指针及一个环境指针,或表示为这样一个结构体的指针。
由我们方案要求的额外空间仅包括两条包含在存根代码中机器指令,在function1中的两条指令用于产生在存根代码中的两条机器指令。分配及传递闭包与我们使用一个结构体的一个指针那样高效。
不过,创建闭包的开销相对不重要。正如我们在上面看到的,仅当要传递一个函数指针时,编译器才需要创建一个闭包。在我们的经验里,一个被传递的函数指针通常是要反复使用的,因此调用一个闭包的开销远比创建它的开销重要。
让我们看一下在这三个闭包不同实现中执行的指令序列。首先,是直接表示为结构体的闭包的指令序列[8]。这个闭包在当前活动记录偏移x处包含两个机器字(指针)[9]:
move x(FR),SL
move x+4(FR),R1
call (R1)
在指向一个闭包的指针的情形里,调用序列变得有些复杂。假定px是在当前活动记录中指向这个闭包的指针的偏移[10]:
move px(FR),R1
move (R1),SL
move 4(R1),R1
call (R1)
对于我们的建议,遇到的指令序列是:
move px(FR),R1
call (R1)
move #staticLink,SL
jmp function
我们看到,当指向一个要求静态链指针的函数的函数指针被调用时,执行的指令序列没有太大的差异。在效率方面主要的差别将可能来自缓存与指令预取的效果。某些简单的实验表明在80386上一个传递一个静态链指针的call/return对需要大约一个简单的间接或直接函数调用时间的1.5倍。如果使用我们提议的指令序列,这个数字增加到大约1.8倍。
为了实现我们的提议,一段代码能够在运行时产生一小段代码并执行它是必须的;不过,产生的代码不需要必须放在栈上。
在大多数现代计算机架构上,像68000系列微处理器【Mot80】,VAX【Dig81b】及80386【int87】,这是可能且简单的。即使在带有单独指令、数据空间(I与D空间)的PDP-11系列处理器上,栈通常同时映射到I、D空间允许栈上指令的执行(在标准PDP-11指令调用序列中,MARK指令在栈外执行,参考PDP-11处理器手册【Dig81a】)。
不过,有某些架构与/或操作系统禁止程序在运行时产生并执行代码。我们认为这个限制是武断的[11],并视它为糟糕的硬件或软件设计。编程语言,像FORTH,Lisp或Smalltalk的实现,可以从在运行时快速产生或修改代码得到很大的好处。
我们可以使用另一个技巧,甚至在这些架构上,实现词法闭包。我们在指令空间预分配这个形式的指令序列数组:
stub_n movelocation_n,R1
move (R1),SL
move location_n+4,R1
jmp (R1)
我们把这个数组用作分配及回收闭包存根的一个栈。在数据空间的一个对应数组保存指向代码及这个闭包静态链的实际指针。这两个新栈的行为本质上与运行时栈一样。特别的,必须修改longjmp来正确地恢复存根栈及位置栈的两个栈指针。
我们观察到在上面的一些例子中,通常不需要显式地命名一个函数。当我们使用接受函数指针作为参数的迭代器时,更是如此。上面给出的Lisp与Smalltalk代码的例子设计界未命名函数。我们建议把一个未命名函数表达为一个复合语句到一个函数指针的转换。例如,下面表达式的值是一个函数指针或一个闭包(依赖于上下文):
(int (*)(int x)){ returnx+1; }
或者,我们可以引入一个新关键字,unnamed,并把相同的构造写作:
(int unnamed(int x){return x+1; })
我们稍微地更喜欢第一个形式,但第二个形式可能更容易解析,并且允许更好的语法错误检查与恢复。
我们提议实现闭包的方式限制它们的生命期为它们所在活动记录的生命期。为了使得闭包成为与C++中其它数据类型相同级别的一个数据类型,应该可能分配及回收闭包。提供可以处理作数据结构的闭包有很好的理由。Abelson与Sussman【AS85】强烈支持这个特性,而在Scheme编程语言【RC86】中,它们通常用于构建复杂的数据类型。不过,类与结构体提供了堆分配闭包的大部分功能。
嵌套函数的定义以及创建带有最少动态生命期的能力,是许多现代编程形式的重要部分。大多数现代编程语言提供它,并且它可以被加入C++(及C)编程语言,而不会影响执行的效率或不采用该特性程序的含义。因此我们期望看到嵌套函数定义及词法闭包将被加入C++语言的定义。我们目前正在致力于扩展GNU C【Sta88a】【Sta88b】与C++【Tie88】编译器,提供嵌套函数定义与闭包。有一个好的、免费的,带有源代码级调试器的编译器可用,将鼓励更多的人使用这个特性。
我要感谢Richard M. Stallman,Robert S. Thau,以及许多对这个提议与论文给出了有用评论的人。
[AS85] Harold Abelson and GeraldJay Sussman. Structure and Interpretation of Computer Programs. MITPress, 1985.
[AU79] Alfred V. Aho and Je_reyD. Ullman. Principles of Compiler Design. Addison-Wesley, 1979.
[Dig81a] Digital EquipmentCorporation. PDP-11 Processor Handbook, 1981.
[Dig81b] Digital EquipmentCorporation. VAX Architecture Handbook, 1981.
[GR83] Adele Goldberg and DavidRobson. Smalltalk-80: The Language and its Implementation. Addison-Wesley,1983.
[Int87] Intel Corporation. 80386Programmer's Reference Manual, 1987.
[Mot80] Motorola SemiconductorsProducts, Inc. MC68000 16-bit Microprocessor User's Manual, 1980.
[RC86] Jonathan Rees and WilliamClinger. The Revised3 Report on the Algorithmic Language Scheme.Technical Report 848a, MIT Arti_cial Intelligence Laboratory, September 1986.
[Sta88a] Richard M. Stallman. Internalsof GNU CC, April 1988.
[Sta88b] Richard M. Stallman. The GNUDebugger for GNU C++ Free Software, April 1988.
[Ste84] Guy L. Steele Jr. CommonLISP: The Language. DigitalPress, 1984.
[Tie88]Michael D. Tiemann. User's Guide to GNU C++, May1988.
[Wir77]Niklaus Wirth. Compilerbau. B. G. Teubner, Stuttgart, 1977.
[Wir79]Niklaus Wirth. Algorithmen undDatenstrukturen. B. G.Teubner, Stuttgart,1979.
[1] 作者的地址: MIT ArtificialIntelligence Laboratory, Room 711, 545 Technology Square, Cambridge,
MA 02139, US。 作者由来自仙童基金会(Fairchild foundation)的一个协会资助。
[2]除非sift声明或定义另一个标识符v
[3] 我们必须为函数count发明一个名字,这仍然是令人烦恼的。在后面章节中,我们将建议定义匿名函数的语法。这里我们使用具名版本以避免这时陷入语法问题。
[4]一个收集(collection)的基类是该收集元素的类型。
[5] 假定所有的数据与指令都是32位宽。临时标号以一个"$"开头。指令moveea把它第一个操作数的实际地址移到一个位置。指令link填充活动记录的"$dl"域。
[6] 我们将在后面讨论可能的扩展,把一个特定静态链的生命期扩展超出该组件活动记录的动态生命期。
[7] 实际上,一个受限形式的嵌套,其中我们不允许获取定义在内部词法层次函数的地址,可以不需要扩展编译器产生代码存根来实现。
[8] 这些不是汇编语言程序片段,而是在调用一个闭包期间执行的汇编语言指令的痕迹。
[9]R1是某个通用寄存器。
[10]在下面例子中位置(R1)与4(R1)的内容可以想见,是被缓存的。
[11] 这样的系统通常提供操作系统调用来移动数据到指令空间,例如,为了辅助载入器;不过,一个操作系统调用的开销对构建一个闭包而言太高了。