Java基礎教程(23)--lambda表達式

一.初識lambda表達式

1.定義

  lambda表達式是一個可傳遞的代碼塊,或者更確切地說,可以把lambda表達式理解爲簡潔地表示可傳遞的匿名方法的一種方式。它沒有名稱,但它有參數列表、函數主體和返回類型。下面闡述了lambda表達式的幾個特點:

  • 匿名——有別於普通方法,lambda表達式沒有名稱。
  • 方法——我們說它是方法,是因爲儘管lambda表達式不像方法那樣屬於某個特定的類。但和方法一樣,lambda有參數列表、方法主體和返回類型。
  • 傳遞——lambda表達式可以作爲參數傳遞給方法或存儲在變量中。
  • 簡潔——無需像匿名類那樣寫很多模板代碼。

2.語法

  下面是一個lambda表達式的例子:

(int a, int b) -> System.out.println("a + b = " + (a + b))

  一個lambda表達式由參數列表、箭頭(->)和主體組成。下面將分別對參數列表和lambda表達式的主體進行詳細介紹。

(1)參數列表

  和普通方法一樣,lambda表達式也有參數列表。例如上面的例子中的(int a, int b)表示lambda主體中的代碼需要使用a和b兩個參數,這和一般的方法並沒有什麼區別。lambda表達式的參數列表使用一對小括號包圍,裏面依次是每個參數的類型和名稱。
  實際上,lambda表達式的參數列表可以省略參數的類型,編譯器會自動推斷出每個參數的類型(具體有關這方面的內容會在稍後進行介紹)。也就是說,上面的lambda表達式可以改寫爲:

(a, b) -> System.out.println("a + b = " + (a + b))

  如果只有一個參數,就可以省略括號。例如:

a -> System.out.println("a = " + a)

  如果沒有參數,那麼仍然需要提供空括號:

() -> System.out.println("No parameter.")

  

(2)lambda表達式的主體

  lambda表達式的主體可以由一個表達式、一條語句或若干條語句組成。當lambda表達式的主體是多條語句時,需要將這些語句放在大括號{}中;當lambda表達式的主體只有一個表達式時,沒有必要使用並且也不能使用大括號;當lambda表達式的主體只有一條語句時,可以省略大括號。
  下面的lambda表達式的主體只有一個表達式:

(a, b) -> a + b

  在執行主體內只有一個表達式的lambda表達式時,JVM會計算出表達式的結果並將其返回。
  下面的lambda表達式的主體只有一條語句:

a -> System.out.println("a = " + a)

  可以爲這條語句加上大括號,注意最後要使用分號。不過這樣做顯然沒什麼意義,這裏僅僅只是做個說明:

a -> {System.out.println("a = " + a);}

  當lambda表達式的主體是多條語句時,需要將這些語句放在大括號{}中。例如:

() -> {
    System.out.println("I'm statement 1.");
    System.out.println("I'm statement 2.");
}

二.使用lambda表達式的例子

  雖然上面已經介紹了lambda表達式的定義和語法,但我相信很多人讀到這裏對於lambda表達式還是一頭霧水。應該在什麼時候使用它?如何去使用它?下面將使用一個例子作爲講解,在這個例子中,我們將使用不同的語法來應對不斷變化的需求,以展示一些讓代碼更靈活的最佳做法。

這個例子來自於《Java 8實戰》一書,如果有需要pdf版本的可以在文章下面留下郵箱,我會及時發出。

1.篩選綠蘋果

  假設有一個Apple類,它有一個getColor方法,還有一個變量inventory,它是一個保存着若干個Apple的列表。現在我們需要幫果農從這些蘋果中篩選出綠色的蘋果,那麼我們的第一個解決方案可能是下面這樣的:

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;
}

2.把顏色作爲參數

  果農突然改變主意了,他還想要篩選紅色的蘋果,那應該怎麼做呢?簡單的解決辦法就是複製這個方法,把名字改成filterRedApples,然後更改if條件來匹配紅蘋果。然而,如果果農需要篩選更多顏色:淺綠色、暗紅色、黃色等,那麼我們就需要爲每一種顏色編寫一個新的方法,這顯然是不可取的。一個良好的原則是在編寫類似的代碼之後,嘗試將其抽象化。
  一種做法是給方法加一個參數,把顏色變成參數,這樣就能靈活地適應變化了:

public static List<Apple> filterApplesByColor(List<Apple> inventory, String color) {
    List<Apple> result = new ArrayList<Apple>();
    for(Apple apple: inventory){
        if(apple.getColor().equals(color)) {
            result.add(apple);
        }
    }
    return result;
}

  現在,只要像下面這樣調用方法,果農朋友就會滿意了:

List<Apple> greenApples = filterApplesByColor(inventory, "green");
List<Apple> redApples = filterApplesByColor(inventory, "red");

3.篩選其他屬性

  現在這位果農又告訴我們:“要是能區分輕的蘋果和重的蘋果就太好了。重的蘋果的重量大於150克。”
  這肯定難不倒我們,於是我們編寫了另一個方法,用一個參數來應對不同的重量:

public static List<Apple> filterApplesByWeight(List<Apple> inventory, int weight) {
    List<Apple> result = new ArrayList<Apple>();
    for(Apple apple: inventory){
        if(apple.getWeight() > weight){
            result.add(apple);
        }
    }
    return result;
}

  這個解決方案看上去不錯,但是請注意,我們複製了大部分的代碼來實現遍歷庫存,並對每個蘋果應用篩選條件。這有點兒令人失望,因爲它打破了DRY(Don’t Repeat Yourself,不要重複自己)的軟件工程原則。如果我們想要改變篩選遍歷方式來提升性能呢?那就得修改所有方法的實現,而不是隻改一個。從工作量的角度來看,這代價太大了。
  可以將顏色和重量結合爲一個方法。不過就算這樣,還是需要一種方式來區分想要篩選哪個屬性。可以加上一個標誌來區分對顏色和重量的查詢,就像下面這樣:

public static List<Apple> filterApples(List<Apple> inventory, String color, int weight, boolean flag) {
    List<Apple> result = new ArrayList<Apple>();
        for(Apple apple: inventory) {
            if( (flag && apple.getColor().equals(color)) ||
                (!flag && apple.getWeight() > weight) ){
            result.add(apple);
        }
    }
    return result;
}

  現在我們可以使用這個方法來篩選顏色和重量:

List<Apple> greenApples = filterApples(inventory, "green", 0, true);
List<Apple> heavyApples = filterApples(inventory, "", 150, false);

  這個方案再差不過了。首先,代碼可讀性很差。true和false是什麼意思?爲什麼篩選一個屬性的時候還要提供另外一個屬性的默認值?此外,這個解決方案還是不能很好地應對變化的需求。如果這位果農要求我們對蘋果的不同屬性做篩選,比如大小、形狀、產地等,又怎麼辦?而且,如果果農要求我們組合屬性,做更復雜的查詢,比如綠色的重蘋果,又該怎麼辦?我們將會有好多個重複的filter方法,或一個巨大的非常複雜的方法。我們需要一種更好的方式,來把蘋果的選擇標準告訴filterApples方法。在下一節中,我們會介紹瞭如何利用行爲參數化實現這種靈活性。

4.對篩選條件進行抽象

  我們需要一種比添加很多參數更好的方法來應對變化的需求。讓我們後退一步來看看更高層次的抽象。一種可能的解決方案是對選擇標準建模:根據Apple的某些屬性(比如它是綠色的嗎?重量超過150克嗎?)來返回一個boolean值。我們把它稱爲謂詞(即一個返回boolean值的函數)。讓我們定義一個接口來對選擇標準建模:

public interface ApplePredicate {
    boolean test(Apple apple);
}

  現在可以用ApplePredicate的多個實現代表不同的選擇標準:

public class AppleHeavyWeightPredicate implements ApplePredicate {  // 選出重的蘋果
    public boolean test(Apple apple) {
        return apple.getWeight() > 150;
    }
}
public class AppleGreenColorPredicate implements ApplePredicate {   // 選出綠蘋果
    public boolean test(Apple apple) {
        return "green".equals(apple.getColor());
    }
}

  但是,該怎麼利用ApplePredicate的不同實現呢?我們需要filterApples方法接受ApplePredicate對象,對Apple做條件測試。這就是行爲參數化:讓方法接受多種行爲作爲參數,並在內部使用,來完成不同的行爲。
  利用ApplePredicate改造之後,filter方法是這樣的:

public static List<Apple> filterApples(List<Apple> inventory, ApplePredicate p){
    List<Apple> result = new ArrayList<>();
    for(Apple apple: inventory){
        if(p.test(apple)){
            result.add(apple);
        }
    }
    return result;
}

  這段代碼比我們第一次嘗試的時候靈活多了,讀起來、用起來也更容易!現在我們可以創建不同的ApplePredicate對象,並將它們傳遞給filterApples方法。比如,如果果農讓我們找出所有重量超過150克的紅蘋果,我們只需要先創建一個類來實現ApplePredicate:

public class AppleRedAndHeavyPredicate implements ApplePredicate{
    public boolean test(Apple apple){
        return "red".equals(apple.getColor()) && apple.getWeight() > 150;
    }
}

  然後在代碼中去使用這個篩選條件:

List<Apple> redAndHeavyApples = filterApples(inventory, new AppleRedAndHeavyPredicate());

5.對付囉嗦

  雖然我們的代碼現在足夠靈活,可以應對任何涉及蘋果屬性的需求變更。但是,每次把新的篩選條件傳遞給filterApples方法的時候,我們不得不聲明一個實現ApplePredicate接口的類並將其實例化,儘管這個類可能只會用到一次。下面的程序總結了你目前看到的一切。這真的很囉嗦,很費時間!

public class AppleHeavyWeightPredicate implements ApplePredicate {
    public boolean test(Apple apple){
        return apple.getWeight() > 150;
    }
}
public class AppleGreenColorPredicate implements ApplePredicate {
    public boolean test(Apple apple){
        return "green".equals(apple.getColor());
    }
}
public class FilteringApples{
    public static void main(String...args){
        List<Apple> inventory = new ArrayList<>();
        inventory.add(new Apple(80,"green"));
        inventory.add(new Apple(155, "green"));
        inventory.add(new Apple(120, "red"));
        
        List<Apple> heavyApples = filterApples(inventory, new AppleHeavyWeightPredicate());
        List<Apple> greenApples = filterApples(inventory, new AppleGreenColorPredicate());
    }

    public static List<Apple> filterApples(List<Apple> inventory, ApplePredicate p) {
        List<Apple> result = new ArrayList<>();
        for (Apple apple : inventory){
            if (p.test(apple)){
                result.add(apple);
            }
        }
        return result;
    }
}

  費這麼大勁兒真沒必要,能不能做得更好呢?還記得之前的匿名類嗎?它可以讓你同時聲明和實例化一個類,可以幫助你進一步改善代碼,讓代碼變得更簡潔。
  下面的代碼展示瞭如何通過創建一個用匿名類實現ApplePredicate的對象,重寫篩選的例子:

List<Apple> redApples = filterApples(inventory, new ApplePredicate() {
    public boolean test(Apple apple){
        return "red".equals(apple.getColor());
    }
});

  當需要新的篩選條件時,我們可以使用匿名類來實現並實例化ApplePredicate接口,這樣就無需爲每一個篩選條件創建一個新的類了。

6.使用lambda表達式

  匿名類還是不夠好。它往往很笨重,因爲它佔用了很多空間。並且它用起來很讓人費解。即使匿名類處理在某種程度上改善了爲每一個篩選條件創建一個實現類的囉嗦問題,但它仍不能令人滿意。
  現在回過頭來觀察我們的代碼。我們的filterApples方法所需要的只是一個篩選固定特徵的方法。但是,由於我們無法向filterApples方法傳遞一個方法,所以我們只能將這個方法放在類裏,將其實例化之後再傳遞給filterApples方法。儘管匿名類解決了爲每一個篩選條件創建一個實現類的問題,但我們傳遞的仍然是對象而不是方法。
  回想一下上面對lambda表達式的定義——lambda表達式是一個可傳遞的匿名方法。這意味着從Java 8之後,我們可以將方法當作參數一樣傳遞了。現在以篩選紅蘋果的方法爲例,使用lambda表達式對它進行改造:

public boolean test(Apple apple){
    return "red".equals(apple.getColor());
}

  這個方法接受一個Apple對象作爲參數,判斷它的顏色是否爲紅色並返回。使用lambda表達式可以將其重寫爲:

apple -> "red".equals(apple.getColor())

  我們可以將這個lambda當成參數一樣傳遞給filterApples方法:

List<Apple> result = filterApples(inventory, apple -> "red".equals(apple.getColor()));

  不得不說,現在的代碼比我們最開始寫的簡潔太多了。我們在靈活性和簡潔性之間找到了最佳平衡點,這在Java 8之前是不可能做到的。

三.函數式接口

  在看完上面的例子之後,相信讀者已經瞭解瞭如何將lambda表達式作爲參數傳遞。但是,什麼樣的參數或者變量纔可以接受lambda表達式呢?答案就是函數式接口。現在我們來詳細解釋什麼是函數式接口。
  函數式接口就是隻含有一個抽象方法的接口。一個接口可能會有超過一個的默認方法或靜態方法,但只要這個接口只有一個抽象方法,它就是函數式接口。
  我們在上面定義的ApplePredicate接口就是一個函數式接口,因爲它只含有一個抽象方法test。我們可以在需要傳遞ApplePredicate類型的變量的地方去傳遞一個lambda表達式,就像上面的例子一樣:

List<Apple> result = filterApples(inventory, apple -> "red".equals(apple.getColor()));

  需要注意的是,不是任何lambda表達式都可以傳遞給函數式接口類型的變量。在傳遞lambda表達式時,lambda表達式的參數和返回值類型要與函數式接口中的抽象方法的參數和返回值類型相匹配。這裏我們使用一個特殊表示法來描述Lambda和函數式接口的簽名。()->void代表了參數列表爲空,且沒有返回值的函數。同理,表達式apple->"red".equals(apple.getColor())所對應的簽名就是(Apple)->boolean,這意味着這個表達式接受一個Apple類型的參數並返回一個布爾值,這和ApplePredicate接口的test方法完全匹配,因此我們可以將這個表達式傳遞給filterApples方法。
  利用這個特性,我們在編寫代碼時,如果某個方法需要傳遞行爲,或者更通俗地說需要傳遞代碼塊,我們就可以先定義一個函數式接口,然後將讓該方法接受一個函數式接口類型的變量,這樣將來在使用這個方法時就可以向它傳遞lambda表達式。
  實際上,Java 8已經爲我們提供了一些常用的函數式接口,它們位於java.util.function包中,一共是43個。下面對這些函數式接口進行簡單地介紹。

1.Perdicate

  Predicate是謂詞的意思,它表示那些接受一個參數並返回一個布爾值的函數式接口。下面是java.util.function包中屬於Predicate類型的函數式接口:

函數式接口 簽名
Predicate T->boolean
IntPredicate int->boolean
LongPredicate long->boolean
DoublePredicate double->boolean
BiPredicate<T,U> (T,U)->boolean

2.Consumer

  Consumer是消費者的意思,它表示那些接受參數並且沒有返回值的函數式接口。下面是java.util.function包中屬於Consumer類型的函數式接口:

函數式接口 簽名
Consumer T->void
IntConsumer int->void
LongConsumer long->void
DoubleConsumer double->void
BiConsumer<T,U> (T,U)->void
ObjIntConsumer (T,int)->void
ObjLongConsumer (T,long)->void
ObjDoubleConsumer (T,double)->void

3.Supplier

  Supplier是提供者的意思,它表示那些不接受參數但是有返回值的函數式接口。下面是java.util.function包中屬於Supplier類型的函數式接口:

函數式接口 簽名
Supplier ()->T
BooleanSupplier ()->boolean
IntSupplier ()->int
LongSupplier ()->long
DoubleSupplier ()->double

4.Function

  Function表示那些接受一種或兩種參數,返回其他類型的返回值的函數式接口。下面是java.util.function包中屬於Function類型的函數式接口:

函數式接口 簽名
Function<T,R> T->R
IntFunction int->R
LongFunction long->R
DoubleFunction double->R
ToIntFunction R->int
ToLongFunction R->long
ToDoubleFunction R->double
IntToLongFunction int->long
IntToDoubleFunction int->double
LongToIntFunction long->int
LongToDoubleFunction long->double
DoubleToIntFunction double->int
DoubleToLongFunction double->long
BiFunction<T,U,R> (T,U)->R
ToIntBiFunction<T,U> (T,U)->int
ToLongBiFunction<T,U> (T,U)->long
ToDoubleFunction<T,U> (T,U)->double

5.Operator

  Operator表示那些參數和返回值類型一樣的函數式接口。下面是java.util.function包中屬於Function類型的函數式接口:

函數式接口 簽名
UnaryOperator T->T
IntUnaryOperator int->int
LongUnaryOperator long->long
DoubleUnaryOperator double->double
BinaryOperator (T,T)->T
IntBinaryOperator (int,int)->int
LongBinaryOperator (long,long)->long
DoubleBinaryOperator (double,double)->double

  上面的這些函數式接口基本覆蓋了我們可能遇到的大部分情況。當然,如果上面的這些函數式接口中沒有能夠滿足我們的需求的,可以自己定義一個函數式接口,就像下面這樣:

@FunctionalInterface
public interface TernaryConsumer<T> {
    void accept(T t1, T t2, T t3);
}

注:並不是說使用@FunctionalInterface註解的接口就是函數式接口,函數式接口只與抽象方法的個數有關。使用@FunctionalInterface註解可以讓編譯器幫我們檢查所設計的接口是不是一個函數式接口。如果使用@FunctionalInterface註解的接口中有超過一個的抽象方法,編譯器就會給出錯誤。這一點和@Override註解類似,它可以讓編譯器幫我們檢查所註解的方法是否重寫了父類或接口的方法。

四.方法引用

  方法引用讓你可以重複使用現有的方法定義,並像lambda一樣傳遞它們。在一些情況下,比起使用Lambda表達式,它們似乎更易讀,感覺也更自然。方法引用可以被看作僅僅調用特定方法的lambda的一種快捷
寫法。它的基本思想是,如果一個lambda代表的只是“直接調用這個方法”,那最好還是用名稱來調用它,而不是去描述如何調用它。
  當需要使用方法引用時,將目標引用放在分隔符::前,方法的名稱放在後面。例如,對於下面的lambda表達式:

(Apple a) -> a.getWeight()

  它僅僅是調用了Apple的getWeight方法,因此可以使用下面的方法引用替換它:

Apple::getWeight

  請記住,方法名稱後面不需要括號,因爲並沒有實際調用這個方法。
  方法引用主要有三類:
(1)指向靜態方法的方法引用(例如Integer的parseInt方法,寫作Integer::parseInt)。
(2)指向任意類型實例方法的方法引用(例如String的length方法,寫作String::length)。
(3)指向現有對象的實例方法的方法引用(假設有一個Apple類型的變量a,那麼它的getWeight可以寫作a::getWeight)。
  還可以創建對構造方法的引用。引用構造方法的語法爲:

ClassName::new

  假設有一個構造函數沒有參數。它適合Supplier的簽名() -> Apple。你可以這樣做:

Supplier<Apple> c1 = Apple::new;
Apple a1 = c1.get();

  這等價於:

Supplier<Apple> c1 = () -> new Apple();
Apple a1 = c1.get();

  如果你的構造函數的簽名是Apple(Integer weight),那麼它就適合Function接口的簽名,於是你可以這樣寫:

Function<Integer, Apple> c2 = Apple::new;
Apple a2 = c2.apply(110);

  這等價於:

Function<Integer, Apple> c2 = (weight) -> new Apple(weight);
Apple a2 = c2.apply(110);

五.捕獲變量

  與局部類和匿名類一樣,lambda表達式也可以捕獲變量,也就是說lambda表達式可以使用外層作用域中定義的變量。例如:

int portNumber = 1337;
Runnable r = () -> System.out.println(portNumber);

  lambda可以沒有限制地捕獲實例變量和靜態變量。但局部變量必須顯式聲明爲final,或實際上是final。例如,下面的代碼將會出現編譯錯誤:

int portNumber = 1337;
Runnable r = () -> System.out.println(portNumber);
portNumber = 31337;

  你可能會問,爲什麼局部變量有這些限制。實例變量和局部變量背後的實現有一個關鍵不同。實例變量都存儲在堆中,而局部變量則保存在棧上。如果lambda是在另一個線程中使用的,則使用lambda的線程,可能會在分配該變量的線程將這個變量收回之後,去訪問該變量。因此,lambda在訪問局部變量時,實際上是在訪問它的副本,而不是訪問原始變量。既然是訪問副本,那麼對副本的修改自然不會應用到真正的那個局部變量。因此,爲了避免造成可以在lambda中修改局部變量的假象,就有了這個限制。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章