《Java8實戰》-第三章讀書筆記(Lambda表示式-02)

雷俠發表於2018-08-18

由於第三章的內容比較多,而且為了讓大家更好的瞭解Lambda表示式的使用,也寫了一些相關的例項,可以在Github或者碼雲上拉取讀書筆記的程式碼進行參考。

型別檢查、型別推斷以及限制

當我們第一次提到Lambda表示式時,說它可以為函式式介面生成一個例項。然而,Lambda表示式本身並不包含它在實現哪個函式式介面的資訊。為了全面瞭解Lambda表示式,你應該知道Lambda的實際型別是什麼。

型別檢查

Lambda的型別是從使用Lambda上下文推斷出來的。上下文(比如,接受它傳遞的方法的引數,或者接受它的值得區域性變數)中Lambda表示式需要型別稱為目標型別。

同樣的Lambda,不同的函式式介面

有了目標型別的概念,同一個Lambda表示式就可以與不同的函式介面關聯起來,只要它們的抽象方法能夠相容。比如,前面提到的Callable,這個介面代表著什麼也不接受且返回一個泛型T的函式。

同一個Lambda可用於多個不同的函式式介面:

Comparator<Apple> c1 = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
ToIntBiFunction<Apple, Apple> c2 = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());
BiFunction<Apple, Apple, Integer> c3 = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());;
複製程式碼

是的,ToIntFunction和BiFunction都是屬於函式式介面。還有很多類似的函式式介面,有興趣的可以去看相關的原始碼。

到目前為止,你應該能夠很好的理解在什麼時候以及在哪裡使用Lambda表示式了。它們可以從賦值的上下文、方法呼叫(引數和返回值),以及型別轉換的上下文中獲得目標型別。為了更好的瞭解Lambda表達的時候方式,我們來看看下面的例子,為什麼不能編譯:

Object o = () -> {System.out.println("Tricky example");};
複製程式碼

答案:很簡單,我們都知道Object這個類並不是一個函式式介面,所以它不支援這樣寫。為了解決這個問題,我們可以把Object改為Runnable,Runnable是一個函式式介面,因為它只有一個抽象方法,在上一節的讀書筆記中我們有提到過它。

Runnable r = () -> {System.out.println("Tricky example");};
複製程式碼

你已經見過如何利用目標型別來檢查一個Lambda是否可以用於某個特定的上下文。其實,它也可以用來做一些略有不同的事情:tuiduanLambda引數的型別。

型別推斷

我們還可以進一步的簡化程式碼。Java編譯器會從上下文(目標型別)推斷出用什麼函式式介面來匹配Lambda表示式,這意味著它也可以推斷出適合Lambda的簽名,因為函式描述符可以通過目標型別來得到。這樣做的好處在於,編譯器可以瞭解Lambda表示式的引數型別,這樣就可以在Lambda與法中省去標註引數型別。換句話說,Java編譯器會向下面這樣推斷Lambda的引數型別:

// 引數a沒有顯示的指定型別
List<Apple> greenApples = filter(apples, a -> "green".equals(a.getColor()));
複製程式碼

Lambda表示式有多個引數,程式碼可獨行的好處就更為明顯。例如,你可以在這用來建立一個Comparator物件:

// 沒有型別推斷,顯示的指定了型別
Comparator<Apple> cApple1 = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight());

// 有型別推斷,沒有現實的指定型別
Comparator<Apple> cApple2 = (a1, a2) -> a1.getWeight().compareTo(a2.getWeight());
複製程式碼

有時候,指定型別的情況下程式碼更易讀,有時候去掉它們也更易讀。並沒有說哪個就一定比哪個好,需要根據自身情況來選擇。

使用區域性變數

我們迄今為止所介紹的所有Lambda表示式都只用到了其主體裡的引數。但Lambda表示式也允許用外部變數,就像匿名類一樣。他們被稱作捕獲Lambda。例如:下面的Lambda捕獲了portNumber變數:

int portNumber = 6666;
Runnable r3 = () -> System.out.println(portNumber);
複製程式碼

儘管如此,還有一點點小麻煩:關於能對這些變數做什麼有一些限制。Lambda可以沒有限制地捕獲(也就是在主體中引用)例項變數和靜態變數。但區域性變數必須顯示的宣告final,或實際上就算final。換句話說,Lambda表示式只能捕獲指派給它們的區域性變數一次。(注:捕獲例項變數可以被看作捕獲最終區域性變數this)。例如,下面的程式碼無法編譯。

int portNumber = 6666;
Runnable r3 = () -> System.out.println(portNumber);
portNumber = 7777;
複製程式碼

portNumber是一個final變數,儘管我們沒有顯示的去指定它。但是,在程式碼編譯的時候,編譯器會自動給這個變數加了一個final,起碼我看反編譯後的程式碼是有一個final的。

對於區域性變數的限制

你可能會有一個疑問,為什麼區域性變數會有這些限制。第一個,例項變數和區域性變數背後的實現有一個關鍵不同。例項變數都儲存在堆中,而區域性變數則儲存在棧上。如果Lambda可以直接訪問區域性變數,而且Lambda是在一個執行緒中使用,則使用Lambda的執行緒,可能會在分配該變數的執行緒將這個變數回收之後,去訪問該變數。因此,Java在訪問自由區域性變數是,實際上是在訪問它的副本,而不是訪問原始變數。如果區域性變數僅僅複製一次那就沒什麼區別了,因此就有了這個限制。

現在,我們來了解你會在Java8程式碼中看到的另一個功能:方法引用。可以把它們視為某些Lambda的快捷方式。

方法引用

方法引用讓你可以重複使用現有的方法,並像Lambda一樣傳遞它們。在一些情況下,比起用Lambda表示式還要易讀,感覺也更自然。下面就是我們藉助Java8 API,用法引用寫的一個排序例子:

// 之前
apples.sort((Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
// 之後,方法引用
apples.sort(Comparator.comparing(Apple::getWeight));
複製程式碼

酷,使用::的程式碼看起來更加簡潔。在此之前,我們也有使用到過,它的確看起來很簡潔。

管中窺豹

方法引用可以被看作僅僅呼叫特定方法的Lambda的一種快捷寫法。它的基本思想是,如果一個Lambda代表的只是:“直接呼叫這個方法”,那最好還是用名稱來呼叫它,而不是去描述如何呼叫它。事實上,方法引用就是讓你根據已有的方法實現來建立Lambda表示式。但是,顯示地指明方法的名稱,你的程式碼可讀性會更好。它是如何工作的?當你需要使用方法引用是,目標引用放在分隔符::前,方法的名稱放在後面。 例如,Apple::getWeight就是引用了Apple類中定義的getWeight方法。請記住,不需要括號,因為你沒有實際呼叫這個方法。方法引用就是用Lambda表示式(Apple a) -> a.getWeight()的快捷寫法。

我們接著來看看關於Lambda與方法引用等效的一些例子:

Lambda:(Apple a) -> a.getWeight() 
方法引用:Apple::getWeight

Lambda:() -> Thread.currentThread().dumpStack() 
方法引用:Thread.currentThread()::dumpStack

Lambda:(str, i) -> str.substring(i)
方法引用:String::substring

Lambda:(String s) -> System.out.println(s)
方法引用:System.out::println
複製程式碼

你可以把方法引用看作是Java8中個一個語法糖,因為它簡化了一部分程式碼。

建構函式引用

對於一個現有的建構函式,你可以利用它的名稱和關鍵字new來建立它的一個引用:ClassName::new。如果,一個建構函式沒有引數,那麼可以使用Supplier來建立一個物件。你可以這樣做:

Supplier<Apple> c1 = Apple::new;
Apple apple = c1.get();
複製程式碼

這樣做等價於

Supplier<Apple> c1 = () -> new Apple();
Apple apple = c1.get();
複製程式碼

如果,你的建構函式的簽名是Apple(Integer weight),那麼可以使用Function介面的簽名,可以這樣寫:

Function<Integer, Apple> c2 = Apple::new;
Apple a2 = c2.apply(120);
複製程式碼

這樣做等價於

Function<Integer, Apple> c2 = (weight) -> new Apple(weight);
Apple a2 = c2.apply(120);
複製程式碼

如果有兩個引數Apple(weight, color),那麼我們可以使用BiFunction:

BiFunction<Integer, String, Apple> c3 = Apple::new;
Apple a3 = c3.apply(120, "red");
複製程式碼

這樣做等價於

BiFunction<Integer, String, Apple> c3 =(weight, color) -> new Apple(weight, color);
Apple a3 = c3.apply(120, "red");
複製程式碼

到目前為止,我們瞭解到了很多新內容:Lambda、函式式介面和方法引用,接下來我們將把這一切付諸實踐。

Lambda和方法引用實戰

為了更好的熟悉Lambda和方法引用的使用,我們繼續研究開始的那個問題,用不同的排序策略給一個Apple列表排序,並需要展示如何把一個圓使出報的解決方案變得更為簡明。這會用到我們目前瞭解到的所有概念和功能:行為引數化、匿名類、Lambda表示式和方法引用。我們想要實現的最終解決方案是這樣的:

apples.sort(comparing(Apple::getWeight));
複製程式碼

第1步:程式碼傳遞

很幸運,Java8的Api已經提供了一個List可用的sort方法,我們可以不用自己再去實現它。那麼最困難部分已經搞定了!但是,如果把排序策略傳遞給sort方法呢?你看,sort方法簽名是這樣的:

void sort(Comparator<? super E> c)
複製程式碼

它需要一個Comparator物件來比較兩個Apple!這就是在Java中傳遞策略的方式:它們必須包裹在一個物件利。我們說sort的行為被引數化了了:傳遞給他的排序策略不同,其行為也會不同。

可能,你的第一個解決方案是這樣的:

public class AppleComparator implements Comparator<Apple> {
    @Override
    public int compare(Apple o1, Apple o2) {
        return o1.getWeight().compareTo(o2.getWeight());
    }
}

apples.sort(new AppleComparator());
複製程式碼

它確實能實現排序,但是還需要去實現一個介面,並且排序的規則也不復雜,或許它還可以簡化一下。

第2步:使用匿名類

或許你已經想到了一個簡化程式碼的辦法,就是使用匿名類而且每次使用只需要例項化一次就可以了:

apples.sort(new Comparator<Apple>() {
    @Override
    public int compare(Apple o1, Apple o2) {
        return o1.getWeight().compareTo(o2.getWeight());
    }
});
複製程式碼

看上去確實簡化一些,但感覺還是有些囉嗦,我們接著繼續簡化:

第3步:使用Lambda表示式

我們可以使用Lambda表示式來替代匿名類,這樣可以提高程式碼的簡潔性和開發效率:

apples.sort((o1, o2) -> o1.getWeight().compareTo(o2.getWeight()));
複製程式碼

太棒了!這樣的程式碼看起來很簡潔,原來四五行的程式碼只需要一行就可以搞定了!但是,我們還可以使這行程式碼更加的簡潔!

第4步:使用方法引用

使用Lambda表示式的程式碼確實簡潔了不少,那你還記得我們前面說的方法引用嗎?它是Lambda表示式的一種快捷寫法,相當於是一種語法糖,那麼我們來試試糖的滋味如何:

apples.sort(Comparator.comparing(Apple::getWeight));
複製程式碼

恭喜你,這就是你的最終解決方案!這樣的程式碼比真的很簡潔,這比Java8之前的程式碼好了很多。這樣的程式碼比較簡短,它的意思也很明顯,並且程式碼讀起來和問題描述的差不多:“對庫存進行排序,比較蘋果的重量”。

複合(組合)Lambda表示式的有用方法

Java8的好幾個函式式介面都有為方便而設計的的方法。具體而言,許多函式式介面,比如用於傳遞Lambda表示式的Comparator、Function和Predicate都提供了允許你進行復合的方法。這是什麼意思呢?在實踐中,這意味著你可以把多個簡單的Lambda複合成複雜的表示式。比如,你可以讓兩個謂詞之間做一個or操作,組合成一個更大的謂詞。而且,你還可以讓一個函式的結果成為另一個函式的輸入。你可能會想,函式式介面中怎麼可能有更多的方法?(畢竟,這違背了函式式介面的定義,只能有一個抽象方法)還記得我們上一節筆記中提到預設方法嗎?它們不是抽象方法。關於預設方法,我們以後在進行詳細的瞭解吧。

比較複合器

還記剛剛我們對蘋果的排序嗎?它只是一個從小到大的一個排序,現在我們需要讓它進行逆序。看看剛剛方法引用的程式碼,你會發現它貌似無法進行逆序啊!不過不用擔心,我們可以讓它進行逆序,而且很簡單。

1.逆序

想要實現逆序其實很簡單,需要使用一個reversed()方法就可以完成我們想要的逆序排序:

apples.sort(Comparator.comparing(Apple::getWeight).reversed());
複製程式碼

按重量遞減排序,就這樣完成了。這個方法很有用,而且用起來很簡單。

2.比較器鏈

上面的程式碼很簡單,但是你仔細想想,如果存在兩個一樣重的蘋果誰前誰後呢?你可能需要再提供一個Comparator來進一步定義這個比較。比如,再按重量比較了兩個蘋果之後,你可能還想要按原產國進行排序。thenComparing方法就是做這個用的。它接受一個函式作為引數(就像comparing方法一樣),如果兩個物件用第一個Comparator比較之後還是一樣,就提供第二個Comparator。我們又可以優雅的解決這個問題了:

apples.sort(Comparator.comparing(Apple::getWeight).reversed()
                .thenComparing(Apple::getCountry));
複製程式碼

複合謂詞

謂詞介面包括了三個方法: negate、and和or,讓你可以重用已有的Predicate來建立更復雜的謂詞。比如,negate方法返回一個Predicate的非,比如蘋果不是紅的:

private static <T> List<T> filter(List<T> list, Predicate<T> predicate) {
    List<T> result = new ArrayList<>();
    for (T t : list) {
        if (predicate.test(t)) {
            result.add(t);
        }
    }
    return result;
}

List<Apple> apples = Arrays.asList(new Apple(150, "red"), new Apple(110, "green"), new Apple(100, "green"));
// 只要紅蘋果
Predicate<Apple> apple = a -> "red".equals(a.getColor());
// 只要紅蘋果的非
Predicate<Apple> notRedApple = apple.negate();
// 篩選
List<Apple> appleList = filter(apples, notRedApple);
// 遍歷列印
appleList.forEach(System.out::println);
複製程式碼

你可能還想要把Lambda用and方法組合起來,比如一個蘋果即是紅色的又比較重:

Predicate<Apple> redAndHeavyApple = apple.and(a -> a.getWeight() >= 150);
複製程式碼

你還可以進一步組合謂詞,表達要麼是重的紅蘋果,要麼是綠蘋果:

 Predicate<Apple> redAndHeavyAppleOrGreen =
                apple.and(a -> a.getWeight() >= 150)
                        .or(a -> "green".equals(a.getColor()));
複製程式碼

這一點為什麼很好呢?從簡單的Lambda表示式出發,你可以構建更復雜的表示式,但讀起來仍然和問題陳述的差不多!請注意,and和or方法是按照表示式鏈中的位置,從左向右確定優先順序的。因此,a.or(b).and(c)可以看作(a || b) && c。

函式複合

最後,你還可以把Function介面所代表的Lambda表示式複合起來。Function介面為此匹配了andThen和compose兩個預設方法,它們都會返回Function的一個例項。

andThen方法會返回一個函式,它先對輸入應用一個給定函式,再對輸出應用另一個函式。假設,有一個函式f給數字加1(x -> x + 1),另外一個函式g給數字乘2,你可以將它們組合成一個函式h:

Function<Integer, Integer> f = x -> x + 1;
Function<Integer, Integer> g = x -> x * 2;
Function<Integer, Integer> h = f.andThen(g);
// result = 4
int result = h.apply(1);
複製程式碼

你也可以類似地使用compose方法,先把給定的函式左右compose的引數裡面給的那個函式,然後再把函式本身用於結果。比如在上一個例子用compose的化,它將意味著f(g(x)),而andThen則意味著g(f(x)):

Function<Integer, Integer> f1 = x -> x + 1;
Function<Integer, Integer> g1 = x -> x * 2;
Function<Integer, Integer> h1 = f1.compose(g1);
// result1 = 3
int result1 = h1.apply(1);
複製程式碼

它們的關係如下圖所示:

image

compose和andThen的不同之處就是函式執行的順序不同。compose函式限制先引數,然後執行呼叫者,而andThen限制先呼叫者,然後再執行引數。

總結

在《Java8實戰》第三章中,我們瞭解到了很多概念關鍵的念。

  1. Lambda表示式可以理解為一種匿名函式:它沒有名稱,但有引數列表、函式主體、返回型別,可能還有一個可丟擲的異常列表。
  2. Lambda表示式讓我們可以簡潔的傳遞程式碼。
  3. 函式式介面就是僅僅只有一個抽象方法的介面。
  4. 只有在接受函式式介面的地方才可以使用Lambda表示式。
  5. Lambda表示式允許你直接內聯,為函式式介面的抽象方法提供實現,並且將整個表示式作為函式式介面的一個例項。
  6. Java8自帶一些常用的函式式介面,在java.util.function包裡,包括了Predicate、Function<T, R>、Supplier、Consumer和BinaryOperatory。
  7. 為了避免裝箱操作,等於Predicate和Function<T, R>等通用的函式式介面的原始型別特化:IntPredicate、IntToLongFunction等。
  8. Lambda表示式所需要代表的型別稱為目標型別。
  9. 方法引用可以讓我們重複使用現有的方法實現並且直接傳遞它們。
  10. Comparator、Predicate和Function等函式式介面都有幾個可以用來結合Lambda表示式的預設方法。

第三章的內容確實很多,而且這一章的內容也很重要,如果你有興趣那麼請慢慢的看,最好自己能動手寫寫程式碼否則過不了多久就會忘記了。

第三章筆記中的程式碼:

Github: chap3

Gitee: chap3

如果,你對Java8中的新特性很感興趣,你可以關注我的公眾號或者當前的技術社群的賬號,利用空閒的時間看看我的文章,非常感謝!

《Java8實戰》-第三章讀書筆記(Lambda表示式-02)

相關文章