編碼技巧 --- 謹防閉包陷阱

2023-07-19 18:01:12

引言

先不論什麼是閉包,什麼是閉包陷阱,我們開篇先看一段程式碼:

static void Main(string[] args)
{
    List<Action> lists = new List<Action>();

    for (int i = 0; i < 5; i++)
    {
        Action action = () => { Console.WriteLine(i); };

        lists.Add(action);
    }

    foreach (var action in lists)
    {
        action();
    }

    Console.ReadLine();
}

那麼思考一下,控制檯輸出是什麼?

閉包陷阱

上述程式碼的本意是想讓 Action宣告的匿名委託方法接收 i 的值,並輸出

0
1
2
3
4

但實際上,上述程式碼輸出的是

5
5
5
5
5

為什麼會這樣?就需要提到兩個個概念,變數作用域閉包

變數作用域就不說了,就是指變數可以被存取的範圍。例如全域性變數,區域性變數等

閉包:簡單點說,就是函數和其參照的上下文的組合體,就是一個閉包。,例如上文程式碼中,for 迴圈內部,匿名方法內參照了變數 i ,那麼變數 i 和匿名方法 () => { Console.WriteLine(i); } 就組合成了一個閉包,在 for 迴圈中,變數 i 就是一個區域性變數,但是在閉包中,變數 i 對於匿名方法來說就是全域性變數。相當於這樣:

int i;

public void AnonymousMethod()
{
    Console.WriteLine(i);
}

所以,當 for 迴圈結束時,在閉包內的全域性變數 i 的值就已經變成了5。則下面 foreach 程式碼每次執行輸出均為5。

根據IL探究原理

實際上,編譯器在執行的時候,也確實為閉包生成了一個類,這個類只包含了一個方法和一個全域性變數。

來驗證一下,將上述程式碼編譯為dll後,通過ILDasm.exe工具檢視生成的IL程式碼

可以看到IL為閉包生成了一個類 <>c_DisplayClass0_0 ,這個類只包含了一個變數 i,如下:

還包含了一個匿名方法<Main>b_0:void,從IL中,也可以看出,先取 <>c_DisplayClass0_0 的變數 i ,再呼叫控制檯輸出方法。如下:

接下來,在看一下整個控制檯程式 Main 方法的IL程式碼:

在IL_0007行,可以看到,宣告建立一個 Action 後就緊接著建立了一個 <>c_DisplayClass0_0 物件。且 for 迴圈的變數 i 就是 <>c_DisplayClass0_0 物件的變數 i (注意這裡指的是參照,並不是值),這樣,在迴圈結束的時候,物件的變數 i 就變成了5。

這樣就可以解釋為什麼輸出會是5了。

:::info{title="相關資訊"}
需要注意的是,Action action = () => { Console.WriteLine(i); };這句程式碼只是宣告了一個委託,委託繫結的是一個匿名方法,並沒有真正執行,只有呼叫該委託的時候才真正執行。比如 action()action.Invoke()
:::

如何避免閉包陷阱

在上面的探究原理的過程中,其實也發現了追根究底的問題其實就是,在建立閉包物件的時候,參照的區域性變數,在外部被修改(比如上面程式碼中的for 迴圈的變數 i 就是閉包物件的變數 i,指的是指標是同一個),那麼可以在建立閉包物件的時候,重新建立一個指標物件,將預期值賦值給它就可以了,比如上面的範例程式碼可以這樣修改:

static void Main(string[] args)
{
    List<Action> lists = new List<Action>();

    for (int i = 0; i < 5; i++)
    {
        int temp = i;
        Action action = () => { Console.WriteLine(temp); };
        lists.Add(action);
    }

    foreach (var action in lists)
    {
        action();
    }

    Console.ReadLine();
}

這樣,閉包物件的變數就不再是 for 迴圈的 i 了,也就不會再被修改。輸出結果為:

0
1
2
3
4