C#中的閉包和意想不到的坑

C#中的閉包和意想不到的坑

使用委派或者lambda表達式,也可以在C#中使用閉包。

最後更新 2022/6/15 下午10:49
老胡写代码
預計閱讀 4 分鐘
分類
.NET
標籤
.NET C#

雖然閉包主要是函數式程式設計的玩意兒,而 C# 的最主要特徵是物件導向,但是利用委派或 Lambda 運算式,C# 也可以寫出具有函數式程式設計風味的程式碼。同樣的,使用委派或者 Lambda 運算式,也可以在 C# 中使用閉包。

根據 WIKI 的定義,閉包又稱語法閉包或函數閉包,是在函數式程式設計語言中實現語法繫結的一種技術。閉包在實作上是一個結構,它儲存了一個函數(通常是其入口位址)和一個關聯的環境(相當於一個符號查詢表)。閉包也可以延遲變數的生命週期。

嗯..看定義好像有點迷糊,讓我們看看下面的例子吧

class Program
{
    static Action CreateGreeting(string message)
    {
        return () => { Console.WriteLine("Hello " + message); };
    }

    static void Main()
    {
        Action action = CreateGreeting("DeathArthas");
        action();
    }
}

這個例子非常簡單,用 Lambda 運算式建立一個 Action 物件,之後再呼叫這個 Action 物件。

但是仔細觀察會發現,當 Action 物件被呼叫的時候,CreateGreeting 方法已經傳回了,作為它的實參的 message 應該已經被銷毀了,那麼為什麼我們在呼叫 Action 物件的時候,還是能夠得到正確的結果呢?

原來奧秘就在於,這裡形成了閉包。雖然 CreateGreeting 已經傳回了,但是它的區域變數被傳回的 Lambda 運算式所捕獲,延遲了其生命週期。怎麼樣,這樣再回頭看閉包定義,是不是更清楚了一些?

閉包就是這麼簡單,其實我們經常都在使用,只是有時候我們都不自知而已。比如大家肯定都寫過類似下面的程式碼。

void AddControlClickLogger(Control control, string message)
{
	control.Click += delegate
	{
		Console.WriteLine("Control clicked: {0}", message);
	}
}

這裡的程式碼其實就用了閉包,因為我們可以肯定,在 control 被點擊的時候,這個 message 早就超過了它的生命週期。合理使用閉包,可以確保我們寫出在空間和時間上面解耦的委派。

不過在使用閉包的時候,要注意一個陷阱。因為閉包會延遲區域變數的生命週期,在某些情況下程式產生的結果會和預想的不一樣。讓我們看看下面的例子。

class Program
{
    static List<Action> CreateActions()
    {
        var result = new List<Action>();
        for(int i = 0; i < 5; i++)
        {
            result.Add(() => Console.WriteLine(i));
        }
        return result;
    }

    static void Main()
    {
        var actions = CreateActions();
        for(int i = 0;i<actions.Count;i++)
        {
            actions[i]();
        }
    }
}

這個例子也非常簡單,建立一個 Action 串列並依序執行它們。看看結果

5
5
5
5
5

相信很多人看到這個結果的表情是這樣的!!難道不應該是 0,1,2,3,4 嗎?出了什麼問題?

追根究底,這兒的問題還是出現在閉包的本質上面,作為「閉包延遲了變數的生命週期」這個硬幣的另一面,是一個變數可能在不經意間被多個閉包所參考。

在這個例子裡面,區域變數 i 同時被 5 個閉包參考,這 5 個閉包共享 i,所以最後他們列印出來的值是一樣的,都是 i 最後退出迴圈時候的值 5。

要想解決這個問題也很簡單,多宣告一個區域變數,讓各個閉包參考自己的區域變數就可以了。

//其他都保持與之前一致
static List<Action> CreateActions()
{
    var result = new List<Action>();
    for (int i = 0; i < 5; i++)
    {
        int temp = i; // 添加區域變數
        result.Add(() => Console.WriteLine(temp));
    }
    return result;
}
0
1
2
3
4

這樣各個閉包參考不同的區域變數,剛剛的問題就解決了。

除此之外,還有一個修正的方法,在建立閉包的時候,使用 foreach 而不是 for。至少在 C# 7.0 的版本上面,這個問題已經被注意到了,使用 foreach 的時候編譯器會自動產生程式碼繞過這個閉包陷阱。

//這樣修正也是可以的
static List<Action> CreateActions()
{
    var result = new List<Action>();
    foreach (var i in Enumerable.Range(0,5))
    {
        result.Add(() => Console.WriteLine(i));
    }
    return result;
}

這就是閉包在 C# 中的使用和其使用中的一個小陷阱,希望大家能透過老胡的文章了解到這個知識點並且在開發中少走彎路!

繼續探索

延伸閱讀

更多文章
同分類 / 同標籤 2026/2/7

AOT使用經驗總結

從專案建立伊始,就應養成良好的習慣,即只要添加了新功能或使用了較新的語法,就及時進行 AOT 發布測試。

繼續閱讀