4.4 生成式DSL:通过模板进行运行时代码生成
元编程有许多侧面,例如上文展示过不少反射式元编程的例子。VM在运行时对各种元对象进行探测,找出当前上下文内可用的对象,然后神奇地调用它们。我们还可以从不同的角度来看待元编程。其实,元编程最经典的定义是:编写“编写代码”的代码。
在不同的语言里面,这个定义的确切含义也不一样。Lisp等语言提供编译时元编程能力,我们在2.5.2节已经见识过。Ruby、Groovy等语言提供运行时元编程能力,可以在运行时通过eval
和方法的动态分发生成代码。本节将用一个具体的例子向你展示如何减少直接编写的代码,转而依靠语言运行时生成余下的部分,以此达到使DSL抽象表面紧凑的目的。你可能会问,这种做法为什么很有意义?
4.4.1 生成式DSL的工作原理
设计生成式的DSL可以少写死板重复的代码。语言将通过元编程手段代替你生成代码。图4-12形象地说明了运行时元编程生成代码的情况。
图4-12 运行时元编程方法在运行时根据元对象产生代码。元对象生成更多对象,结果是减少了死板代码的数量
程序员除了直接编写一部分对象,还操控元对象在程序运行的时候产生更多的程序元件。这些生成的元件材料就相当于语言运行时帮你编写的代码。这就好比你为了让自己集中精力对付更重要的工作而精明地指使助手,让他按照你的指令照顾好所有的例行工作。那么元对象具体是什么样子?它们在什么地方?它们怎样完成你的要求?我们一起用Ruby语言设计一些生成式DSL,边做边解释。
4.4.2 利用Ruby元编程实现简洁的DSL设计
关于证券交易和结算领域,本章已经围绕“交易”谈了不少内容。在现实的应用程序中,Trade
是一个非常复杂的模块,含有大量的领域对象和相关的业务规则、约束条件、验证逻辑。其中一些验证逻辑是通用的,适用于同一上下文内的所有相似属性,而另外一些验证逻辑专用于特定的上下文,需要在类定义中明确地写出来。但不管哪一种验证逻辑,其一般的执行过程是相同的,可以集中在一起,也可以通过合适的技术手段统一生成。
我们来看看Ruby元编程在这方面有什么办法。
1. 用类方法对验证逻辑进行抽象
假设你正在用Rails开发交易程序,其中用了ActiveRecord
进行持久化。那么按照一般惯例,Trade
模型中会出现这样的代码片段:
class Trade < ActiveRecord::Base
has_one :ref_no
has_one :account
has_one :instrument
has_one :currency
has_many :tax_fees
## ...
validates_presence_of :account, :instrument, :currency
validates_uniqueness_of:ref_no
## ...
end
如果你有过开发Rails项目的经验,肯定知道上面类定义中最后两行的作用。这两个Ruby类方法(validates_presence_of
和validates_uniqueness_of
)封装了一些针对属性的验证逻辑,设置属性的时候,传进去的参数要经过它们的检查。注意看那些作用于属性的领域约束,它们被干净利落地从公开的API界面上剥离出来,很好地示范了什么是精炼的模型设计。关于抽象设计的精炼原则,请翻阅附录A.3。在运行时,这些方法会生成相应的代码片段去验证各属性。
Ruby知识点
Ruby元编程基础知识。Ruby的对象模型包含许多可以用于反射式和生成式元编程的元件材料,例如类、对象、实例方法、类方法、单例方法等。Ruby元编程机制允许你在运行时探查其对象模型,也允许动态地改变行为或生成代码。
模块,以及通过mixin来扩展现有抽象的时候,模块在其中所起的作用。
2. 用mixin动态生成方法
我们之前在代码清单4-6中用mixin手法设计过Trade
抽象,现在做点儿类似的事情。我们希望在Trade
的类定义内写入内联的验证逻辑,但同时希望把调用和异常报告方面的烦琐构造隐藏起来,把那些重复出现的死板代码推到运行时去生成。完成后的抽象应该是下面的样子:
class Trade
include ... ➊ include什么?
attr_accessor :ref_no, :account, :instrument
trd_validate :principal do |val| ➋ 验证逻辑以块的形式出现
val > 100
end
## ...
end
这段代码在➊的位置还少了些东西(我很快会说明少了什么)。trd_validate
就是负责验证的“装置”,它要对以块的形式传入的验证逻辑➋生成运行时调用代码。
可是trd_validate
是从哪里来的呢?我们必然要在别的什么地方定义这个方法,然后把它和Trade
类的主体代码联系起来。答案也许就隐藏在➊省略的部分,我们把代码再看清楚一点儿。先不管Trade
模型怎么获得trd_validate
方法,先来看定义类方法trd_validate
的Ruby模块TradeClassMethods
:
module TradeClassMethods
def trd_validate(attribute, &check)
define_method "#{attribute}=" do |val| ➊ 为属性生成设置方法
raise 'Validation failed' unless check.call(val)
instance_variable_set("@#{attribute}", val)
end
define_method attribute do ➋ 为属性生成获取方法
instance_variable_get "@#{attribute}"
end
end
end
这段代码做了哪些事情?它利用Ruby语言在运行时动态定义方法的能力,为传递给trd_validate
方法的属性生成setter
➊和getter
➋方法,此外还顺便生成代码去调用用户以块的形式传入的验证逻辑。真不错!想想这种元编程手段给每一次调用trd_validate
省下了多少代码,再乘以所有需要trd_validate
经手的属性数量,这一代码量相当可观。
3. 最后组装
一切就绪,到了最后组装起来的时刻。我们再定义一个模块把TradeClassMethods
模块和Trade
类粘合在一起,使trd_validate
成为Trade
的一部分。代码清单4-13起到最后画龙点睛的作用。
代码清单4-13 含有领域验证的
Trade
抽象
## enable_trade_validation.rb
require 'trade_class_methods'
module EnableTradeValidation
def self.included(base)
base.extend TradeClassMethods
end
end
## trade.rb
require 'trade_class_methods'
require 'enable_trade_validation'
class Trade
include EnableTradeValidation
attr_accessor :ref_no, :account, :instrument
trd_validate :principal do |val|
val > 100
end
## ...
end
本例至此大功告成。为了避免直接编写验证逻辑的重复劳动,我们亲身体验了一把运用元编程手段生成验证逻辑代码的活动。注意,代码是在Ruby VM执行程序的时候,也就是运行时生成的。
本节要点
本章讨论的大多数模式重点放在降低DSL的烦琐程度,同时提高表现力上。Ruby和Groovy具有很强的运行时元编程和代码生成能力。当你发现DSL的实现代码显露出重复的迹象,请掂量一下要不要打开元编程的锦囊。与其自己写那些死板代码,不如让语言运行时帮你写。
本章虽长,但绝不沉闷。我们一直在演练各式绝招,将来你动手编写DSL的时候肯定会派上用场。看第一遍的时候感觉没学到家也不要紧,当你掌握了这些技巧背后的总体思路,就能看透问题的实质,用最恰当的方式刻画解答域。现在不妨换换脑子,在编程能力方面给自己充下电,因为下面即将讨论一种古老的程序开发范式在JVM平台上的新发展。我们要说的是Clojure——改头换面出现在JVM上的Lisp元编程。Ruby和Groovy元编程主要基于运行时代码生成,而Clojure元编程是通过宏在编译时完成的。我们将探讨宏会给内部DSL带来怎样的设计思路。