B.2 作为DSL载体的Lisp
元编程和代码生成可以造就出色的DSL设计,这一点我们已经在第2.3节有过详细的叙述。用户所期待的出色的DSL设计,除了表面语法紧凑外,还要具备充分的领域词汇表达能力。这就要求宿主语言必须具备充足的程序转换语义,这种转换可在编译层面进行,也可在运行时层面进行。
我们从2.3.1小节得知,Groovy等语言利用运行时MOP生成代码,并通过诸如方法合成、方法拦截等元对象操纵手段改变程序的行为。但运行时元编程确实存在性能方面的弊病,因为变换涉及的程序结构需要通过元对象的反射和内省来实施操纵。
Lisp等语言通过语法宏来提供编译时元编程能力,这是第B.1节刚刚讨论过的。正是由于语法宏的功劳,Lisp运行时完全摆脱一切元结构,执行Lisp程序时只需考虑核心语言运行时中定义的有效Lisp语法成分即可。这个特点使得Lisp元编程独树一帜,也使得语法宏成为实现Lisp DSL的根本。本节我们将更进一步剖析Lisp程序的构造,以图理解Lisp在实现DSL的方法上与其他语言的区别。
B.2.1 Lisp的特殊之处
是什么原因令Java、C++这类语言难以实施编译时元编程?要想有效率地编译时元编程,我们需要在程序的AST上执行各种变换操作。下面的内容大致说明了抽象语法树和具体语法树的含义。
在大多数语言里,我们编写的程序都会被表示成一棵CST(concrete syntax tree,具体语法树)。CST真实地反映程序内容,包括代码中的空白、注释以及编写中产生的一切元信息。然后,程序依次经过扫描器、词法分析器、语法分析器的处理,生成所谓的AST(abstract syntax tree,抽象语法树)。AST代表了经过一系列编译阶段,从程序代码中提取出来的、具有语法意义的实质部分。从CST到AST一般要经历变换、优化、代码生成等步骤,所有这些变换操作都主要由语言的语法分析器负责实施,变换的结果是产生AST。
大多数语言如Java或C++都用字符串来表示程序,从CST产生AST的唯一方法是通过语法分析器,而语法分析器只能分析有效的语法成分。语法分析器不是一个独立的模块,在程序的预编译阶段并没有语法分析器可供使用。(这个说法并不完全准确。现在有的语言,如第9章简略提到的Template Haskell和MetaOCaml,已经实现了基于语法宏的编译时元编程。)于是,在语法分析器缺席的情况下,这些语言如果要处理程序中的新语法或者进行预编译时的程序变换,就只能通过以下几种原始的手段:
- 像C语言那样,依靠一个预编译器来进行文本替换式的宏展开;
- 通过(Java)标注或者(C++)模板在预编译阶段选择性地做一些预处理;
- 像AspectJ在Java语言下进行AOP那样,在字节码中间插入另外的指令。
有C语言背景的读者,很可能体会过使用文本替换式的宏所带来的混乱、痛苦和小心翼翼。C语言宏的窘迫恰恰反衬出Lisp宏的高明。语法的扩展性从一开始就是Lisp的设计目标,而且相关的支持设施也已经贯穿语言的整个设计过程。当Lisp之父John McCarthy决定这种语言要能够访问其自身的抽象语法时,就注定它会成为现在的样子。
到目前为止,本篇基本都在谈论宏。它操纵AST,将新的语法转换成基本的Lisp成分。宏之所以能够成为Lisp的扩展性来源,归根结底,还是在于Lisp语言本身的设计。独特的设计哲学塑造了Lisp这种与Java和C++截然不同的语言,下文将略述其中几点。
B.2.2 代码等同于数据
在Lisp语言里,所有的程序都是一个列表结构,同时这个列表结构也是代码本身的AST。这条规则导致程序代码与数据的具有完全相同的表达形式和语法。如果再推而广之,让语言的抽象语法也遵守这条简单的设计规则,那么我们就可以像访问代码和数据一样访问抽象语法,而显然这抽象语法也是一个极为简单的列表。我们用Lisp制造的任何元程序都只要遵守这种简单的、统一的表达形式即可。
B.2.3 数据等同于代码
我们可以通过Lisp的特殊成分quote
,毫不费力地在表示代码的语言构造中嵌入表示数据的构造。Lisp宏即为这种用法思路的代表例子。实际上,Lisp将它数据等同于代码的范式做了进一步的拓展,形成一种可用于编写元程序的完善的模板机制。这种机制在Common Lisp语言中称为拟引用(quasiquotation)。Clojure语言也具备同样的特性,由语法引用(syntax quote)、解引用(unquote)和接合解引用(splicing unquote)几部分构成。我们可以看下面的例子,这是Clojure语言中defstruct
宏的定义:
(defmacro defstruct
[name & keys]
`(def ~name (create-struct ~@keys)))
语法引用由反引号(`
)表示,其作用是指示Lisp将紧跟在反引号之后成分视为数据,效果与一般的引用相同。但是我们可以在被施加语法引用的成分内部,用解引用符号(~
)指示Lisp停止对指定成分的引用,并对该成分求值。Common Lisp语言的拟引用也具有类似的作用,我们可以用它来定义数据模板,其中一部分数据是固定的,而另一些数据则是计算得出的。这一套语言特性几乎相当于在Lisp的语法里内嵌一种完整的模板子语言。
第5章详细介绍了元编程的实践,并对Lisp这种对代码和数据一视同仁的特性进行了深入探讨。如果你还没有习惯Lisp的各种编程范式,那么现在可以停下来,好好想象一下这种特性会给代码生成带来怎样的精彩和活力。
B.2.4 简单到只分析列表结构的语法分析器
Lisp是一种语法极其精简的语言。Lisp的语法分析器之所以如此简单,是因为它需要分析的就只有列表而已!无论是数据还是代码,其表达的语法都是统一的列表结构。甚至我们所关心的Lisp宏,其宏体部分也是一个列表结构。
具备强大编译时元编程能力的Lisp,是一种同像(homoiconic)的语言。这一点跟Lisp之所以具有卓越的DSL实现能力有关系吗?答案很简单:我们可以贯彻Lisp的“同像”哲学,把DSL也表达成一个列表结构,并且用宏来组织DSL中出现的重复性的构造和模式。这样设计出来的DSL不需要任何额外的语法分析器,可以将一切都交给Lisp本身的语法分析器去处理。宏可以帮助我们突破Lisp成分的形式限制,拓展出新的语法和语义,向领域用语靠拢。图B-4形象地说明了用Lisp语言作为DSL载体的基本思路。
定义 同像(homoiconic)这个煞有介事的术语,描述的是语言的一种性质。如果一种语言的程序,能够用它本身所能处理的一种数据结构来表示,我们就说这种语言是“同像”的,以Lisp为例,它用列表这种结构来统一地表示代码和数据。
图B-4 作为DSL载体的Lisp。Lisp宏被转换为有效的Lisp成分,然后送到编译器
你能够从图B-4中看出Lisp是怎样集外部DSL和内部DSL于一身的吗?一方面,DSL里面含有外部语法,也就是各种宏。宏不是有效的Lisp成分。另一方面,我们不需要使用任何外部的分析器去处理这些外部语法。列表结构串起了所有的环节,而且Lisp本身的语法分析器就是万能的DSL处理器。我们在此讨论的Lisp语言的众多特点几乎使它成为一种完美的DSL实现语言。
元编程是让我们通过编写程序来编写程序的一种技术。Lisp用它的编译时宏机制来实现元编程。第B.1节是对编译时元编程的全面综述,而本节则针对Lisp这种最早具备元编程能力的语言之一,讨论了该语言下的具体实现。只有对“元”的力量有了透彻的理解,我们才能在现实的DSL实现中得心应手地运用这种范式,并领略其中的妙处。第4章和第5章准备了大量动态语言的的编译时和运行时元编程例子,所用语言包括Ruby、Groovy和Clojure。