《Java8實戰》筆記(01):爲什麼要關心Java8

2020-08-14 01:04:38

Java 8 新特性:

  • Stream API
  • 向方法傳遞程式碼的技巧
  • 介面中的預設方法

助記:

//Java8主要有哪些新特性?

//1.Stream API 2.介面的預設實現 3.方法晉升一級公民
inventory.stream().filter((Apple a) -> a.getWeight() > 150)
			.collect(Collectors.toList());

在Java 8之前:

//對inventory中的蘋果按照重量進行排序
Collections.sort(inventory, new Comparator<Apple>() {
	public int compare(Apple a1, Apple a2){
		return a1.getWeight().compareTo(a2.getWeight());
	}
});

在Java 8之後:

//給庫存排序,比較蘋果重量,讀起來言簡意賅
inventory.sort(Collectors.comparing(Apple::getWeight));

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

從有點修正主義的角度來看,在Java 8中加入Streams可以看作把另外兩項擴充加入Java 8的直接原因:把程式碼傳遞給方法的簡潔方式(方法參照、Lambda)和介面中的預設方法

如果僅僅「把程式碼傳遞給方法」看作Streams的一個結果,那就低估了它在Java 8中的應用範圍。它提供了一種新的方式,這種方式簡潔地表達了行爲參數化。比方說,你想要寫兩個只有幾行程式碼不同的方法,那現在你只需要把不同的那部分程式碼作爲參數傳遞進去就可以了。採用這種程式設計技巧,程式碼會更短、更清晰,也比常用的複製貼上更不容易出錯。

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

Java 怎麼還在變

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

流處理

流是一系列數據項,一次只生成一項。程式可以從輸入流中一個一個讀取數據項,然後以同樣的方式將數據項寫入輸出流。一個程式的輸出流很可能是另一個程式的輸入流。

舉一個例子:Unix的cat命令會把兩個檔案連線起來建立一個流,tr會轉換流中的字元,sort會對流中的行進行排序,而tail -3則給出流的最後三行。Unix命令列允許這些程式通過管道(|)連線在一起,命令如下

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

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

推動這種做法的關鍵在於,現在你可以在一個更高的抽象層次上寫Java 8程式了:思路變成了把這樣的流變成那樣的流(就像寫數據庫查詢語句時的那種思路),而不是一次只處理一個專案。另一個好處是,Java 8可以透明地把輸入的不相關部分拿到幾個CPU內核上去分別執行你的Stream操作流水線——這是幾乎簡單易行的並行,用不着去費勁搞Thread了

用行爲參數化把程式碼傳遞給方法

方法當作參數傳入方法

Java8前只能傳基本型別,物件型別,不能單純存入方法

並行與共用的可變數據

一般情況下這就意味着,寫程式碼時不能存取共用的可變數據。這些函數有時被稱爲「純函數」或「無副作用函數」或「無狀態函數」,

並行只有在假定你的程式碼的多個副本可以獨立工作時才能 纔能進行。但如果要寫入的是一個共用變數或物件,這就行不通了:如果兩個進程需要同時修改這個共用變數怎麼辦?

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

這兩個要點(沒有共用的可變數據,將方法和函數即程式碼傳遞給其他方法的能力)是函數語言程式設計範式的基石。與此相反,在指令式程式設計範式中,你寫的程式則是一系列改變狀態的指令。

「不能有共用的可變數據」的要求意味着,一個方法是可以通過它將參數值轉換爲結果的方式完全描述的;換句話說,它的行爲就像一個數學函數,沒有可見的副作用。

Java 需要演變

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

其他改變讓普通的東西更容易表達,比如,使用for-each回圈而不用暴露Iterator裏面的套路寫法。

Java 8中的主要變化反映了它開始遠離常側重改變現有值的經典物件導向思想,而向函數語言程式設計領域轉變,在大面上考慮做什麼(例如,建立一個值代表所有從A到B低於給定價格的交通線路)被認爲是頭等大事,並和如何實現(例如,掃描一個數據結構並修改某些元素)區分開來。

請注意,如果極端點兒來說,傳統的物件導向程式設計和函數式可能看起來是衝突的。但是我們的理念是獲得兩種程式設計範式中最好的東西,這樣你就有更大的機會爲任務找到理想的工具了。(取長補短)

語言需要不斷改進以跟進硬體的更新或滿足程式設計師的期待。要堅持下去,Java必須通過增加新功能來改進,而且只有新功能被人使用,變化纔有意義。所以,使用Java 8,你就是在保護你作爲Java程式設計師的職業生涯

Java 中的函數

程式語言中的函數一詞通常是指方法,尤其是靜態方法;這是在數學函數,也就是沒有副作用的函數之外的新含義。

Java 8中新增了函數——值的一種新形式。有了它,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 8的設計者把這個功能加入到了Java中。

方法和Lambda 作爲一等公民

Scala和Groovy等語言的實踐已經證明,讓方法等概唸作爲一等值可以擴充程式設計師的工具庫,從而讓程式設計變得更容易。

Java 8的第一個新功能是方法參照

MethodArgument

Java 8 之前:

File[] hiddenFiles = new File(".").listFiles(new FileFilter() {
	public boolean accept(File file) {
		return file.isHidden();
	}
});

Java 8 之後:

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

Java 8的方法參照::語法(即「把這個方法作爲值」)將其傳給listFiles方法,也開始用函數代表方法了。一個好處是,你的程式碼現在讀起來更接近問題的陳述了。方法不再是二等值了。

與用物件參照傳遞物件類似(物件參照是用new建立的),在Java 8裡寫下File::isHidden的時候,建立了一個方法參照,同樣可以傳遞它


Lambda——匿名函數

除了允許(命名)函數成爲一等值外,Java 8還體現了更廣義的將函數作爲值的思想,包括Lambda(或匿名函數)。比如,你現在可以寫(int x) -> x + 1,表示「呼叫時給定參數x,就返回x + 1值的函數」。

你可能會想這有什麼必要呢?因爲你可以在MyMathsUtils類裏面定義一個add1方法,然後寫MyMathsUtils::add1嘛!確實是可以,但要是你沒有方便的方法和類可用,新的Lambda語法更簡潔。

傳遞程式碼:一個例子

FilteringApples

需求

假設你有一個Apple類,它有一個getColor方法,還有一個變數inventory儲存着一個Apples的列表。你可能想要選出所有的綠蘋果,並返回一個列表。

Java 8之前的寫法:

public static List<Apple> filterGreenApples(List<Apple> inventory) {
	List<Apple> result = new ArrayList<>();
	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;
}

上面有程式碼重複,重構的氣味出現


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 static List<Apple> filterApples(List<Apple> inventory, java.util.function.Predicate<Apple> p) {
	List<Apple> result = new ArrayList<>();
	for (Apple apple : inventory) {
		if (p.test(apple)) {
			result.add(apple);
		}
	}
	return result;
}

要用它的話,可以寫成

filterApples(inventory, FilteringApples::isGreenApple);

filterApples(inventory, FilteringApples::isHeavyApple);

什麼是謂詞Predicate?

在數學上常常用來代表一個類似函數的東西,它接受一個參數值,並返回true或false

從傳遞方法到Lambda

把方法作爲值來傳遞顯然很有用,但要是爲類似於isHeavyApple和isGreenApple這種可能只用一兩次的短方法寫一堆定義很煩人。

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

//or

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

//or

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(inventory, (Apple a) -> a.getWeight() > 150);

就可以直接呼叫庫方法filter

inventory.stream().filter((Apple a) -> a.getWeight() > 150)
			.collect(Collectors.toList())

Stream

幾乎每個Java應用都會製造和處理集合。但集合用起來並不總是那麼理想。

需求

Java 8前

比方說,你需要從一個列表中篩選金額較高的交易,然後按貨幣分組。你需要寫一大堆套路化的程式碼來實現這個數據處理命令。

Map<Currency, List<Transaction>> transactionsByCurrencies = new HashMap<>();

for (Transaction transaction : transactions) {
	if(transaction.getPrice() > 1000){
		Currency currency = transaction.getCurrency();

		List<Transaction> transactionsForCurrency = transactionsByCurrencies.get(currency);

		if (transactionsForCurrency == null) {
			transactionsForCurrency = new ArrayList<>();
			transactionsByCurrencies.put(currency,
				transactionsForCurrency);
		}
		transactionsForCurrency.add(transaction);
	}
}

Java 8後

import static java.util.stream.Collectors.toList;
Map<Currency, List<Transaction>> transactionsByCurrencies = transactions.stream()
		.filter((Transaction t) -> t.getPrice() > 1000)
		.collect(groupingBy(Transaction::getCurrency));

和Collection API相比,Stream API處理數據的方式非常不同。用集合的話,你得自己去做迭代的過程。你得用for-each回圈一個個去迭代元素,然後再處理元素。我們把這種數據迭代的方法稱爲外部迭代

相反,有了Stream API,根本用不着操心回圈的事情。數據處理完全是在庫內部進行的。我們把這種思想叫作內部迭代

使用流的好處——更高效利用多核CPU

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

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

多執行緒並非易事

問題在於,通過多執行緒程式碼來利用並行(使用先前Java版本中的Thread API)並非易事。

譬如:執行緒可能會同時存取並更新共用變數

因此,如果沒有協調好,數據可能會被意外改變。相比一步步執行的順序模型,這個模型不太好理解。

下圖就展示瞭如果沒有同步好,兩個執行緒同時向共用變數sum加上一個數時,可能出現的問題。

Java 8也用Stream API(java.util.stream)解決了這兩個問題:集合處理時的套路和晦澀,以及難以利用多核。

這樣設計的第一個原因是,有許多反覆 反復出現的數據處理模式,類似於前一節所說的filterApples或SQL等數據庫查詢語言裡熟悉的操作,如果在庫中有這些就會很方便:根據標準篩選數據(比如較重的蘋果),提取數據(例如抽取列表中每個蘋果的重量欄位),或給數據分組(例如,將一個數字列表分組,奇數和偶數分別列表)等。

第二個原因是,這類操作常常可以並行化

例如,如下圖所示,在兩個CPU上篩選列表,可以讓一個CPU處理列表的前一半,第二個CPU處理後一半,這稱爲分支步驟(1)。CPU隨後對各自的半個列表做篩選(2)。最後(3),一個CPU會把兩個結果合併起來

現在最好記得,Collection主要是爲了儲存和存取數據,而Stream則主要用於描述對數據的計算

// 順序處理
List<Apple> heavyApples3 = inventory.stream().filter((Apple a) -> a.getWeight() > 150)
		.collect(Collectors.toList());
System.out.println(heavyApples3);

// 並行處理
List<Apple> heavyApples4 = inventory.parallelStream().filter((Apple a) -> a.getWeight() > 150)
		.collect(Collectors.toList());
System.out.println(heavyApples4);

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

大家都說Java裏面並行很難,而且和synchronized相關的玩意兒都容易出問題。那Java 8裏面有什麼「靈丹妙藥」呢?事實上有兩個。

首先,庫會負責分塊,即把大的流分成幾個小的流,以便並行處理。

其次,流提供的這個幾乎免費的並行,只有在傳遞給filter之類的庫方法的方法不會互動(比方說有可變的共用物件)時才能 纔能工作。

但是其實這個限制對於程式設計師來說挺自然的,舉個例子,我們的Apple::isGreenApple就是這樣。確實,雖然函數語言程式設計中的函數的主要意思是「把函數作爲一等值」,不過它也常常隱含着第二層意思,即「執行時在元素之間無互動」。

預設方法

Java 8中加入預設方法主要是爲了支援庫設計師,讓他們能夠寫出更容易改進的介面

這一方法很重要,因爲你會在介面中遇到越來越多的預設方法,但由於真正需要編寫預設方法的程式設計師相對較少,而且它們只是有助於程式改進,而不是用於編寫任何具體的程式


譬如

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

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

這裏有個問題:在Java 8之前,List並沒有stream或parallelStream方法,它實現的Collection介面也沒有,因爲當初還沒有想到這些方法嘛!可沒有這些方法,這些程式碼就不能編譯

換作你自己的介面的話,最簡單的解決方案就是讓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中有了某種形式的多重繼承?Java 8用一些限制來避免出現類似於C++中臭名昭著的菱形繼承問題。

來自函數語言程式設計的其他好思想

Java 8裡有一個Optional類,如果你能一致地使用它的話,就可以幫助你避免出現NullPointer異常。它是一個容器物件,可以包含,也可以不包含一個值。Optional中有方法來明確處理值不存在的情況,這樣就可以避免NullPointer異常了。換句話說,它使用型別系統, 允許你表明我們知道一個變數可能會沒有值。


(結構)模式匹配

模式匹配看作switch的擴充套件形式