從 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 特性的描述如有失當之處,希望有關讀者予以指出,深表謝意。