第3章 实现DSL
至此,对于什么是DSL,以及为何要用DSL,我们已经透彻理解。如果要开始构建DSL,那么现在该深入研究所用的技术了。虽然构建内部DSL和外部DSL所用的技术有所不同,但它们还是有一些共通之处的。本章主要关注内部DSL和外部DSL的一些共通问题,而下一章再讨论各自具体的问题。本章先不谈语言工作台,留待后续探讨。
3.1DSL处理之架构
关于DSL实现的大体结构(见图3-1),也就是所谓的DSL系统架构─可能是我们要谈论的最重要的内容之一。
迄今为止,你应该已经厌倦了听我说了无数次的“DSL是模型上面薄薄的一层结构”。这里所说的“模型”,称为“语义模型”(第11章)模式。这个模式背后的概念是:所有重要的语义行为都可以在模型中捕获,而DSL的任务就是通过解析来填充模型。所以,根据我的理解,语义模型在DSL中扮演着核心角色─事实上,全书都会首先假设,我们在使用语 义模型。(当然,在本节的最后,在我们有足够的上下文去讨论它们的章节,我会谈谈语义模型的替代方案。)
因为我是一个面向对象偏执狂,所以我理解的语义模型首先是一个对象模型。我喜欢既有数据又有行为的多功能对象模型 (Rich Object Model),但语义模型不必拘泥于此,它也可以仅仅是一种数据结构。虽然我坚持我们应该使用一个合适的对象,但有数据模型来表示语义模型总好过没有。所以,在本书的讨论中,尽管我会将其假定为有合适行为的对象,但事实上,数据结构也是可以用来描述语义模型的选择之一。
很多系统都使用Domain Model [Fowler PoEAA]捕获系统的核心行为,而且通常DSL就是负责组装Domain Model的重要部分,但我依然坚持把Domain Model和语义模型区分开。DSL的语义模型通常是一个系统的Domain Model的子集,因为并不是Domain Model的所有部分都适合用DSL处理。另外,DSL的任务不仅仅是填充Domain Model,它还用于 其他任务。
语义模型完全就是一个普通的对象模型,可以像操作其他所有对象模型一样操作它。在前面关于状态(state)的例子中,用状态模型的命令–查询API组装一个状态机,然后运行它,获取状态对象的行为。从某种意义上说,它与DSL是相互独立的,但在现实中,它们又是焦不离孟,孟不离焦。
(如果有编译器背景知识,你可能会将Domain Model等同于抽象语法树。简而言之,二者不同。我们会在3.2节中再进行分析。)
分离语义模型和DSL有几个好处。首先,我们可以暂时不纠结于DSL的语法和解析器,而专注于当前领域的语义。如果用上DSL,这就说明我们所表达的东西已经非常复杂,复杂到了要拥有自己的模型来表示。
特别是,这样一来,就可以直接创建语义模型中的对象,操作它们进行测试。比如,可以创建一堆状态和迁移(transition),测试事件(event)和命令(command)是否运行良好,而无须处理解析。如果状态机的执行存在问题,问题必然在模型中,无须理解解析如何运作。
对于显式的语义模型而言,可以用多种DSL组装它。比如,从简单的内部DSL开始,稍后再给出一个更加易读的外部DSL版本。考虑到既有的脚本和用户,也许我们希望保留既有的内部DSL,同时支持内部和外部DSL。因为二者都可以解析成相同的语义模型,所以这么做并不困难。它还可以帮助我们避免语言之间的重复。
更为重要的是,拥有一个独立的语义模型,模型和语言就可以独立演化。如果要改变模型,无须修改DSL就可以探索做法,模型能够工作之后,给DSL添加必要的语言构造即可。同样,如果需要尝试不同的DSL语法,只要验证它们是否可以创建相同的模型对象即可。比较它们组装语义模型方式的不同,就可以知道两种语法之间的区别。
从许多方面来看,语义模型和DSL语法的分离实际上反映了领域对象及其表现的分离,这种做法在企业级软件设计中是很常见的。有时,我甚至把DSL视为另一种类型的用户界面。
对比DSL和表现,也有一些局限性。DSL和语义模型始终还是关联的。如果要给DSL增加新构造,就需要保证语义模型中有支持,这也就意味着二者需要同时修改才可以。但是另一方面,二者的分离可以将语义问题和解析问题分开思考,从而简化了很多事情。
内部DSL和外部DSL的不同就在于解析这一步,既包括解析的目标,也包括解析的方式。两种风格的DSL都会产生同样的语义模型,正如前文暗示的一样,没有理由不使用单独的语义模型。事实上,这也正是我在编写状态机的例子中所使用的策略:多种DSL,一个语义模型。
当使用外部DSL时,DSL脚本、解析器和语义模型之间有条清晰的界限。DSL脚本由一种独立的语言编写,解析器读取这些脚本,然后组装语义模型。而使用内部DSL时,它们之间更容易混杂在一起。我提倡使用一个显式的对象层次(“表达式生成器”(第32章)),后者提供必要的连贯接口作为一种语言,然后,运行DSL脚本,调用表达式生成器的方法组装语义模型。所以对内部DSL而言,DSL脚本的处理是由宿主语言解析器和表达式生成器共同完成的。
这让我们想到另一个有趣的问题:在内部DSL中使用“解析”这个词可能令人感觉古怪,我承认,我对此也并不感到十分舒服。然而,我发现,对内部DSL和外部DSL的处理进行类比思考其实是非常有用的。传统的解析方式是,获得文本流,将文本组织为解析树,处理解析树产生有用的输出。而解析内部DSL,输入就是一系列方法调用,将它们组织到一个层次结构中(通常是隐式地在栈中组织),以便后续产生有用的输出。
这里使用“解析”这个词还涉及另一个因素,在很多场景下它并不直接处理文本。在内部DSL中,宿主语言解析器处理文本,而DSL处理器处理的更多是语言构造。但是XML DSL也有同样的情况:XML解析器把文本翻译成XML元素,而DSL处理器基于这个基础进行工作。
是时候重温一下内部DSL和外部DSL的区别了。之前采用的区分标准─是否以应用的基础语言编写─通常而言是对的,但不绝对。一个极端的例子是,应用以Java编写,而DSL以JRuby编写。在这种情况下,我依然会将其归为内部DSL,而我们用到的技术都是来自本书关于内部DSL的章节。
二者之间的真正区别在于,内部DSL使用一种可执行的语言编写,然后,通过在那种语言中执行DSL进行解析。无论 是JRuby还是XML,DSL都是嵌入载体语法中,但不同之处在于,JRuby代码是要执行的,而XML数据结构只是读取而已。当然,大多数时候,内部DSL都是以应用的主要语言所实现的,因此,通常来说,这个定义还是有用的。
一旦有了语义模型,就需要让模型按照期望工作。在状态机的例子中,这个任务就是控制安全系统。有两种方法可以达到这个目的。最简单的,通常也是最好的办法就是: 执行语义模型,因为语义模型本身就是代码,可以根据需要执行它。
另一个方法是代码生成。代码生成是指,生成单独编译和运行的代码。在一些圈子中,代码生成被视为DSL的根本。我看到一些讨论,认为任何和DSL相关的工作都需要通过生成代码来实现,代码生成不可或缺。在个别情况下,我甚至发现一些人在谈论或编写“解析器生成器”(第23章)时,总是不可避免地要谈论代码生成。DSL同代码生成并没有本质关联,大多数情况下,执行语义模型是最好的选择。
代码生成在一种情况下最为有用,就是运行模型同解析DSL不在同一个地方的时候。一个很恰当的例子就是,代码执行在一个语言选择有限的环境中,比如在硬件有限制,或者在关系数据库中。你肯定不希望在一个烤箱或者在SQL中运行解析器,因此用一种更加合适的语言生成解析器和语义模型,然后再用它生成C或者SQL。另一个类似的场景是,解析器依赖于生产环境用不到的程序库。这种情况很普遍,比如,为了DSL使用一个复杂工具,这也就是语言工作台倾向于使用代码生成的原因。
对于这些情况,在解析环境下,有一个“无须生成代码也可以运行”的语义模型还是很有用的。运行语义模型,就可以在不了解代码生成如何运作的情况下,也能够体验DSL的执行。如果不生成代码,就能够测试解析和语义,就可以更快测试以及隔离问题。基于语义模型进行验证,可以在生成代码之前就捕获一些错误。
即便在一个能够很好地解释语义模型的环境里,关于代码生成,也存在一个争论,许多开发人员发现,富语义模型中的某些逻辑非常难以理解。从语义模型生成的代码会让一切变得更加明显,不再如魔法般隐晦。在一个缺乏高水平开发人员的团队中,这点至关重要。
就代码生成而言,务必记住一点,它只是DSL蓝图中的一个可选项。用到时,它极为关键,但大多数情况用不到。在我看来,DSL如同雪地靴,在雪地中远足,当然要有一双保暖防水的靴子,而在炎炎夏日,却完全用不着。
使用语义模型生成代码还有一个好处,它解耦了代码生成器和解析器。纵然对解析过程一无所知,我也能够写出一个代码生成器,并对其进行独立测试。单凭这一点,语义模型就已值回票价了。另外,它也更容易支持生成多种目标代码,如果需要的话。