引子
帶著三分無奈和七分不情愿,終于把 Java 復(fù)習(xí)了一遍。教材用的是我大學(xué)時買的《Java 2 編程指南: SDK 1.4》,雖說老了一些,但書絕對是好書,講得很透徹。我終于想起來了,Java 語言如果是 1995 年左右誕生的話, 我當(dāng)時在雜志上讀到了,《大眾軟件》或者《計算機(jī)應(yīng)用文摘》,主標(biāo)題好像是《Java 來了》。可惜當(dāng)時我還在學(xué) Turbo C 呢,忙不過來,于是把 Java 忽略了。
那么 Java 跟 Lisp 之間又有什么關(guān)系呢?首先,Sun 公司 Java 語言規(guī)范的制定者之一 Guy Steele 同時也是 Common Lisp 標(biāo)準(zhǔn)化委員會的成員之一,Common Lisp 標(biāo)準(zhǔn)草案文檔《CLTL2》的作者,以及 Lisp 論文《The Evolution of Lisp》的作者之一,這就意味著 Java 語言在定義的時候深受 Common Lisp 的影響,至少在定義 Java 的時候知道 Lisp 究竟是什么樣子的;其次,Java 語言發(fā)明時引入的一些新特性(虛擬機(jī),GC,流)根本就是來自 Common Lisp 的。
我對 Java 語言的總體理解是,設(shè)計者試圖實現(xiàn)一個 OO 語言,它要在語法上盡可能接近 C,運行時環(huán)境上接近 Lisp,OO 部分則需要解決 C++ 中的一些難題。最后得到的是一個丑陋的設(shè)計,而且經(jīng)常拆東墻補西墻。Java 的唯一創(chuàng)新應(yīng)該是強(qiáng)制的軟件包(庫)管理系統(tǒng),這對實現(xiàn)軟件工程卻極其有利。鋪天蓋地的 jar 包極大地擴(kuò)展了 Java 語言的應(yīng)用范圍,組件重用也變得輕而易舉了。最后,各種 Java IDE 彌補了程序中廢話太多的不足。
非 OO 部分
Java 雖然有 GC 系統(tǒng)幫忙清理內(nèi)存,但整個語言似乎在鼓勵程序員肆意浪費內(nèi)存,我從 hello world 上就看到這點了。為了生成格式化的輸出,Java 提供了 System.out.println(),其地位相當(dāng)于 C 的 printf() 和 Common Lisp 的 format。Java 版本是最浪費內(nèi)存的,因為它在運行期是通過字符串拼接的方式來產(chǎn)生需要輸出的最終字符串的,而字符串拼接操作的所有中間結(jié)果以及最終結(jié)果在輸出完成以后都要被丟棄,然后等待 GC。相比之下,printf 或 format 的格式化字符串更像是一段執(zhí)行輸出操作的微程序,不但表達(dá)能力上來了,格式字符串本身也不存在運行期的自我復(fù)制。
Java 數(shù)據(jù)的創(chuàng)建過程和 C 差不多,允許對數(shù)據(jù)進(jìn)行靜態(tài)初始化。問題是數(shù)組初始化語法 { ... } 不但局限性很大(無法簡單地將所有數(shù)組元素初始化成同一個值),而且該語法本身并不是一個合法的表達(dá)式,但卻可以寫在等號的后面,從而給編譯器帶來了額外的負(fù)擔(dān)。相比之下,Common Lisp 的數(shù)組是由一個普通的函數(shù) make-array 生成的,不但接受用來初始化數(shù)組元素的列表,還接受用來初始化整個數(shù)組的單個值;更重要的是,通過使用特殊的關(guān)鍵字參數(shù),Common Lisp 的數(shù)組是可變大小的,必要時還存在類似指針的配套游標(biāo)對象 (fill-pointer) 以支持靈活地向數(shù)組中輸入數(shù)據(jù)。
Java 把所有從 C 那里過繼來的基本數(shù)據(jù)類型又給重新封裝了一次,例如 int 封裝成了 java.lang.Integer。這樣做真的有必要嗎?我看也未必。究其根源,Java 語言雖然讓類 (class) 成為程序的最基本元素了,卻沒有配套地把所有的函數(shù) (function) 都變成方法 (method)。諸如 sin/cos 和 max/min 這樣的操作符仍然沿用了 C 語法,但 Java 設(shè)計者卻不能接受更多的這類全局函數(shù)了,于是創(chuàng)造了基本數(shù)據(jù)類型的封裝類,然后把更多的高級運算符以類方法的形式只供封裝類的對象使用。Common Lisp 也有對象系統(tǒng),稱為 CLOS。知道 CLOS 是怎么做的嗎?所有的方法調(diào)用 (method call) 都跟普通函數(shù)調(diào)用在形式上是一樣的,而所有基本數(shù)據(jù)類型直接被并入 CLOS 的類層次體系了,在 Common Lisp 中,如果單純觀察一段用戶代碼的話,甚至無法鑒別究竟一個操作符是函數(shù)還是方法。我們把具有相同名稱的所有方法稱為廣義函數(shù) (generic function)。
P. S. 近年來某些更惡心的語言——我不確定是 Python 還是 Ruby——試圖避免 Java 的這種尷尬,直接允許基本數(shù)據(jù)類型作為對象使用,例如 sin(1) 可以寫成 1.sin()。這在一方面說明 Java 在這個地方確實設(shè)計得不怎么樣,另一方面即便這么做也是誤入歧途了。一門語言中所有不同類型的子程序調(diào)用都應(yīng)該具有統(tǒng)一的形式,無論是普通函數(shù)還是具有多態(tài)性的方法 (method),這才是最美的設(shè)計。你們寫 1+1 時,我們寫 (+ 1 1);你們寫 sin(x) 時,我們寫 (sin x);你們說 you.fuck() 時,我們可以說 (fuck you) !!!
Java 的字符串系列操作符(String, StringBuffer, StringTokenizer, interning, ...)大概是整個基礎(chǔ)語言中花費心思最多的部分了。這部分的主要問題是 "正交性“ 不足。就是說,字符串這種數(shù)據(jù)類型事實上包含了兩個屬性,首先它是一個串,也就是向量或者一維數(shù)組,其次它是由字符所組成的。一個充分正交的語言應(yīng)當(dāng)把串操作符和字符操作符分開定義,并讓前者可在向量或一維數(shù)組上使用。比如說 Java 定義了一些在字符串中做查找和替換之類的方法,但這些事情其實在一維數(shù)組里也是有用的;而另一個方法,比如說檢測整個字符串是否全部由數(shù)字或字母所構(gòu)成,或者在不考慮大小寫的前提下比較兩個字符串的內(nèi)容,這些才是 String 類的份內(nèi)工作!Common Lisp 的基本數(shù)據(jù)類型是具有層次關(guān)系的,一維數(shù)組 (也稱為向量) 和列表通稱為序列 (sequence),并且諸如查找、替換和著名的 map 與 reduce 函數(shù)都是用于一般性序列的操作符。C++ 的 STL 也有類似的特征,不知道是不是跟 Lisp 學(xué)的。
P. S. Java 的字符數(shù)組和字符串是不同的類型?一切都是字符串整體作為一個對象所惹的禍。
OO 部分
Java 語言的 OO 部分整體感覺比 C++ 略強(qiáng)一些,但很多 C++ 的 OO 問題并不是真的解決了,而是被語言直接禁止了。(比較遺憾的是我 Objective-C 不熟,沒法比較,這么多年蘋果電腦算是白用了)
Java 類名和程序中的變量名似乎是在同一個名字空間的。這是因為 Java 在調(diào)用類的靜態(tài)方法或靜態(tài)成員時是將類的名字放在對象的位置上,例如 System.out 以及 Class.forName()。這恐怕就是為什么 Java 教材中建議所有變量的名字都采用小寫開頭,而所有類的名字都用大寫開頭的緣故,怕程序員一不小心就名字沖突了。我相信 Java 編譯器才不管這一套,所有出現(xiàn)在 . 之前的符號在編譯期都要仔細(xì)地檢查它究竟是附近定義的一個變量,還是來自遙遠(yuǎn) jar 包的一個類名。Common Lisp 怎么處理靜態(tài)成員的問題?我們可以用 MOP 的 class-prototype 函數(shù)從任何類中提取出一個原型對象來,然后就像使用正規(guī)對象一樣來使用它。而且由于類的實例化過程是通過普通函數(shù)實現(xiàn)的,類的名字有自己的命名空間,跟函數(shù)、變量同名也沒有關(guān)系。
嵌套類的存在就是一個悲劇,還嫌不夠亂嗎?我們接受局部函數(shù)是因為這可以消除重復(fù)的模式,讓局部代碼可重用;我們接受局部變量是因為這些東西可以幫助我們緩存中間結(jié)果;嵌套類有什么意義?類是對象結(jié)構(gòu)的描述,這點兒破事兒難道還要掖著藏著不讓整個程序知道嗎?Java 書的這個地方我沒仔細(xì)看,但如果一個嵌套類的實例被傳給了完全無關(guān)的其他類的話,嵌套類的私有方法還能隨便地被調(diào)用嗎?
P. S. 我可以接受匿名類及其存在的理由,但 Java 編譯器不應(yīng)該針對每個匿名類 (還有嵌套類) 都分別編譯出單獨的 .class 文件啊!ABCL 源代碼中的一個 .java 文件經(jīng)??梢员痪幾g出超過 100 個 .class 文件,這不是精神病嘛。
Java 對多繼承問題的妥協(xié)。我聽說 C++ 里麻煩的鉆石繼承問題,推薦的解決方案是改用虛繼承;Java 用一種不允許帶有成員變量的特殊類——接口 (interface),把這個事情給避開了。為什么類不能多繼承而接口就可以呢?哦,因為 Java 類的繼承過程是跟 C++ 學(xué)的,子類的數(shù)據(jù)結(jié)構(gòu)直接掛接在基類數(shù)據(jù)結(jié)構(gòu)的后面,子類所定義的成員變量都被認(rèn)為是全新的,而無論其名字是否與某個基類的成員同名。多繼承是必需的,因為整個世界在本體論的意義上確實是單根多繼承的。于是接口作為一種半殘廢的類出現(xiàn)了——它只允許有象征性的成員函數(shù),而決不允許擁有成員變量。這樣接口多繼承中的鉆石繼承問題總算是混過去了,但這樣搞出來的一切都是虛的,為了讓這些接口類能真正的用來做事,你不得不用一個類來配合它,給它注入成員變量和實際的方法代碼。
Common Lisp 對象系統(tǒng) (CLOS) 是如何處理鉆石繼承問題的?簡單地說,我們沒有必要處理。因為所有類層次關(guān)系中同名的成員變量都被認(rèn)為是同一個!但是子類為什么要重復(fù)地定義基類已有的成員變量呢?因為它需要特化基類的成員類型和其他屬性,例如基類的某個成員是數(shù)值類型的,那么子類可以進(jìn)一步說它是整型的,這是有意義的。Common Lisp 之所以能做到這點,是因為 Lisp 系統(tǒng)有權(quán)限訪問所有那些基類的成員清單,但 Java 和 C++ 似乎都不可以。當(dāng)然,如果允許同名的成員變量被視為等價的話,名字空間的問題就再次浮出水面了。Java 似乎把 C++ 的 namespace 特性直接干掉了,這樣一來,如果采用了 Common Lisp 的解決方案,那么名字沖突就太可怕了,隨便給私有成員變量起個名字就可能跟某個上層基類的同名成員相沖突,這顯然是不好的。
后記
敝人的 Java 純屬初學(xué),以上關(guān)于 Java 特性的描述如有失當(dāng)之處,希望有關(guān)讀者予以指出,深表謝意。