Java8 新語法習慣 (使用閉包捕獲狀態)

我是傳奇哈哈發表於2018-01-16

在 Java 程式設計中,我們以不嚴格的術語 lambda 表示式來表示 lambda 表示式和閉包。但是在某些情況下,理解它們的區別很重要。lambda 表示式是無狀態的,而閉包是帶有狀態的。將 lambda 表示式替換為閉包,是一種管理函式式程式中的狀態的好方法。

無狀態的生活

我們在這個系列中介紹了 lambda 表示式,您應該已經對他們非常的瞭解了。它們是小巧的匿名函式,接受可選的引數,執行某種計算或操作,而且可能返回一個結果。lambda 表示式也是無狀態的,這可能會在您的程式碼中產生重大影響。

我們首先來看一個使用 lambda 表示式的簡單示例。假設我們想將一個數字集合中的偶數乘以二。一種是使用 Stream 和 lambda 表示式建立一個函式管道,入下所示:

numbers.stream()
  .filter(e -> e % 2 == 0)
  .map(e -> e * 2)
  .collect(toList());
複製程式碼

我們傳入 filter 中的 lambda 表示式取代了 Predicate 函式介面。它接收一個數字,如果該數字是偶數,則返回 true,否則返回 false。另一方面,我們傳遞給 map 的 lambda 表示式取代了 Function 函式介面:它接受任何數字並返回該值的兩倍。這個lambda 表示式都依賴傳入的引數和字面常量。二者都是獨立的,這意味著他們沒有任何外部依賴項。因為它們依賴於傳入的引數,而且可能還依賴於一些常量,所以 lambda 表示式是無狀態的。

我們為什麼需要狀態

現在讓我們更仔細地看看傳遞給 map 方法的 lambda 表示式。如果我們希望計算給定值的三倍或四倍,該怎麼辦?我們可以將常量 2 轉換為一個變數(比如 factor),但 lambda 表示式仍需要一種方式來獲取該變數。

我們可以推斷,lambda 表示式可以採用與接收引數 e 的相同方式來接收 factor,如下所示:

.map((e, factor) -> e * factor)
複製程式碼

還不錯,但不幸的是它不起作用。方法 map 要求接受函式介面 Function<T, R> 的一個實現作為引數。如果我們傳入該介面外的任何內容(比如一個 BiFunction<T, U, R>),map 不會接受。需要採用另一種方式將 factor 提供給我們的 lambda 表示式。

詞法範圍

函式要求變數在限定範圍內。因為它們實際上是匿名函式,所以 lambda 表示式也要求引用的變數在限定範圍內。一些變數以引數形式被函式或 lambda 表示式接收。一些變數是區域性定義的。一些變數來自函式外部,位於所謂的詞法範圍中。

詞法範圍示例:

public static void print() {
  String location = "World";

  Runnable runnable = new Runnable() {
    public void run() {
      System.out.println("Hello " + location);
    }
  };

  runnable.run();
}
複製程式碼

在 print 方法中,location 是一個區域性變數。但是,Runnable 的 run 方法還引用了一個不是 run 方法的區域性變數或引數的 location。對 Hello 旁邊的 location 的引用被繫結到 print 方法的 location 變數。

詞法範圍是函式的定義範圍。反過來,它也可能是該定義範圍的定義範圍,等等。

在前面的程式碼中,方法 run 沒有定義 location 或接收它作為引數。run 的定義範圍是 Runnable 的匿名內部物件。因為沒有將 location 定義為該例項中的欄位,所以會繼續搜尋匿名內部物件的定義範圍 — 在本例中為方法 print 的區域性範圍。

如果 location 不在該範圍中,編譯器會繼續在 print 的定義範圍內搜尋,直到找到該變數或搜尋失敗。

lambda表示式中的詞法範圍

我們使用 lambda 表示式重寫前面的程式碼:

public static void print() {
  String location = "World";

  Runnable runnable = () -> System.out.println("Hello " + location);

  runnable.run();
}
複製程式碼

得益於 lambda 表示式,程式碼變得更簡潔,但 location 的範圍和繫結沒有更改。lambda 表示式中的變數 location 被繫結到 lambda 表示式的詞法範圍中的變數 location。嚴格來講,此程式碼中的 lambda 表示式是一個閉包。

閉包如何攜帶狀態

Lambda 表示式不依賴於任何外部實體;它們是依賴於自身引數和常量的內容。另一方面,閉包既依賴於引數和常量,也依賴於它們的詞法範圍中的變數。從邏輯上講,閉包被繫結到它們的詞法範圍中的變數。但是,儘管邏輯上講是這樣,但實際上並不總是這麼做。有時甚至無法執行這樣的繫結。兩個場景可以證明這一點。

下面這段程式碼將一個 lambda 表示式或閉包傳遞給一個 call 方法:

class Sample {
  public static void call(Runnable runnable) {
    System.out.println("calling runnable");

    //level 2 of stack
    runnable.run();
  }

  public static void main(String[] args) {
    int value = 4;  //level 1 of stack
    call(
      () -> System.out.println(value) //level 3 of stack
    );
  }
}
複製程式碼

此程式碼中的閉包使用了來自它的詞法範圍的變數 value。如果 main 是在堆疊級別 1 上執行的,那麼 call 方法的主體會在堆疊級別 2 上執行。因為 Runnable 的 run 方法是從 call 內呼叫的,所以該閉包的主體會在級別 3 上執行。如果 call 方法要將該閉包傳遞給另一個方法(進而推遲呼叫的位置),則執行的堆疊級別可能高於 3。

您現在可能想知道在一個堆疊級別中的執行究竟如何能獲取之前的另一個堆疊級別中的變數 — 尤其是未在呼叫中傳遞上下文時。簡單來講就是無法獲取該變數。

看另外一個示例:

class Sample {
  public static Runnable create() {                   
    int value = 4;
    Runnable runnable = () -> System.out.println(value);

    System.out.println("exiting create");
    return runnable;
  }

  public static void main(String[] args) {
    Runnable runnable = create();

    System.out.println("In main");
    runnable.run();
  }
}
複製程式碼

測試結果:

exiting create
In main
4
複製程式碼

在這個示例中,create 方法有一個區域性變數 value,該變數的壽命很短:只要我們退出 create,它就會消失。create 內建立的閉包在其詞法範圍中引用了這個變數。在完成 create 方法後,該方法將閉包返回給 main 中的呼叫方。在此過程中,它從自己的堆疊中刪除變數 value,而且 lambda 表示式將會執行。

我們知道,在 main 中呼叫 run 時,create 中的 value 就會終止。儘管我們可以假設 lambda 表示式中的 value 直接被繫結到它的詞法範圍中的變數,但該假設並不成立。

閉包午休時間

假設我的辦公室離家約 16 公,而且我早上 8 點出門上班。中午,我有短暫的時間用午餐,但出於健康考慮,我喜歡吃家裡烹飪的飯菜。由於休息時間很短,只有在離家時帶上午餐,我才能吃上家裡的飯菜。

這就是閉包要完成的任務:它們攜帶自己的午餐(或狀態)。

讓我們再看看 create 中的 lambda 表示式:

Runnable runnable = () -> System.out.println(value);
複製程式碼

我們編寫的 lambda 表示式沒有接受任何引數,但需要它的 value。編譯類 Sample 並執行 javap -c -p Sample.class 來檢查位元組碼。您會注意到,編譯器為該閉包建立了一個方法,該方法接受一個 int 引數:

private static void lambda$create$0(int);
    Code:
       0: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;
       3: iload_0
       4: invokevirtual #9                  // Method java/io/PrintStream.println:(I)V
       7: return
}
複製程式碼

現在看看為 create 方法生成的位元組碼:

0: iconst_4
1: istore_0
2: iload_0
3: invokedynamic #2,  0              // InvokeDynamic #0:run:(I)Ljava/lang/Runnable;

複製程式碼

值 4 儲存在一個變數中,然後,該變數被載入並傳遞到為閉包建立的函式。在本例中,閉包保留著 value 的一個副本。這就是閉包攜帶狀態的方式。

使用閉包

現在,我們回頭看看本文開頭的示例。除了計算集合中的偶數值的兩倍,如果我們想要計算它們的三倍或四倍,該怎麼辦?為此,我們可以將原始 lambda 表示式轉換為一個閉包。

這是我們之前看到的無狀態程式碼:

numbers.stream()
  .filter(e -> e % 2 == 0)
  .map(e -> e * 2)
  .collect(toList());
複製程式碼

使用閉包而不是 lambda 表示式,程式碼就會變成:

int factor = 3;

numbers.stream()
  .filter(e -> e % 2 == 0)
  .map(e -> e * factor)
  .collect(toList());
複製程式碼

map 方法現在接受一個閉包,而不是一個 lambda 表示式。我們知道,這個閉包接受一個引數 e,但它也捕獲並攜帶 factor 變數的狀態。

此變數位於該閉包的詞法範圍中。它可以是定義 lambda 表示式的函式中的區域性變數;可以作為該外部函式的一個引數傳入;可以位於閉包的定義範圍(或該定義範圍的定義範圍等)中的任何地方。無論如何,該閉包將狀態從定義該閉包的程式碼傳遞到了需要該變數的執行點。

總結

閉包不同於 lambda 表示式,因為它們依賴於自己的詞法範圍來獲取一些變數。因此,閉包可以捕獲並攜帶狀態。lambda 表示式是無狀態的,閉包是有狀態的。可以在您的程式中使用閉包,將狀態從定義上下文攜帶到執行點。

感謝 Venkat Subramaniam 博士

Venkat Subramaniam 博士站點:http://agiledeveloper.com/

我的部落格

知識改變命運,努力改變生活

Java8 新語法習慣 (使用閉包捕獲狀態)

相關文章