第 1 章 可扩展语言
最后更新于:2022-04-01 02:44:57
## 第 1 章 可扩展语言
不久前,如果你问 Lisp 是用来干什么的,很多人会回答说 "人工智能(articial intelligence)" 。事实上,Lisp 和人工智能之间的联系只是历史的偶然。 Lisp 由 John McCarthy 发明,同样是他首次提出了 "人工智能" 这一名词。那时他的学生和同事用 Lisp 写程序,于是它就被称作一种 AI 语言。这个典故在 1980 年代 AI 短暂升温时又被多次提起,到现在已经差不多成了习惯。
幸运的是, "AI 并非 Lisp 的全部" 的观点已经开始为人们所了解。近年来软硬件的长足发展已经让 Lisp 走出了象牙塔:
> 它目前用于GNUEmacs -- Unix 下最好的文本编辑器;
>
> AutoCAD -- 工业标准的桌面CAD 程序;
>
> 还有Interleaf -- 领先的高端出版系统。
Lisp 在这些程序里的应用跟AI 已经没有了任何关系。
如果 Lisp 不是一种 AI 语言,那它是什么?与其根据那些使用它的公司来判断 Lisp ,我们不如直接看看语言本身。有什么是你可以用 Lisp 做到,而其他语言没法做到的呢? Lisp 的一个最显著的优点是可以对其量身定制,让它与用它写的程序相配合。Lisp 本身就是一个 Lisp 程序,Lisp 程序可以表达成列表,那也是 Lisp 的数据结构。
总之,这两个原则意味着任何用户都可以为 Lisp 增加新的操作符,而这些新成员和那些内置的操作符是没有区别的。
### 1.1 渐进式设计
由于 Lisp 赋予了你自定义操作符的自由,因而你得以随心所欲地将它塑造成你所需要的语言。
> 如果你在写一个文本编辑器,那么可以把 Lisp 转换成专门写文本编辑器的语言。
>
> 如果你在编写 CAD 程序,那么可以把 Lisp 转换成专用于写 CAD 程序的语言。
>
> 并且如果你还不太清楚你要写哪种程序,那么用 Lisp 来写会比较安全。
因为无论你想写哪种程序,在你写的时候,Lisp 都可以演变成用于写那种程序的语言。
你还没想好要写哪种程序?一样可以。对有些人来说,这种说法有点不对劲。这和某种行事方式很不一样,这种方式有两步:
> (1) 仔细计划你打算做的事情,接下来
>
> (2) 去执行它。
按照这个逻辑,如果 Lisp 鼓励你在决定程序应该如何工作之前就开始写程序,它只不过是怂恿你匆忙上马,草率决定而已。
事实并非如此。先计划再实施的方法可能是建造水坝或者发起战役的方式,但经验并未表明这种方法也适用于写程序。为什么?也许是因为计算机的要求太苛刻了。也许是因为程序中的变数比水坝或者战役更多。或许老方法不再奏效的原因,是因为旧式的冗余观念不适用于软件开发:
> 如果一座大坝浇铸了额外的 30% 的混凝土,那是为以后的误操作留下的裕量,但如果一个程序多做了额外 30% 的工作,那就是一个错误。
很难说清原来的办法为什么会失效,但所有人都心知肚明老办法不再行之有效。究竟有几次软件按时交付过?有经验的程序员知道无论你多小心地计划一个程序,当你着手写它的时候,之前制定的计划在某些地方就会变得不够完美。有时计划甚至会错得无可救药。却很少有"先策划再实施" 这一方法的受害者站出来质疑它的有效性。相反,他们把这都归咎于人为过失:
> 只要计划做的更周详,所有的问题就都可以避免。
就算是最杰出的程序员,在进行具体实现的时候也难免陷入麻烦,因此要人们必须具备那种程度的前瞻性可能过于苛求了。也许这种先策划再实施的方法可以用另外一种更适合我们自身限制的方法取而代之。
如果有合适的工具,我们完全可以换一种角度看待编程。为什么我们要在具体实现之前计划好一切呢?盲目启动一个项目的最大危险是我们可能不小心就使自己陷入困境。但如果存在一种更加灵活的语言,是否能为我们分忧呢?我们可以,而且确实如此。Lisp 的灵活性带来了全新的编程方式。在 Lisp 中,可以边写程序边做计划。
为什么要等事后诸葛亮呢?正如 Montaigne 所发现的那样,如果要理清自己的思路,试着把它写下来会是最好的办法。一旦你能把自己从陷入困境的危险中解脱出来,那你就可以完全驾驭这种可能性。边设计边施工有两个重要的后果:程序可以花更少的时间去写,因为当你把计划和实际动手写放在一起的时候,你总可以把精力集中在一个实际的程序上;然后让它变得日益完善,因为最终的设计必定是进化的成果。
只要在把握你程序的命运时坚持一个原则:一旦定位错误的地方,就立即重写它,那么最终的产品将会比事先你花几个星期的时间精心设计的结果更加优雅。
Lisp 的适应能力使这种编程思想成为可能。确实,Lisp 的最大危险是它可能会把你宠坏了。使用 Lisp 一段时间后,你会开始对语言和应用程序之间的结合变得敏感,当你回过头去使用另一种语言时,总会有这样的感觉:
> 它无法提供你所需要的灵活性。
### 1.2 自底向上程序设计
有一条编程原则由来已久:作为程序的功能性单元不宜过于臃肿。如果程序里某些组件的规模增长超过了它可读的程度,它就会成为一团乱麻,藏匿其中的错误就好像巨型城市里的逃犯那样难以捉摸。这样的软件将难以阅读,难以测试,调试起来也会痛苦不堪。
按照这个原则,大型程序必须细分成小块,并且程序的规模越大就应该分得越细。但你怎样划分一个程序呢?传统的观点被称为自顶向下的设计:你说 "这个程序的目的是完成这七件事,那么我就把它分成七个主要的子例程。第一个子例程要做这四件事,所以它将进一步细分成它自己的四个子例程",如此这般。这一过程持续到整个程序被细分到合适的粒度 每一部分都足够大可以做一些实际的事情,但也足够小到可以作为一个基本单元来理解。
有经验的 Lisp 程序员用另一种不同的方式来细化他们的程序。和自顶向下的设计方法类似,他们遵循一种叫做自底向上的设计原则, 即通过改变语言来适应程序。在 Lisp 中,你不仅是根据语言向下编写程序,也可以根据程序向上构造语言。在编程的时候你可能会想 " Lisp 要是有这样或者那样的操作符就好了。" 那你就可以直接去实现它。之后,你会意识到使用新的操作符也可以简化程序中另一部分的设计,如此种种。语言和程序一同演进。就像交战两国的边界一样,语言和程序的界限不断地移动,直到最终沿着山脉和河流确定下来,这也就是你要解决的问题本身的自然边界。最后你的程序看起来就好像语言就是为解决它而设计的。并且当语言和程序彼此都配合得非常完美时,你得到的将是清晰、简短和高效的代码。
需要强调的是,自底向上的设计并不意味着只是换个次序写程序。当以自底向上的方式工作时,你通常写出来的程序会彻底改观。你将得到一个带有更多抽象操作符的更大的语言,和一个用它写的更精练的程序,而不是单个的整块的程序。你得到将是拱而非梁。
在典型的程序中,一旦把那些仅仅是做非逻辑工作的部分抽象掉,剩下的代码就短小多了;你构造的语言越高阶,程序从上层逻辑到下层语言的距离就越近。这有几个好处:
1. 通过让语言担当更多的工作,自底向上设计产生的程序会更加短小轻快。一个更短小的程序就不必划分成那么多的组件了,并且更少的组件意味着程序会更易于阅读和修改。更少的组件也使得着组件之间的连接会更少,因而错误发生的机会也会相应减少。一个机械设计师往往努力去减少机器上运动部件的数量,同样有经验的 Lisp 程序员使用自底向上的设计方法来减小他们程序的规模和复杂度。
2. 自底向上的设计促进了代码重用。当你写两个或更多程序时,许多你为第一个程序写的工具也会对之后的程序开发有帮助。一旦积累下了雄厚的工具基础,写一个新程序所耗费的精力和从原始(raw) Lisp 环境白手起家相比,前者可能只是后者的几分之一。
3. 自底向上的设计提高了程序的可读性。一个这种类型的抽象要求读者理解一个通用操作符,而一个具体的函数抽象要求读者去理解的则是一个专用的子例程。
译者注:Montaigne,即MichelRyquemdeMontaigne。国内一般译作"蒙田"。他是法国文艺复兴后期重要的人文主义学者,他曾说过"我本人就是作品的内容"。"但是没人能读懂你的程序,除非理解了所有新的实用函数"。要想知道为什么这种认识是一种误解,请参考第4.8 节。
1. 由于自底向上的设计驱使你总是去关注代码中的模式,这种工作方式有助于理清设计程序时的思路。
如果一个程序中两个关系很远的组件在形式上很相似,你就会因此注意到这种相似性,然后也许会以更简单的方式重新设计程序。
对于其他非 Lisp 的语言来说,自底向上的设计在某种程度上也是可能的。大家熟悉的库函数就是自底向上设计的一种体现。然而在这方面,Lisp 还能提供比其他语言更强大的威力,而且在以 Lisp 风格编程时,扩展这门语言的重要性也相应提高了,所以 Lisp 不仅是一门不同的编程语言,而且是一种完全不一样的编程方式。
确实,这种开发风格更适合那种可以小规模开发的程序。不过,与此同时,它却让一个小组所能做更多的事情。在《人月神话》一书中,FrederickBrooks 提出"一组程序员的生产力并不随人员的数量呈线性增长"。
随着组内人数的增加,个体程序员的生产力将有所下降。 Lisp 编程经验以一种更加令人振奋的方式重申这个定律:随着组内人数的减少,个体程序员的生产力将会提高。一个小组取得成功的原因,仅仅是因为它的规模相对较小。如果一个小组能利用 Lisp 带来的技术优势,它必定会走向成功。
### 1.3 可扩展软件
随着软件复杂度的提高,编程的 Lisp 风格也变得愈加重要。专业用户现在对软件的要求如此之多以致于我们几乎无法预见到他们的所有需求。就算用户自己也没办法预测到他们所有的需求。但如果我们不能给他们一个现成的软件,让它能完成用户想要的每个功能,那么我们也可以交付一个可扩展的软件。我们把自己的软件从单单一个程序变成了一门编程语言,然后高级用户就可以在此基础上构造他们需要的额外特性。
自底向上的设计很自然地产生了可扩展的程序。最简单的自底向上程序包括两层:语言和程序。复杂的程序可以被写成多个层次,每一层作为其上层的编程语言。如果这一哲学被一直沿用到最上面的那层,那最上面的这一层对于用户来说就变成了一门编程语言。这样一个可扩展性体现在每一层次的程序,与那些先按照传统黑盒方法写成,事后才加上可扩展性的那些系统相比,更有可能成为一门好得多的编程语言。
X-Window 和 TEX 就是遵循这一设计原则编写而成的早期典范。在 1980 年代,更强大的硬件使得新一代的 程序能使用 Lisp 作为它们的扩展语言。首先是 GNUEmacs,流行的 Unix 文本编辑器。紧接着是 AutoCAD ,第一个把 Lisp 作为扩展语言的大型商业软件。1991 年 Interleaf 发布了他们软件的新版本,它不仅采用 Lisp 作为扩展语言,甚至该软件大部分就是用 Lisp 实现的。
Lisp 这门语言特别适合编写可扩展程序,主要原因是因为它本身就是一个可扩展的程序。如果你用 Lisp 写你的程序以便将这种可扩展性转移到用户那里,你事实上已经毫不费力地得到了一个可扩展语言。并且用 Lisp 扩展 Lisp 程序,和用一个传统语言做同样的事情相比,它们的区别就好比面对面交谈和使用书信联系的区别。如果一个程序只是简单提供了一些供外部程序访问的方式,以期获得可扩展性,那么我们最乐观的估计也无非是两个黑箱之间彼此通过预先定义好的渠道进行通信。在 Lisp 里,这些扩展有权限直接访问整个底层程序。这并不是说你必须授予用户你程序中每一个部分的访问权限,只是说你现在有机会决定是否赋给他们这样的权限。
当权限的取舍和交互式环境结合在一起,你就拥有了处于最佳状态的可扩展性。任何软件,如果你想以它为基础,在其上进行扩展,为己所用,在你心中就好比有了一张非常大,可能过于巨大的完整的蓝图。要是其中的有些东西不敢确定,该怎么办?如果原始程序是用 Lisp 开发的,那就可以交互式地试探它:你可以检查它的数据结构;你可以调用它的函数;你甚至可能去看它最初的源代码。这种反馈信息让你能信心百倍地写程序 去写更加雄心勃勃的扩展,并且会写得更快。一般而言,交互式环境可以让编程更轻松,但它对写扩展的人来说尤其有用。
可扩展的程序是一柄双刃剑,但近来的经验表明,和钝剑相比,用户更喜欢双刃剑。可扩展的程序看起来正在流行,无论它们是否暗藏危机。
1.4 扩展 Lisp
有两种方式可以为 Lisp 增加新的操作符:函数和宏。在 Lisp 里,你定义的函数和那些内置函数具有相同的地位。如果想要一个新的改版的mapcar ,那你就可以先自己定义,然后就像使用mapcar 那样来使用它。
例如,如果有一个函数,你想把从 1 到 10 之间的所有整数分别传给它,然后把函数的返回值组成的列表留下,你可以创建一个新列表然后把它传给 mapcar :
~~~
(mapcar fn
(do* ((x 1 (1+ x))
(result (list x) (push x result)))
((= x 10) (nreverse result))))
~~~
但这样做既不美观又没效率。换种办法,你也可以定义一个新的映射函数 map1-n (见36 页),然后像下面那样调用它:
~~~
(map1-n fn 10)
~~~
定义函数相对而言比较直截了当。而用宏来定义新操作符,虽然更通用,但不太容易理解。宏是用来写程序的程序。这句话意味深长,深入地探究这个问题正是本书的主要目的之一。
深思熟虑地使用宏,可以让程序惊人的清晰简洁。这些好处绝非唾手可得。尽管到最后,宏将被视为世上最自然的东西,但最初理解它的时候却会举步维艰。部分原因是因为宏比函数更加一般化,所以编写的时候要考虑的事情更多。但宏难于理解,最主要的原因是它太另类了。没有任何一门语言有像 Lisp 宏那样的东西。所以学习宏,可能先要从头脑中清除从其他语言那里潜移默化接受的先入为主的观念。这些观念中,首当其冲就是为那些陈词滥调所累的程序。凭什么数据结构可以变化,并且其中的数据可以修改,而程序却不能呢?在 Lisp 里,程序就是数据,但其中深意需要假以时日才能体会到。
如果你需要花些时间才能习惯宏,那么这些时间绝对是值得的。即使像迭代这样平淡无奇的用法中,宏也可以使程序明显变得更短小精悍。假设一个程序需要在某个程序体上从 a 到 b 来迭代x 。Lisp 内置的 do 可以用于更加一般的场合。而对于简单的迭代来说,用它并不能写出可读性最好的代码:
(do ((x a (+ 1 x))) ((> x b)) (print x))
另一方面,假如我们可以只写成这样:
(for (x a b) (print x))
宏使这成为可能。用六行代码(见第104 页),我们就能把 for 加入到语言中,就好像原装的一样。并且正如后面的章节所展示的,写个 for 对宏的广阔天地来说,不过是小试牛刀。
没有人对你横加限制,说每次只能为 Lisp 扩展一个函数或是宏。如果需要,你可以在 Lisp 之上构造一个完整的语言,然后用它来编写程序。 Lisp 对于写编译器和解释器来说是极为优秀的语言,但它定义新语言的方式和以往完全不同,这种方式通常更加简洁,而且自然,也更省力:即在原有的 Lisp 基础上加以修改,成为一门新的语言。这样,Lisp 中保持不变部分可以在新语言里(例如数学计算或者I/O 操作) 得以继续沿用,
你只需要实现有变化的那部分(例如控制结构)。以这种方式实现的语言被称为嵌入式语言。
嵌入式语言是自底向上程序设计的自然产物。Common Lisp 里已经有了好几种这样的语言。其中最著名的 将在最后一章里讨论。但你也可以定义自己的嵌入式语言。然后就能得到一个完全为你程序度身定制的语言,甚至它们最后看起来跟 Lisp 已经截然不同。
### 1.5 为什么(或说何时) 用 Lisp
这些新的可能性并非来自某一个神奇的源头。这样说吧,Lisp 就像一个拱顶。究竟哪一块楔形石头(拱石)托起了整个拱呢?这个问题本身就是错误的;每一块都是。和拱一样,Lisp 是一组相互契合的特性的集合。
你也可以使用 Common Lisp 的series 宏把代码写得更简洁,但那也只能证明同样的观点,因为这些宏就是 Lisp 本身的扩展。
我们可以列出这些特性中的一部分:动态存储分配和垃圾收集、运行时类型系统、函数对象、生成列表的内置解析器、一个接受列表形式的程序的编译器、交互式环境等等,但 Lisp 的威力不能单单归功于它们中的任何一个。是上述这些特性一同造就了 Lisp 编程现在的模样。
在过去的二十年间,人们的编程方式发生了变化。其中许多变化 交互式环境、动态链接,甚至面向对象的程序设计,就是一次又一次的尝试,它们把 Lisp 的一些灵活性带给其它编程语言。关于拱顶的那个比喻说明了这些尝试是怎样的成功。
众所周知,Lisp 和 Fortran 是目前仍在使用中的两门最古老的编程语言。可能更有意思的是,它们在语言设计的哲学上代表了截然相反的两个极端。Fortran 被发明出来以替代汇编语言。Lisp 被发明出来表述算法。如此截然不同的意图产生了迥异的两门语言,Fortran 使编译器作者的生活更轻松;而 Lisp 则让程序员的生活更舒服。自从那时起,大多数编程语言都落在了两极之间。Fortran 和 Lisp 它们自己也逐渐在向中间地带靠拢。Fortran 现在看起来更像Algol 了,而 Lisp 也改掉了它年幼时一些很低效的语言习惯。
最初的 Fortran 和 Lisp 在某种程度上定义了一个战场。战场的一边的口号是"效率!(并且,还有几乎不可能实现。)" 在战场的另一边,口号是"抽象!(并且不管怎么说,这不是产品级软件。)" 就好像诸神在冥冥之中决定古希腊战争的胜败那样,编程语言这场战争的结局取决于硬件。每一年都在往 Lisp 更有利的方向发展。现在对 Lisp 的争议听起来已经有点儿像1970 年代早期汇编语言程序员对于高级语言的论点。
问题不再是为什么用 Lisp?而是何时用 Lisp?