B.1 DSL中的元编程
我们从2.1节得知Groovy语言具有强大的元编程能力,用它实现的DSL的表现力远远超过相应的Java实现。Groovy和Ruby这类语言允许我们动态地调整对象的运行时行为。对象在运行期间获得的各种机能使其语义具备非常高的可塑性。这些动态行为受元MOP(MetaObject Protocol,对象协议)支配,并在语言的运行时得以实现(参见B.3节文献[5])。语言的元对象协议决定了语言各构成元素的扩展性语义。下面对编程语言的MOP做进一步的解释。
定义 元对象是一种用来操纵其他对象的行为的抽象。有些OOP语言有“元类”的概念,由其负责各种类的创建和操作。为了履行职责,元类需要保有与类有关的一切信息,如类型、接口、方法、扩展对象等。
语言的MOP决定了用该语言写成的程序的扩展性语义。程序的行为取决于MOP,程序中哪些内容在编译或运行时会被扩展也取决于MOP。
元编程可以通过编写程序来产生新的程序,也能改变已有程序的行为。在Ruby和Groovy等OO语言里,元编程能够扩展既有的对象模型,它通过添加钩子来改变既有方法甚至类的行为,也可通过运行时的内省机制置入新的方法、属性或模块。而在Lisp等语言中,则以宏作为元编程的实现手段,在编译阶段对语言进行句法层面的扩展。对比起来,Groovy、Ruby的主要元编程实现形式是运行时的元编程,而Lisp的元编程是编译时的元编程,且不会引入任何额外的运行时负担(Groovy及Ruby都可以通过库来实现对AST的显式操作,这是一种编译时元编程,但其精美程度不可与Lisp同日而语,其原因将在B.2节揭晓。)Java语言也通过标注处理机制(annotation processing)和面向切面编程(aspect-oriented programming,简称AOP)的途径实现了元编程能力,并且完全在MOP内定义其扩展机制。
这种语言能用代码来生成代码吗?当你需要决断手头的语言是否足以胜任DSL实现时,必须要问自己这个至关重要的问题。元编程可以拓展宿主语言的语法,使之向领域用语靠拢,用在DSL设计上将发挥极大的威力。传统上依赖纯内嵌方式实现DSL语义的静态类型语言,如Haskell和OCaml,现在也分别通过Template Haskell(http://www.haskell.org/th/)和MetaOCaml(http://www.metaocaml.org/)进行扩展,并提供了类型安全的编译时元编程机制。
这一节我们分析几种时新语言的基本元编程能力及其在DSL设计中的作用。本书第二部分对这些元编程特性一一进行了深入探讨,同时提供了大量的应用实例。
B.1.1 DSL实现中的运行时元编程
对于DSL的宿主语言来说,是否支持元编程这种语言特性为什么如此重要?因为元编程令语言拥有扩展的能力,如果我们用一种可扩展的语言来实现DSL,那么DSL就会自动地获得扩展能力。空口白话或许不好理解,我们可以用一些例子来辅助说明什么是所谓的扩展性。
图B-1显示了一种支持运行时元编程的语言的DSL执行模型。如果语言的MOP允许对各种核心语言特性进行扩展,那么我们可以在DSL实现中利用这种能力去改变和扩展一众关联对象的核心行为。这样,DSL与MOP携手将复杂的实现隐藏起来,从而使表面语法得以保持简洁。如图B-1所示,DSL脚本通过DSL实现的解译,并在核心语言运行时及语言元编程行为机制的共同作用下,最终完成其处理过程。
图B-1 语言元模型在DSL执行中承担的角色
从上面的抽象模型中,我们可以了解元编程在DSL执行过程中所扮演的角色。MOP对增强语言的表现力有着举足轻重的的作用,我们可以通过回顾第2章Groovy实现的交易指令处理DSL来说明这一点。图B-2标示了其中的关键点。
图B-2 Groovy实现的交易指令处理DSL中,元编程发挥作用的几处关键点
图B-2中每一条标注指示的位置,都在Groovy MOP所定义的元编程机制下发生了对核心语言抽象的动态修改或扩展。这些修改和扩展都隐藏于DSL实现之内,对外的契约则保持简洁精炼,并且不增加任何非本质复杂性。
B.1.2 DSL实现中的编译时元编程
我们从第2章的例子中得知,Groovy MOP通过扩展核心语言的语义来实现在运行时期间的动态程序行为。代码生成、方法合成、消息拦截这些动作,都是在程序开始执行后发生的,这意味着Groovy和Ruby的所有元对象都是语言的运行时产物。编译时元编程允许我们在编译期间构造和操纵程序。我们可以定义新的程序构造,操作编译器完成语法变换,有针对性地对应用进行优化。编译时元编程的这些能力,恰好完美地呼应了Steele提出的设想(参见B.3节文献[6]):“对发展的规划应该是语言设计的一个主要目标。”的确,我们可以凭借编译时元编程,让语言的语法平滑地向着需要的方向演变。
语法宏(syntactic macro)是最普通的一种编译时元编程形式。不同语言的宏在复杂度和能力上有很大的差异,有像C语言预处理器那样简单的文本式的宏,也有像各种Lisp变体和Template Haskel、MetaOCaml等静态类型语言那样在AST层面进行操作的精巧的宏。本节我们将详细探讨宏及编译时元编程的某些能力,它们对精炼DSL的设计起到强大的推动作用。
除了宏,有些语言还提供其他依靠预处理器的编译时元编程方式,例如C++模板、面向切面编程(AOP)和标注处理机制。像Groovy和Scala等语言,还可以通过显式实现的编译器插件,以操作AST的方式获得一些元编程能力。这些编译时元编程方式我们会在下文一一谈及,其中讨论的重点是以Lisp语言家族为代表的基于宏的方案。
1.C++:模板
模板是C++语言首要的元编程机制。C++模板通过编译时期间对数据结构的操纵,获得强大的代码生成能力。模板这种编译时元编程形式在科学和数值计算方面的应用十分成功,被用来生成算法的内联版本,并运用诸如循环展开等技巧来优化算法的性能。
表达式模板(expression templates)也是一种有用的C++元编程技巧(参见B.3节文献[1]),它可以有效地替代C风格的回调。回调函数不可避免地会带来函数调用的系统开销,而表达式模板把各种逻辑和代数表达式直接内联在函数体内,因而避免了相应的系统开销。C++数组处理类库Blitz++(参见B.3节文献[2])就运用“表达式模板”的技巧来建立数组运算表达式的语法分析树,并进而产生优化的定制计算内核。将这种能够在编译时生成代码的技巧用于DSL设计时,我们可以把涉及向量、矩阵等高阶数据结构的运算代码写成下面的样子:
Vector<double> result(20), x(20), y(20), z(20);
result = (x + y) / z;
除了通过模板的实例化来生成代码,C++的操作符重载也是一种原始的元编程形式。作为C语言的后继者,C++也继承了C语言的宏机制,由一个位于编译器之前的预处理器来负责对宏的处理。通过宏来进行编译时元编程的,还有另外一群语言,也就是我们下一小节要谈到的Lisp语言家族。
2.Lisp和Clojure:宏
Lisp的宏机制提供了最为成熟完善的编译时元编程支持。C语言的宏局限于文本替换操作,表现力匮乏;相对地,Lisp的宏可以全面调动语言的一切扩展能力。
当Lisp表达式含有宏调用时,Lisp编译器不对调用的参数进行求值,而是原样传递给宏代码。宏代码经过处理,返回一段新的Lisp语言成分来替换处理前的宏成分,然后编译器对新的表达式进行求值。对宏调用的整个转换过程完全在编译时进行,转换产生的代码完全由有效的Lisp语言成分构成,而且完全与主程序的AST合为一体。图B-3简要示意了Lisp编译时元编程机制的构成。
图B-3 Lisp语言通过宏来提供编译时元编程能力
除了语法宏之外,Common Lisp语言还有很多天生就适合元编程的特性。比如它的代码和数据有着统一的表达形式,它的递归求值模型,它的代码由表达式而非语句构成,类似这样的语言特性会给元编程带来很大的便利。
Clojure(http://www.clojure.org)是一种由Rich Hickey开发的,在JVM上的Lisp实现。Clojure也像Common Lisp一样,通过语法宏来进行元编程。由于Clojure在JVM上实现,所以它可以无障碍地与Java相集成,且具有与Java对象互操作的能力。在本篇余下的段落,我们将用Clojure代码片段来演示Lisp语言的DSL设计之道。而且这些例子所代表的编程范式,我们也一概用Lisp来称呼。因为说到底,Clojure语言也是一种Lisp。Lisp语言本身的设计就恰好满足DSL实现对表现力的追求,其中的缘由我们会在第B.2节详述。不介意的话,现在请再看一眼图B-3。图中非常简略地描绘了预编译阶段Lisp宏生成代码的过程。
现在就让我们来对这个过程作一点深入的探索,仔细地观察宏展开过程中,变换产生最终的Lisp成分,并且被编译器求值的每一个步骤。假设我们有这样一段处理客户交易指令的DSL,它的任务是根据某些条件,将交易指令提交给交易引擎:
(when (and (> (value order) 1000000)
(is-premium-client? client))
(make-trade order broker)
(update-journal client))
片段中的when
是一个宏,其定义如下:
(defmacro when [test & body]
(list ‘if test (cons ‘do body)))
当Lisp编译器遇到宏调用时,它手头并没有可以用来对形参求值的运行时实参。编译器能看到的只有源代码。因此它将作为源代码的以下三个Lisp列表,不经求值,原样地传递给宏:
(and (> (value order) 1000000) (is-premium-client? client))
(make-trade order broker)
(update-journal client)
然后编译器以这三个列表成分为实参运行宏。形参test
被绑定为列表成分(and (> (value order) 1000000) (is-premium-client? client))
,而另外的(make-trade order broker)
和(update-journal client)
成分则被绑定到形参body
。于是在宏定义体内的反引号表达式(backquote expression)作用下,宏被下面展开之后生成的新代码取而代之:
(if (and (> (value order) 1000000)
(is-premium-client? client))
(do
(make-trade order broker)
(update-journal client)))
Common Lisp的宏机制也像Groovy MOP一样,存在一个代码生成的过程,但与Groovy不同的地方在于,这个过程发生在预编译阶段。因此Lisp运行时绝对不会遇见任何元对象,在它面前出现的全部都是有效的Lisp成分。
3.Java:标注处理机制和AOP
Java也拥有一定程度的编译时元编程能力,标注处理机制(annotation processing)和面向切面编程(AOP,参见B.3节文献[4])是它实施元编程的两个途径。Java程序里的标注会在程序构建时得到处理,而处理时生成的代码可以补充或修改原本的程序行为。
AspectJ(参见B.3节文献[3])是Java语言的AOP扩展,它有一套数量不多但威力强大的程序控制结构,可以插入到字节码当中,从而向既有程序注入新的行为。我们可以指定程序执行路径中某些明确的点,称为连接点(join point),向这些点注入含有新行为定义的通知(advice)。连接点的集合称为切入点(pointcut)。切入点、通知、再加上一些相关的Java成员定义,就构成了AspectJ的模块单元切面(aspect)。切面的作用是在特定的切入点上生成代码,相当于给Java增加了一套元对象协议。在Java语言下,利用切面来实现有限形态的DSL是可行的。例如Java EE框架的代表Spring(http://www.springframework.org)就通过这种途径给开发者提供了精干的领域语言,算是一个相当成功的范例。