Category Archives: Java

编译性和解释性语言实现

你可能听说过“解释性语言”这个词,它意味着一种间接被软件解释器程序执行(解释)的语言;与之相对的是“编译性语言”,即可以直接被翻译为机器码并交给 CPU 执行的语言(实际上,CPU 也可以被看作一种机器指令的解释器,只不过是硬件集成的)。然而,无论是“解释”或是“编译”,都并非是语言本质上的特性,而只是实现语言的方式。理论上来说,任何语言都同时可以被编译和解释。所以,“解释性语言”、“编译性语言”在实践时通常是指“频繁被解释的语言”、“频繁被编译的语言”。

早期的程序语言,要么就是被编译,要么就是被解释,所以他们只需要编译器或解释器的其中一个。比如,Shell 脚本就总是由命令行解释器(软件)来执行,而 C 程序总是由 C 编译器一次性翻译为机器码。随着计算机科学的不断发展,这种单调性也逐渐被淘汰,出现了很多使用混合模式的语言实现——同时拥有解释性和编译性。

官方的 Ruby 实现(CRuby)在 1.9 版本之前还没有一个虚拟机,所以它只有一个被称为 MRI(Matz’s Ruby Interpreter)的解释器。MRI 是一个纯解释性质的 AST walker(AST = Abstract Syntax Tree),硬直翻译成中文就是“抽象语法树步行器”,意即是在表示了整个程序的抽象语法树上行走并求值(之所以说“抽象”,是因为这个语法树并不一定非得有一个物理的树结构)。

到了 CRuby 1.9 版本时,出现了 YARV 这个 Ruby 虚拟机,“解释性”再也不是 CRuby 的唯一特性。在执行 Ruby 程序时,YARV 会首先将 Ruby 代码编译为 YARV 虚拟机器指令,这是一种中间代码,和 JVM 的字节码,CLR 的 托管代码类似。之后,YARV 再在这一系列 YARV 指令上进行动态的解释。所以 YARV 即是编译器,也是解释器。它进行了一种被称为 预编译(AOT,Ahead of Time Compilation)的处理,使得优化代码的空间得到了提升。

同理,同样是基于虚拟机之上的 JRuby 和 IronRuby 等实现自然也都会先对代码进行编译,产生一种中间代码。当然,他们还有别的模式,比如下文提到的 JIT 编译。

为了进一步强调第一段黑体文字所描述的事实,这里再举两个例子。

C 语言通常被描述为“编译性语言”,但实际上只是“频繁被编译的语言”。目前已经存在很多 C 解释器以及 C 虚拟机,能够让 C 被解释执行,或混合编译和解释执行。两个比较有名的是 CINT 和 Ch。

Sun 的 Java 实现更是典型的混合模式实现。它的虚拟机(JVM)发展至今,已然十分健全,除了预编译器以外,还有即时编译器(JIT,Just-in-Time Compiler),充分应用了 LLVM 结构。即时编译机制会进行运行时程序概要分析,巧妙地找到所谓的被经常执行的被称为“热点”(hot-spot)的一段指令,在运行时重编译为本地机器码,并让 CPU 静态执行。基于 JVM 之上的 JRuby 也早已支持 JIT 了。

方法分派

Ruby 和大部分 OO 语言一样,都是在语言、语法层仅支持单一方法分派的语言。单一分派(single dispatch)是在通过迟(动态)绑定实现多态时的一种思路,它依靠接收者的运行时类型动态地分派应该调用的方法。

Smalltalk、Python 以及 Ruby 使用的都是一种实现方式,那就是通过方法分派函数(dispatch function)和分派表(dispatch table)进行消息到方法的映射。每个类型在定义时就内置了这样的分派函数和分派表,后者是一个消息(符号)到实际方法的映射表,而前者接受一个输入符号,然后通过分派表映射到正确的方法(其间包含处理派生类继承的方法和模块 mixin 后的方法等比较复杂的过程)。当我们在 Ruby 中以 obj.method 的形式调用一个方法时,按照 Smalltalk 的程序语言概念,就是向 obj 这个对象发送了一个消息,传递了一个 :method 符号,而作为接收者的 obj 会通过分派函数在分派表中找到对应的方法。这整个过程就是所谓的动态方法分派(dynamic method dispatch)。

比如:基类和派生类有同名方法,但在实际调用时,随着接收者的不同,实际调用的方法也不同,当接收者是基类实例时调用基类实例方法,当接收者是派生类实例时调用派生类实力方法。关于这个的更多信息,还可以参考本主题早期的一篇关于迟绑定的回帖。

既然 Ruby 使用的分派模式唤作单一分派,那自然还有多重分派(multiple dispatch)。Common Lisp 是一个众所周知的最早支持多重分派的语言。多重分派通常是指在动态判断接收者类型的同时也判断方法参数的动态类型的分派模式。更有甚者,会判断参数的值,返回类型,或返回值。

要注意这里是方法参数的动态类型(运行时类型),而不是静态类型,这是关键。对于支持静态类型的语言来说,由于方法参数的类型可以在编译时决定,所以他完全可以做静态分派,也就是静态绑定(早绑定)。静态类型语言中的方法重载就是这样的一个概念——静态类型的参数在编译时就可以确定,所以方法的分派也是静态的。

比如,C++ 的方法重载,就是通过不同的静态类型的参数实现的,这个过程不属于动态分派,自然也就不能称之为多重分派。实际上,大多数 C++ 编译器都会给重载的方法/函数进行一个所谓的方法名/函数名装饰(function name decoration)的处理,所以在链接的时候,实际参与链接使用的正式函数名并不是你在源代码中敲下的函数名,而会有一些别的奇怪的符号,这个想必自己写过 C++ 共享库的朋友都深有体会。动态多重分派的严格定义,是指方法名不变,根据接收者和参数类型(甚至参数值、返回类型、返回值)判断应该调用的方法的一种多态。当然,由于 C++ 本身有虚函数的机制,所以还是支持动态分派的,只不过不支持多重分派罢了。

像后来的 Java 这样的静态类型语言,看起来也是支持方法重载,实际上也和 C++ 一样,只是单一分派罢了。C# 是个怪胎,C# 4.0 同时支持静态类型和动态类型,这使得多重分派在 4.0 中得到了支持。Python、Ruby 都是没有静态类型的语言,所以不可能像 C++ 那样去根据静态类型在编译时做函数名装饰,也就不可能有静态分派。因此,传统意义上的方法重载在这样的纯动态类型语言中是不存在的。由于不能重载,相同名称的方法就只有一个定义,所以在语言层、语法层是不能实现多重分派的。要想在仅支持单一分派的语言中使用多重分派,只能在高层模拟进行,比如 Ruby 可以通过 Object#class 去判断对象的运行时类(型),然后根据类型的不同分别处理。很多第三方的扩展库就是这么做的。

def foo(bar)
  case bar
  when String
    p 'bar'
  when Symbol
    p :bar
  end
end

foo :foo
foo 'foo'

输出:

:bar
"bar"

字符串扣留

先从一个 Ruby 实例看起:

p "hello".object_id, "hello".object_id

object_id 可以获取一个对象的唯一标识,而这行代码打印出了不同的 ID,说明两次引用的字符串字面值生成了两个不同的字符串对象,但它们的内容是一致的。这在面向对象语言中是一个普遍的现象,在某些场合下可能会引起以下问题:

  1. 当频繁使用 Ruby 的字面值指定某个字符串时,每次通过 Ruby 的字面值指定的字符串都会生成一个新的对象,长此以往就会产生大量的内容相同的字符串对象。这种空间的浪费意味着不必要的数据占用了更多的 Cache 和内存,减少了其它资源利用 Cache 的机会,也同时增加了内存分配和回收的复杂度;
  2. 我们经常需要比较字符串是否相等(如散列表的键值比较),而无论是什么语言,底层的实现方法也无非是调用 memcmp、strcmp 之类的函数来比较内存,理论上这个过程需要 O(n) 的时间(n = 字符串长度),而如果这样的比较需要频繁进行,那代价也是不容忽视的。

当今不少高级语言中,都内置一种被称为“扣留”(Intern)的机制,它解决了这一难题。它的思想是:使用一个全局的字符串常量池来管理字符串,每次准备分配新的字符串对象之前,程序都会先检查内容相同的字符串对象是否已经被“扣留”在了常量池中,若是,则不分配新的对象,直接引用常量池中的对象;若不是则分配新的对象,并“扣留”在常量池中。这当然增加了字符串对象构造时的时间,但却解决了上述两种情况下的空间(没有产生字符串的克隆)和时间(逐字节比较字符串变为了指针相等的判断)效率问题。

这个机制最早由 Lisp 用来管理符号,后来也被 Python(intern(string))、Java(String#intern)、.NET(String.intern) 等语言、平台继承,用来实现字符串的扣留。Ruby 通过符号表实现了符号对象的扣留机制,但内置的字符串对象并没有扣留。Ruby 的 String#intern 方法看似扣留,实则是把一个已有的字符串对象转换为符号对象。使用这个方法并不会节省内存,只能起到简化字符串比较的时间复杂度的作用。所以,在 Ruby 中,操作常量的字符串并处于上述两种情况时应该尽量使用符号而不是字符串。Ruby 1.9 的新 Hash 字面值支持一种更简洁的符号键语法糖,其设计目的就是为了推荐用户使用符号而不是字符串。

防不胜防的命名冲突

这只是一个小经验——在使用 Apache Ant 构建 J2EE 工程的时候可能时不时会有这样的情况:编译时发生了错误 “undefined method `YYY’ for type `XXX’”,但在已包含的 `XXX’ 库定义中一查,证实 `XXX’ 类确实有 `YYY’ 方法,那为什么编译器还会报这个错呢?

之前在尝试编译一个工程时就发生了这样的情况,编译器提示“Node 类型没有定义 GetTextContent 方法”。通过 MyEclipse 提供的动态追踪功能找到了 GetContentText 方法的接收者(Node 类型)的声明地点,原来是一个处理 XML 文档的 JAR 类库,里面有一个 Node 类型,用来表示 XML 文档中的节点,但这个类中并没有定义 GetTextContent 方法。一查之下,才发现原来这是一个过期的 JAXP 库,而 JAXP 1.3(DOM 3)之前是没有提供 GetTextContent 这个接口的。出于某种原因,从 CVS 同步过来的工程包含了这个过期的 JAR 库,而由于 Ant 包含 JAR 会自动导入所有包,使得原有的 Node 类的反而被新导入的这个 Node 覆盖了,所以有了这个错误。

这是一个典型的命名冲突问题——由于两个不同的类有着相同的名字,使得它们不能同时生存在一个环境下。本来 Java 的包机制就是用来解决这个问题的,但由于 Ant 导入了所有包,使得不同命名空间的符号都被糅合到了全局命名空间中,失去了命名空间原本的命名冲突兼容性。

因此,在不使用 Ant 这样的构建工具时,我们在命名的时候一定要注意管理类和包的分配,最大限度利用命名空间,避免命名冲突。

package FSL;

public class Node {
    public Node() {
        # ...
    }
}
package global;
// import FSL.*; * Don't do this! *

public class Node {
    public Node() {
        # ...
    }
    public static void main(String[] args) {
        FSL.Node node1 = new FSL.Node();
        Node node2 = new Node();
        # ... We're good from here.
    }
}