1. 認識閉包
閉包 — 是一種函式(或函式物件),它不僅使用自己的參數,還會「記住」建立它時所處外部環境中的變數。更直白地說,如果方法中的 Lambda 運算式或匿名類別使用了該方法的區域變數 — 它就成為閉包。
簡單範例
public class ClosureDemo {
public static void main(String[] args) {
String greeting = "你好, ";
Runnable sayHello = () -> System.out.println(greeting + "世界!");
sayHello.run(); // 會輸出: 你好, 世界!
}
}
這裡 Lambda「捕捉」了外部方法中的變數 greeting,並在自身內部使用。這就是閉包!
2. 有效 final 變數:是什麼,為什麼需要?
在 Java 中,Lambda 運算式(和匿名類別)只能使用來自外部方法、且宣告為 final 或在初始化後未再變更的變數。這類變數稱為有效 final。
為什麼?
這項限制與以下事實有關:Lambda 運算式可能在建立它的方法結束後才被呼叫。如果變數還能變動,就會產生混淆:應該使用變數的哪個版本?為了避免驚喜,Java 要求該變數是不可變的(或至少表面上不可變)。
範例:正確的用法
public static void main(String[] args) {
int number = 42; // number — 有效 final
Runnable r = () -> System.out.println(number);
r.run(); // 42
}
範例:嘗試在使用後修改變數
public static void main(String[] args) {
int number = 42;
Runnable r = () -> System.out.println(number);
number++; // 錯誤: 變數 number 必須是 final 或有效 final
r.run();
}
編譯器會拋出錯誤:Variable used in lambda expression should be final or effectively final。
有效 final 是…… 指被指派恰好一次,之後就不再變更的變數。不一定要寫上 final,編譯器會自己判斷。
3. Lambda 運算式如何捕捉變數?
當你撰寫使用外部變數的 Lambda 時,Java 會將該變數與 Lambda 一起「打包」。即使建立 Lambda 的方法已經結束,該變數也不會消失——它會隨著閉包一起存活。
示意:Lambda「記住」了變數
public static Runnable createGreeter(String name) {
// name — 方法參數,會被 Lambda 捕捉
return () -> System.out.println("你好, " + name + "!");
}
public static void main(String[] args) {
Runnable greeter = createGreeter("Vasya");
greeter.run(); // 你好, Vasya!
}
此處變數 name 在 main 方法的堆疊中已不存在,但 greeter 仍然「記得」它的值。
內部是如何實作的?
Java 編譯器會建立一個特殊的輔助物件(也稱為「capture/display class」),用來保存所有被捕捉的變數。Lambda 運算式會成為一個物件,並持有指向這個「容器」的參考。
4. 閉包範例:回傳使用變數的函式
我們寫一個函式,回傳一個使用其語境中變數的 Lambda:
import java.util.function.IntSupplier;
public class ClosureFactory {
public static IntSupplier makeAdder(int x) {
// x — 由 Lambda 捕捉
return () -> x + 10;
}
public static void main(String[] args) {
IntSupplier adder = makeAdder(5);
System.out.println(adder.getAsInt()); // 15
}
}
這裡變數 x 已經從方法堆疊中「離開」,但 Lambda 仍然可以使用它。
5. 為什麼不能修改被捕捉的變數?
public static void main(String[] args) {
int base = 100;
Runnable printer = () -> System.out.println(base);
base = 200; // 錯誤!
printer.run();
}
編譯器不允許這麼做。若能修改 base,就會不清楚 Lambda 應該使用舊值還是新值。因此 Java 禁止修改被 Lambda 捕捉的區域變數。
在 Lambda 中可以用什麼?
- 初始化後不再變更的區域變數(有效 final)。
- 類別欄位(包含 static 與非 static)—— 可以變更,但這是另一種機制(存取物件狀態),而非捕捉區域變數。
6. 與匿名類別的比較
在 Lambda 出現之前,Java 可以透過匿名類別來實作閉包:
public static void main(String[] args) {
String word = "Java";
Runnable r = new Runnable() {
public void run() {
System.out.println(word);
}
};
r.run(); // Java
}
規則相同:變數 word 必須是 final 或有效 final。
差異:this 的作用域
- 在匿名類別中,this 指向匿名類別的實例。
- 在 Lambda 運算式中,this 指向外部物件(例如目前的類別實例)。
7. 閉包與類別欄位
若 Lambda 使用的是類別欄位,嚴格來說這不是「捕捉區域變數」—— 欄位總是可用,且可以被變更。
public class Counter {
private int count = 0;
public Runnable makeCounter() {
return () -> {
count++;
System.out.println("計數器: " + count);
};
}
public static void main(String[] args) {
Counter c = new Counter();
Runnable r = c.makeCounter();
r.run(); // 計數器: 1
r.run(); // 計數器: 2
}
}
8. 常見錯誤與 Java 中閉包的特性
錯誤 №1:嘗試修改被 Lambda 捕捉的變數。 最常見的錯誤,是在變數已被 Lambda 使用後仍嘗試修改該區域變數。編譯器會提示:Variable used in lambda expression should be final or effectively final。
錯誤 №2:以為變數被「凍結」。 在 Java 中,對於類別欄位而言,使用的是原始欄位的參考而非值的拷貝。如果類別欄位改變,Lambda 會看到新值。但在 Lambda 中使用的區域變數則必須是有效 final。
錯誤 №3:以為 Lambda 會為 this 建立新的作用域。 在 Lambda 中,this 是外部物件(容器類別)。在匿名類別中,this 則是匿名類別本身。
錯誤 №4:使用可變的物件。 如果你捕捉的是指向可變物件(例如 List)的參考,即使變數本身是有效 final,你仍然可以在 Lambda 中改變其內容:
public static void main(String[] args) {
java.util.List<String> list = new java.util.ArrayList<>();
Runnable r = () -> list.add("Hello");
r.run();
System.out.println(list); // [Hello]
}
這裡變數 list 本身沒有變(我們沒有做 list = ...),但其中的物件內容被修改了。
GO TO FULL VERSION