Java 8 學習筆記1——Java 8 概述
Java 8
提供了一個新的API
(稱為“流”,Stream
),它支援許多處理資料的並行操作,其思路和在資料庫查詢語言中的思路類似——用更高階的方式表達想要的東西,而由“實現”(在這裡是Streams
庫)來選擇最佳低階執行機制。這樣就可以避免用synchronized
編寫程式碼,這一程式碼不僅容易出錯,而且在多核CPU
上執行所需的成本也比你想象的要高。
在Java 8
中加入Streams
可以看作把另外兩項擴充加入Java 8
的直接原因:把程式碼傳遞給方法的簡潔方式(方法引用、Lambda
)和介面中的預設方法。如果僅僅“把程式碼傳遞給方法”看作Streams
的一個結果,那就低估了它在Java 8
Java 8
裡面將程式碼傳遞給方法的功能(同時也能夠返回程式碼並將其包含在資料結構中)還讓我們能夠使用一整套新技巧,通常稱為函數語言程式設計。一言以蔽之,這種被函數語言程式設計界稱為函式的程式碼,可以被來回傳遞並加以組合,以產生強大的程式設計語彙。
流處理
流是一系列資料項,一次只生成一項。程式可以從輸入流中一個一個讀取資料項,然後以同樣的方式將資料項寫入輸出流。一個程式的輸出流很可能是另一個程式的輸入流。一個實際的例子是在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
會(假設file1
和file2
中每行都只有一個詞)先把字母轉換成小寫字母,然後打印出按照詞典排序出現在最後的三個單詞。我們說sort
請注意在
Unix
中,命令(cat
、tr
、sort
和tail
)是同時執行的,這樣sort
就可以在cat
或tr
完成前先處理頭幾行。就像汽車組裝流水線一樣,汽車排隊進入加工站,每個加工站會接收、修改汽車,然後將之傳遞給下一站做進一步的處理。儘管流水線實際上是一個序列,但不同加工站的執行一般是並行的。
基於這一思想,Java 8
在java.util.stream
中添加了一個Stream API
;Stream<T>
就是一系列T
型別的專案。你現在可以把它看成一種比較花哨的迭代器。Stream API
的很多方法可以連結起來形成一個複雜的流水線,就像先前例子裡面連結起來的Unix
命令一樣。
推動這種做法的關鍵在於,現在你可以在一個更高的抽象層次上寫Java 8
程式了:思路變成了把這樣的流變成那樣的流(就像寫資料庫查詢語句時的那種思路),而不是一次只處理一個專案。另一個好處是,Java 8
可以透明地把輸入的不相關部分拿到幾個CPU
核心上去分別執行你的Stream
操作流水線——這是幾乎免費的並行,用不著去費勁搞Thread
了。
用行為引數化把程式碼傳遞給方法
Java 8
中增加的另一個程式設計概念是通過API
來傳遞程式碼的能力。
Java 8
增加了把方法(你的程式碼)作為引數傳遞給另一個方法的能力。下圖(將compareUsingCustomerId
方法作為引數傳給sort
)描繪了這種思路。我們把這一概念稱為行為引數化。
Stream API
就是構建在通過傳遞程式碼使操作行為實現引數化的思想上的,當把compareUsingCustomerId
傳進去,你就把sort
的行為引數化了。
其他改變讓普通的東西更容易表達,比如,使用for-each
迴圈而不用暴露Iterator
裡面的套路寫法。Java 8
中的主要變化反映了它開始遠離常側重改變現有值的經典面向物件思想,而向函數語言程式設計領域轉變,在大面上考慮做什麼(例如,建立一個值代表所有從A
到B
低於給定價格的交通線路)被認為是頭等大事,並和如何實現(例如,掃描一個數據結構並修改某些元素)區分開來。
Java中的函式
Java 8
中新增了函式——值的一種新形式。它有助於使用流,有了它,Java 8
可以進行多核處理器上的並行程式設計。
這裡介紹的Java 8
的第一個新功能是方法引用。比方說,你想要篩選一個目錄中的所有隱藏檔案。你需要編寫一個方法,然後給它一個File
,它就會告訴你檔案是不是隱藏的。幸好,File
類裡面有一個叫作isHidden
的方法。我們可以把它看作一個函式,接受一個File
,返回一個布林值。但要用它做篩選,你需要把它包在一個FileFilter
物件裡,然後傳遞給File.listFiles
方法,如下所示:
File[] hiddenFiles = new File(".").listFiles( new FileFilter() {
public boolean accept( File file) {
return file.isHidden(); //← ─ 篩選 隱藏 檔案
}
});
如今在Java 8
裡,你可以把程式碼重寫成這個樣子:
File[] hiddenFiles = new File(".").listFiles( File:: isHidden);
你已經有了函式isHidden
,因此只需用Java 8
的方法引用: :
語法(即“把這個方法作為值”)將其傳給listFiles
方法;這裡也開始用函式代表方法了。一個好處是,你的程式碼現在讀起來更接近問題的陳述了。方法不再是二等值了。與用物件引用傳遞物件類似(物件引用是用new
建立的),在Java 8
裡寫下File: : isHidden
的時候,你就建立了一個方法引用,你同樣可以傳遞它。
除了允許(命名)函式成為一等值外,Java 8
還體現了更廣義的將函式作為值的思想,包括Lambda
(或匿名函式)。比如,你現在可以寫(int x) -> x+1
,表示“呼叫時給定引數x
,就返回x+1
值的函式”。
我們說使用這些概念的程式為函數語言程式設計風格,這句話的意思是“編寫把函式作為一等值來傳遞的程式”。
假設你有一個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
)在數學上常常用來代表一個類似函式的東西,它接受一個引數值,並返回true
或false
。
Java 8
也會允許你寫Function<Apple,Boolean>
,但用Predicate<Apple>
是更標準的方式,效率也會更高一點兒,這避免了把boolean
封裝在Boolean
裡面。
把方法作為值來傳遞顯然很有用,但要是為類似於isHeavyApple
和isGreenApple
這種可能只用一兩次的短方法寫一堆定義有點兒煩人。不過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
應用都會製造和處理集合。但集合用起來並不總是那麼理想。比方說,你需要從一個列表中篩選金額較高的交易,然後按貨幣分組。你需要寫一大堆套路化的程式碼來實現這個資料處理命令,並且很難一眼看出來這些程式碼時做什麼的,因為有好幾個巢狀的控制流指令。
有了Stream API
,你現在可以這樣解決這個問題了:
import static java.util.stream.Collectors.toList;
Map<Currency,List<Transaction>>transactionsByCurrencies=
transactions.stream()
.filter((Transactiont)->t.getPrice()>1000) //←─篩選金額較高的交易
.collect(groupingBy(Transaction::getCurrency)); //←─按貨幣分組
和Collection API
相比,Stream API
處理資料的方式非常不同。
用集合的話,你得自己去做迭代的過程。你得用for-each
迴圈一個個去迭代元素,然後再處理元素。我們把這種資料迭代的方法稱為外部迭代。
相反,有了Stream API
,你根本用不著操心迴圈的事情。資料處理完全是在庫內部進行的。我們把這種思想叫作內部迭代。
使用集合的另一個頭疼的地方是,想想看,要是你的交易量非常龐大,你要怎麼處理這個巨大的列表呢?單個CPU
根本搞不定這麼大量的資料,但你很可能已經有了一臺多核電腦。理想的情況下,你可能想讓這些CPU
核心共同分擔處理工作,以縮短處理時間。理論上來說,要是你有八個核,那並行起來,處理資料的速度應該是單核的八倍。
通過多執行緒程式碼來利用並行(使用先前Java
版本中的Thread API
)並非易事。執行緒可能會同時訪問並更新共享變數。因此,如果沒有協調好,資料可能會被意外改變。相比一步步執行的順序模型,這個模型不太好理解。比如,下圖就展示瞭如果沒有同步好,兩個執行緒同時向共享變數sum
加上一個數時,可能出現的問題,結果是105
,而不是預想的108
。
Java 8
也用Stream API
(java.util.stream
)解決了這兩個問題:集合處理時的套路和晦澀,以及難以利用多核。
這樣設計的第一個原因是,有許多反覆出現的資料處理模式,類似於前一節所說的filterApples
或SQL
等資料庫查詢語言裡熟悉的操作,如果在庫中有這些就會很方便:根據標準篩選資料(比如較重的蘋果),提取資料(例如抽取列表中每個蘋果的重量欄位),或給資料分組(例如,將一個數字列表分組,奇數和偶數分別列表)等。
第二個原因是,這類操作常常可以並行化。例如,如下圖所示,在兩個CPU
上篩選列表,可以讓一個CPU
處理列表的前一半,第二個CPU
處理後一半,這稱為分支步驟。CPU隨後對各自的半個列表做篩選。最後,一個CPU
會把兩個結果合併起來(Google
搜尋這麼快就與此緊密相關,當然他們用的CPU
遠遠不止兩個了)。
新的Stream API
和Java
現有的Collection 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());
- 並行處理:
import static java.util.stream.Collectors.toList;
List<Apple> heavyApples=
inventory.parallelStream().filter((Apple a) -> a.getWeight() > 150)
.collect(toList());
預設方法
Java 8
中加入預設方法主要是為了支援庫設計師,讓他們能夠寫出更容易改進的介面。
有很多的替代集合框架都用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
中有了某種形式的多重繼承?是的,在某種程度上是這樣。
來自函數語言程式設計的好思想
- 將方法和
Lambda
作為一等值(核心思想) - 在沒有可變共享狀態時,函式或方法可以有效、安全地並行執行(核心思想)
- 通過使用更多的描述性資料型別來避免
null
。在Java 8
裡有一個Optional<T>
類,如果你能一致地使用它的話,就可以幫助你避免出現NullPointer
異常。它是一個容器物件,可以包含,也可以不包含一個值。Optional<T>
中有方法來明確處理值不存在的情況,這樣就可以避免NullPointer
異常了。換句話說,它使用型別系統,允許你表明我們知道一個變數可能會沒有值。 - (結構)模式匹配。函式是分情況定義的,而不是使用
if-then-else
。在Java
中,你可以在這裡寫一個if-then-else
語句或一個switch
語句。其他語言表明,對於更復雜的資料型別,模式匹配可以比if-then-else
更簡明地表達程式設計思想。對於這種資料型別,你也可以使用多型和方法過載來替代if-then-else
,但對於哪種方式更合適,就語言設計而言仍有一些爭論。兩者都是有用的工具,都應該掌握。不幸的是,Java 8
對模式匹配的支援並不完全。可以把模式匹配看作switch
的擴充套件形式,可以同時將一個數據型別分解成元素。Java
中的switch
語句限於原始型別和Strings
,函式式語言傾向於允許switch
用在更多的資料型別上,包括允許模式匹配。在面向物件設計中,常用的訪客模式可以用來遍歷一組類,並對每個訪問的物件執行操作。模式匹配的一個優點是編譯器可以報告常見錯誤,如“Brakers
類屬於用來表示Car
類的元件的一族類,你忘記了要顯式處理它。”