讀古今文學網 > Java 8實戰 > 第1章 為什麼要關心Java 8 >

第1章 為什麼要關心Java 8

本章內容

  • Java怎麼又變了

  • 日新月異的計算應用背景:多核和處理大型數據集(大數據)

  • 改進的壓力:函數式比命令式更適應新的體系架構

  • Java 8的核心新特性:Lambda(匿名函數)、流、默認方法

自1998年JDK 1.0(Java 1.0)發佈以來,Java已經受到了學生、項目經理和程序員等一大批活躍用戶的歡迎。這一語言極富活力,不斷被用在大大小小的項目裡。從Java 1.1(1997年) 一直到Java 7(2011年),Java通過增加新功能,不斷得到良好的升級。Java 8則是在2014年3月發佈的。那麼,問題來了:為什麼你應該關心Java 8?

我們的理由是,Java 8所做的改變,在許多方面比Java歷史上任何一次改變都深遠。而且好消息是,這些改變會讓你編起程來更容易,用不著再寫類似下面這種囉嗦的程序了(對inventory中的蘋果按照重量進行排序):

Collections.sort(inventory, new Comparator<Apple> {    public int compare(Apple a1, Apple a2){        return a1.getWeight.compareTo(a2.getWeight);    }});  

在Java 8里面,你可以編寫更為簡潔的代碼,這些代碼讀起來更接近問題的描述:

inventory.sort(comparing(Apple::getWeight));     ←─本書中第一段Java 8的代碼!  

它念起來就是“給庫存排序,比較蘋果的重量”。現在你不用太關注這段代碼,本書後面的章節將會介紹它是做什麼用的,以及你如何寫出類似的代碼。

Java 8對硬件也有影響:平常我們用的CPU都是多核的——你的筆記本電腦或台式機上的處理器可能有四個CPU內核,甚至更多。但是,絕大多數現有的Java程序都只使用其中一個內核,其他三個都閒著,或只是用一小部分的處理能力來運行操作系統或殺毒程序。

在Java 8之前,專家們可能會告訴你,必須利用線程才能使用多個內核。問題是,線程用起來很難,也容易出現錯誤。從Java的演變路徑來看,它一直致力於讓並發編程更容易、出錯更少。Java 1.0里有線程和鎖,甚至有一個內存模型——這是當時的最佳做法,但事實證明,不具備專門知識的項目團隊很難可靠地使用這些基本模型。Java 5添加了工業級的構建模塊,如線程池和並發集合。Java 7添加了分支/合併(fork/join)框架,使得並行變得更實用,但仍然很困難。而Java 8對並行有了一個更簡單的新思路,不過你仍要遵循一些規則,本書中會談到。

我們用兩個例子(它們有更簡潔的代碼,且更簡單地使用了多核處理器)就可以管中窺豹,看到一座拔地而起相互勾連一致的Java 8大廈。首先讓你快速瞭解一下這些想法(希望能引起你的興趣,也希望我們總結得足夠簡潔):

  • Stream API

  • 向方法傳遞代碼的技巧

  • 接口中的默認方法

Java 8提供了一個新的API(稱為“流”,Stream),它支持許多處理數據的並行操作,其思路和在數據庫查詢語言中的思路類似——用更高級的方式表達想要的東西,而由“實現”(在這裡是Streams庫)來選擇最佳低級執行機制。這樣就可以避免用synchronized編寫代碼,這一代碼不僅容易出錯,而且在多核CPU上執行所需的成本也比你想像的要高。1

1多核CPU的每個處理器內核都有獨立的高速緩存。加鎖需要這些高速緩存同步運行,然而這又需要在內核間進行較慢的緩存一致性協議通信。

從有點修正主義的角度來看,在Java 8中加入Streams可以看作把另外兩項擴充加入Java 8的直接原因:把代碼傳遞給方法的簡潔方式(方法引用、Lambda)和接口中的默認方法。

如果僅僅“把代碼傳遞給方法”看作Streams的一個結果,那就低估了它在Java 8中的應用範圍。它提供了一種新的方式,這種方式簡潔地表達了行為參數化。比方說,你想要寫兩個只有幾行代碼不同的方法,那現在你只需要把不同的那部分代碼作為參數傳遞進去就可以了。採用這種編程技巧,代碼會更短、更清晰,也比常用的複製粘貼更不容易出錯。高手看到這裡就會想,在Java 8之前可以用匿名類實現行為參數化呀——但是想想本章開頭那個Java 8代碼更加簡潔的例子,代碼本身就說明了它有多清晰!

Java 8里面將代碼傳遞給方法的功能(同時也能夠返回代碼並將其包含在數據結構中)還讓我們能夠使用一整套新技巧,通常稱為函數式編程。一言以蔽之,這種被函數式編程界稱為函數的代碼,可以被來回傳遞並加以組合,以產生強大的編程語彙。這樣的例子在本書中隨處可見。

本章主要從宏觀角度探討了語言為什麼會演變,接下來幾節介紹Java 8的核心特性,然後介紹函數式編程思想——其新的特性簡化了使用,而且更適應新的計算機體系結構。簡而言之,1.1節討論了Java的演變過程和概念,指出Java以前缺乏以簡易方式利用多核並行的能力。1.2節介紹了為什麼把代碼傳遞給方法在Java 8里是如此強大的一個新的編程語彙。1.3節對Streams做了同樣的介紹:Streams是Java 8表示有序數據,並能靈活地表示這些數據是否可以並行處理的新方式。1.4節解釋了如何利用Java 8中的默認方法功能讓接口和庫的演變更順暢、編譯更少。最後,1.5節展望了在Java和其他共用JVM的語言中進行函數式編程的思想。總的來說,本章會介紹整體脈絡,而細節會在本書的其餘部分中逐一展開。請盡情享受吧!

1.1 Java怎麼還在變

20世紀60年代,人們開始追求完美的編程語言。當時著名的計算機科學家彼得·蘭丁(Peter Landin)在1966年的一篇標誌性論文2中寫道,當時已經有700種編程語言了,並推測了接下來的700種會是什麼樣子,文中也對類似於Java 8中的函數式編程進行了討論。

2P. J. Landin,“The Next 700 Programming Languages,”CACM 9(3):157–65, March 1966.

之後,又出現了數以千計的編程語言。學者們得出結論,編程語言就像生態系統一樣,新的語言會出現,舊語言則被取代,除非它們不斷演變。我們都希望出現一種完美的通用語言,可在現實中,某些語言只是更適合某些方面。比如,C和C++仍然是構建操作系統和各種嵌入式系統的流行工具,因為它們編出的程序儘管安全性不佳,但運行時佔用資源少。缺乏安全性可能導致程序意外崩潰,並把安全漏洞暴露給病毒和其他東西;確實,Java和C#等安全型語言在諸多運行資源不太緊張的應用中已經取代了C和C++。

先搶佔市場往往能夠嚇退競爭對手。為了一個功能而改用新的語言和工具鏈往往太過痛苦了,但新來者最終會取代現有的語言,除非後者演變得夠快,能跟上節奏。年紀大一點的讀者大多可以舉出一堆這樣的語言——他們以前用過,但是現在這些語言已經不時髦了。隨便列舉幾個吧:Ada、Algol、COBOL、Pascal、Delphi、SNOBOL等。

你是一位Java程序員。在過去15年的時間裡,Java已經成功地霸佔了編程生態系統中的一大塊,同時替代了競爭對手語言。讓我們來看看其中的原因。

1.1.1 Java在編程語言生態系統中的位置

Java天資不錯。從一開始,它就是一個精心設計的面向對象的語言,有許多有用的庫。有了集成的線程和鎖的支持,它從第一天起就支持小規模並發(並且它十分有先知之明地承認,在與硬件無關的內存模型裡,多核處理器上的並發線程可能比在單核處理器上出現的意外行為更多)。此外,將Java編譯成JVM字節碼(一種很快就被每一種瀏覽器支持的虛擬機代碼)意味著它成為了互聯網applet(小應用)的首選(你還記得applet嗎?)。確實,Java虛擬機(JVM)及其字節碼可能會變得比Java語言本身更重要,而且對於某些應用來說,Java可能會被同樣運行在JVM上的競爭對手語言(如Scala或Groovy)取代。JVM各種最新的更新(例如JDK7中的新invokedynamic字節碼)旨在幫助這些競爭對手語言在JVM上順利運行,並與Java交互操作。Java也已成功地佔領了嵌入式計算的若干領域,從智能卡、烤麵包機、機頂盒到汽車制動系統。

Java是怎麼進入通用編程市場的?

面向對像在20世紀90年代開始時興的原因有兩個:封裝原則使得其軟件工程問題比C少;作為一個思維模型,它輕鬆地反映了Windows 95及之後的WIMP編程模式。可以這樣總結:一切都是對像;單擊鼠標就能給處理程序發送一個事件消息(在Mouse對像中觸發Clicked方法)。Java的“一次編寫,隨處運行”模式,以及早期瀏覽器安全地執行Java小應用的能力讓它佔領了大學市場,畢業生隨後把它帶進了業界。開始時由於運行成本比C/C++要高,Java還遇到了一些阻力,但後來機器變得越來越快,程序員的時間也變得越來越重要了。微軟的C#進一步驗證了Java的面向對像模型。

但是,編程語言生態系統的氣候正在變化。程序員越來越多地要處理所謂的大數據(數百萬兆甚至更多字節的數據集),並希望利用多核計算機或計算集群來有效地處理。這意味著需要使用並行處理——Java以前對此並不支持。

你可能接觸過其他編程領域的思想,比如Google的map-reduce,或如SQL等數據庫查詢語言的便捷數據操作,它們能幫助你處理大數據量和多核CPU。圖1-1總結了語言生態系統:把這幅圖看作編程問題空間,每個特定地方生長的主要植物就是程序最喜歡的語言。氣候變化的意思是,新的硬件或新的編程因素(例如,“我為什麼不能用SQL的風格來寫程序?”)意味著新項目優選的語言各有不同,就像地區氣溫上升就意味著葡萄在較高的緯度也能長得好。當然這會有滯後——很多老農一直在種植傳統作物。總之,新的語言不斷出現,並因為迅速適應了氣候變化,越來越受歡迎。

圖 1-1 編程語言生態系統和氣候變化

Java 8對於程序員的主要好處在於它提供了更多的編程工具和概念,能以更快,更重要的是能以更為簡潔、更易於維護的方式解決新的或現有的編程問題。雖然這些概念對於Java來說是新的,但是研究型的語言已經證明了它們的強大。我們會突出並探討三個這樣的編程概念背後的思想,它們促使Java 8中開發出並行和編寫更簡潔通用代碼的功能。我們這裡介紹它們的順序和本書其餘的部分略有不同,一方面是為了類比Unix,另一方面是為了揭示Java 8新的多核並行中存在的“因為這個所以需要那個”的依賴關係。

1.1.2 流處理

第一個編程概念是流處理。介紹一下,流是一系列數據項,一次只生成一項。程序可以從輸入流中一個一個讀取數據項,然後以同樣的方式將數據項寫入輸出流。一個程序的輸出流很可能是另一個程序的輸入流。

一個實際的例子是在Unix或Linux中,很多程序都從標準輸入(Unix和C中的stdin,Java中的System.in)讀取數據,然後把結果寫入標準輸出(Unix和C中的stdout,Java中的System.out)。首先我們來看一點點背景:Unix的cat命令會把兩個文件連接起來創建一個流,tr會轉換流中的字符,sort會對流中的行進行排序,而tail -3則給出流的最後三行。Unix命令行允許這些程序通過管道(|)連接在一起,比如

cat file1 file2 | tr "[A-Z]"  "[a-z]"  |  sort  |  tail -3  

會(假設file1file2中每行都只有一個詞)先把字母轉換成小寫字母,然後打印出按照詞典排序出現在最後的三個單詞。我們說sort把一個行流3作為輸入,產生了另一個行流(進行排序)作為輸出,如圖1-2所示。請注意在Unix中,命令(cattrsorttail)是同時執行的,這樣sort就可以在cattr完成前先處理頭幾行。就像汽車組裝流水線一樣,汽車排隊進入加工站,每個加工站會接收、修改汽車,然後將之傳遞給下一站做進一步的處理。儘管流水線實際上是一個序列,但不同加工站的運行一般是並行的。

3有語言潔癖的人會說“字符流”,不過認為sort會對行排序比較簡單。

圖 1-2 操作流的Unix命令

基於這一思想,Java 8在java.util.stream中添加了一個Stream API;Stream<T>就是一系列T類型的項目。你現在可以把它看成一種比較花哨的迭代器。Stream API的很多方法可以鏈接起來形成一個複雜的流水線,就像先前例子裡面鏈接起來的Unix命令一樣。

推動這種做法的關鍵在於,現在你可以在一個更高的抽像層次上寫Java 8程序了:思路變成了把這樣的流變成那樣的流(就像寫數據庫查詢語句時的那種思路),而不是一次只處理一個項目。另一個好處是,Java 8可以透明地把輸入的不相關部分拿到幾個CPU內核上去分別執行你的 Stream操作流水線——這是幾乎免費的並行,用不著去費勁搞Thread了。我們會在第4~7章仔細討論Java 8的Stream API。

1.1.3 用行為參數化把代碼傳遞給方法

Java 8中增加的另一個編程概念是通過API來傳遞代碼的能力。這聽起來實在太抽像了。在Unix的例子裡,你可能想告訴sort命令使用自定義排序。雖然sort命令支持通過命令行參數來執行各種預定義類型的排序,比如倒序,但這畢竟是有限的。

比方說,你有一堆發票代碼,格式類似於2013UK0001、2014US0002……前四位數代表年份,接下來兩個字母代表國家,最後四位是客戶的代碼。你可能想按照年份、客戶代碼,甚至國家來對發票進行排序。你真正想要的是,能夠給sort命令一個參數讓用戶定義順序:給sort命令傳遞一段獨立代碼。

那麼,直接套在Java上,你是要讓sort方法利用自定義的順序進行比較。你可以寫一個compareUsingCustomerId來比較兩張發票的代碼,但是在Java 8之前,你沒法把這個方法傳給另一個方法。你可以像本章開頭時介紹的那樣,創建一個Comparator對象,將之傳遞給sort方法,但這不但囉嗦,而且讓“重複使用現有行為”的思想變得不那麼清楚了。Java 8增加了把方法(你的代碼)作為參數傳遞給另一個方法的能力。圖1-3是基於圖1-2畫出的,它描繪了這種思路。我們把這一概念稱為行為參數化。它的重要之處在哪兒呢?Stream API就是構建在通過傳遞代碼使操作行為實現參數化的思想上的,當把compareUsingCustomerId傳進去,你就把sort的行為參數化了。

圖 1-3 將compareUsingCustomerId方法作為參數傳給sort

我們將在1.2節中概述這種方式,但詳細討論留在第2章和第3章。第13章和第14章將討論這一功能的高級用法,還有函數式編程自身的一些技巧。

1.1.4 並行與共享的可變數據

第三個編程概念更隱晦一點,它來自我們前面討論流處理能力時說的“幾乎免費的並行”。你需要放棄什麼嗎?你可能需要對傳給流方法的行為的寫法稍作改變。這些改變可能一開始會讓你感覺有點兒不舒服,但一旦習慣了你就會愛上它們。你的行為必須能夠同時對不同的輸入安全地執行。一般情況下這就意味著,你寫代碼時不能訪問共享的可變數據。這些函數有時被稱為“純函數”或“無副作用函數”或“無狀態函數”,這一點我們會在第7章和第13章詳細討論。前面說的並行只有在假定你的代碼的多個副本可以獨立工作時才能進行。但如果要寫入的是一個共享變量或對象,這就行不通了:如果兩個進程需要同時修改這個共享變量怎麼辦?(1.3節配圖給出了更詳細的解釋。)你在本書中會對這種風格有更多的瞭解。

Java 8的流實現並行比Java現有的線程API更容易,因此,儘管可以使用synchronized來打破“不能有共享的可變數據”這一規則,但這相當於是在和整個體系作對,因為它使所有圍繞這一規則做出的優化都失去意義了。在多個處理器內核之間使用synchronized,其代價往往比你預期的要大得多,因為同步迫使代碼按照順序執行,而這與並行處理的宗旨相悖。

這兩個要點(沒有共享的可變數據,將方法和函數即代碼傳遞給其他方法的能力)是我們平常所說的函數式編程範式的基石,我們在第13章和第14章會詳細討論。與此相反,在命令式編程範式中,你寫的程序則是一系列改變狀態的指令。“不能有共享的可變數據”的要求意味著,一個方法是可以通過它將參數值轉換為結果的方式完全描述的;換句話說,它的行為就像一個數學函數,沒有可見的副作用。

1.1.5 Java需要演變

你之前已經見過了Java的演變。例如,引入泛型,使用List<String>而不只是List,可能一開始都挺煩人的。但現在你已經熟悉了這種風格和它所帶來的好處,即在編譯時能發現更多錯誤,且代碼更易讀,因為你現在知道列表裡面是什麼了。

其他改變讓普通的東西更容易表達,比如,使用for-each循環而不用暴露Iterator裡面的套路寫法。Java 8中的主要變化反映了它開始遠離常側重改變現有值的經典面向對像思想,而向函數式編程領域轉變,在大面上考慮做什麼(例如,創建一個值代表所有從A到B低於給定價格的交通線路)被認為是頭等大事,並和如何實現(例如,掃瞄一個數據結構並修改某些元素)區分開來。請注意,如果極端點兒來說,傳統的面向對像編程和函數式可能看起來是衝突的。但是我們的理念是獲得兩種編程範式中最好的東西,這樣你就有更大的機會為任務找到理想的工具了。我們會在接下來的兩節中詳細討論:Java中的函數和新的Stream API。

總結下來可能就是這麼一句話:語言需要不斷改進以跟進硬件的更新或滿足程序員的期待(如果你還不夠信服,想想COBOL還一度是商業上最重要的語言之一呢)。要堅持下去,Java必須通過增加新功能來改進,而且只有新功能被人使用,變化才有意義。所以,使用Java 8,你就是在保護你作為Java程序員的職業生涯。除此之外,我們有一種感覺——你一定會喜歡Java 8的新功能。隨便問問哪個用過Java 8的人,看看他們願不願意退回去。還有,用生態系統打比方的話,新的Java 8的功能使得Java能夠征服如今被其他語言佔領的編程任務領地,所以Java 8程序員就更需要學習它了。

下面逐一介紹Java 8中的新概念,並順便指出在哪一章中還會仔細討論這些概念。

1.2 Java中的函數

編程語言中的函數一詞通常是指方法,尤其是靜態方法;這是在數學函數,也就是沒有副作用的函數之外的新含義。幸運的是,你將會看到,在Java 8談到函數時,這兩種用法幾乎是一致的。

Java 8中新增了函數——值的一種新形式。它有助於使用1.3節中談到的流,有了它,Java 8可以進行多核處理器上的並行編程。我們首先來展示一下作為值的函數本身的有用之處。

想想Java程序可能操作的值吧。首先有原始值,比如42(int類型)和3.14(double類型)。 其次,值可以是對像(更嚴格地說是對象的引用)。獲得對象的唯一途徑是利用new,也許是通過工廠方法或庫函數實現的;對像引用指向類的一個實例。例子包括"abc"String類型),new Integer(1111)Integer類型),以及new HashMap<Integer,String>(100)的結果——它顯然調用了HashMap的構造函數。甚至數組也是對象。那麼有什麼問題呢?

為了幫助回答這個問題,我們要注意到,編程語言的整個目的就在於操作值,要是按照歷史上編程語言的傳統,這些值因此被稱為一等值(或一等公民,這個術語是從20世紀60年代美國民權運動中借用來的)。編程語言中的其他結構也許有助於我們表示值的結構,但在程序執行期間不能傳遞,因而是二等公民。前面所說的值是Java中的一等公民,但其他很多Java概念(如方法和類等)則是二等公民。用方法來定義類很不錯,類還可以實例化來產生值,但方法和類本身都不是值。這又有什麼關係呢?還真有,人們發現,在運行時傳遞方法能將方法變成一等公民。這在編程中非常有用,因此Java 8的設計者把這個功能加入到了Java中。順便說一下,你可能會想,讓類等其他二等公民也變成一等公民可能也是個好主意。有很多語言,如Smalltalk和JavaScript,都探索過這條路。

1.2.1 方法和Lambda作為一等公民

Scala和Groovy等語言的實踐已經證明,讓方法等概念作為一等值可以擴充程序員的工具庫,從而讓編程變得更容易。一旦程序員熟悉了這個強大的功能,他們就再也不願意使用沒有這一功能的語言了。因此,Java 8的設計者決定允許方法作為值,讓編程更輕鬆。此外,讓方法作為值也構成了其他若干Java 8功能(如Stream)的基礎。

我們介紹的Java 8的第一個新功能是方法引用。比方說,你想要篩選一個目錄中的所有隱藏文件。你需要編寫一個方法,然後給它一個File,它就會告訴你文件是不是隱藏的。幸好,File類裡面有一個叫作isHidden的方法。我們可以把它看作一個函數,接受一個File,返回一個布爾值。但要用它做篩選,你需要把它包在一個FileFilter對像裡,然後傳遞給File.listFiles方法,如下所示:

File hiddenFiles = new File(".").listFiles(new FileFilter {    public boolean accept(File file) {        return file.isHidden;        ←─篩選隱藏文件    }});  

呃!真可怕!雖然只有三行,但這三行可真夠繞的。我們第一次碰到的時候肯定都說過:“非得這樣不可嗎?”我們已經有一個方法isHidden可以使用,為什麼非得把它包在一個囉嗦的FileFilter類裡面再實例化呢?因為在Java 8之前你必須這麼做!

如今在Java 8里,你可以把代碼重寫成這個樣子:

File hiddenFiles = new File(".").listFiles(File::isHidden);  

哇!酷不酷?你已經有了函數isHidden,因此只需用Java 8的方法引用::語法(即“把這個方法作為值”)將其傳給listFiles方法;請注意,我們也開始用函數代表方法了。稍後我們會解釋這個機制是如何工作的。一個好處是,你的代碼現在讀起來更接近問題的陳述了。方法不再是二等值了。與用對像引用傳遞對像類似(對像引用是用new創建的),在Java 8里寫下File::isHidden的時候,你就創建了一個方法引用,你同樣可以傳遞它。第3章會詳細討論這一概念。只要方法中有代碼(方法中的可執行部分),那麼用方法引用就可以傳遞代碼,如圖1-3所示。圖1-4說明了這一概念。你在下一節中還將看到一個具體的例子——從庫存中選擇蘋果。

圖 1-4 將方法引用File::isHidden傳遞給listFiles方法

Lambda——匿名函數

除了允許(命名)函數成為一等值外,Java 8還體現了更廣義的將函數作為值的思想,包括Lambda4(或匿名函數)。比如,你現在可以寫(int x) -> x + 1,表示“調用時給定參數x,就返回x + 1值的函數”。你可能會想這有什麼必要呢?因為你可以在MyMathsUtils類裡面定義一個add1方法,然後寫MyMathsUtils::add1嘛!確實是可以,但要是你沒有方便的方法和類可用,新的Lambda語法更簡潔。第3章會詳細討論Lambda。我們說使用這些概念的程序為函數式編程風格,這句話的意思是“編寫把函數作為一等值來傳遞的程序”。

4最初是根據希臘字母λ命名的。雖然Java中不使用這個符號,名稱還是被保留了下來。

1.2.2 傳遞代碼:一個例子

來看一個例子,看看它是如何幫助你寫程序的,我們在第2章還會進行更詳細的討論。所有的示例代碼均可見於本書的GitHub頁面(https://github.com/java8/)。假設你有一個Apple類,它有一個getColor方法,還有一個變量inventory保存著一個Apples的列表。你可能想要選出所有的綠蘋果,並返回一個列表。通常我們用篩選(filter)一詞來表達這個概念。在Java 8之前,你可能會寫這樣一個方法filterGreenApples

public static List<Apple> filterGreenApples(List<Apple> inventory){    List<Apple> result = new ArrayList<>;       ←─result是用來累積結果的List,開始為空,然後一個個加入綠蘋果    for (Apple apple: inventory){        if ("green".equals(apple.getColor)) { ←─高亮顯示的代碼會僅僅選出綠蘋果            result.add(apple);        }    }    return result;}  

但是接下來,有人可能想要選出重的蘋果,比如超過150克,於是你心情沉重地寫了下面這個方法,甚至用了複製粘貼:

public static List<Apple> filterHeavyApples(List<Apple> inventory){    List<Apple> result = new ArrayList<>;    for (Apple apple: inventory){        if (apple.getWeight > 150) {     ←─這裡高亮顯示的代碼會僅僅選出重的蘋果            result.add(apple);        }    }    return result;}  

我們都知道軟件工程中複製粘貼的危險——給一個做了更新和修正,卻忘了另一個。嘿,這兩個方法只有一行不同:if裡面高亮的那行條件。如果這兩個高亮的方法之間的差異僅僅是接受的重量範圍不同,那麼你只要把接受的重量上下限作為參數傳遞給filter就行了,比如指定(150, 1000)來選出重的蘋果(超過150克),或者指定 (0, 80)來選出輕的蘋果(低於80克)。

但是,我們前面提過了,Java 8會把條件代碼作為參數傳遞進去,這樣可以避免filter方法出現重複的代碼。現在你可以寫:

public static boolean isGreenApple(Apple apple) {    return "green".equals(apple.getColor);}public static boolean isHeavyApple(Apple apple) {    return apple.getWeight > 150;}public interface Predicate<T>{       ←─寫出來是為了清晰(平常只要從java.util.function導入就可以了)    boolean test(T t);}static List<Apple> filterApples(List<Apple> inventory,                                Predicate<Apple> p) {    ←─方法作為Predicate參數p傳遞進去(見附註欄“什麼是謂詞?”)    List<Apple> result = new ArrayList<>;    for (Apple apple: inventory){        if (p.test(apple)) {        ←─蘋果符合p所代表的條件嗎            result.add(apple);        }    }    return result;}  

要用它的話,你可以寫:

filterApples(inventory, Apple::isGreenApple);  

或者

filterApples(inventory, Apple::isHeavyApple);  

我們會在接下來的兩章中詳細討論它是怎麼工作的。現在重要的是你可以在Java 8里面傳遞方法了!

什麼是謂詞?

前面的代碼傳遞了方法Apple::isGreenApple(它接受參數Apple並返回一個boolean)給filterApples,後者則希望接受一個Predicate<Apple>參數。謂詞(predicate)在數學上常常用來代表一個類似函數的東西,它接受一個參數值,並返回truefalse。你在後面會看到,Java 8也會允許你寫Function<Apple,Boolean>——在學校學過函數卻沒學過謂詞的讀者對此可能更熟悉,但用Predicate<Apple>是更標準的方式,效率也會更高一點兒,這避免了把boolean封裝在Boolean裡面。

1.2.3 從傳遞方法到Lambda

把方法作為值來傳遞顯然很有用,但要是為類似於isHeavyAppleisGreenApple這種可能只用一兩次的短方法寫一堆定義有點兒煩人。不過Java 8也解決了這個問題,它引入了一套新記法(匿名函數或Lambda),讓你可以寫

filterApples(inventory, (Apple a) -> "green".equals(a.getColor) );  

或者

filterApples(inventory, (Apple a) -> a.getWeight > 150 );  

甚至

filterApples(inventory, (Apple a) -> a.getWeight < 80 ||                                     "brown".equals(a.getColor) );  

所以,你甚至都不需要為只用一次的方法寫定義;代碼更乾淨、更清晰,因為你用不著去找自己到底傳遞了什麼代碼。但要是Lambda的長度多於幾行(它的行為也不是一目瞭然)的話,那你還是應該用方法引用來指向一個有描述性名稱的方法,而不是使用匿名的Lambda。你應該以代碼的清晰度為準繩。

Java 8的設計師幾乎可以就此打住了,要是沒有多核CPU,可能他們真的就到此為止了。我們迄今為止談到的函數式編程竟然如此強大,在後面你更會體會到這一點。本來,Java加上filter和幾個相關的東西作為通用庫方法就足以讓人滿意了,比如

static <T> Collection<T> filter(Collection<T> c, Predicate<T> p);  

這樣你甚至都不需要寫filterApples了,因為比如先前的調用

filterApples(inventory, (Apple a) -> a.getWeight > 150 );  

就可以直接調用庫方法filter

filter(inventory, (Apple a) -> a.getWeight > 150 );  

但是,為了更好地利用並行,Java的設計師沒有這麼做。Java 8中有一整套新的類集合API——Stream,它有一套函數式程序員熟悉的、類似於filter的操作,比如mapreduce,還有我們接下來要討論的在CollectionsStreams之間做轉換的方法。

1.3 流

幾乎每個Java應用都會製造和處理集合。但集合用起來並不總是那麼理想。比方說,你需要從一個列表中篩選金額較高的交易,然後按貨幣分組。你需要寫一大堆套路化的代碼來實現這個數據處理命令,如下所示:

Map<Currency, List<Transaction>> transactionsByCurrencies = new HashMap<>;    ←─建立累積交易分組的Mapfor (Transaction transaction : transactions) {    ←─遍歷交易的List    if(transaction.getPrice > 1000){    ←─篩選金額較高的交易        Currency currency = transaction.getCurrency;    ←─提取交易貨幣        List<Transaction> transactionsForCurrency =            transactionsByCurrencies.get(currency);        if (transactionsForCurrency == null) {    ←─如果這個貨幣的分組Map是空的,那就建立一個            transactionsForCurrency = new ArrayList<>;            transactionsByCurrencies.put(currency,                                         transactionsForCurrency);        }        transactionsForCurrency.add(transaction);    ←─將當前遍歷的交易添加到具有同一貨幣的交易List中    }}  

此外,我們很難一眼看出來這些代碼是做什麼的,因為有好幾個嵌套的控制流指令。

有了Stream API,你現在可以這樣解決這個問題了:

import static java.util.stream.Collectors.toList;Map<Currency, List<Transaction>> transactionsByCurrencies =    transactions.stream                .filter((Transaction t) -> t.getPrice > 1000)    ←─篩選金額較高的交易                .collect(groupingBy(Transaction::getCurrency));    ←─按貨幣分組  

這看起來有點兒神奇,不過現在先不用擔心。第4~7章會專門講述怎麼理解Stream API。現在值得注意的是,和Collection API相比,Stream API處理數據的方式非常不同。用集合的話,你得自己去做迭代的過程。你得用for-each循環一個個去迭代元素,然後再處理元素。我們把這種數據迭代的方法稱為外部迭代。相反,有了Stream API,你根本用不著操心循環的事情。數據處理完全是在庫內部進行的。我們把這種思想叫作內部迭代。在第4章我們還會談到這些思想。

使用集合的另一個頭疼的地方是,想想看,要是你的交易量非常龐大,你要怎麼處理這個巨大的列表呢?單個CPU根本搞不定這麼大量的數據,但你很可能已經有了一台多核電腦。理想的情況下,你可能想讓這些CPU內核共同分擔處理工作,以縮短處理時間。理論上來說,要是你有八個核,那並行起來,處理數據的速度應該是單核的八倍。

多核

所有新的台式和筆記本電腦都是多核的。它們不是僅有一個CPU,而是有四個、八個,甚至更多CPU,通常稱為內核5。問題是,經典的Java程序只能利用其中一個核,其他核的處理能力都浪費了。類似地,很多公司利用計算集群(用高速網絡連接起來的多台計算機)來高效處理海量數據。Java 8提供了新的編程風格,可更好地利用這樣的計算機。

Google的搜索引擎就是一個無法在單台計算機上運行的代碼的例子。它要讀取互聯網上的每個頁面並建立索引,將每個互聯網網頁上出現的每個詞都映射到包含該詞的網址上。然後,如果你用多個單詞進行搜索,軟件就可以快速利用索引,給你一個包含這些詞的網頁集合。想想看,你會如何在Java中實現這個算法,哪怕是比Google小的引擎也需要你利用計算機上所有的核。

5從某種意義上說,這個名字不太好。一塊多核芯片上的每個核都是一個五臟俱全的CPU。但“多核CPU”的說法很流行,所以我們就用內核來指代各個CPU。

多線程並非易事

問題在於,通過多線程代碼來利用並行(使用先前Java版本中的Thread API)並非易事。你得換一種思路:線程可能會同時訪問並更新共享變量。因此,如果沒有協調好6,數據可能會被意外改變。相比一步步執行的順序模型,這個模型不太好理解7。比如,圖1-5就展示了如果沒有同步好,兩個線程同時向共享變量sum加上一個數時,可能出現的問題。

6傳統上是利用synchronized關鍵字,但是要是用錯了地方,就可能出現很多難以察覺的錯誤。Java 8基於Stream的並行提倡很少使用synchronized的函數式編程風格,它關注數據分塊而不是協調訪問。

7啊哈,促使語言發展的一個動力源!

圖 1-5 兩個線程對共享的sum變量做加法的一種可能方式。結果是105,而不是預想的108

Java 8也用Stream API(java.util.stream)解決了這兩個問題:集合處理時的套路和晦澀,以及難以利用多核。這樣設計的第一個原因是,有許多反覆出現的數據處理模式,類似於前一節所說的filterApples或SQL等數據庫查詢語言裡熟悉的操作,如果在庫中有這些就會很方便:根據標準篩選數據(比如較重的蘋果),提取數據(例如抽取列表中每個蘋果的重量字段),或給數據分組(例如,將一個數字列表分組,奇數和偶數分別列表)等。第二個原因是,這類操作常常可以並行化。例如,如圖1-6所示,在兩個CPU上篩選列表,可以讓一個CPU處理列表的前一半,第二個CPU處理後一半,這稱為分支步驟(1)。CPU隨後對各自的半個列表做篩選(2)。最後(3),一個CPU會把兩個結果合併起來(Google搜索這麼快就與此緊密相關,當然他們用的CPU遠遠不止兩個了)。

到這裡,我們只是說新的Stream API和Java現有的集合API的行為差不多:它們都能夠訪問數據項目的序列。不過,現在最好記得,Collection主要是為了存儲和訪問數據,而Stream則主要用於描述對數據的計算。這裡的關鍵點在於,Stream允許並提倡並行處理一個Stream中的元素。雖然可能乍看上去有點兒怪,但篩選一個Collection(將上一節的filterApples應用在一個List上)的最快方法常常是將其轉換為Stream,進行並行處理,然後再轉換回List,下面舉的串行和並行的例子都是如此。我們這裡還只是說“幾乎免費的並行”,讓你稍微體驗一下,如何利用Stream和Lambda表達式順序或並行地從一個列表裡篩選比較重的蘋果。

順序處理:

import static java.util.stream.Collectors.toList;List<Apple> heavyApples =    inventory.stream.filter((Apple a) -> a.getWeight > 150)                      .collect(toList);  

圖 1-6 將filter分支到兩個CPU上並聚合結果

並行處理:

import static java.util.stream.Collectors.toList;List<Apple> heavyApples =    inventory.parallelStream.filter((Apple a) -> a.getWeight > 150)                              .collect(toList);  

第7章會更詳細地探討Java 8中的並行數據處理及其特點。在加入所有這些新玩意兒改進Java的時候,Java 8設計者發現的一個現實問題就是現有的接口也在改進。比如,Collections.sort方法真的應該屬於List接口,但卻從來沒有放在後者裡。理想的情況下,你會希望做list.sort(comparator),而不是Collections.sort(list, comparator)。這看起來無關緊要,但是在Java 8之前,你可能會更新一個接口,然後發現你把所有實現它的類也給更新了——簡直是邏輯災難!這個問題在Java 8里由默認方法解決了。

Java中的並行與無共享可變狀態

大家都說Java裡面並行很難,而且和synchronized相關的玩意兒都容易出問題。那Java 8里面有什麼“靈丹妙藥”呢?事實上有兩個。首先,庫會負責分塊,即把大的流分成幾個小的流,以便並行處理。其次,流提供的這個幾乎免費的並行,只有在傳遞給filter之類的庫方法的方法不會互動(比方說有可變的共享對像)時才能工作。但是其實這個限制對於程序員來說挺自然的,舉個例子,我們的Apple::isGreenApple就是這樣。確實,雖然函數式編程中的函數的主要意思是“把函數作為一等值”,不過它也常常隱含著第二層意思,即“執行時在元素之間無互動”。

1.4 默認方法

Java 8中加入默認方法主要是為了支持庫設計師,讓他們能夠寫出更容易改進的接口。這一點會在第9章中詳談。這一方法很重要,因為你會在接口中遇到越來越多的默認方法,但由於真正需要編寫默認方法的程序員相對較少,而且它們只是有助於程序改進,而不是用於編寫任何具體的程序,我們這裡還是不要囉嗦了,舉個例子吧。

在1.3節中,我們給出了下面這段Java 8示例代碼:

List<Apple> heavyApples1 =    inventory.stream.filter((Apple a) -> a.getWeight > 150)                      .collect(toList);List<Apple> heavyApples2 =    inventory.parallelStream.filter((Apple a) -> a.getWeight > 150)                              .collect(toList);  

但這裡有個問題:在Java 8之前,List<T>並沒有 streamparallelStream方法,它實現的Collection<T>接口也沒有,因為當初還沒有想到這些方法嘛!可沒有這些方法,這些代碼就不能編譯。換作你自己的接口的話,最簡單的解決方案就是讓Java 8的設計者把stream方法加入Collection接口,並加入ArrayList類的實現。

可要是這樣做,對用戶來說就是噩夢了。有很多的替代集合框架都用Collection API實現了接口。但給接口加入一個新方法,意味著所有的實體類都必須為其提供一個實現。語言設計者沒法控制Collections所有現有的實現,這下你就進退兩難了:你如何改變已發佈的接口而不破壞已有的實現呢?

Java 8的解決方法就是打破最後一環——接口如今可以包含實現類沒有提供實現的方法簽名了!那誰來實現它呢?缺失的方法主體隨接口提供了(因此就有了默認實現),而不是由實現類提供。

這就給接口設計者提供了一個擴充接口的方式,而不會破壞現有的代碼。Java 8在接口聲明中使用新的default關鍵字來表示這一點。

例如,在Java 8里,你現在可以直接對List調用sort方法。它是用Java 8 List接口中如下所示的默認方法實現的,它會調用Collections.sort靜態方法:

default void sort(Comparator<? super E> c) {    Collections.sort(this, c);}  

這意味著List的任何實體類都不需要顯式實現sort,而在以前的Java版本中,除非提供了sort的實現,否則這些實體類在重新編譯時都會失敗。

不過慢著,一個類可以實現多個接口,不是嗎?那麼,如果在好幾個接口裡有多個默認實現,是否意味著Java中有了某種形式的多重繼承?是的,在某種程度上是這樣。我們在第9章中會談到,Java 8用一些限制來避免出現類似於C++中臭名昭著的菱形繼承問題。

1.5 來自函數式編程的其他好思想

前幾節介紹了Java中從函數式編程中引入的兩個核心思想:將方法和Lambda作為一等值,以及在沒有可變共享狀態時,函數或方法可以有效、安全地並行執行。前面說到的新的Stream API把這兩種思想都用到了。

常見的函數式語言,如SML、OCaml、Haskell,還提供了進一步的結構來幫助程序員。其中之一就是通過使用更多的描述性數據類型來避免null。確實,計算機科學巨擘之一托尼·霍爾(Tony Hoare)在2009年倫敦QCon上的一個演講中說道:

我把它叫作我的“價值億萬美金的錯誤”。就是在1965年發明了空引用……我無法抗拒放進一個空引用的誘惑,僅僅是因為它實現起來非常容易。

在Java 8里有一個Optional<T>類,如果你能一致地使用它的話,就可以幫助你避免出現NullPointer異常。它是一個容器對象,可以包含,也可以不包含一個值。Optional<T>中有方法來明確處理值不存在的情況,這樣就可以避免NullPointer異常了。換句話說,它使用類型系統,允許你表明我們知道一個變量可能會沒有值。我們會在第10章中詳細討論Optional<T>

第二個想法是(結構)模式匹配8。這在數學中也有使用,例如:

8這個術語有兩個意思,這裡我們指的是數學和函數式編程上所用的,即函數是分情況定義的,而不是使用if-then-else。它的另一個意思類似於“在給定目錄中找到所有類似於IMG*.JPG形式的文件”,和所謂的正則表達式有關。

f(0) = 1f(n) = n*f(n-1) otherwise  

在Java中,你可以在這裡寫一個if-then-else語句或一個switch語句。其他語言表明,對於更複雜的數據類型,模式匹配可以比if-then-else更簡明地表達編程思想。對於這種數據類型,你也可以使用多態和方法重載來替代if-then-else,但對於哪種方式更合適,就語言設計而言仍有一些爭論。9我們認為兩者都是有用的工具,你都應該掌握。不幸的是,Java 8對模式匹配的支持並不完全,雖然我們會在第14章中介紹如何對其進行表達。與此同時,我們會用一個以Scala語言(另一個使用JVM的類Java語言,啟發了Java在一些方面的發展;請參閱第15章)表達的例子加以描述。比方說,你要寫一個程序對描述算術表達式的樹做基本的簡化。給定一個數據類型Expr代表這樣的表達式,在Scala裡你可以寫以下代碼,把Expr分解給它的各個部分,然後返回另一個Expr

9維基百科中文章“Expression Problem”(由Phil Wadler發明的術語)對這一討論有所介紹。

def simplifyExpression(expr: Expr): Expr = expr match {    case BinOp("+", e, Number(0)) => e    ←─加上0    case BinOp("*", e, Number(1)) => e    ←─乘以1    case BinOp("/", e, Number(1)) => e    ←─除以1    case _ => expr    ←─不能簡化expr}  

這裡,Scala的語法expr match就對應於Java中的switch (expr)。現在你不用擔心這段代碼,你可以在第14章閱讀更多有關模式匹配的內容。現在,你可以把模式匹配看作switch的擴展形式,可以同時將一個數據類型分解成元素。

為什麼Java中的switch語句應該限於原始類型值和Strings呢?函數式語言傾向於允許switch用在更多的數據類型上,包括允許模式匹配(在Scala代碼中是通過match操作實現的)。在面向對像設計中,常用的訪客模式可以用來遍歷一組類(如汽車的不同組件:車輪、發動機、底盤等),並對每個訪問的對象執行操作。模式匹配的一個優點是編譯器可以報告常見錯誤,如:“Brakes類屬於用來表示Car類的組件的一族類。你忘記了要顯式處理它。”

第13章和第14章給出了完整的教程,介紹函數式編程,以及如何在Java 8中編寫函數式風格的程序,包括其庫中提供的函數工具。第15章討論Java 8的功能並與Scala進行比較。Scala和Java一樣是在JVM上實現的,且近年來發展迅速,在編程語言生態系統中已經在一些方面威脅到了Java。這部分內容在書的後面幾章,會讓你進一步瞭解Java 8為什麼加上了這些新功能。

1.6 小結

以下是你應從本章中學到的關鍵概念。

  • 請記住語言生態系統的思想,以及語言面臨的“要麼改變,要麼衰亡”的壓力。雖然Java可能現在非常有活力,但你可以回憶一下其他曾經也有活力但未能及時改進的語言的命運,如COBOL。

  • Java 8中新增的核心內容提供了令人激動的新概念和功能,方便我們編寫既有效又簡潔的程序。

  • 現有的Java編程實踐並不能很好地利用多核處理器。

  • 函數是一等值;記得方法如何作為函數式值來傳遞,還有Lambda是怎樣寫的。

  • Java 8中Streams的概念使得Collections的許多方面得以推廣,讓代碼更為易讀,並允許並行處理流元素。

  • 你可以在接口中使用默認方法,在實現類沒有實現方法時提供方法內容。

  • 其他來自函數式編程的有趣思想,包括處理null和使用模式匹配。