2.4 选择DSL的实现方式
程序员随时都要面对许多选择,无论设计方针、编程范式,还是具体到某个实现的惯用法,都等待我们作决策。怎样选择才会有好的DSL,怎样选择才会有好的抽象,以及怎样选择才能满足领域用户对表现力的要求,这些我们全都讲过了;现在,我们要介绍一下你将会面对的另外一些选择。
当你决定让项目走上基于DSL开发的道路,也确定了能在DSL设计中派上用场的业务领域组件,这时候怎样决定实现策略呢?应该利用宿主语言把问题建模成内部DSL,还是应该为了表现力水平而选择外部DSL?这个问题和大多数的软件工程问题一样,并没有放之四海而皆准的正确答案。问题域不许做什么和解答域允许做什么,共同决定了我们的答案。在你下决心选择某种DSL实现技术之前,有几大因素是应该考虑的,本节就带你审视一番。
1. 重用现有设施
内部DSL搭了宿主语言的顺风车,所有的设施、语法、语义、模块系统、类型系统、错误报告方法、完整的工具链,内部DSL都能沾光。这一点绝对是内部DSL的最大实现优势。而对于外部DSL来说,任何设施都要从零开始建设,这绝非易事。在内部DSL范围内,我们又有多种实现模式可以选择,这在上一节都已经讨论过。决策主要取决于宿主语言的能力和它所支持的抽象层次。
如果宿主语言如Scala或Haskell那样拥有强大的类型系统,那么你可以考虑用其类型系统来表达领域类型,从而得到纯粹的内嵌式DSL。不过,类型内嵌不一定总是最优的选项,只有当客体语言的语法、语义都接近于宿主语言时,这才能取得好的效果。任何一方面不匹配都会使DSL与宿主语言的系统环境格格不入,使宿主语言的控制结构无法顺利地组织起DSL语句。在这种情况下,你也许应该求助于元编程技术——如果宿主语言提供了这种选择。前面已经介绍过,因为元编程允许扩展宿主语言,允许在其中加入原本没有的领域构造,所以最后得到的DSL表面语法能比类型内嵌方案表现力更强。
2. 充分利用现有的知识
有些时候,你只能根据团队成员现有的知识水平来选择实现范式。内部DSL在这一点上较有优势。不过要注意,程序员熟悉某种语言,并不等于就熟悉该语言下的DSL实现惯例。连贯接口在Java和Ruby中很常见,但并非没有陷阱。在具体的条件下必须考虑许多方面才能保证DSL语义上的一致性,比如抽象是否可变、连贯API是否上下文敏感,还有方法链的收尾问题(finishing problem,参见2.6节参考文献[4])。所有这些考虑角度都牵涉到模式或惯用法的微妙变化,对DSL的一致性有不可忽视的影响。
利用现有的知识肯定是必须考虑的因素。团队领导判断成员的专业能力,不应该根据他们对语言表面语法的熟悉程度,而应该放在实现DSL的背景下去衡量。有的团队不会勉强坚持用Java实现内部DSL的方案,而是会选用XML来实现外部DSL,并且极大地提高了生产效率,也赢得了用户的认可。
3. 外部DSL的学习曲线
也许你不敢选择外部DSL,因为觉得设计外部DSL就像设计通用编程语言那么复杂。我不怪你有这样的想法。只要一提语法制导翻译、递归下降解析、LALR和SLR这些术语,人们就很难不鉴于复杂性被它们吓到。
现实中,大多数应用程序所要求的外部DSL没必要做得像完整的编程语言那么复杂。不过,确实有些外部DSL相当复杂,必须将其学习曲线纳入开发成本去考虑。外部DSL的优点是可以定制几乎任何东西,连如何处理错误和异常都可以定制,你不会因为宿主语言的限制而被束手束脚。
4. 恰当的表现力水平
虽然内部DSL对现有设施的重用是很大的优势,但宿主语言强加的约束使你设计出来的DSL很难达到领域用户要求的表现力水平,这也是事实。在现实中,往往要等到开发环境和工具链都已经不可能更改的时候,我们才发现有的模块很适合应用DSL。因此,你不见得有机会选择最合适的语言来设计内部DSL。
在这种时候,我们有必要考虑在应用程序架构中纳入外部DSL。用外部DSL建模领域问题的最大优点,是你可以把语言的复杂度设计得正好和手头问题相匹配。外部DSL给予开发者充足的调整空间去适应用户反馈,而内部DSL就不一定能做到这一点,因为语法、语义始终在宿主语言的约束之下。
5. 组合性
在典型的应用程序开发场景中,不同的DSL或者DSL和宿主语言都有可能需要组合起来使用。内部DSL与宿主语言的组合很简单,毕竟DSL是使用同种语言实现的,而且一般都实现成宿主语言的一个库。
组合使用多种DSL就值得讨论一番了,即使所有DSL的宿主语言都一样,情况也不那么单纯。对于静态类型语言下实现的几种内嵌式DSL,必须在宿主语言类型系统的支持下才有可能无缝地组合在一起。支持函数式编程范式的语言,一般鼓励你基于函数式的组合子设计内部DSL。如果设计得当,内部DSL和组合子完全可以组合。外部DSL比较难做到组合使用,尤其当两种DSL被分别设计,又没有预先考虑组合要求的情况下,就更不可能实现了。