天天看點

深入探讨 Lambda 表達式(下)

說明:

由于 Lambda 表達式涉及的周邊知識點實在太多,是以拆分為上、下兩篇文章講解。

本篇為下篇,上篇請點選:

深入探讨 Lambda 表達式(上)

目錄介紹:

深入探讨 Lambda 表達式(下)

在上篇 “

” 中,主要講述了 1~4 章節,本篇,主要介紹 5~8 章節。

5. 與匿名類的差別

在一定程度上,Lambda 表達式是對匿名内部類的一種替代,避免了備援醜陋的代碼風格,但又不能完全取而代之。

我們知道,Lambda 表達式簡化的是符合函數式接口定義的匿名内部類,如果一個接口有多個抽象方法,那這種接口不是函數式接口,也無法使用 Lambda 表達式來替換。

舉個示例:

public interface DataOperate {
    public boolean accept(Integer value);

    public Integer convertValue(Integer value);
}

public static List<Integer> process(List<Integer> valueList, DataOperate operate) {
    return valueList.stream()
        .filter(value -> operate.accept(value))
        .map(value -> operate.convertValue(value))
        .collect(Collectors.toList());
}

public static void main(String[] args) {
    List<Integer> valueList = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9);

    // 示例場景1: 将大于3的值翻倍,否則丢棄,得到新數組
    List<Integer> newValueList1 = process(valueList, new DataOperate() {
        @Override
        public boolean accept(Integer value) {
            return value > 3 ? true : false;
        }

        @Override
        public Integer convertValue(Integer value) {
            return value * 2;
        }
    });

    // 示例場景2:将為偶數的值除以2,否則丢棄,得到新數組
    List<Integer> newValueList2 = process(valueList, new DataOperate() {
        @Override
        public boolean accept(Integer value) {
            return value % 2 == 0 ? true : false;
        }

        @Override
        public Integer convertValue(Integer value) {
            return value / 2;
        }
    });
}           

上面示例中的

DataOperate

接口,因存在兩個接口,是無法使用 Lambda 表達式的,隻能在調用的地方通過匿名内部類來實作。

若  

DataOperate

  接口多種不同的應用場景,要麼使用匿名内部類來實作,要麼就優雅一些,使用設計模式中的政策模式來封裝一下,Lambda 在這裡是不适用的。

6. 變量作用域

不少人在使用 Lambda 表達式的嘗鮮階段,可能都遇到過一個錯誤提示:

Variable used in lambda expression should be final or effectively final           

以上報錯,就涉及到外部變量在 Labmda 表達式中的作用域,且有以下幾個文法規則。

6.1 變量作用域的規則

  • 規則 1:局部變量不可變,域變量或靜态變量是可變的

何為局部變量?局部變量是指在我們普通的方法内部,且在 Lambda 表達式外部聲明的變量。

在  Lambda 表達式内使用局部變量時,該局部變量必須是不可變的。

如下的代碼展示中,變量

a

  就是一個局部變量,因在 Lambda 表達式中調用且改變了值,在編譯期就會報錯:

public class AClass {
    private Integer num1 = 1;
    private static Integer num2 = 10;

    public void testA() {
        int a = 1;
        int b = 2;
        int c = 3;
        a++;
        new Thread(() -> {
            System.out.println("a=" + a); // 在 Lambda 表達式使用前有改動,編譯報錯
            b++; // 在 Lambda 表達式中更改,報錯
            System.out.println("c=" + c); // 在 Lambda 表達式使用之後有改動,編譯報錯

            System.out.println("num1=" + this.num1++); // 對象變量,或叫域變量,編譯通過
            AClass.num2 = AClass.num2 + 1;
            System.out.println("num2=" + AClass.num2); // 靜态變量,編譯通過
        }).start();
        c++;
    }
}           

上面的代碼中,變量

a

b

c

都是局部變量,無論在 Lambda 表達式前、表達式中或表達式後修改,都是不允許的,直接編譯報錯。而對于域變量

num1

,以及靜态變量

num2

,不受此規則限制。

  • 規則 2:表達式内的變量名不能與局部變量重名,域變量和靜态變量不受限制

不解釋,看代碼示例:

public class AClass {
    private Integer num1 = 1;
    private static Integer num2 = 10;

    public void testA() {
        int a = 1;
        new Thread(() -> {
            int a = 3; // 與外部的局部變量重名,編譯報錯
            Integer num1 = 232; // 雖與域變量重名,允許,編譯通過
            Integer num2 = 11; // 雖與靜态變量重名,允許,編譯通過
        }).start();
    }
}           

友情提醒:雖然域變量和靜态變量可以重名,從可讀性的角度考慮,最好也不用重複,養成良好的編碼習慣。

  • 規則 3:可使用

    this

    super

    關鍵字,等同于在普通方法中使用
public class AClass extends ParentClass {
    @Override
    public void printHello() {
        System.out.println("subClass: hello budy!");
    }

    @Override
    public void printName(String name) {
        System.out.println("subClass: name=" + name);
    }

    public void testA() {
        this.printHello();  // 輸出:subClass: hello budy!
        super.printName("susu"); // 輸出:ParentClass: name=susu

        new Thread(() -> {
            this.printHello();  // 輸出:subClass: hello budy!
            super.printName("susu"); // 輸出:ParentClass: name=susu
        }).start();

    }
}

class ParentClass {
    public void printHello() {
        System.out.println("ParentClass: hello budy!");
    }

    public void printName(String name) {
        System.out.println("ParentClass: name=" + name);
    }
}           

對于

this

super

關鍵字,大家記住一點就行啦:在 Lambda 表達式中使用,跟在普通方法中使用沒有差別!

  • 規則 4:不能使用接口中的預設方法(default 方法)
public class AClass implements testInterface {
    public void testA() {
        new Thread(() -> {
            String name = super.getName(); // 編譯報錯:cannot resolve method 'getName()'
        }).start();
    }
}

interface testInterface {
    // 預設方法
    default public String getName() {
        return "susu";
    }
}           

6.2 為何要 final?

不管是 Lambda 表達式,還是匿名内部類,編譯器都要求了變量必須是 final 類型的,即使不顯式聲明,也要確定沒有修改。那大家有沒有想過,為何編譯器要強制設定變量為 final 或 effectively final 呢?

  • 原因 1:引入的局部變量是副本,改變不了原本的值

看以下代碼:

public static void main(String args[]) {
    int a = 3;
    String str = "susu";
    Susu123 susu123 = (x) -> System.out.println(x * 2 + str);
    susu123.print(a);
}

interface Susu123 {
    void print(int x);
}           

在編譯器看來,main 方法所在類的方法是如下幾個:

public class Java8Tester {
    public Java8Tester(){
    }
    public static void main(java.lang.String[]){
        ...
    }
    private static void lambda$main$0(java.lang.String, int);
        ...
    }
}           

可以看到,編譯後的檔案中,多了一個方法  

lambda$main$0(java.lang.String, int)

,這個方法就對應了 Lambda 表達式。它有兩個參數,第一個是 String 類型的參數,對應了引入的 局部變量

str

,第二個參數是 int 類型,對應了傳入的變量

a

若在 Lambda 表達式中修改變量 str 的值,依然不會影響到外部的值,這對很多使用者來說,會造成誤解,甚至不了解。

既然在表達式内部改變不了,那就索性直接從編譯器層面做限制,把有在表達式内部使用到的局部變量強制為 final 的,直接告訴使用者:這個局部變量在表達式内部不能改動,在外部也不要改啦!

  • 原因 2:局部變量存于棧中,多線程中使用有問題

大家都知道,局部變量是存于 JVM 的棧中的,也就是線程私有的,若 Lambda 表達式中可直接修改這邊變量,會不會引起什麼問題?

很多小夥伴想到了,如果這個 Lambda 表達式是在另一個線程中執行的,是拿不到局部變量的,是以表達式中擁有的隻能是局部變量的副本。

如下的代碼:

public static void main(String args[]) {
    int b = 1;
    new Thread(() -> System.out.println(b++));
}           

假設在 Lambda 表達式中是可以修改局部變量的,那在上面的代碼中,就出現沖突了。變量

b

是一個局部變量,是目前線程私有的,而 Lambda 表達式是在另外一個線程中執行的,它又怎麼能改變這個局部變量

b

的值呢?這是沖突的。

  • 原因 3:線程安全問題

舉一個經常被列舉的一個例子:

public void test() {
    boolean flag = true;
    new Thread(() -> {
        while(flag) {
            ...
            flag = false;
        }
    });
    flag = false;
}           

先假設 Lambda 表達式中的 flag 與外部的有關聯。那麼在多線程環境中,線程 A、線程 B 都在執行 Lambda 表達式,那麼線程之間如何彼此知道 flag 的值呢?且外部的 flag 變量是在主線程的棧(stack)中,其他線程也無法得到其值,是以,這是自相沖突的。

小結:

前面我們列舉了多個局部變量必須為 final 或 effectively final 的原因,而 Lambda 表達式并沒有對執行個體變量或靜态變量做任何限制。

雖然沒做限制,大家也應該明白,允許使用,并不代表就是線程安全的,看下面的例子:

// 執行個體變量
private int a = 1;

public static void main(String args[]) {
    Java8Tester java8Tester = new Java8Tester();
    java8Tester.test();
    System.out.println(java8Tester.a);

}

public void test() {
    for (int i = 0; i < 10; i++) {
        new Thread(() -> this.a++).start();
    }
}           

以上的代碼,并不是每次執行的結果都是 11,是以也存線上程安全問題。

7. Java 中的閉包

前面已經把 Lmabda 表達式講的差不多了,是時候該講一下閉包了。

閉包是函數式程式設計中的一個概念。在介紹 Java 中的閉包前,我們先看下 JavaScript 語言中的閉包。

function func1() {
  var s1 = 32;
    incre = function() {
        s1 + 1;
    };
    return function func2(y) {
        return s1 + y;
    };
}

tmp = func1();
console.log(tmp(1)); // 33

incre();
console.log(tmp(1)); // 34           

上面的 JavaScript 示例代碼中,函數

func2(y)

就是一個閉包,特征如下:

  • 第一點,它本身是一個函數,且是一個在其他函數内部定義的函數;
  • 第二點,它還攜帶了它作用域外的變量

    s1

    ,即外部變量。

正常來說,語句

tmp = func1();

在執行完之後,

func1()

函數的聲明周期就結束啦,并且變量

s1

還使用了

var

修飾符,即它是一個方法内的局部變量,是存在于方法棧中的,在該語句執行完後,是要随

func1()

函數一起被回收的。

但在執行第二條語句

console.log(tmp(1));

時,它竟然沒有報錯,還仍然保有變量

s1

的值!

繼續往下看。

在執行完第三條語句

incre();

後,再次執行語句

console.log(tmp(1));

,會發現輸出值是 34。這說明在整個執行的過程中,函數

func2(y)

是持有了變量

s1

的引用,而不單純是數值 32!

通過以上的代碼示例,我們可以用依據通俗的話來總結閉包:

閉包是由函數和其外部的引用環境組成的一個實體,并且這個外部引用必須是在堆上的(在棧中就直接回收掉了,無法共享)。

在上面的 JavaScript 示例中,變量

s1

就是外部引用環境,而且是 capture by Reference。

說完 JavaScript 中的閉包,我們再來看下 Java 中的閉包是什麼樣子的。Java 中的内部類就是一個很好的闡述閉包概念的例子。

public class OuterClass {
    private String name = "susu";

    private class InnerClass {
        private String firstName = "Shan";

        public String getFullName() {
            return new StringBuilder(firstName).append(" ").append(name).toString();
        }

        public OuterClass getOuterObj() {
            // 通過 外部類.this 得到對外部環境的引用
            return OuterClass.this;
        }
    }

    public static void main(String[] args) {
        OuterClass outerClass = new OuterClass();
        InnerClass innerClass = outerClass.new InnerClass();
        System.out.println(innerClass.getFullName());

        outerClass.name = "susu1";
        System.out.println(innerClass.getFullName());

        System.out.println(Objects.equals(outerClass, innerClass.getOuterObj()));
    }
}

#### 輸出 ####
Shan susu
Shan susu1
true           

上面的例子中,函數

getFullName()

就是一個閉包函數,其持有一個外部引用的變量

name

,從輸出結果可以看到,引用的外部變量變化,輸出值也會跟随變化的,也是 capture by reference。

内部類可以通過

外部類.this

來得到對外部環境的引用,上面示例的輸出結果為 true 說明了這點。在内部類的

getFullName()

方法中,可直接引用外部變量

name

,其實也是通過内部類持有的外部引用來調用的,比如,該方法也可以寫成如下形式:

public String getFullName() {
    return new StringBuilder(firstName).append(" ").append(OutClass.this.name).toString();
}           

OutClass.this

就是内部類持有的外部引用。

内部類可以有多種形式,比如匿名内部類,局部内部類,成員内部類(上面的示例中

InnerClass

類就是),靜态内部類(可用于實作單例模式),這裡不再一一列舉。

對于 Lambda 表達式,在一定條件下可替換匿名内部類,但都是要求引入的外部變量必須是 final 的,前面也解釋了為何變量必須是 final 的。

寬泛了解,Lambda 表達式也是一種閉包,也是在函數内部引入了外部環境的變量,但不同于 JavaScript 語言中的閉包,函數内一直持有外部變量,即使對應的外部函數已經銷毀,外部變量依然可以存在并可以修改,Java 中 Lambda 表達式中對外部變量的持有,是一種值拷貝,Lambda 表達式内并不持有外部變量的引用,實際上是一種 capture by value,是以 Java 中的 Lambda 表達式所呈現的閉包是一種僞閉包。

8. Consumer、Supplier 等函數式接口

說實話,在第一次看到這類函數式接口的定時時,我是一臉懵逼的,這類接口有什麼用?看不懂有什麼含義,這類接口定義的莫名其妙。

就像 Consumer 接口的定義:

@FunctionalInterface
public interface Consumer<T> {
    /**
     * Performs this operation on the given argument.
     *
     * @param t the input argument
     */
    void accept(T t);

    default Consumer<T> andThen(Consumer<? super T> after) {
        Objects.requireNonNull(after);
        return (T t) -> { accept(t); after.accept(t); };
    }
}           

單看

accept(T t)

  抽象方法,需傳入一個入參,沒有傳回值。這個方法做了啥?有什麼語義上的功能嗎?木有!

衆所周知,Java 是一門面向對象的語言,一切皆對象。我們自定義的類(比如:

HashMap

ArrayList

)或方法(如:

getName()

execute()

),都是有一定的語義(semantic)資訊的,是暗含了它的使用範圍和場景的,通俗點說,我們明顯的可以知道它們可以幹啥。

但回過頭看

accept(T t)

  這個抽象方法,你卻不知道它是幹啥的。其實,對于函數式接口中的抽象方法,它們是從另外一個次元去定義的,即結構化(structure)的定義。它們就是一種結構化意義的存在,本身就不能從語義角度去了解。

這裡介紹幾種常見的函數式接口的用法。

  • Consumer 接口:消費型函數式接口

從其抽象方法

void accept(T t)

來了解,就是一個參數傳入了進去,整個方法的具體實作都與目前這個參數有關聯。這與清單元素的循環擷取很像,比如集合類的  

Foreach()

方法:

default void forEach(Consumer<? super T> action) {
    Objects.requireNonNull(action);
    for (T t : this) {
        action.accept(t);
    }
}           

再舉一個例子。在日常開發中,可能會遇到連接配接,如資料庫的連接配接,網絡的連接配接等,假設有這麼一個連接配接類:

public class Connection {

    public Connection() {
    }

    public void operate() {
        System.out.println("do something.");
    }

    public void close() {

    }           

每次使用時,都需要建立連接配接、使用連接配接和關閉連接配接三個步驟,比如:

public void executeTask() {
    Connection conn = new Connection();
    try {
        conn.operate();
    } catch (Exception e) {
        e.printStackTrace();
    } finally {
        conn.close();
    }
}           

當有多處代碼都需要用到此類用法時,就需要在多處去建立連接配接、使用和關閉連接配接等操作。

這樣有沒有什麼問題呢?萬一某處代碼忘記關閉其建立的連接配接對象,就可能會導緻記憶體洩漏!

有沒有比較好的方式呢?

可以将這部分常用代碼做抽象,且不允許外部随意建立連接配接對象,隻能自己建立自己的對象,如下:

public class Connection {

    private Connection() {

    }

    public void operate() {
        System.out.println("do something.");
    }

    public void close() {

    }

    public static void useConnection(Consumer<Connection> consumer) {
        Connection conn = new Connection();
        try {
            consumer.accept(conn);
        } catch (Exception e) {
        } finally {
            conn.close();
        }
    }
}           

注意,上面的構造函數是私有的,進而避免了由外部建立

Connection

對象,同時在其内部提供了一個靜态方法  

useConnection()

,入參就是一個

Consumer

對象。當我們外部想使用時,使用如下調用語句即可:

Connection.useConnection(conn -> conn.operate());           
  • Supplier 接口:供給型函數式接口

接口定義如下:

public interface Supplier<T> {
    /**
     * Gets a result.
     *
     * @return a result
     */
    T get();
}           

抽象方法

T get()

沒有入參,傳回一個對象,和前面的

Consumer

接口的

void accept(T t)

抽象方法正好相反。

看下基本用法:

// 示例 1
Supplier<Integer> supplier1 = () -> Integer.valueOf(32);
System.out.println(supplier1.get());  // 32

// 示例 2
Supplier<Runnable> supplier2 = () -> () -> System.out.println("abc");
supplier2.get().run(); // abc           

第 2 個示例,你有沒有看糊塗?其等價代碼如下:

Supplier<Runnable> supplier2 = () -> {
    Runnable runnable = () -> System.out.println("abc");
    return runnable;
};
supplier2.get().run();           

像 Predicate、BiConsumer 等其他函數式接口,這裡不再一一列舉,感興趣的小夥伴可自行查閱學習。

小結

關于 Lambda 表達式的知識點,上篇文章

和本篇就已經全部介紹完畢。各位小夥伴,你都掌握了嗎?