Lisp 已死,Lisp 万岁!
有一句古话,叫做“国王已死,国王万岁!”它的意思是,老国王已经死去,国王的儿子现在继位。这句话的幽默,就在于这两个“国王”其实指的不是同一个人,而你咋一看还以为它自相矛盾。今天我的话题仿效了这句话,叫做“Lisp 已死,Lisp 万岁!”希望到最后你会明白这是什么意思。
首先,我想总结一下 Lisp 的优点。你也许已经知道,Lisp 身上最重要的一些优点,其实已经“遗传”到了几乎每种流行的语言身上(Java,C#,JavaScript,Python, Ruby,Haskell,……)。由于我已经在其他博文里详细的叙述过其中一些,所以现在只把这些 Lisp 的优点简单列出来(关键部分加了链接):
-
Lisp 的语法是世界上最精炼,最美观,也是语法分析起来最高效的语法。这是 Lisp 独一无二的,其他语言都没有的优点。有些人喜欢设计看起来很炫的语法,其实都是自找麻烦。为什么这么说呢,请参考这篇《谈语法》。
-
Lisp 是第一个可以在程序的任何位置定义函数,并且可以把函数作为值传递的语言。这样的设计使得它的表达能力非常强大。这种理念被 Python,JavaScript,Ruby 等语言所借鉴。
-
Lisp 有世界上最强大的宏系统(macro system)。这种宏系统的表达力几乎达到了理论所允许的极限。如果你只见过 C 语言的“宏”,那我可以告诉你它是完全没法跟 Lisp 的宏系统相提并论的。
-
Lisp 是世界上第一个使用垃圾回收(garbage collection)的语言。这种超前的理念,后来被 Java,C# 等语言借鉴。
想不到吧,现代语言的很多优点,其实都是来自于 Lisp — 世界上第二古老的程序语言。所以有人才会说,每一种现代语言都在朝着 Lisp 的方向“进化”。如果你相信了这话,也许就会疑惑,为什么 Lisp 今天没有成为主流,为什么 Lisp Machine 会被 Unix 打败。其实除了商业原因之外,还有技术上的问题。
早期的 Lisp 其实普遍存在一个非常严重的问题:它使用 dynamic scoping。所谓 dynamic scoping 就是说,如果你的函数定义里面有“自由变量”,那么这个自由变量的值,会随着函数的“调用位置”的不同而发生变化。
比如下面我定义一个函数 f,它接受一个参数 y,然后返回 x 和 y 的积。
(setq f
(let ((x 1))
(lambda (y) (* x y))))
这里 x 对于函数 (lambda (y) (* x y)) 来说是个“自由变量”(free variable),因为它不是它的参数。
看着这段代码,你会很自然的认为,因为 x 的值是 1,那么 f 被调用的时候,结果应该等于 (* 1 y),也就是说应该等于 y 的值。可是这在 dynamic scoping 的语言里结果如何呢?我们来看看吧。
(你可以在 emacs 里面试验以下的结果,因为 Emacs Lisp 使用的就是 dynamic scoping。)
如果我们在函数调用的外层定义一个 x,值为 2:
(let ((x 2))
(funcall f 2))
因为这个 x 跟 f 定义处的 x 的作用域不同,所以它们不应该互相干扰。所以我们应该得到 2。可是,这段代码返回的结果却为 4。
再来。我们另外定义一个 x,值为 3:
(let ((x 3))
(funcall f 2))
我们的期望值还是 2,可是结果却是 6。
再来。如果我们直接调用:
(funcall f 2)
你想这次总该得到 2 了吧?结果,出错了:
Debugger entered--Lisp error: (void-variable x)
(* x y)
(lambda (y) (* x y))(2)
funcall((lambda (y) (* x y)) 2)
eval_r((funcall f 2) nil)
eval-last-sexp-1(nil)
eval-last-sexp(nil)
call-interactively(eval-last-sexp nil nil)
看到问题了吗?f 的行为,随着调用位置的一个“名叫 x”的变量的值而发生变化。而这个 x,跟 f 定义处的 x 其实根本就不是同一个变量,它们只不过名字相同而已。这会导致非常难以发现的错误,也就是早期的 Lisp 最令人头痛的地方。好在现在的大部分语言其实已经吸取了这个教训,所以你不再会遇到这种让人发疯的痛苦。不管是 Scheme, Common Lisp, Haskell, OCaml, Python, JavaScript…… 都不使用 dynamic scoping。
那现在也许你了解了,什么是让人深恶痛绝的 dynamic scoping。如果我告诉你,Lisp Machine 所使用的语言 Lisp Machine Lisp 使用的也是 dynamic scoping,你也许就明白了为什么 Lisp Machine 会失败。因为它跟现在的 Common Lisp 和 Scheme,真的是天壤之别。我宁愿写 C++,Java 或者 Python,也不愿意写 Lisp Machine Lisp 或者 Emacs Lisp。
话说回来,为什么早期的 Lisp 会使用 dynamic scoping 呢?其实这根本就不是一个有意的“设计”,而是一个无意的“巧合”。你几乎什么都不用做,它就成那个样子了。这不是开玩笑,如果你在 emacs 里面显示 f 的值,它会打印出:
'(lambda (y) (* x y))
这说明 f 的值其实是一个 S 表达式,而不是像 Scheme 一样的“闭包”(closure)。原来,Emacs Lisp 直接把函数定义处的 S 表达式 ‘(lambda (y) (* x y)) 作为了函数的“值”,这是一种很幼稚的做法。如果你是第一次实现函数式语言的新手,很有可能就会这样做。Lisp 的设计者当年也是这样的情况。
简单倒是简单,麻烦事接着就来了。调用 f 的时候,比如 (funcall f 2),y 的值当然来自参数 2,可是 x 的值是多少呢?答案是:不知道!不知道怎么办?到“外层环境”去找呗,看到哪个就用哪个,看不到就报错。所以你就看到了之前出现的现象,函数的行为随着一个完全无关的变量而变化。如果你单独调用 (funcall f 2) 就会因为找不到 x 的值而出错。
那么正确的实现函数的做法是什么呢?是制造“闭包”(closure)。这也就是 Scheme,Common Lisp 以及 Python,C# 的做法。在函数定义被解释或者编译的时候,当时的自由变量(比如 x)的值,会跟函数的代码绑在一起,被放进一种叫做“闭包”的结构里。比如上面的函数,就可以表示成这个样子:(Closure ‘(lambda (y) (* x y)) ‘((x . 1)))。
在这里我用 (Closure …) 表示一个“结构”(就像 C 语言的 struct)。它的第一个部分,是这个函数的定义。第二个部分是 ‘((x . 1)),它是一个“环境”,其实就是一个从变量到值的映射(map)。利用这个映射,我们记住函数定义处的那个 x 的值,而不是在调用的时候才去瞎找。
我不想在这里深入细节。如果你对实现语言感兴趣的话,可以参考我的另一篇博文《怎样写一个解释器》。它教你如何实现一个正确的,没有以上毛病的解释器。
与 dynamic scoping 相对的就是“lexical scoping”。我刚才告诉你的闭包,就是 lexical scoping 的实现方法。第一个实现 lexical scoping 的语言,其实不是 Lisp 家族的,而是 Algol 60。“Algol”之所以叫这名字,是因为它的设计初衷是用来实现算法(algorithm)。其实 Algol 比起 Lisp 有很多不足,但在 lexical scoping 这一点上它却做对了。Scheme 从 Algol 60 身上学到了 lexical scoping,成为了第一个使用 lexical scoping 的“Lisp 方言”。9 年之后,Lisp 家族的“集大成者” Common Lisp 诞生了,它也采用了 lexical scoping。看来英雄所见略同。
你也许发现了,Lisp 其实不是一种语言,而是很多种语言。这些被人叫做“Lisp 家族”的语言,其实共同点只是它们的“语法”:它们都是基于 S 表达式。如果你因此对它们同样赞美的话,那么你赞美的其实只是 S 表达式,而不是这些语言本身。因为一个语言的本质应该是由它的语义决定的,而跟语法没有很大关系。你甚至可以给同一种语言设计多种不同的语法,而不改变这语言的本质。比如,我曾经给 TeX 设计了 Lisp 的语法,我把它叫做 SchTeX(Scheme + TeX)。SchTeX 的文件看起来是这个样子:
(documentclass article (11pt))
(document
(abstract (...))
(section (First Section)
... )
(section (Second Section)
... )
)
很明显,虽然这看起来像是 Scheme,本质却仍然是 TeX。
所以,因为 Scheme 的语法使用 S 表达式,就把 Scheme 叫做 Lisp 的“方言”,其实是不大准确的做法。Scheme 和 Emacs Lisp,Common Lisp 其实是三种不同的语言。Racket 曾经叫做 PLT Scheme,但是它跟 Scheme 的区别日益增加,以至于现在 PLT 把它改名叫 Racket。这是有他们的道理的。
所以,你也许明白了为什么这篇文章的标题叫做“Lisp 已死,Lisp 万岁!” 因为这句话里面的两个 “Lisp”其实是完全不同的语言。“Lisp 已死”,其实是说 Lisp Machine Lisp 这样的 Lisp,由于严重的设计问题,已经死去。而“Lisp 万岁”,是说像 Scheme,Common Lisp 这样的 Lisp,还会继续存在。它们先进于其它语言的地方,也会更多的被借鉴,被发扬广大。
(其实老 Lisp 的死去还有另外一个重要的原因,那就是因为早期的 Lisp 编译器生成的代码效率非常低下。这个问题我留到下一篇博文再讲。)