讀古今文學網 > Java 8實戰 > 第16章 結論以及Java的未來 >

第16章 結論以及Java的未來

本章內容

  • Java 8的新特性以及其對編程風格顛覆性的影響

  • 由Java 8萌生的一些尚未成熟的編程思想

  • Java 9以及Java 10可能發生的變化

我們在本書中討論了很多內容,希望你現在已經有足夠的信心開始使用Java 8編寫你自己的代碼,或者編譯書中提供的例子和測驗。這一章裡,我們會回顧我們的Java 8學習之路和函數式編程這一潮流。除此之外,還會展望在Java 8之後的版本中可能出現的新的改進和重大的新特性。

16.1 回顧Java 8的語言特性

Java 8是一種實踐性強、實用性好的語言,想要很好地理解它,方法之一是重溫它的各種特性。本章不會簡單地羅列Java 8的各種特性,而是會將這些特性串接起來,希望大家不僅能理解這些新特性,還能從語言設計的高度理解Java 8中語言設計的連貫性。作為回顧,本章的另一個目的是闡釋Java 8的這些新特性是如何促進Java函數式編程風格的發展的。請記住,這些新特性並非語言設計上的突發奇想,而是一種刻意的設計,它源於兩種趨勢,即我們在第1章中所說的形勢的變化。

  • 對多核處理器處理能力的需求日益增長,雖然硅開發技術也在不斷進步,但依據摩爾定律每年新增的晶體管數量已經無法使獨立CPU核的速度更快了。簡單來說,要讓你的代碼運行得更快,需要你的代碼具備並行運算的能力。

  • 更簡潔地調度以顯示風格處理數據的數據集合,這一趨勢不斷增長。比如,創建一些數據源,抽像所有數據以符合給定的標準,給結果運用一些操作,而不是概括結果或者將結果組成集合以後再做進一步處理。這一風格與使用不變對像和集合相關,它們之後會進一步生成不變值。

不過這兩種訴求都不能很好地得到傳統的、面向對像編程的支持,命令式的方式和通過迭代器訪問修改字段都不能滿足新的需要。在CPU的一個核上修改數據,在另一個核上讀取該數據的值,這種方式的代價是非常高的,更不用說你還需要考慮容易出錯的鎖;類似地,當你的思考局限於通過迭代訪問和修改現存的對象時,類流(stream-like)式編程方法看起來就非常地異類。不過,這兩種新的潮流都能通過使用函數式編程非常輕鬆地得到支持,這也解釋了為什麼Java 8的重心要從我們最初理解的Java大幅地轉型。

現在,我們一起從統一、宏觀的角度來回顧一下,看看我們都從這本書中學習了哪些東西,它們又是如何相互協作構建出一片新的編程天地的。

16.1.1 行為參數化(Lambda以及方法引用)

為了編寫可重用的方法,比如filter,你需要為其指定一個參數,它能夠精確地描述過濾條件。雖然Java專家們使用之前的版本也能達到同樣的目的(將過濾條件封裝成類的一個方法,傳遞該類的一個實例),但這種方案卻很難推廣,因為它通常非常臃腫,既難於編寫,也不易於維護。

正如你在第2章和第3章中所瞭解的,Java 8通過借鑒函數式編程,提供了一種新的方式——通過向方法傳遞代碼片段來解決這一問題。這種新的方法非常方便地提供了兩種變體。

  • 傳遞一個Lambda表達式,即一段精簡的代碼片段,比如

    apple -> apple.getWeight > 150
      
  • 傳遞一個方法引用,該方法引用指向了一個現有的方法,比如這樣的代碼:

    Apple::isHeavy
    
      

這些值具有類似Function<T, R>Predicate<T>或者BiFunction<T, U, R>這樣的類型,值的接收方可以通過applytest或其他類似的方法執行這些方法。Lambda表達式自身是一個相當酷炫的概念,不過Java 8對它們的使用方式——將它們與全新的Stream API相結合,最終把它們推向了新一代Java的核心。

16.1.2 流

集合類、迭代器,以及for-each結構在Java中歷史悠久,也為廣大程序員所熟知。直接在集合類中添加filter或者map這樣的方法,利用我們前面介紹的Lambda實現類數據庫查詢對於Java 8的設計者而言要簡單得多。不過他們並沒有採用這種方式,而是引入了一套全新的Stream API,即第4章到第7章所介紹的內容——這是值得我們深思的,他們為什麼要這麼做呢?

集合到底有什麼問題,以至於我們需要另起爐灶替換掉它們,或通過一個類似卻不同的概念Stream對其進行增強。我們把二者之間的差異概括如下:如果你有一個數據量龐大的集合,你需要對這個集合應用三個操作,比如對這個集合中的對象進行映射,對其中的兩個字段進行求和,這之後依據某種條件過濾出滿足條件的和,最後對結果進行排序,即為得到結果你需要分三次遍歷集合。Stream API則與之相反,它採用延遲算法將這些操作組成一個流水線,通過單次流遍歷,一次性完成所有的操作。對於大型的數據集,這種操作方式要高效得多。不過,還有一些需要我們考慮的因素,比如內存緩存,數據集越大,越需要盡可能地減少遍歷的次數。

還有其他一些原因也會影響元素並發處理的能力,這些也非常關鍵,對高效地利用多處理器的能力至關重要。Stream,尤其是它的parallel方法能幫助將一個Stream標記為適合進行並行處理。還記得嗎?並行處理和對象的可變狀態是水火不容的,所以核心的函數式概念(如我們在第4章中介紹的,包括無副作用的操作,通過Lambda表達式和方法引用對方法進行參數化,用內部迭代替換外部迭代)對於並行使用mapfilter或者其他方法發掘Stream的處理能力非常重要。

現在,讓我們看看這些觀念(介紹Stream時使用過這些術語)怎樣直接影響了CompletableFuture類的設計。

16.1.3 CompletableFuture

Java從Java 5版本就提供了Future接口。Future對於充分利用多核處理能力是非常有益的,因為它允許一個任務在一個新的核上生成一個新的子線程,新生成的任務可以和原來的任務同時運行。原來的任務需要結果時,它可以通過get方法等待Future運行結束(生成其計算的結果值)。

第11章介紹了Java 8中對FutureCompletableFuture實現。這裡再次利用了Lambda表達式。一個非常有用,不過不那麼精確的格言這麼說:“Completable-Future對於Future的意義就像Stream之於Collection。”讓我們比較一下這二者。

  • 通過Stream你可以對一系列的操作進行流水線,通過mapfilter或者其他類似的方法提供行為參數化,它可有效避免使用迭代器時總是出現模板代碼。

  • 類似地,CompletableFuture提供了像thenComposethenCombineallOf這樣的操作,對Future涉及的通用設計模式提供了函數式編程的細粒度控制,有助於避免使用命令式編程的模板代碼。

這種類型的操作,雖然大多數只能用於非常簡單的場景,不過仍然適用於Java 8的Optional操作,我們一起來回顧下這部分內容。

16.1.4 Optional

Java 8的庫提供了Optional<T>類,這個類允許你在代碼中指定哪一個變量的值既可能是類型T的值,也可能是由靜態方法Optional.empty表示的缺失值。無論是對於理解程序邏輯,抑或是對於編寫產品文檔而言,這都是一個重大的好消息,你現在可以通過一種數據類型表示顯式缺失的值——使用空指針的問題在於你無法確切瞭解出現空指針的原因,它是預期的情況,還是說由於之前的某一次計算出錯導致的一個偶然性的空值,有了Optional之後你就不需要再使用之前容易出錯的空指針來表示缺失的值了。

正如我們在第10章中討論的,如果在程序中始終如一地使用Optional<T>,你的應用應該永遠不會發生NullPointerException異常。你可以將這看成另一個絕無僅有的特性,它和Java 8中其他部分都不直接相關,問自己一個問題:“為什麼用一種表示值缺失的形式替換另一種能幫助我們更好地編寫程序?”進一步審視,我們發現Optional類提供了mapfilterifPresent方法。這些方法和Streams類中的對應方法有著相似的行為,它們都能以函數式的結構串接計算,由於庫自身提供了缺失值的檢測機制,不再需要用戶代碼的干預。這種進行內部檢測還是外部檢測的選擇和在Stream庫中進行內部迭代還是在用戶代碼中進行外部迭代的選擇極其類似。

本節最後我們不再涉及函數式編程的內容,而是要討論一下Java 8對庫的前向兼容性支持,這一技術受到了軟件工程發展的推動。

16.1.5 默認方法

Java 8中增加了不少新特性,但是它們一般都不對個體程序的行為帶來影響。不過,有一件事情是例外,那就是新增的默認方法。接口中新引入的默認方法對類庫的設計者而言簡直是如魚得水。Java 8之前,接口主要用於定義方法簽名,現在它們還能為接口的使用者提供方法的默認實現,如果接口的設計者認為接口中聲明的某個方法並不需要每一個接口的用戶顯式地提供實現,他就可以考慮在接口的方法聲明中為其定義默認方法。

對類庫的設計者而言,這是個偉大的新工具,原因很簡單,它提供的能力能幫助類庫的設計者們定義新的操作,增強接口的能力,類庫的用戶們(即那些實現該接口的程序員們)不需要花費額外的精力重新實現該方法。因此,默認方法與庫的用戶也有關係,它們屏蔽了將來的變化對用戶的影響。第9章針對這一問題進行了更加深入的探討。

自此,我們已經完成了對Java 8中新概念的總結。現在我們會轉向更為棘手的主題,那就是Java 8之後的版本中可能會有哪些新的改進以及新的特性出現。

16.2 Java的未來

讓我們看看關於Java未來的一些討論。關於這一主題的大多數內容都會在JDK改進提議(JDK Enhancement Proposal)中進行討論,它的網址是http://openjdk.java.net/jeps/0。我們在這裡想要討論的主要是一些看起來很合理、實現起來卻頗有難度的部分,以及一些由於和現存特性的協作有問題而無法引入到Java中的部分。

16.2.1 集合

Java的發展是一個循序漸進的過程,它從來就不是一蹴而就的。Java中融入了大量偉大的思想,比如:數組取代了集合,之後的Stream又進一步增強了集合的功能。當然,烏龍的情況也偶有發生,有的特性其優勢變得更加明顯(比如集合之於數組),但我們在做替代時卻忽略了被替代特性的一些優點。一個比較典型的例子是容器的初始化。比如,Java中數組可以通過下面這種形式,在聲明數組的同時進行初始化:

Double  a = {1.2, 3.4, 5.9};

  

它是以下這種語法的簡略形式:

Double  a = new Double{1.2, 3.4, 5.9};

  

為處理諸如由數組表示的順序數據結構,集合(通過Collection接口)提供了一種更優秀也更一致的解決方案。不過它們的初始化被忽略了。讓我們回想一下你是如何初始化一個HashMap的。你只能通過下面這樣的代碼完成初始化工作:

Map<String, Integer> map = new HashMap<>;
map.put("raoul", 23);
map.put("mario", 40);
map.put("alan", 53);

  

你可能更願意通過下面的方式達到這一目標:

Map<String, Integer> map = #{"Raoul" -> 23, "Mario" -> 40, "Alan" -> 53};

  

這裡的#{...}是一種集合常量,它們代表了集合中的一系列值組成的列表。這似乎是一個毫無爭議的特性1,不過它當前在Java中還不支持。

1當前的Java新特性提議請參考http://openjdk.java.net/jeps/186。

16.2.2 類型系統的改進

我們會討論對Java當前類型系統的兩種潛在可能的改進,分別是聲明位置變量(declaration-site variance)和本地變量類型推斷(local variable type inference)。

1. 聲明位置變量

Java加入了對通配符的支持,來更靈活地支持泛型的子類型(subtyping), 或者我們可以更通俗地稱之為“用戶定義變量”(use-site variance)。這也是下面這段代碼合法的原因:

List<? extends Number> numbers = new ArrayList<Integer>;

  

不過下面的這段賦值(省略了? extends)會產生一個編譯錯誤:

List<Number> numbers = new ArrayList<Integer>;    ←─類型不兼容

  

很多編程語言(比如C#和Scala)都支持一種比較獨特的變量機制,名為聲明位置變量。它們允許程序員們在定義泛型時指定變量。對於天生就為變量的類而言,這一特性尤其有用。比如,Iterator就是一個天生的協變量,而Comparator則是一個天生的逆變量。使用它們時你無需考慮到底是應該使用? extends,還是使用 ? super。這也是說在Java中添加聲明位置變量極其有用的原因,因為這些規範會在聲明類時就出現。這樣一來,程序員的認知負荷就會減少。注意,截至本書寫作時(2014年6月),已經有一個提議處於研究過程中,希望能在Java 9中引入聲明位置變量2。

2參見https://bugs.openjdk.java.net/browse/JDK-8043488。

2. 更多的類型推斷

最初在Java中,無論何時我們使用一個變量或方法,都需要同時給出它的類型。例如:

double convertUSDToGBP(double money) { ExchangeRate e = ...; }

  

它包含了三種類型;這段代碼給出了函數convertUSDToGBP的結果類型,它的參數money的類型,以及方法使用的本地變量e的類型。隨著時間的推移,這種限制被逐漸放開了。首先,你可以在一個表達式中忽略泛型參數的類型,通過上下文決定其類型。比如:

Map<String, List<String>> myMap = new HashMap<String, List<String>>;

  

這段代碼在Java 7之後可以縮略為:

Map<String, List<String>> myMap = new HashMap<>;

  

其次,利用同樣的思想,你可以將由上下文決定的類型交由一個表達式決定,即由Lambda表達式來決定,比如:

Function<Integer, Boolean> p = (Integer x) -> booleanExpression;

  

省略類型後,這段代碼可以精簡為:

Function<Integer, Boolean> p = x -> booleanExpression;

  

這兩種情況都是由編譯器對省略的類型進行推斷的。

如果一種類型僅包含單一的標識符,類型推斷能帶來一系列的好處,其中比較主要的一點是,用一種類型替換另一種可以減少編輯工作量。不過,隨著類型數量的增加,出現了由更加泛型的類型參數化的泛型,這時類型推斷就帶來了新的價值,它能幫助我們改善程序的可讀性。3

3當然,以一種直觀的方式進行類型推斷也是非常重要的。類型推斷最適合的情況是只存在一種可能性,或者一種比較容易文檔化的方式,借此重建用戶省略的類型。如果系統推斷出的類型與用戶最初設想的類型並不一致,就會帶來很多問題;所以良好的類型推斷設計在面臨兩種不可比較的類型時,都會給出一個默認的類型,利用默認類型來避免出現隨機選擇錯誤的類型。

Scala和C#中都允許使用關鍵詞var替換本地變量的初始化聲明,編譯器會依據右邊的變量填充恰當的類型。比如,我們之前展示過的使用Java語法的myMap聲明可以像下面這樣改寫:

var myMap = new HashMap<String, List<String>>;

  

這種思想被稱為本地變量類型推斷,你可能期待Java中也提供類似的特性,因為它能消除冗余的類型,減少雜亂的代碼。

然而,它也可能受到一些質疑,比如,類Car繼承類Vehicle後,你進行了下面這樣的聲明:

var x = new Vehicle;

  

那麼,你到底期望x的類型為Car還是Vehicle呢?這個例子中,一個簡單的解釋就能解決問題,即缺失的類型就是初始化器對象的類型(這裡為Vehicle),由此我們可以得出一個結論,沒有初始化器時,不要使用var聲明對象。

16.2.3 模式匹配

我們曾經在第14章中討論過,函數式語言通常都會提供某種形式的模式匹配——作為switch的一種改良形式。通過這種模式匹配,你可以查詢“這個值是某個類的實例嗎”,或者你也可以選擇遞歸地查詢某個字段是否包含了某些值。

我們有必要提醒你,即使是傳統的面向對像設計也已經不推薦使用switch了,現在大家更推薦的方式是採用一些設計模式,比如訪問者模式,使用訪問者模式時,程序利用dispatch方法,依據數據類型來選擇相應的控制流,不再使用傳統的switch方式。這並非另一種編程語言中的事——函數式編程語言中使用基於數據類型的模式匹配通常也是設計程序最便捷的方式。

將類Scala的模式匹配全盤地移植到Java中似乎是個巨大的工程,但是基於switch語法最近的泛化(switch現在已經不再局限於只允許對String進行操作),你可以想像更加現代的語法擴展會有哪些。現在,憑借instanceof,你可以通過switch直接對對像進行操作。這裡,我們會對14.4節中的示例進行重構,假設有這樣一個類Expr,它有兩個子類,分別是BinOpNumber

switch (someExpr) {
      case (op instanceof BinOp):
         doSomething(op.opname, op.left, op.right);
      case (n instanceof Number):
         dealWithLeafNode(n.val);
      default:
         defaultAction(someExpr);
}

  

這裡有幾點需要特別注意。我們在case (op instanceof BinOp):這段代碼中借用了模式匹配的思想,op是一個新的局部變量(類型為BinOp),它和SomeExpr都綁定到了同一個值;類似地,在Numbercase判斷中,n被轉化為了Number類型的變量。而默認情況不需要進行任何變量綁定。和採用串接的if-then-else加子類型轉換比起來,這種實現方式避免了大量的模板代碼。習慣了傳統面向對像方式的設計者很可能會說如果採用訪問者模式在子類型中實現這種“數據類型”式的分派,表達的效果會更好,不過從函數式編程的角度看,後者會導致相關代碼散落於多個類的定義中,也不太理想。這是一種典型的設計二分法(design dichotomy)問題,經常會在技術粉間挑起以“表達問題”(expression problem)4為幌子的口舌之爭。

4更加完整的解釋請參見http://en.wikipedia.org/wiki/Expression_problem。

16.2.4 更加豐富的泛型形式

本節會討論Java泛型的兩個局限性,並探討可能的解決方案。

1. 具化泛型

Java 5中初次引入泛型時,需要它們盡量保持與現存JVM的後向兼容性。為了達到這一目的,ArrayList<String>ArrayList<Integer>的運行時表示是相同的。這被稱作泛型多態(generic polymorphism)的消除模式(erasure model)。這一選擇伴隨著一定程度的運行時消耗,不過對於程序員而言,這無關痛癢,影響最大的是傳給泛型的參數只能為對像類型。如果Java支持ArrayList<int>這種類型的泛型,那麼你就可以在堆上分配由簡單數據值構成的ArrayList對象,比如42,不過這樣一來ArrayList容器就無法瞭解它所容納的到底是一個對像類型的值,比如一個String,還是一個簡單的int值,比如42

某種程度上看,這並沒有什麼危害——如果你可以從ArrayList<int>中得到簡單值42,或者從ArrayList<String>中得到String對像abc,為什麼還要擔憂ArrayList容器無法辨識呢?非常不幸,答案是垃圾收集,因為一旦缺失了ArrayList中內容的運行時信息,JVM就無法判斷ArrayList中的元素13到底是一個Integer的引用(可以被垃圾收集器標記為“in use”並進行跟蹤),還是int類型的簡單數據(幾乎可以說是無法跟蹤的)。

C#語言中,ArrayList<String>ArrayList<Integer>以及ArrayList<int>的運行時表示在原則上就是不同的。即使它們的值是相同的,也伴隨著足夠的運行時類型信息,這些信息可以幫助垃圾收集器判斷一個字段值到底是引用,還是簡單數據。這被稱為泛型多態的具化模式,或具化泛型。“具化”這個詞意味著“將某些默認隱式的東西變為顯式的”。

很明顯,具化泛型是眾望所歸的,它們能將簡單數據類型及其對應的對象類型更好地融合——下一節中,你會看到這之前的一些問題。實現具化泛型的主要難點在於,Java需要保持後向兼容性,並且這種兼容需要同時覆蓋JVM,以及使用了反射且希望進行泛型清除的遺留代碼。

2. 泛型中特別為函數類型增加的語法靈活性

自從被Java 5引入,泛型就證明了其獨特的價值。它們還特別適用於表示Java 8中的Lambda類型以及各種方法引用。通過下面這種方式你可以表示使用單一參數的函數:

Function<Integer, Integer> square = x -> x * x;

  

如果你有一個使用兩個參數的函數,可以採用類型BiFunction<T, U, R>,這裡的T表示第一個參數的類型,U表示第二個參數的類型,而R是計算的結果。不過,Java 8中並未提供TriFunction這樣的函數,除非你自己聲明了一個!

同理,你不能用Function<T, R>引用表示某個不接受任何參數,返回值為R類型的函數;只能通過Supplier<R>達到這一目的。

從本質上來說,Java 8的Lambda極大地拓展了我們的編程能力,但可惜的是,它的類型系統並未跟上代碼靈活度提升的腳步。在很多的函數式編程語言中,你可以用(Integer, Double) => String這樣的類型實現Java 8中BiFunction<Integer, Double, String>調用得到同樣的效果;類似地,可以用Integer => String表示Function<Integer, String>,甚至可以用 => String表示Supplier<String>。你可以將=>符號看作FunctionBiFunctionSupplier,以及其他相似函數的中綴表達式版本。我們只需要對現有Java語言的類型格式稍作擴展就能提供Scala語言那樣更具可讀性的類型,關於Java和Scala的比較我們已經在第15章中詳細討論過了。

3. 原型特化和泛型

在Java語言中,所有的簡單數據類型,比如int,都有對應的對象類型(以剛才的例子而言,它是java.lang.Integer);通常我們把它們稱為不裝箱類型和裝箱類型。雖然這種區分有助於提升運行時的效率,但是這種方式定義的類型也可能帶來一些困擾。比如,有人可能會問為什麼Java 8中我們需要編寫Predicate<Apple>,而不是直接採用Function<Apple, Boolean>的方式?事實上,Predicate<Apple>類型的對象在執行test方法調用時,其返回值依舊是簡單類型boolean

與此相反,和所有泛型一樣,Function只能使用對像類型的參數。以Function<Apple, Boolean>為例,它使用的是對像類型Boolean,而不是簡單數據類型boolean。所以使用Predicate<Apple>更加高效,因為它無需將boolean裝箱為Boolean。因為存在這樣的問題,導致類庫的設計者在Java時創建了多個類似的接口,比如LongToIntFunctionBooleanSupplier,而這又進一步增加了大家理解的負擔。另一個例子和void之間的區別有關,void只能修飾不帶任何值的方法,而Void對像實際包含了一個值,它有且僅有一個null值——這是一個經常在論壇上討論的問題。對於Function的特殊情況,比如Supplier<T>,你可以用前面建議的新操作符將其改寫為 => T,這進一步佐證了由於簡單數據類型(primitive type)與對像類型(object type)的差異所導致的分歧。我們在之前的內容中已經介紹了怎樣通過具化泛型解決這其中的很多問題。

16.2.5 對不變性的更深層支持

Java 8只支持三種類型的值,分別為:

  • 簡單類型值

  • 指向對象的引用

  • 指向函數的引用

聽我們說起這些,有些專業的讀者可能會感到失望。我們在某種程度上會堅持自己的觀點,介紹說“現在方法可以使用這些值作為參數,並返回相應的結果了”。不過,我們也承認這其中的確還存在著一定的問題,比如,當你返回一個指向可變數組的引用時,它多大程度上應該是一個(算術)值?很明顯,字符串或者不可變數組都是值,不過對於可變對像或者數組而言,情況遠非那麼涇渭分明——你的方法可能返回一個元素以升序排列的數組,不過另一些代碼可能在之後對其中的某些元素進行修改。

如果我們想在Java中真正實現函數式編程,那麼語言層面的支持就必不可少了,比如“不可變值”。正如我們在第13章中所瞭解的那樣,關鍵字final並未在真正意義上是要達到這一目標,它僅僅避免了對它所修飾字段的更新。我們看看下面這個例子:

final int arr = {1, 2, 3};
final List<T> list = new ArrayList<>;

  

前者禁止了直接的賦值操作arr = ...,不過它並未阻止以arr[1]=2這樣的方式對數組進行修改;而後者禁止了對列表的賦值操作,但並未禁止以其他方法修改列表中的元素!關鍵字final對於簡單數據類型的值操作效果很好,不過對於對像引用,它通常只是一種錯誤的安全感。

那麼我們該如何解決這一問題呢?由於函數式編程對不能修改現存數據結構有非常嚴格的要求,所以它提供了一個更強大的關鍵字,比如 transitively_final,該關鍵字用於修飾引用類型的字段,確保無論是直接對該字段本身的修改,還是對通過該字段能直接或間接訪問到的對象的修改都不會發生。

這些類型體現了關於值的一個理念:變量值是不可修改的,只有變量(它們存儲著具體的值)可以被修改,修改之後變量中包含了其他一些不可變值。正如我們在本節開頭所提及的,Java的作者,包括我們,時不時地都喜歡針對Java中值與可變數組的轉化展開討論。接下來的一節,我們會討論一下值類型(value type),聲明為值類型的變量只能包含不可變值,然而,除非使用了final關鍵詞進行修飾,否則變量的值還是能夠進行更新。

16.2.6 值類型

這一節,我們會討論簡單數據類型和對像類型之間的差異,並結合前文針對值類型的討論,希望能借此幫助你以函數式的方式進行編程,就像對像類型是面向對像編程不可缺失的一環那樣。我們討論的很多問題都是相互交織的,所以,很難以區隔的方式解釋某一個單獨的問題。所以,我們會從不同的角度定位這些問題。

1. 為什麼編譯器不能對Integerint一視同仁

自從Java 1.1版本以來,Java語言逐漸具備了隱式地進行裝箱和拆箱的能力,你可能會問現在是否是一個恰當的時機,讓Java語言一視同仁地處理簡單數據類型和對像數據類型,比如將Integerint同等對待,依賴Java編譯器將它們優化為JVM最適合的形式。

這個想法在原則上是非常美好的,不過讓我們看看在Java中添加Complex類型後會引發哪些問題,以及為什麼裝箱會導致這樣的問題。用於建模複數的Complex包含了兩個部分,分別是實數(real)和虛數(imaginary),一種很直觀的定義如下:

class Complex {
    public final double re;
    public final double im;
    public Complex(double re, double im) {
        this.re = re;
        this.im = im;
    }
     public static Complex add(Complex a, Complex b) {
        return new Complex(a.re+b.re, a.im+b.im);
     }
}

  

不過類型Complex的值為引用類型,對Complex的每個操作都需要進行對像分配——增加了add中兩次加法操作的開銷。我們需要的是類似Complex的簡單數據類型,我們也許可以稱其為complex

這裡的問題是我們想要一種“不裝箱的對象”,可是無論Java還是JVM,對此都沒有實質的支持。至此,我們只能悲歎了,“噢,當然編譯器可以對它進行優化”。壞消息是,這遠比看起來要複雜得多;雖然Java帶有基於名為“逃逸分析”的編譯器優化(這一技術自Java 1.1版本開始就已經有了),它能在某些時候判斷拆箱的結果是否正確,然而其能力依舊有一定的限制,它受制於Java對對像類型的判斷。以下面的這個難題為例:

double d1 = 3.14;
double d2 = d1;
Double o1 = d1;
Double o2 = d2;
Double ox = o1;
System.out.println(d1 == d2 ? "yes" : "no");
System.out.println(o1 == o2 ? "yes" : "no");
System.out.println(o1 == ox ? "yes" : "no");

  

最後這段代碼輸出的結果為“yes”“no”“yes”。專業的Java程序員可能會說“多愚蠢的代碼,每個人都知道最後這兩行你應該使用equals而不是==”。不過,請允許我們繼續用這個例子進行說明。雖然所有這些簡單變量和對象都保存了不可變值3.14,實際上也應該是沒有差別的,但是由於有對o1o2的定義,程序會創建新的對象,而==操作符(利用特徵比較)可以將這二者區分開來。請注意,對於簡單變量,特徵比較採用的是逐位比較(bitwise comparison),對於對像類型它採用的是引用比較(reference equality)。因此,很多時候由於編譯器需要遵守對象的語義,我們隨機創建的新的Double對像(Double對像繼承自Object)也需要遵守該語義。你之前見過這些討論,無論是較早的時候關於值對象的討論,還是第14章圍繞更新持久化數據結構保證引用透明性的方法討論。

2. 值對像——無論簡單類型還是對像類型都不能包打天下

關於這個問題,我們建議的解決方案是重新回顧一下Java的初心:(1) 任何事物,如果不是簡單數據類型,就是對像類型,所有的對象類型都繼承自Object;(2) 所有的引用都是指向對象的引用。

事情的發展是這樣開始的。Java中有兩種類型的值:一類是對像類型,它們包含著可變的字段(除非使用了final關鍵字進行修飾),對這種類型值的特徵,可以使用==進行比較;還有一類是值類型,這種類型的變量是不能改變的,也不帶任何的引用特徵(reference identity),簡單類型就屬於這種更寬泛意義上的值類型。這樣,我們就能創建用戶自定義值的類型了(這種類型的變量推薦小寫字符開頭,突出它們與intboolean這類簡單類型的相似性)。對於值類型,默認情況下,硬件對int進行比較時會以一個字節接著一個字節逐次的方式進行,==會以同樣的方式一個元素接著一個元素地對兩個變量進行比較。你可以將這看成對浮點比較的覆蓋,不過這裡會進行一些更加複雜的操作。Complex是一個絕佳的例子用於介紹非簡單類型的值;它們和C#中的結構struct極其類似。

此外,值類型可以減少對存儲的要求,因為它們並不包含引用特徵。圖16-1引用了容量為3的一個數組,其中的元素012分別用淡灰、白色和深灰色標記。左邊的圖展示了一種比較典型的存儲需求,其中的PairComplex都是對像類型,而右邊展示的是一種更優的佈局,這裡的PairComplex都是值類型(注意,我們在這裡特意使用了小寫的paircomplex,目的就是想強調它們與簡單類型的相似性)。也請注意,值類型極有可能提供更好的性能,無論是數據訪問(用單一的索引地址指令替換多層的指針轉換),還是對硬件緩存的利用率(因為數據現在採用的是連續存儲)。

圖 16-1 對象與值類型

注意,由於值類型並不包含引用特徵,編譯器可以隨意對它們進行裝箱和拆箱。如果你將一個complex變量作為參數從一個函數傳遞給另一個函數,編譯器可以很自然地將它們拆分為兩個單獨的double類型的參數。(由於JVM只提供了以64位寄存器傳遞值的方法返回指令,所以在JVM中要實現不裝箱,直接返回是比較複雜的。)不過,如果你傳遞一個很大的值作為參數(比如說一個很大的不可變數組),那麼編譯器可以以透明的方式(透明於用戶),對其進行裝箱,將其轉化為一個引用進程傳遞。類似的技術已經在C#中存在;下面引用了一段微軟的介紹5:

5如需瞭解結構語法和使用,以及類與結構之間的差異,請訪問http://msdn.microsoft.com/en-us/library/aa288471(v=vs.71).aspx。

結構看起來和類十分相似,但是二者之間存在重大差異,你應該瞭解它們之間的不同。首先,類[這裡指的是C#中的類]屬於引用類型,而結構(struct)屬於值類型。使用結構,你可以創建對像[比如sic],它的行為就像那些內置[簡單]類型一樣,享受同等的待遇。

截至本書寫作時(2014年6月),Java也已經接受了一份採用值類型的具體建議6。

6John Rose等, “值的狀態”,2014年4月初始版本,http://cr.openjdk.java.net/~jrose/values/values-0.html。

3. 裝箱、泛型、值類型——互相交織的問題

我們希望能夠在Java中引入值類型,因為函數式編程處理的不可變對象並不含有特徵。我們希望簡單數據類型可以作為值類型的特例,但又不要有當前Java所攜帶的泛型的消除模式,因為這意味著值類型不做裝箱就不能使用泛型。由於對象的消除模式,簡單類型(比如int)的對象(裝箱)版本(比如Integer)對集合和Java泛型依舊非常重要,不過它們繼承自Object(並因此引用相等),這被當成了一種缺點。解決這些問題中的任何一個就意味著解決了所有的問題。

16.3 寫在最後的話

本書探索了Java 8新增加的一系列新特性;它們所代表的可能是自Java創建以來最大的一次演進——唯一可以與之相提並論的大的演進也是在10年之前,即Java 5中所引入的泛型。這一章裡我們還瞭解了Java進一步發展所面臨的壓力。用一句話來總結,我們會說:

Java 8已經佔據了一個非常好的位置,可以暫時歇口氣,但這絕不是終點!

我們希望你能享受這一段Java 8的探索旅程,也希望本書能燃起你對瞭解函數式編程及Java 8進一步發展的興趣。