从 Lisp 程序员的视角看 Java 语言的缺点
引子
带着三分无奈和七分不情愿,终于把 Java 复习了一遍。教材用的是我大学时买的《Java 2 编程指南: SDK 1.4》,虽说老了一些,但书绝对是好书,讲得很透彻。我终于想起来了,Java 语言如果是 1995 年左右诞生的话, 我当时在杂志上读到了,《大众软件》或者《计算机应用文摘》,主标题好像是《Java 来了》。可惜当时我还在学 Turbo C 呢,忙不过来,于是把 Java 忽略了。
那么 Java 跟 Lisp 之间又有什么关系呢?首先,Sun 公司 Java 语言规范的制定者之一 Guy Steele 同时也是 Common Lisp 标准化委员会的成员之一,Common Lisp 标准草案文档《CLTL2》的作者,以及 Lisp 论文《The Evolution of Lisp》的作者之一,这就意味着 Java 语言在定义的时候深受 Common Lisp 的影响,至少在定义 Java 的时候知道 Lisp 究竟是什么样子的;其次,Java 语言发明时引入的一些新特性(虚拟机,GC,流)根本就是来自 Common Lisp 的。
我对 Java 语言的总体理解是,设计者试图实现一个 OO 语言,它要在语法上尽可能接近 C,运行时环境上接近 Lisp,OO 部分则需要解决 C++ 中的一些难题。最后得到的是一个丑陋的设计,而且经常拆东墙补西墙。Java 的唯一创新应该是强制的软件包(库)管理系统,这对实现软件工程却极其有利。铺天盖地的 jar 包极大地扩展了 Java 语言的应用范围,组件重用也变得轻而易举了。最后,各种 Java IDE 弥补了程序中废话太多的不足。
非 OO 部分
Java 虽然有 GC 系统帮忙清理内存,但整个语言似乎在鼓励程序员肆意浪费内存,我从 hello world 上就看到这点了。为了生成格式化的输出,Java 提供了 System.out.println(),其地位相当于 C 的 printf() 和 Common Lisp 的 format。Java 版本是最浪费内存的,因为它在运行期是通过字符串拼接的方式来产生需要输出的最终字符串的,而字符串拼接操作的所有中间结果以及最终结果在输出完成以后都要被丢弃,然后等待 GC。相比之下,printf 或 format 的格式化字符串更像是一段执行输出操作的微程序,不但表达能力上来了,格式字符串本身也不存在运行期的自我复制。
Java 数据的创建过程和 C 差不多,允许对数据进行静态初始化。问题是数组初始化语法 { ... } 不但局限性很大(无法简单地将所有数组元素初始化成同一个值),而且该语法本身并不是一个合法的表达式,但却可以写在等号的后面,从而给编译器带来了额外的负担。相比之下,Common Lisp 的数组是由一个普通的函数 make-array 生成的,不但接受用来初始化数组元素的列表,还接受用来初始化整个数组的单个值;更重要的是,通过使用特殊的关键字参数,Common Lisp 的数组是可变大小的,必要时还存在类似指针的配套游标对象 (fill-pointer) 以支持灵活地向数组中输入数据。
Java 把所有从 C 那里过继来的基本数据类型又给重新封装了一次,例如 int 封装成了 java.lang.Integer。这样做真的有必要吗?我看也未必。究其根源,Java 语言虽然让类 (class) 成为程序的最基本元素了,却没有配套地把所有的函数 (function) 都变成方法 (method)。诸如 sin/cos 和 max/min 这样的操作符仍然沿用了 C 语法,但 Java 设计者却不能接受更多的这类全局函数了,于是创造了基本数据类型的封装类,然后把更多的高级运算符以类方法的形式只供封装类的对象使用。Common Lisp 也有对象系统,称为 CLOS。知道 CLOS 是怎么做的吗?所有的方法调用 (method call) 都跟普通函数调用在形式上是一样的,而所有基本数据类型直接被并入 CLOS 的类层次体系了,在 Common Lisp 中,如果单纯观察一段用户代码的话,甚至无法鉴别究竟一个操作符是函数还是方法。我们把具有相同名称的所有方法称为广义函数 (generic function)。
P. S. 近年来某些更恶心的语言——我不确定是 Python 还是 Ruby——试图避免 Java 的这种尴尬,直接允许基本数据类型作为对象使用,例如 sin(1) 可以写成 1.sin()。这在一方面说明 Java 在这个地方确实设计得不怎么样,另一方面即便这么做也是误入歧途了。一门语言中所有不同类型的子程序调用都应该具有统一的形式,无论是普通函数还是具有多态性的方法 (method),这才是最美的设计。你们写 1+1 时,我们写 (+ 1 1);你们写 sin(x) 时,我们写 (sin x);你们说 you.fuck() 时,我们可以说 (fuck you) !!!
Java 的字符串系列操作符(String, StringBuffer, StringTokenizer, interning, ...)大概是整个基础语言中花费心思最多的部分了。这部分的主要问题是 "正交性“ 不足。就是说,字符串这种数据类型事实上包含了两个属性,首先它是一个串,也就是向量或者一维数组,其次它是由字符所组成的。一个充分正交的语言应当把串操作符和字符操作符分开定义,并让前者可在向量或一维数组上使用。比如说 Java 定义了一些在字符串中做查找和替换之类的方法,但这些事情其实在一维数组里也是有用的;而另一个方法,比如说检测整个字符串是否全部由数字或字母所构成,或者在不考虑大小写的前提下比较两个字符串的内容,这些才是 String 类的份内工作!Common Lisp 的基本数据类型是具有层次关系的,一维数组 (也称为向量) 和列表通称为序列 (sequence),并且诸如查找、替换和著名的 map 与 reduce 函数都是用于一般性序列的操作符。C++ 的 STL 也有类似的特征,不知道是不是跟 Lisp 学的。
P. S. Java 的字符数组和字符串是不同的类型?一切都是字符串整体作为一个对象所惹的祸。
OO 部分
Java 语言的 OO 部分整体感觉比 C++ 略强一些,但很多 C++ 的 OO 问题并不是真的解决了,而是被语言直接禁止了。(比较遗憾的是我 Objective-C 不熟,没法比较,这么多年苹果电脑算是白用了)
Java 类名和程序中的变量名似乎是在同一个名字空间的。这是因为 Java 在调用类的静态方法或静态成员时是将类的名字放在对象的位置上,例如 System.out 以及 Class.forName()。这恐怕就是为什么 Java 教材中建议所有变量的名字都采用小写开头,而所有类的名字都用大写开头的缘故,怕程序员一不小心就名字冲突了。我相信 Java 编译器才不管这一套,所有出现在 . 之前的符号在编译期都要仔细地检查它究竟是附近定义的一个变量,还是来自遥远 jar 包的一个类名。Common Lisp 怎么处理静态成员的问题?我们可以用 MOP 的 class-prototype 函数从任何类中提取出一个原型对象来,然后就像使用正规对象一样来使用它。而且由于类的实例化过程是通过普通函数实现的,类的名字有自己的命名空间,跟函数、变量同名也没有关系。
嵌套类的存在就是一个悲剧,还嫌不够乱吗?我们接受局部函数是因为这可以消除重复的模式,让局部代码可重用;我们接受局部变量是因为这些东西可以帮助我们缓存中间结果;嵌套类有什么意义?类是对象结构的描述,这点儿破事儿难道还要掖着藏着不让整个程序知道吗?Java 书的这个地方我没仔细看,但如果一个嵌套类的实例被传给了完全无关的其他类的话,嵌套类的私有方法还能随便地被调用吗?
P. S. 我可以接受匿名类及其存在的理由,但 Java 编译器不应该针对每个匿名类 (还有嵌套类) 都分别编译出单独的 .class 文件啊!ABCL 源代码中的一个 .java 文件经常可以被编译出超过 100 个 .class 文件,这不是精神病嘛。
Java 对多继承问题的妥协。我听说 C++ 里麻烦的钻石继承问题,推荐的解决方案是改用虚继承;Java 用一种不允许带有成员变量的特殊类——接口 (interface),把这个事情给避开了。为什么类不能多继承而接口就可以呢?哦,因为 Java 类的继承过程是跟 C++ 学的,子类的数据结构直接挂接在基类数据结构的后面,子类所定义的成员变量都被认为是全新的,而无论其名字是否与某个基类的成员同名。多继承是必需的,因为整个世界在本体论的意义上确实是单根多继承的。于是接口作为一种半残废的类出现了——它只允许有象征性的成员函数,而决不允许拥有成员变量。这样接口多继承中的钻石继承问题总算是混过去了,但这样搞出来的一切都是虚的,为了让这些接口类能真正的用来做事,你不得不用一个类来配合它,给它注入成员变量和实际的方法代码。
Common Lisp 对象系统 (CLOS) 是如何处理钻石继承问题的?简单地说,我们没有必要处理。因为所有类层次关系中同名的成员变量都被认为是同一个!但是子类为什么要重复地定义基类已有的成员变量呢?因为它需要特化基类的成员类型和其他属性,例如基类的某个成员是数值类型的,那么子类可以进一步说它是整型的,这是有意义的。Common Lisp 之所以能做到这点,是因为 Lisp 系统有权限访问所有那些基类的成员清单,但 Java 和 C++ 似乎都不可以。当然,如果允许同名的成员变量被视为等价的话,名字空间的问题就再次浮出水面了。Java 似乎把 C++ 的 namespace 特性直接干掉了,这样一来,如果采用了 Common Lisp 的解决方案,那么名字冲突就太可怕了,随便给私有成员变量起个名字就可能跟某个上层基类的同名成员相冲突,这显然是不好的。
后记
敝人的 Java 纯属初学,以上关于 Java 特性的描述如有失当之处,希望有关读者予以指出,深表谢意。