Java 中將lambda 表示式體中的變數賦值給lambda體之外的一個區域性變數時,要求那個區域性變數是final 修飾的

gongchengship發表於2024-10-28

在 Java 中,lambda 表示式要求捕獲的區域性變數是 final 或者 effectively final(“實際上是 final”)的,即在宣告後沒有被重新賦值。這一限制是由 lambda 表示式的設計原理和作用域管理機制決定的,目的是確保程式碼行為的一致性和執行緒安全。下面詳細解釋這個原因。

1. Lambda 表示式中的“變數捕獲”機制

Java 的 lambda 表示式可以訪問外部作用域中的變數(包括區域性變數和例項變數)。這種訪問叫做變數捕獲。當 lambda 表示式捕獲某個區域性變數時,它實際上並不是直接引用這個變數,而是捕獲該變數的一個副本:

  • 例項變數類變數的引用可以直接在 lambda 表示式中使用,因為它們的生命週期和 lambda 表示式相同。
  • 區域性變數則不同,因為它們是棧上分配的,生命週期僅在當前方法呼叫期間有效。而 lambda 表示式可能會在方法執行結束後還繼續存在,比如作為執行緒、非同步回撥等情況。

因此,lambda 表示式必須捕獲區域性變數的值,而不能直接訪問它們,以避免生命週期衝突。

2. 為什麼要求區域性變數是 final 或者 effectively final

為了確保 lambda 表示式的捕獲變數值在其生命週期內保持不變,Java 要求捕獲的區域性變數必須是 final 或者 effectively final。這有助於在以下方面保證一致性:

  • 不可變性:Java 透過 final 限制確保區域性變數在 lambda 表示式中是不可變的,防止 lambda 表示式在不同的執行時間看到不同的變數值。例如,lambda 表示式可能會在非同步執行緒中被執行,如果 lambda 捕獲的變數值可以被修改,那麼可能會出現併發修改問題。

  • 變數副本的穩定性:由於 lambda 表示式捕獲的是區域性變數的副本,不是原始變數,如果允許它們在外部方法中被修改,會導致 lambda 捕獲的值和外部變數的值不一致,進而引發意料之外的行為。例如:

    int count = 0;
    Runnable r = () -> System.out.println(count);
    count++; // 會導致編譯錯誤,因為 count 不是 final 的
    

    這裡 count++ 的賦值操作會讓 count 不再是 effectively final,從而無法被 lambda 捕獲。

3. Lambda 表示式的執行緒安全需求

在多執行緒環境下,如果 lambda 表示式捕獲的區域性變數是可變的,不同執行緒可能會對同一變數進行併發修改,這會帶來潛在的執行緒安全問題。透過限制 lambda 表示式只能訪問不可變的 final 或 effectively final 的變數,Java 避免了可能的併發修改問題,確保了 lambda 表示式行為的執行緒安全性。

4. 與匿名內部類的區別

Java 中匿名內部類在訪問外部區域性變數時也有類似的要求,即捕獲的區域性變數必須是 final 或 effectively final。但 lambda 表示式相較於匿名內部類,具有更嚴格的不可變性要求,確保了 lambda 表示式的函數語言程式設計特性,並使其更適合在併發場景下使用。

示例

以下是一個演示 lambda 表示式如何捕獲外部變數的示例:

public class LambdaExample {
    public static void main(String[] args) {
        int number = 5; // effectively final 變數
        Runnable runnable = () -> System.out.println(number);

        number++; // 此行會導致編譯錯誤,因為 number 不是 effectively final
    }
}

在這個示例中,如果嘗試在 lambda 表示式外部對 number 進行修改,編譯器會報錯,因為這樣 number 就不再是 effectively final。如果將 number++ 行刪除,則 number 是 effectively final 的,可以被 lambda 表示式捕獲和使用。

總結

Java 要求 lambda 表示式捕獲的區域性變數是 final 或 effectively final 的,原因在於:

  1. 保證不可變性:lambda 表示式捕獲的是變數的副本,不能改變,避免 lambda 中的行為隨著外部變數的變化而變化。
  2. 確保執行緒安全:區域性變數一旦是 final 或 effectively final 的,避免了 lambda 中對該變數的併發修改問題。
  3. 避免生命週期衝突:區域性變數的生命週期僅在方法呼叫期間,而 lambda 表示式可能在方法結束後仍然存在,需要捕獲值的副本而不是引用。

這樣,lambda 表示式可以在更廣泛的上下文和執行緒環境中安全使用,同時簡化了編譯器的實現。


然而 Java 中並沒有自帶只可以賦值一次並且賦值後不能再更改的容器,所以安全起見,可以自己實現一個這樣的容器,如下:

Java 原生庫中並沒有專門的工具類來包裝一個只能被 set 一次的值。不過,我們可以透過建立一個自定義的“單賦值容器”類來實現這個功能。這類容器允許我們對值進行一次 set 操作,一旦設定後,再次 set 將丟擲異常,從而確保該值在 lambda 表示式中被捕獲後不會再被修改。

以下是實現該功能的自定義工具類示例:

自定義 SingleAssignmentContainer 工具類

public class SingleAssignmentContainer<T> {
    private T value;
    private boolean isSet = false;

    // 建構函式,設定值時初始化
    private SingleAssignmentContainer() {}

    // 靜態工廠方法
    public static <T> SingleAssignmentContainer<T> create() {
        return new SingleAssignmentContainer<>();
    }

    // 只允許設定一次的 set 方法
    public void set(T value) {
        if (isSet) {
            throw new IllegalStateException("Value has already been set and cannot be changed.");
        }
        this.value = value;
        isSet = true;
    }

    // 獲取值
    public T get() {
        if (!isSet) {
            throw new IllegalStateException("Value has not been set yet.");
        }
        return value;
    }

    // 檢查值是否已設定
    public boolean isSet() {
        return isSet;
    }
}

使用示例

以下示例展示瞭如何在 lambda 表示式中使用 SingleAssignmentContainer 來捕獲和設定一個只能被賦值一次的變數:

public class LambdaExample {
    public static void main(String[] args) {
        SingleAssignmentContainer<Integer> container = SingleAssignmentContainer.create();

        // 在 lambda 表示式中捕獲並設定值
        Runnable runnable = () -> {
            if (!container.isSet()) {
                container.set(42);
                System.out.println("Value set to: " + container.get());
            } else {
                System.out.println("Value is already set to: " + container.get());
            }
        };

        runnable.run(); // 輸出: Value set to: 42
        runnable.run(); // 輸出: Value is already set to: 42

        // 試圖在另一個地方重新設定值會丟擲異常
        try {
            container.set(100); // 丟擲異常
        } catch (IllegalStateException e) {
            System.out.println(e.getMessage()); // 輸出: Value has already been set and cannot be changed.
        }
    }
}

實現原理

  • SingleAssignmentContainer 類的 set() 方法只允許在值未被設定時進行賦值,第一次賦值後會將 isSet 標誌設為 true
  • 再次呼叫 set() 時,如果 isSettrue,則丟擲 IllegalStateException 異常。
  • get() 方法只有在值已經設定的情況下可以安全呼叫,如果尚未設定則丟擲異常。

使用場景

這種包裝類特別適合在 lambda 表示式中使用,比如在併發操作中共享一個只能初始化一次的變數,或者在程式碼塊中捕獲外部變數,確保其值只會被設定一次,防止意外更改。

相關文章