三、和SICP的相遇
一天,我闲得无聊,就去逛新华书城。忽然耳边传来了那个熟悉的女声,“现在我为大家献歌一曲,高天上流云……”。这不是音乐交通广播的scheme吗,她怎么也来书城了。我和没头苍蝇一样,在书城里四处乱窜。后来才搞清楚情况,原来书城五楼正在搞音乐交通广播听友见面会。等我赶到,已经曲终人散。唉,错过了和scheme姐姐见面的机会。
我正郁闷着,随手抓起一本别人不要的丢在书架上的书。哈哈,没想到情场失意,淘书竟然淘出一个宝贝。那就是大名鼎鼎的SICP,北大裘宗燕老师的译本《计算机程序的构造和解释》。以前一直听说过Lisp这门古老的语言,可是总不得其门而入,今日终于得偿所愿。
该书使用的程序设计语言是上个世纪70年代诞生在MIT人工智能实验室的Lisp方言,名为Scheme。它的发明者是Guy L.Steele和他的老师Gerald Jay Sussman。Scheme语言秉承了Lisp传统,采取了基于表达式计值和函数施用的计算模型,而且无论程序还是数据都具有一致的S表达式语法。
虽然Lisp方言很多,但Scheme绝对是有特殊历史地位的一个。Scheme比其他早期lisp方言具有更坚实的理论基础,也可以说更“纯”,它的许多概念奠基于数学里的无类型Lambda演算。虽然Lisp也是来源于此,但它的许多方言并不严格遵循该理论。Scheme语言把词法作用域引入了Lisp家族,而其他的Lisp方言除了后来的Common Lisp外,大都采用动态作用域。Scheme语言还鼓励大家大胆的使用递归,而不用担心堆栈溢出,因为它把尾递归优化作为实现标准。
SICP这本书(Sussman还是作者之一),实际上就是他们Scheme语言设计和研究的一个缩影。书的难度确实不低,我当时了解到这竟然是MIT入门课程6.001的教材,简直震惊了。难道中美计算机科学教育的差距有这么大吗?后来我在“Joe on Software”里看到Joe对这本书及相关课程的说法:(译文摘自《More Joe on Software》中文版《软件随想录》中的“Java语言学校的危险性”)
“我读的这门导论课,是宾夕法尼亚大学的CSE 121课程,真是读得苦不堪言。我注意到很多学生,也许是大部分的学生,都无法完成这门课。课程的内容实在太难了。我给教授写了一封长长的声泪俱下的Email,控诉这门课不是给人学的。宾夕法尼亚大学里一定有人听到了我的呼声(或者听到了其他抱怨者的呼声),因为如今这门课讲授的计算机语言是Java。”
看到这里,心里平衡了不少,因为我清楚的记得我在大学一二年级的水平。不过SICP这本书对Scheme语言的介绍只能算是中级,它的重点是编程思想和计算模型,而非语言本身。从语言角度讲,它至少忽略了两个重要主题:宏Macro和延续Continuation。从理论角度讲,它并没有多讲Lambda演算方面的内容。如果把这本书当做入口,再深度钻研下去,你就会发现一个更宏大的领域——函数程序设计FP。
四、仰望FP星空
函数程序设计作为一场运动,已经统治欧美程序设计语言理论界多年。如果把FP当做一个武林门派,那开山鼻祖就是逻辑学家Alonzo Church。他在1941年发明Lambda演算,这是一个研究可计算函数及函数施用的形式系统。上个世纪50年代,John McCarthy和他的小组发明了Lisp语言,把Lambda演算引入了计算机世界。到了70~80年代,FP终于成为显学。连命令式语言的老祖宗Fortran创始人JohnBackus都倒戈了,他在1977年发表了著名的图灵奖演讲“Can Programming Be Liberated From the von Neumann Style?”,大力鼓吹FP。FP分支众多,目前比较流行的程序设计语言有Lisp/Scheme、ML、Haskell、Erlang等。最近连微软这样一向保守的工业主流企业,也借鉴ML语言出了F#这样的东西,这在以前想都不敢想。
那时我也是附庸风雅,根本不是学术界的一份子,竟然叛变革命,也开始看函数程序设计方面的文献。我下载了Steele和Sussman为Scheme语言发表的一系列论文,这些文章完整讨论了Scheme的语言设计、解释器实现、编译器实现,甚至一个Scheme硬件处理器的实现,堪称计算机科学技术的学术典范,名噪一时。每篇论文都以Lambda作为题目开头,比如“Lambda: The Ultimate Imperative”、“ Lambda: The Ultimate Declarative”等等。此风一起,后有好事者加以模仿,在写Scheme语言或其他函数语言编译技术有关的论文时,也常用Lambda开头。国外一个讨论程序设计语言的学术网站,起名叫Lambda the Ultimate,看来也是受此影响。
一个晚上,我读论文读累了,走到房间阳台透一透气。小区里的空气是闷热而污浊的,空调外机响着,楼下几个大排档人头耸动,吵得要死。我如同一个文艺青年,抬头仰望星空,回想函数程序设计60多年的历史,心潮澎湃,颇有众人皆醉唯我独醒之态。忽然接到麦东电话,叫我一起到某新开酒吧happy。这帮俗人!我坚决予以拒绝,决定今夜青灯古佛,陪论文度过。麦东使劲诱惑,“来吧,我还请了音乐交通广播的几个美女,嘿嘿!”。什么,音乐交通广播,美女!我脑中立刻想到了scheme姐姐,立马把论文丢在一边,直奔酒吧而去。到了才发现此美女非彼美女,是电台广告部的几个人,又被骗了!
六、从理论到机器的旅程
函数程序语言研究群体毕竟是计算机科学家,不是搞纯数学。您鼓吹了半天,总得有个在机器上可运行的东西出来吧。所以我们的理论家也不得不脏一下手,考虑一下编译问题。他们的套路和传统命令式语言的编译技术是极为不同的。很多函数语言编译器开发者经常分两个层次考虑问题:
1、如何把各种各样的语言构造,统一归结为函数计算模型,即转换为lambda演算形式;
2、如何把lambda演算形式及其归约操作,在主流von Neumann机器这个异构的设施上有效予以实现。
在这些问题上,无尽的努力已经耗去了,多少人白了少年头。下面我们看看,在经典的Scheme语言实现中,是如何处理这两个问题的。
Lisp传统中,由于数据和程序表示的一致性,程序的转换可以通过数据结构的操作来实现。由于可以用read原语像输入数据一样输入S表达式,不用再进行语法解析,在Lisp中编写解释器或编译器是一件很容易的事情。Lisp中经常用于程序转换的手段称之为特殊型。首先可以实现很小一个子集的特殊型,然后利用这个子集再去定义新的特殊型。在解释器或编译器的处理中,在此子集合外的特殊型被翻译成预先定义的特殊型,然后再对变换后的表达式进行操作。新定义的特殊型称之为宏,翻译过程称之为宏展开。
所以对于第一个问题,Scheme的回答就是把lambda演算形式作为核心子集,而其他的程序构造定义为语言的宏。
看两个简单的Scheme语言程序。
程序一:引入局部词法环境的let表达式
(let
((a 1) (b 2))
(+ a b))
它被宏展开后,可以转换为类似这样的一种形式:
((lambda(a b) (+ a b))
1 2)
可以看到,变成了一个lambda表达式对常数1和2的施用操作,结果是3。
程序二:引入副作用的begin表达式和set!表达式
(define a 0)
(define b 0)
(begin
(set! a 1)
(set! b 2)
(+ a b))
其中的begin表达式被宏展开后,可以转换为如下一种形式:
((lambda(x)
((lambda (y)
((lambda (z) z)
(+ (getbox a) (getbox b))))
(setbox! b 2)))
(setbox! a 1))
变成了三个lambda表达式的连续的施用操作。可以看到x和y变量实际上是没用的,(setbox! a 1)和(setbox! b 2)两个表达式的返回值被抛弃了,只有最后一个(+ (getbox a) (getbox b))的值作为整个表达式的返回值。所以,如果begin表达式中的子表达式没有副作用,那对整个程序的计算是没有意义的。
还要注意的是对set!赋值表达式的转换。如果不对set!表达式进行处理,变量值允许变化,就无法保持lambda演算理论的纯洁性,也无法对程序进行数学语义推理了。
这里变的戏法是为被赋值的变量引入一个间接层,把对变量的赋值和引用转换为对内存数据结构的读写原语getbox和setbox!。变量指向该内存数据结构,在程序运行时间内不再改变,这样在形式上就符合lambda演算中变量一经绑定不再改变的特性了。
同样,Scheme语言中其他特殊型比如什么let*、letrec、and、or、cond、case、do、define,它们可以被宏转换成quote,setbox!,getbox,if和lambda这些核心lambda演算的形式。
下面解决第二个问题,lambda演算这种可计算函数的模型如何能在状态转换的底层机器上跑起来。
现在忘掉计算机,回到初中时代,怎么算三角形面积?底*高/2,表示成代数式:
a * b / 2
代入实际数值就可以计算了,有的人先算a*b,然后再除2;有的人先算b/2,然后再乘上a。都可以。如果让计算机这个电子傻瓜帮你算,用贴近机器的汇编语言,你就得明确计算顺序。我们用CPS(Continuation passing style)形式的Scheme代码来显式的表达计算顺序。
一种是( * a b (lambda (x) (/ x 2 (lambda (z) z))))。
另一种是( / b 2 (lambda (x) (* x a (lambda (z) z))))。
注意*和/的操作类型不再是 number number -> number,而变成了number number continuation -> number 。多了一个continuation延续参数,*和/的结果不是立即返回,而是作为参数传给continuation,使得计算继续持续下去。现在这个式子,不仅是一个和原来的代数式语义相同的代数式,而且可以被看成凸显控制流的中间代码。
我们就第二个式子继续变换:
j = (/ b 2 (g a))
g = (lambda (y) (lambda (x) (* x y f)))
f = (lambda (z) z)
这里生成了三个新表达式,注意原来的两个continuation不再是匿名函数了,而被提升到全局环境,分别用f和g引用。注意这里的函数g,它对应于原来的(lambda (x)(* x a (lambda (z) z))),多了一个参数y,代替式子里的自由变量a。
假设有一个堆栈机作为目标机器,其中push是入栈指令,div和mul分别是除法和乘法指令,goto是跳转指令。我们就可以把上面的中间形式转换为汇编伪代码:
j: // 汇编语言标号
dd a … // 这里定义了两个变量
dd b …
push b
push 2
div
push a
goto g
g:
mul
goto f
f:
注意这里所有对延续的函数调用都属于尾调用tail call,在编译处理上都不生成保存函数返回地址的代码,直接goto到一个标号地址即可。可以看到,代码还可以再简化,很多编译器生成的指令是不必要的。这就是两个不同计算模型转换的代价,我们这个例子太小,还不怎么能说明问题。
以上我们杀鸡用牛刀,展现了经典Scheme语言编译器如何把一个弱智的lambda表达式转换为机器代码的过程,演示了每步的程序形式。在其中的第一步和第二步分别使用CPS变换和Lambda Lift变换,最终生成了虚拟堆栈机的代码。这些程序变换是否语义上与原来一致呢,这是一个关于可信编译器的证明问题了,太麻烦了,还是留给那些计算机科学家吧。