Java語言設計人員筆記: 首先不要造成傷害

發表於2011-09-21

注:本文轉載自IBM – developerWorks

簡介: 儘管一些建議的語言功能可以解決遇到的某個問題,但其中大部分功能的存在都有現實環境中的根源,在這些環境中,現有功能無法使程式設計師輕鬆、清晰、簡潔或安全地表達他們想要的概念。儘管頭腦中有一個用例,“此功能使我能夠編寫我希望能夠編寫的程式碼”,但語言設計師還需要評估語言功能可能帶來的糟糕程式碼。

關於本系列

每個 Java™ 開發人員可能都有一些關於如何改進 Java 語言的想法。在本系列中,Java 語言架構師 Brian Goetz 探討一些為 Java 語言在 Java SE 7、Java SE 8 及更高版本中的演化帶來挑戰的語言設計問題。 Brian Goetz 著有一本Java 併發的權威性著作《JAVA併發程式設計實踐》。

當從頭設計語言時,我們有機會分組評估語言功能,調整它們以實現協同互動或避免消極互動。而且我們有機會通過選擇語言功能來挑選我們想要的程式語言風格和構思模型。在考慮現有 語言的新語言功能時,我們的選擇較少。我們常常無法(至少不容易)調整其他功能來適應新功能,而且某些程式語言風格已存在於該語言的方言中。在這些情況下,我們常常只能圍繞它們進行設計。

儘管一些建議的功能是由行不通的思路啟發得到的,但大部分功能在具體的用例中擁有它們的根源。它們的誕生常常離不開語言中目前表達特定語言風格的非常繁雜、冗長或零散的程式碼所帶來的挫敗感,以及 “如果我可以僅編寫……該多好” 的想法。但從啟用這段很酷的程式碼良好的功能 是一個漫長的過程。顯然,一種語言功能要值得擁有,它必須使我們能夠表達某些在之前無法表達的 “良好的” 新程式,但新語言功能也可以使我們能夠表達一些 “糟糕” 的程式。而且即使新功能可以避免新的 “糟糕” 程式,它也可能破壞現有的固定語言、使用者期望或效能模型特徵。改進現有的語言需要權衡更強的表達能力的優勢與更低的安全性、功能互動或使用者混淆的危害

一個簡單示例:在物件上使用 switch

Java SE 7 中引入的一種語言功能是允許 switch 語句操作 String 型別的變數以及原語型別和列舉。擴充套件 switch 語句的應用範圍,不僅擴充套件到字串,也擴充套件到其他型別,這已成為多年來反覆的增強請求的主題。(例如,RFE 5012262 請求不僅可在字串上使用 switch,也可在任何物件上使用 switch,通過其 equals() 方法進行比較(參見 參考資料)。另一個類似的頻繁請求是允許非常量表示式顯示為 switch 語句的 case 標籤。

乍看起來,switch 語句似乎就是等效的 ifelse 語句巢狀在句法上的改進。實際上,開發人員在 switchifelse 語句巢狀之間的選擇常常主要基於哪個在程式碼中更美觀。但它們並不相同。switch 語句的內在限制(case 標籤必須是常量值,switch 操作符僅限於行為類似於常量的型別)的存在具有效能和安全兩方面的原因。常量標籤的限制使分支計算成為了一種 O(1) 運算,而不是 O(n) 運算。在 ifelse 語句巢狀中,到達 else 塊需要執行所有比較,因為 ifelse 語句的語義需要順序執行。case 標籤被限制為類似常量的值(原語、列舉和字串),這確保了比較運算沒有副作用(因此可以實現在其他情況下無法實現的優化)。如果我們允許將任意物件用作 case 標籤,那麼呼叫 equals() 方法可能具有意外的副作用。

如果我們從頭設計語言,我們可能有更大的自由來決定程式設計師便捷性是否比這裡的可預測性更重要,並相應地定義 switch 語句的語義(和限制)。但對於 Java 語言,時機已過。將 switch 擴充套件到類似常量的值以外,可能破壞 Java 開發人員多年建立起來的效能模型,所以 switch 中允許任意物件的更高表達能力無法抵消成本。因為 String 類是不變的,並且是高度具體化和受控的,所以將它放入 switch 中很實用,但最好不要止步於此。

一個具有更大爭議的示例

Java SE 8 中最重要的新語言功能是 lambda 表示式(或閉包)。閉包是一種函式字面量,包含可視為一個值並在以後呼叫的延遲計算的表示式。而且它們具有詞法作用域,這意味著閉包內的符號的含義應該與它們的外部含義相同(在閉包內對區域性變數求模,這種求模可從詞法作用域投影變數)。自 Java SE 1.1 開始,Java 語言就擁有閉包的一種簡單形式,即內部類,但它們的限制和繁雜的語法阻礙了真正探索這類程式碼即資料機制所允許的抽象能力的 API 開發。

在語言中擁有閉包,使 API 能夠表達更具協作性的(進而更豐富的)計算,允許客戶端提供部分計算。Collections API 支援這種行為的一種有限形式,比如將一個 Comparator 傳遞到 Collections.sort(),但僅用於相對重量級的運算,比如排序。對於像 “建立一個大小大於 10 的元素列表”,我們會強制客戶端手動公開該運算,如以下示例所示:

儘管此程式碼非常緊湊且具可讀性,但 Collections API 對我們沒有太大幫助,我們被迫進入了一種基本的順序執行模式(因為 for 迴圈的語義是順序的,所以這是我們迭代 Collections 元素的惟一方式)。此運算從 Collections 提取想要的元素子集 ,是一種常見的運算。如果可以將所有控制邏輯(序列或並行的)轉移到一個庫例程中,僅使用關於我們想要哪些元素的謂詞來進行引數化。那麼程式碼可精減為以下形式:

我們可以使用內部類實現此目的,但它們的使用非常繁雜,以至於有時似乎解決辦法比問題更嚴重。內部類在開發出集合框架時就已誕生,但內部類的句法開銷使支援使用它們的 Collections API 的建立令人不太滿意。(這裡的 lambda 表示式的語法,以及集合 API 的改進,是暫時的,而且僅能作為可使用 Java SE 8 編寫哪些程式碼的建議。) 上一個例子中的 lambda 表示式是一種具有特別良好的行為的 lambda 表示式,是一種不從其詞法作用域捕獲任何值的表示式。但表達一種相對於作用域內已有的其他值的計算常常很有用,比如以下方法中對區域性變數 n 的捕獲:

內部類(以及 lambda 表示式)的一個限制是,它們僅能從其詞法作用域引用 final 的區域性變數。Java SE 8 中的 lambda 表示式使這一限制更受歡迎,它們還允許捕獲有效的 final 變數,即那些沒有宣告為 final、但在最初賦值後不會修改的變數。(如果例項上下文中存在內部類表示式,那麼內部類可引用易變的例項欄位,但這不是一回事。可以將此情況視為,內部類中對來自閉包類的欄位 x 的引用實際上是 Outer.this.x 的簡寫,其中 Outer.this 是一個隱式宣告的 final 區域性變數。)在此限制的多種動機中,最大的動機是,將區域性變數捕獲限制到 final 欄位就會允許閉包複製引用,進而保留這樣一種行為:區域性變數的生命週期就是宣告它的程式碼塊的生命週期。 毫無疑問,僅從詞法上下文捕獲不變的狀態這一限制,令程式設計師非常不滿意。他們可能不滿意的是,似乎儘管 Java 語言 final 會獲得閉包,但閉包的這一方面似乎沒有用武之地。

希望捕獲易變的區域性變數的典型程式碼示例可能類似於清單 1:

清單 1. 通過閉包捕獲易變的區域性變數(在 Java SE 8 中無效)

這看起來是一種想要做的合理(甚至明顯)的事情,這無疑也是其他一些支援閉包的語言中的一種常見語言風格。為什麼我們不想在 Java 中支援此程式碼? 首先,它看起來與最初並不相同,它是對區域性變數語義的一項重要更改。區域性變數的生命週期被限制到它的宣告所在的程式碼塊的生命週期。但是,lambda 表示式被視為值,因此可儲存在變數中並在將捕獲的變數宣告為超出範圍的程式碼塊執行之後執行。如果允許捕獲易變區域性變數,該平臺將需要將區域性變數的生命週期延長到任何捕獲它的 lambda 表示式的動態生命週期。這是對程式設計師關於區域性變數的預期的重大變更,具體來講,缺少了任何將此變數宣告為奇怪的、新的耐久區域性變數的特殊宣告。

當您認為 forEach() 方法可能希望從其他執行緒呼叫 lambda、從而易變函式可以並行應用到集合的不同元素時,問題會變得更糟。現在,我們在區域性變數 sum 上創造了一場資料競爭,因為多個執行緒可能同時希望更新它。區域性變數上的資料競爭將是一種新的危險,因為我們目前始終期望區域性變數訪問沒有資料競爭。沒有直觀的途徑來使 清單 1 中的程式碼是執行緒安全的,這使這種語言風格成為了一種等待時機發生的事故。

在這一點上,明智的方法是規避它。在併發 Java 程式中避免資料爭用比我們想象的困難得多。一個遠離此危險的安全情形是區域性變數不受資料競爭影響,因為它們只能從單一執行緒訪問。通過允許 lambda 表示式捕獲易變的區域性變數,會使它們的行為類似於欄位,而不是不可見的區域性變數,進而將它們暴露在資料競爭的危險之中。在 2011 年對語言進行讓併發和並行運算更加危險的更改是很愚蠢的。

這種語言風格有可能得到補救,比如,通過在可捕獲的易變區域性變數上定義一個修飾符(進而明確區分它們與普通區域性變數),該修飾符將捕獲這些變數的 lambda 的語義限制到定義該變數的執行緒和詞法作用域內。這樣一種功能有利有弊,它增加了語言保留特定程式語言風格(以及一種在本質上序列的過時語言風格)的複雜性。

一種更好的解決方案

此刻不增加額外的複雜性來支援此語言風格的原因在於,有一種更好的方法來獲得相同結果。此語言風格是對映(mapping) 運算與減(reduction)摺疊(folding) 運算相結合的一個示例,其中將一個聯接運算子(比如 summax)成對應用到了一個值序列。得益於聯接性,這種減運算支援並行化。我們可以直接在集合上公開一個 mapReduce() 方法,如下所示:

這裡,第一個 lambda 表示式是對映器(將每個元素對映到它的大小),第二個 lambda 表示式是一個減法器,它獲取兩個大小並相加。此程式碼計算的結果與 清單 1 中的示例相同,但採用並行友好的方式。(並行性不是沒有代價的,庫必須提供並行化,但至少在使用此方式表達語言風格時,庫可以 並行實現該運算。不僅對映和減法支援並行化,對映和減法運算也可結合到單個並行迴圈中,這樣效率更高。(而且這無需在客戶端程式碼中包含易變狀態即可完成。)

事實上,對於對映器和為整數求和而預定義的減法器,我們可以使用 size() 方法的方法引用,更緊湊地表達此過程:

一旦熟悉了以這種方式指定計算的理念,此程式碼看起來就像一個問題語句:將整數求和應用到集合中每個元素的 size() 方法的結果上。

不要與它抗爭

大部分開發人員可能無需太多時間即可確定易變區域性變數的捕獲限制有一種 “解決辦法”:將區域性變數替換為對一個一元素陣列的最終引用,如清單 2 所示:

清單 2. 使用對一元素陣列的最終引用欺騙編譯器。不要這麼做!

這段程式碼通過編譯器,進而可能提供 “在系統上成功完成一項任務” 的短暫滿足感。但它重新帶來了資料競爭的可能性。這不是個好主意,而且您不應該嘗試。就像去除了桌上型鋸床的保護套,它將增加事故風險。但與桌上型鋸床不同的是,任何受傷的手指更可能是其他人的,而不是您自己的。如果存在一種針對此情形的更安全(且可能更快)的語言風格(對映-減法),則沒有藉口編寫這樣的不安全程式碼,即使它 “在此情形下” 看起來是安全的。

結束語

對於一項新語言功能,很容易僅看到它會帶來的優秀程式碼。我們應該不停尋找更好的辦事方式,但新語言功能也可能導致發生一些確實很糟糕的事情。因為引入糟糕語言功能的風險如此之高,所以語言設計師在進行關於優勢是否多於劣勢的成本-收益分析時需要持保守態度。如果新功能值得懷疑,我們應該謹記格言 Primum non nocere:首先,不要造成傷害。

參考資料
學習

獲得產品和技術
IBM SDK for Java 7 開放 beta 程式:該開放 beta 程式提供了對最新 IBM SDK for Java 7 beta 的授權訪問。

相關文章