假设你有大量的对象 —— 一个对象图 ——它们是一些操作或者 API 调用的结果。任务:分析数据并将分析结果作为对象图的元数据。
这么说过于抽象?请考虑一个编辑器是如何工作的:解析器产生一个代表代码的树状结构的解析树(或者抽象语法树,AST)。然后:许多算法遍历解析树,处理数据:收集在符号表中的符号,做类型推论,使用类型做数据类型检查,等等。
但是请稍等:最后两步处理有问题:类型推论代码在哪里存贮它产生的数据为类型检查者使用?最方便的方式是将数据存储到需要数据的地方 —— 例如,如果推论出(AST 中的)一个表达式节点返回类型 Foo,那么最好就在这个节点中存储该信息。
为了说明我们的解决方案,我们会看一些 AST 方面的工具 —— 不是一个编译器,而是与编译器有类似需求的工具。在 Ruby 中, ParseTree 类库以 AST 的形式返回 Ruby 源代码。例子:
[:vcall, :obj, :say_hello, [:array, [:lit, 42], [:lvar, :foo]]]
这是一些以 ParseTree AST 方式展现的 Ruby 代码。由于此处是一篇文章而不是一个虚拟机,以对象和组织为一个图的对象引用的方式来说明问题有点困难 —— 所以我们使用 ParseTree 的 s-expr 形式来表示树。S-exprs 是嵌套的列表,每个列表代表树中的一个节点,列表的第一个元素表明了节点的类型。在上例中,该节点代表对一个虚拟方法(vcall)的调用。参数包括方法接收者(即被调用的方法中的“self”对象),方法名称和方法的参数。
我们讨论的工具可以是一个类型推论器,静态分析工具或者自动重构工具。对于这类工具的工作需求之一是类型推论,也就是,决定变量或者表达式的类型。例如,这个 AST 子句代表了一个对方法 to_s 的调用:
[:vcall, :obj, :to_s]
从上面的句子中可以收集到什么信息,可以用这些信息做什么?例如,返回值的类型是难以决定的 —— 但是既然它是 to_s 方法,它会返回代表一个对象的字符串 —— 那就让我们认为它返回”String”类型。这不是必然为真的 —— 这是一个猜测。对于诸如代码补全之类的工具,这个推测已经足够好了 —— 100%的精确在此情况下是不可能的。在某些情况下,可以收集到更多信息,分析器可以确定更精确的信息。
分析器处理解析树,用分析产生的元数据来注解 AST。为了保持代码的模块化,将分析器与元数据的消费者分离是个好主意。元数据的消费者可能是遍历 AST 并使用元数据做某些事的代码。例如,Ruby 编辑器可以高亮一个覆盖了其超类中方法的方法。
解决方案
让我们长话短说:这里是一个 ParseTree 节点的注解方案:
node = [:vcall, :obj, :to_s] <br></br>def node.set_metadata(key, value)<br></br> @_metadata ||= Hash.new<br></br> @_metadata[key] = value<br></br>end <br></br>def node.metadata<br></br> @_metadata ||= {}<br></br>end<br></br>node.set_metadata(:type, :String)
这段代码是做什么的?
秘密武器:单实例类
在 Ruby 中每个对象都是一个类的实例。不象许多其他 OOP 语言,Ruby 允许你改变一个对象的类。不要把改变对象的类与打开类(open class)相混淆。在 Ruby 中,修改一个类是可能的 —— 甚至在运行时。单实例类同样是可以修改的 —— 只不过它们的改变仅仅影响一个对象的类。看起来差别似乎很小 —— 但是它对于限制修改类对该类对象带来的影响有很大的好处。另一方面,打开类对类定义的修改影响了所有的代码和所有相应对象。打开类是有用的,但是也可能会和其他人的代码搅和在一起,而且如果有太多的代码在公共类上作打开动作,可能会与已有方法发生名称冲突。在 ParseTree 节点这个案例中 —— 这些节点是普通的 Ruby 数组 —— 意味着仅仅真正被使用的数组对象才受到影响 —— 而不是在堆中的所有的数组都受到了影响。
再换个角度来看:使用单实例类,对于类的改变是局部化的,仅对产生和使用单实例类的代码有影响 —— 这些变化对于外界永远是不可见的。而另一方面,打开类是全局性的改变:一个类名称是一个全局的变量,例如“String”指向代表字符串的类对象。就像作用域较小的变量(本地变量,成员变量)应该比作用域较大的全局变量更优先使用,在单实例类中作出的变化也应该优先于打开类。
当然,选择哪个方案(单实例或者打开类)取决于具体的情况。对于打开类,类的改变发生一次 —— 在改变之后,该类的所有对象都拥有了增加的方法。对于单实例类,类的改变(即创建单实例类,改变对象的类指针指向单实例类)在每次改变的时候都发生。
如下所示,语法是简单的:
def object_variable.method_name()<br></br> # code<br></br>end
指向对象的变量前缀于方法名。一种更灵活的更模块化的方式是使用混入(Mixin)来做。混入允许将处理某些方面的方法集合在一个模块中然后将这些方法一次性的混入到类中。例如:
module Metadata def set_metadata(key, val) @_metadata[key] = val end def metadata @_metadata ||= {} end end x = [:vcall, :obj, :to_s] x.extend Metadata x.set_metadata(:type, :String)
混入允许将定义在一个模块中的方法混入一个类中 —— 在这里,混入的是节点对象的单实例类。再次重申:现在只有这个对象具有这些混入的方法。
另一个例子 —— 除去死代码
如果静态分析器代码的例子过于抽象,那么让我们尝试另外一个工具:一个死代码移除器。“死代码”是实际上没有做任何事情的代码,它们被除去后不会影响程序的行为。例如这样的代码:
for x in [1,2,3] do<br></br>end
根据我们的注解概念,我们可以找出这类代码并象这样来注解:
node.metadata(:dead_code, :true)
但是将代码标记为死的仅仅是第一步 —— 现在我们需要除去它们。在这里我们会容易看到将代码分析和具体动作分离的意义。一个 IDE 也许仅仅希望高亮某些代码为死代码,但是 IDE 也可能提供了一种简单的方式(一种快速修复)来除去代码。
如何除去这段死代码?当然 ,可以将节点从 AST 中取出然后使用 Ruby2Ruby 来生成 Ruby 源代码。但是,这不是一个非常优美的解决方案:Ruby2Ruby 将 ParseTree AST 转换为 Ruby 源代码,但是它会丢失很多有用的信息,例如格式(空格)和注释。在 IDE 或者重构 / 清理工具中丢失这些信息是不可接受的。
元数据来救场了:节点可以用注解来记录来源的位置 —— 例如:在起初的源代码中的什么地方发现了这个特定的节点。有了这个信息就容易了:清理代码只需要扫描代码,发现所有标记为 dead_code 的节点并根据元数据中的源代码位置的字符偏移量将这些节点在源文件中删除。(当然 一旦这些节点被删除了,剩下的节点的偏移量会变化。这个问题可以通过简单地跟踪被删除的字符数量,并将此数量从实际的偏移量中减去来解决。这样,就不必重新解析和分析代码了)。
结论
这篇文章中的例子着重于语言方面的工具 —— 但是文章的思想是适用于所有的对象集合的。在任何对象图需要被注解的地方,也许是在独立开发的解析器的多个处理步骤中,将注解与节点一起保存是很方便的。如果对象图的类不是在开发者的控制之下而且在其他地方被广泛使用(例如,在 ParseTree 中的数组类),单实例类是个好的选择。对于其他情况,打开类也许是一个更好的方案。
本文所提到的特殊工具的实现:ParseTree 在 Ruby 1.8. x、Rubinius 和 JRuby ( jparsetree ) 中都有实现。JRuby 中的 ParseTree 版本增加了每个节点的来源位置信息,所以更容易修改源文件。愿你也能想出一些工具的创意并实现它们 —— 用 Ruby 是很容易的。
查看英文原文: Using singleton classes for object metadata
译者简介: 曹云飞,西安交通大学计算机软件硕士。现就职于 Ethos ,热衷于计算机理论与应用技术的钻研,软件架构与敏捷开发,目前从事 consumer product 方面的工作。参与 InfoQ 中文站内容建设,请邮件至 china-editorial@infoq.com 。
评论