转自 Lisp 的永恒之道 |
Lisp 之魅
长久以来,Lisp 一直被许多人视为史上最非凡的编程语言。它不仅在 50 多年前诞生的时候带来了诸多革命性的创新并极大地影响了后来编程语言的发展,即使在一大批现代语言不断涌现的今天,Lisp 的诸多特性仍然未被超越。当各式各样的编程语言摆在面前,我们可以从运行效率、学习曲线、社区活跃度、厂商支持等多种不同的角度进行评判和选择,但我特别看中的一点在于语言能否有效地表达编程者的设计思想。学习 C 意味着学习如何用过程来表达设计思想,学习 Java 意味着学习如何用对象来表达设计思想,而虽然 Lisp 与函数式编程有很大的关系,但学习 Lisp 绝不仅仅是学习如何用函数表达设计思想。实际上,函数式编程并非 Lisp 的本质,在已经掌握了 lambda、高阶函数、闭包、惰性求值等函数式编程概念之后,学习 Lisp 仍然大大加深了我对编程的理解。学习 Lisp 所收获的是如何“自由地”表达你的思想,这正是 Lisp 最大的魅力所在,也是这门古老的语言仍然具有很强的生命力的根本原因。
Lisp 之源
Lisp 意为表处理 (List Processing),源自设计者 John McCarthy 于 1960 年发表的一篇论文《符号表达式的递归函数及其机器计算》。McCarthy 在这篇论文中向我们展示了用一种简单的数据结构 S 表达式 (S-expression) 来表示代码和数据,并在此基础上构建一种完整的语言。Lisp 语言形式简单、内涵深刻,Paul Graham 在《Lisp 之根源》中将其对编程的贡献与欧几里德对几何的贡献相提并论。
Lisp 之形
然而,与数学世界中简单易懂的欧氏几何形成鲜明对比,程序世界中的 Lisp 却一直是一种古老而又神秘的存在,真正理解其精妙的人还是少数。从表面上看,Lisp 最明显的特征是它“古怪”的 S 表达式语法。S 表达式是一个原子 (atom),或者若干 S 表达式组成的列表 (list),表达式之间用空格分开,放入一对括号中。“列表“这个术语可能会容易让人联想到数据结构中的链表之类的线形结构,实际上,Lisp 的列表是一种可嵌套的树形结构。下面是一些 S 表达式的例子:
foo
()
(a b (c d) e)
(+ (* 2 3) 5)
(defun factorial (N)
(if (= N 1)
1
(* N (factorial (- N 1)))
)
)
据说,这个古怪的 S 表达式是 McCarthy 在发明 Lisp 时候所采用的一种临时语法,他实际上是准备为 Lisp 加上一种被称为 M 表达式 (M-expression) 的语法,然后再把 M 表达式编译为 S 表达式。用一个通俗的类比,S 表达式相当于是 JVM 的字节码,而 M 表达式相当于 Java 语言,但是后来 Lisp 的使用者都熟悉并喜欢上了直接用 S 表达式编写程序,并且他们发现 S 表达式有许多独特的优点,所以 M 表达式的引入也就被无限期延迟了。
许多 Lisp 的入门文章都比较强调 Lisp 的函数式特性,而我认为这是一种误导。真正的 Lisp 之门不在函数式编程,而在 S 表达式本身,Lisp 最大的奥秘就藏在 S 表达式后面。S 表达式是 Lisp 的语法基础,语法是语义的载体,形式是实质的寄托。“S 表达式”是程序的一种形,正如“七言”是诗的一种形,“微博”是信息的一种形。正是形的不同,让微博与博客有了质的差异,同样的道理,正是 S 表达式让 Lisp 与 C、Java、SQL 等语言有了天壤之别。
Lisp 之道
一门语言能否有效地表达编程者的设计思想取决于其抽象机制的语义表达能力。根据抽象机制的不同,语言的抽象机制形成了面向过程、面向对象、函数式、并发式等不同的范式。当你采用某一种语言,基本上就表示你已经“面向 XXX“了,你的思维方式和解决问题的手段就会依赖于语言所提供的抽象方式。比如,采用 Java 语言通常意味着采用面向对象分析设计;采用 Erlang 通常意味着按 Actor 模型对并发任务进行建模。
有经验的程序员都知道,无论是面向 XXX 编程,程序设计都有一条“抽象原则“:What 与 How 解耦。但是,普通语言的问题就在于表达 What 的手段非常有限,无非是过程、类、接口、函数等几种方式,而诸多领域问题是无法直接抽象为函数或接口的。比如,你完全可以在 C 语言中定义若干函数来做到 make file 所做的事情,但 C 代码很难像 make file 那样声明式地体现出 target、depends 等语义,它们只会作为实现细节被淹没在一个个的 C 函数之中。采用 OOP 或是 FP 等其它范式也会遇到同样的困难,也就是说 make file 语言所代表的抽象维度与面向过程、OOP 以及 FP 的抽象维度是正交的,使得各种范式无法直接表达出 make file 的语义。这就是普通语言的“刚性”特征,它要求我们必须以语言的抽象维度去分析和解决问题,把问题映射到语言的基本语法和语义。
更进一步,如果仔细探究这种刚性的根源,我们会发现正是由于普通语言语法和语义的紧耦合造成了这种刚性。比如,C 语言中 printf("hello %s", name)
符合函数调用语法,它表达了函数调用语义,除此之外别无他义;Java 中 interface IRunnable { … }
符合接口定义语法,它表达了接口定义语义,除此之外别无他义。如果你认为“语法和语义紧耦合“是理所当然的,看不出这有什么问题,那么理解 Lisp 就会让你对此产生更深的认识。
当你看到 Lisp 的 (f a (b c))
的时候,你会想到什么?会不会马上联想到函数求值或是宏扩展?就像在 C 语言里看到 gcd(10, 15)
马上想到函数调用,或者在 Java 里看到 class A
马上想到类定义一样。如果真是这样,那它就是你理解 Lisp 的一道障碍,因为你已经习惯了顺着语言去思考,总是在想这一句话机器怎么解释执行?那一句话又对应语言的哪个特性?理解 Lisp 要反过来,让语言顺着你,Lisp 的 (f a (b c))
可以是任何语义,完全由你来定,它可以是函数定义、类定义、数据库查询、文件依赖关系,异步任务的执行关系,业务规则
下面我准备先通过几个具体的例子逐步展示 Lisp 的本质。需要说明的是,由于 Lisp 的 S 表达式和 XML 的语法形式都是一种树形结构,在语义表达方面二者并无本质的差别。所以,为了理解方便,下面我暂且用多数人更为熟悉的 XML 来写代码,请记住我们可以很轻易地把 XML 代码和 Lisp 代码相互转换。
首先,我们可以轻易地用 XML 来定义一个求两个数最大公约数的函数:
<func name='gcd' return_type='int'>
<params>
<a type='int'/>
<b type='int'/>
</params>
<body>
<if>
<equals>
<a/>
<int>0</int>
</equals>
</if>
<then>
<return><b/></return>
</then>
<else>
<return>
<gcd>
<modulo><b/><a/></modulo>
<a/>
</gcd>
</return>
</else>
</body>
</func>
其次,我们可以用它来定义类:
<class name="Computer">
<field access="private" type="MainBoard" name="main-board" />
<field access="private" type="CPU" name="cpu" />
<field access="private" type="Memory" name="memory" />
<method access="public" return_type="boolean" name="powerOn" />
<params>...</params>
<body>...</body>
</method>
<method access="public" return_type="boolean" name="powerOff" />
<params>...</params>
<body>...</body>
</method>
</class>
还可以轻易地用它来编写关系查询:
<sql>
<select>
<column name="employees.id" />
<column name="bonus.amount" />
</select>
<from>
<table name="employees" />
<table name="bonus" />
</from>
<where>
<equals>
<column name="employees.id" />
<column name="bonus.employee_id" />
</equals>
</where>
</sql>
还可以用它来实现类似 make file 的自动化构建 (语法取自 ant):
<project name="MyProject" default="dist" basedir=".">
<property name="src" location="src"/>
<property name="build" location="build"/>
<property name="dist" location="dist"/>
<target name="init">
<mkdir dir="${build}"/>
</target>
<target name="compile" depends="init" description="compile the source " >
<javac srcdir="${src}" destdir="${build}"/>
</target>
<target name="dist" depends="compile" description="generate the distribution" >
<mkdir dir="${dist}/lib"/>
<jar jarfile="${dist}/lib/MyProject-${DSTAMP}.jar" basedir="${build}"/>
</target>
<target name="clean" description="clean up" >
<delete dir="${build}"/>
<delete dir="${dist}"/>
</target>
</project>
一口气举了这么多个例子,目的在于用 XML 这种树形结构来说明 Lisp 的 S 表达式所能够描述的语义。不知道你是否发现了 S 表达式和 XML 这种树形语法在语义构造方面有着特别的“柔性”?我们可以轻易地用它构造出函数、变量、条件判断语义;类、属性、方法语义;可以轻易地构造出关系模型的 select、where 语义;可以轻易地构造出 make 的 target、depends 语义,等等数不清的语义。在普通语言里,你可以定义一个函数、一个类,但你无法为 C 语言增加匿名函数特性,也没法给 Java 语言加上 RAII 语义,甚至连自己创造一个 foreach 循环都不行,而自定义语义意味着在 Lisp 之上你创造了一门语言!不管是面向过程,面向对象,函数式,还是关系模型,在 Lisp 里统统都变成了一种 DSL,而 Lisp 本身也就成了一种定义语言的语言,即元语言 (Meta Language)。
Lisp 的柔性与 S 表达式有着密切的关系。Lisp 并不限制你用 S 表达式来表达什么语义,同样的 S 表达式语法可以表达各种不同领域的语义,这就是语法和语义解耦。如果说普通语言的刚性源于“语法和语义紧耦合”,那么 Lisp 的柔性正是源于“语法和语义解耦”!“语法和语义解耦”使得 Lisp 可以随意地构造各种领域的 DSL,而不强制用某一种范式或是领域视角去分析和解决问题。本质上,Lisp 编程是一种超越了普通编程范式的范式,这就是Lisp 之道:面向语言编程 (LOP, Language Oriented Programming)。Wikipedia 上是这样描述 LOP 的:
Language oriented programming (LOP) is a style of computer programming in which, rather than solving problems in general-purpose programming languages, the programmer creates one or more domain-specific languages for the problem first, and solves the problem in those languages … The concept of Language Oriented Programming takes the approach to capture requirements in the user’s terms, and then to try to create an implementation language as isomorphic as possible to the user’s descriptions, so that the mapping between requirements and implementation is as direct as possible.
LOP 范式的基本思想是从问题出发,先创建一门描述领域模型的 DSL,再用 DSL 去解决问题,它具有高度的声明性和抽象性。SQL、make file、CSS 等 DSL 都可以被认为是 LOP 的具体实例,下面我们再通过两个常见的例子来理解 LOP 的优势。
例 1:在股票交易系统中,交易协议定义若干二进制的消息格式,交易所和客户端需要对消息进行编码和解码。
消息格式是一种抽象的规范,本身不对语言做任何的限制,你可以用 C,C++,Java,或者 Python。普通的实现方式是按照消息格式规范,在相应的语言中定义消息结构,并编写相应的编解码函数。假设为一个消息定义结构和实现编解码函数的工作量为 M,不同消息类型的数量为 N,这种方式的工作量大致为 M*N。也就是说每增加一种消息类型,就需要为该消息定义结构,实现编解码函数,引入 bug 的可能性当然也和 M*N 成正比。如果仔细观察不难发现,各个消息结构其实是高度类似的,编解码函数也大同小异,但是普通语言却找不到一种抽象机制能表达这种共性,比如,我们无法通过面向对象的方法定义一个基类把消息结构的共性抽象出来,然后让具体的消息去继承它,达到复用的目的。这正是由于普通语言的抽象维度限制所致,在普通语言中,你只能从函数、接口等维度对事物进行抽象,而恰好消息格式共性所在的维度与这些抽象维度并不匹配。
其实,不同消息类型的共性在于它们都具有相同的领域语义,比如:“某字段内容是另一个字段内容的 md5 码”就是一种消息格式的领域语义,这种领域语义是 OOP 的抽象机制无法描述的。LOP 的思路是先创建一门消息定义 DSL,比如,类似 Google 的 Protocol Buffer,Android 的 AIDL。然后,通过 DSL 编写消息定义文件,直接声明式地描述消息的结构特征,比如,我们可以声明式地描述“某字段内容是另一个字段内容的 md5 码”。我们还需要为 DSL 开发编译器用于生成 C、Java 等通用语言的消息定义和编解码函数。
有了消息定义 DSL 和编译器之后,由于 DSL 编写消息定义是一种高度声明式的编程方法,每增加一种消息的只需要多编写一个消息定义文件而已,工作量几乎可以忽略不计。所有的工作量都集中在编译器的开发上,工作量是一个常数 C,与消息的数量没有关系;质量保证方面也只需要关注编译器这一点,不会因为增加新的消息类型而引入 bug。
例 2:在图书管理系统中,需要支持在管理界面上对书籍、学生、班级等各种实体进行管理操作。
如果按传统的三层架构,一般需要在后端程序中为每一种实体定义一个类,并定义相应的方法实现 CRUD 操作,与之相应的,还需要在前端页面中为每一个实体编写相应的管理页面。这些实体类的 CRUD 操作都是大同小异的,但细节又各不相同,虽然我们很想复用某些共同的设计实现,但 OOP 所提供的封装、继承、多态等抽象机制不足以有效捕获实体之间的共性,大量的代码还是必须放在子类中来完成。比如,Student 和 Book 实体类的实现非常相似,但是如果要通过 OOP 的方式去抽象它们的共性,得出的结果多半是 Entity 这样的大而空的基类,很难起到复用的效果。
其实,不同实体之间的共性还是在于它们具有相同的领域语义,比如:实体具有属性,属性具有类型,属性具有取值范围,属性具有可读取、可编辑等访问属性,实体之间有关联关系等。LOP方法正是直接面向这种领域语义的。采用LOP方法,我们并不需要为每一个实体类单独编写CRUD方法,也不需要单独编写管理页面,只需要定义一种DSL并实现其编译器;然后,用DSL声明式地编写实体描述文件,去描述实体的属性列表,属性的类型、取值范围,属性所支持的操作,属性之间的关系和约束条件等;最后,通过这个实体描述文件自动生成后端的实体类和前端管理页面。采用LOP,不论前后端采用何种技术,Java也好,C#也好,JSP也好,ASP.NET也好,都可以自动生成它们的代码。采用LOP的工作量和质量都集中在DSL的设计和编译器的开发,与实体的数量无关,也就是说,越是庞大的系统,实体类越多越是能体现LOP的优势。
通过上面两个小例子我们可以感受到,LOP 是一种面向领域的,高度声明式的编程方式,它的抽象维度与领域模型的维度完全一致。LOP 能让程序员从复杂的实现细节中解脱出来,把关注点集中在问题的本质上,从而提高编程的效率和质量。
接下来的问题是如果需要为某领域设计 DSL,我们是应该发明一门类似 SQL 这样的专用 DSL 呢,还是用 XML 或 S 表达式去定义 DSL 呢?它们各有何优缺点呢?
我认为采用 XML 或 S 表达式定义 DSL 的优点主要有:
SQL、make file、CSS 等专用 DSL 都只能面向各自的领域,而一个实际的领域问题通常是跨越多个领域的,有时我们需要将不同领域融合在一起,但是由于普通语言的刚性,多语言融合通常会是一件非常困难的事情,而 XML 和 S 表达式语法结构的单一性和“代码及数据”的特点使得跨领域融合毫无障碍。
在为 DSL 开发编译器或解释器的方面,二者难度不同。对 XML 和 S 表达式定义的 DSL 进行语法分析非常简单,相比之下,对 SQL 这样的专用 DSL 进行语法分析,虽然可以借助 Lex、Yacc、ANTLR 等代码生成工具,但总的来讲复杂度还是要明显高一些。
当然,XML 和 S 表达式的优点也正好是其缺点,由于 XML 和 S 表达式的语法形式是固定的,不能像专用 DSL 那样自由地设计语法。所以,一般来讲专用 DSL 的语法显得更加简洁。换句话说,XML 和 Lisp 其实是在语法和语义间做了一个交换,用语法的限制换来了语义的灵活。
Lisp 之器
接下来我们继续探讨 DSL 的解释执行问题。DSL 代码的解释执行一般分为 3 种典型的方式:
通过专门的解释器解释执行;
编译生成其他语言的代码,再通过其他语言的解释器解释执行 (或编译运行);
自解释。比如,第 1 类的代表是 SQL,上一节举的两个例子都属于第 2 类,而第 3 类自解释正是 Lisp 的特色。
为了理解自解释,我们可以先从内部 DSL 的解释执行说起。内部 DSL 是指嵌入在宿主语言中的 DSL,比如,Google Test 单元测试框架定义了一套基于流畅接口 (Fluent Interface) 的 C++ 单元测试 DSL。从语义构造的角度看,内部 DSL 直接借用宿主语言的语法定义了自己的领域语义,是一种语法和语义解耦;从解释执行的角度看,内部 DSL 是随宿主语言的解释器而自动解释的,不需要像外部 DSL 一样开发专门的解释器,因而实现的代价很低。当然,并不是说设计内部 DSL 不用关心任何的解释实现,实际上,还是需要熟悉宿主语言的特性,并利用该特性使得 DSL 能随着宿主语言的解释器得到解释执行。
Lisp 拥有强大的自解释特性,这得益于独一无二的Lisp 之器:宏 (macro)。宏使得 Lisp 编写的 DSL 可以被 Lisp 解释器直接解释执行,这在原理上与内部 DSL 是相通的,只是内部 DSL 一般是利用宿主语言的链式调用等特性,通常形式简陋,功能有限,而 Lisp 的宏则要强大和灵活得多。
C 语言中也有宏的概念,不过 Lisp 的宏与 C 语言的宏完全不同,C 语言的宏是简单的字符串替换。比如,下面的宏定义:
#define square(x) (x*x)
square(1+1)
的期望结果是 4,而实际上它会被替换成 (1+1*1+1)
,结果是 3。这个例子说明,C 语言的宏只在预编译阶段进行简单的字符串替换,对程序语法结构缺乏理解,非常脆弱。Lisp 的宏不是简单的字符串替换,而是一套完整的代码生成系统,它是在语法解析的基础上把 Lisp 代码从一种形式转换为另一种形式,本质上起到了普通语言编译器的作用。不同的是,普通编译器是把一种语言的代码转换为另一种语言的代码,比如,Java 编译器把 Java 代码转换成 Java 字节码;而 Lisp 宏的输入和输出都是 S 表达式,它本质上是把一种 DSL 转换为另一种 DSL。下面的例子是宏的一个典型用法。
例 3:假设 Lisp 解释器已经具备解释执行面向过程 DSL 的能力,需要实现类似 ant 的自动化构建工具。
我们可以基于宏构建一门类 ant 的 DSL,宏的作用是把类 ant DSL 通过宏展开变成面向过程的 DSL,最后被 Lisp 解释器所解释执行。这样用 Lisp 编写的 ant DSL 就不需要被编译为其他语言,也不需要像 XML 的 ant 一样依赖于专门的解释器了。
当然,和开发专门的解释器/编译器相比,Lisp 的宏也并非没有缺点,宏难以理解,开发和调试更加困难。到底是开发专门的解释器/编译器还是直接采用宏应该视具体情况而定。
总结
Lisp 采用单一的 S 表达式语法表达不同的语义,实现了语法和语义解耦。这使得 Lisp 具有强大的语义构造能力,擅长于构造 DSL 实现面向语言编程,而宏使得 Lisp 具有自解释能力,让不同 DSL 之间的转换游刃有余。进入 Lisp 的世界应当从理解面向语言编程入门,这是 Lisp 之道,而函数式编程和宏皆为 Lisp 之器,以道驭器方为正途。
后记
本文是我学习 Lisp 的一个总结,也是写给有兴趣学习 Lisp 的程序员的入门资料。必须说明,我还是一个标准的 Lisp 初学者,几乎没有写过像样的 Lisp 程序,文中的错误和不足在所难免,希望读者批评指正,感谢!