使用詞法作用域和閉包
許多開發人員都存在這種誤解,認為使用lambda表達式會導致程式碼冗餘,降低程式碼品質。恰恰相反,就算程式碼變得再複雜,我們也不會為了程式碼的簡潔性而在程式碼品質上做任何妥協,下面我們就會看到。
在前面一個例子中我們已經可以重用lambda表達式了;然而,如果再匹配另一個字母,代碼冗餘的問題很快又捲土重來了。我們先來進一步分析下這個問題,然後再用詞法作用域和閉包來把它解決掉。
lambda表達式帶來的冗餘
我們來從friends中過濾出那些以N或B開頭的字母。繼續延用上面的例子,我們寫出的程式碼可能會是這樣的:
複製代碼代碼如下:
final Predicate<String> startsWithN = name -> name.startsWith("N");
final Predicate<String> startsWithB = name -> name.startsWith("B");
final long countFriendsStartN =
friends.stream()
.filter(startsWithN).count();
final long countFriendsStartB =
friends.stream()
.filter(startsWithB).count();
第一個predicate判斷名字是否是以N開頭的,而第二個是判斷是否以B開頭的。我們把這兩個實例分別傳遞給兩次filter方法呼叫。這樣看起來很合理,但是兩個predicate產生了冗餘,它們只是那個檢查的字母不同而已。我們來看下如何能避免這種冗餘。
使用詞法作用域來避免冗餘
第一個方案,我們可以把字母抽出來當函數的參數,同時把這個函數傳遞給filter方法。這是個不錯的方法,不過filter可不是什麼函數都接受的。它只接受只有一個參數的函數,那個參數對應的是集合中的元素,回傳一個boolean值,它希望傳進來的是一個Predicate。
我們希望有一個地方能把這個字母先緩存起來,一直到參數傳遞過來(這裡就是name這個參數)。下面來新建一個這樣的函數。
複製代碼代碼如下:
public static Predicate<String> checkIfStartsWith(final String letter) {
return name -> name.startsWith(letter);
}
我們定義了一個靜態函數checkIfStartsWith,它接收一個String參數,並且傳回一個Predicate對象,它正好可以傳遞給filter方法,以便後面可以使用。不像前面看到的高階函數是以函數當參數的,這個方法回傳的是一個函數。不過它也是一個高階函數,這個我們在12頁的進化,而非變革中已經提到過了。
checkIfStartsWith方法傳回的Predicate物件和其它lambda表達式有些不同。在return name -> name.startsWith(letter)語句中,我們很清楚name是什麼,它是傳入到lambda表達式中的參數。不過變數letter到底是什麼?它是在這個匿名函數的域外邊的,Java找到了定義這個lambda表達式的域,並發現了這個變數letter。這個就叫做詞法作用域。詞法作用域是個很有用的東西,它使得我們可以在一個用用域中快取一個變量,以便後面在另一個上下文中使用。由於這個lambda表達式使用了它的定義域中的變量,因此這種情況也稱為閉包。關於詞法作用域的存取限制,可以看下31頁的詞法作用域有什麼限制嗎?
詞法作用域有什麼限制嗎?
在lambda表達式中,我們只能存取它的定義域中的final類型或實際上是final類型的本地變數。
lambda表達式可能馬上就會被調用,也可能延遲進行調用,或從不同的線程發起調用。為了避免競爭衝突,我們訪問的定義域中的本地變量,一旦初始化後是不允許進行修改的。任何修改操作都會導致編譯異常。
標記成final後解決了這個問題,不過Java並不強迫我們一定要這麼標記。事實上,Java看的是兩點。一個是存取的這個變數必須在定義它的方法中完成初始化,並且在定義lambda表達式之前。第二,這些變數的值不能進行修改――也就是說,它們事實上就是final類型的,儘管沒有這麼標記。
無狀態的lambda表達式是運行時常數,而那些使用了本地變數的lambda表達式會有額外的計算開銷。
在呼叫filter方法的時候我們就可以用checkIfStartsWith方法回傳的lambda表達式了,就像這樣:
複製代碼代碼如下:
final long countFriendsStartN =
friends.stream() .filter(checkIfStartsWith("N")).count();
final long countFriendsStartB = friends.stream()
.filter(checkIfStartsWith("B")).count();
在呼叫filter方法之前,我們先呼叫了checkIfStartsWith()方法,把想要的字母傳參進去。這個呼叫很快就回傳了一個lambda表達式,然後我們把它傳給filter方法。
透過創建了一個高階函數(這裡是checkIfStartsWith)並且使用了詞法作用域,我們成功的去除了程式碼中的冗餘。我們不用再重複的判斷name是不是以某個字母開頭了。
重構,縮小作用域
在前面的例子中我們用了一個static方法,不過我們不希望用static方法來快取變量,這樣把我們的程式碼搞亂了。最好能把這個函數的作用域縮小到使用它的地方。我們可以用一個Function介面來實作這個。
複製代碼代碼如下:
final Function<String, Predicate<String>> startsWithLetter = (String letter) -> {
Predicate<String> checkStarts = (String name) -> name.startsWith(letter);
return checkStarts; };
這個lambda表達式取代了原來的static方法,它可以放到函數裡面,在需要用到它之前就定義一下就好了。 startWithLetter變數引用的是一個入參是String,出參是Predicate的Function。
和使用static方法相比,這個版本簡單多了,不過我們還可以對它繼續重構讓它更簡潔點。從實際的角度來看,這個函數和前面的static方法是一樣的;它們都接收一個String回傳一個Predicate。為了不明確的宣告一個Predicate, 我們用一個lamdba表達式整個給替換掉。
複製代碼代碼如下:
final Function<String, Predicate<String>> startsWithLetter = (String letter) -> (String name) -> name.startsWith(letter);
我們把那些亂七八糟的東西給幹掉了,但是我們還可以去掉類型聲明,讓它更簡潔一點,Java編譯器會根據上下文去做類型推導的。我們來看下改進後的版本。
複製代碼代碼如下:
final Function<String, Predicate<String>> startsWithLetter =
letter -> name -> name.startsWith(letter);
要適應這種簡潔的語法可得下點工夫。如果它亮瞎了你的眼睛的話,先看看別的地方吧。我們已經完成了程式碼的重構,現在可以用它來替換掉原來的checkIfStartsWith()方法了,就像這樣:
複製代碼代碼如下:
final long countFriendsStartN = friends.stream()
.filter(startsWithLetter.apply("N")).count();
final long countFriendsStartB = friends.stream()
.filter(startsWithLetter.apply("B")).count();
在這一節中我們用到了高階函數。我們看到如果把函數傳遞給另一個函數,如何在函數中建立函數,以及如何透過函數來傳回一個函數。這些例子都顯示了lambda表達式帶來的簡潔性和可重複使用性。
本節我們充分發揮了Function和Predicate的作用,不過我們來看它們兩個到底有什麼差別。 Predicate接受一個類型為T的參數,傳回一個boolean值來代表它對應的判斷條件的真假。當我們需要做條件判斷的時候,我們可以使用Predicateg來完成。像filter這類對元素進行篩選的方法都會接收Predicate作為參數。而Funciton代表的是一個函數,它的入參是類型為T的變量,回傳的是R類型的一個結果。它和只能返回boolean的Predicate相比要更加通用。只要是將輸入轉換成一個輸出的,我們都可以使用Function,因此map使用Function作為參數也是情理之中的事情了。
可以看到,從集合中選取元素非常簡單。以下我們將介紹下如何從集合中只挑選出一個元素。