1. 程式人生 > >c#閉包問題--踩過的一個大坑

c#閉包問題--踩過的一個大坑

引言

     之前在做c#遊戲開發的過程中,遇到了一個大坑。當時為了實現動態載入圖鑑,用到了button的迴圈委託,誰知道這樣就踩了c#閉包問題的大坑!現在對這個問題進行總結,避免下次再踩入這樣的巨坑。      先貼下結論:在C#中,原來閉包只是編譯器玩的花招而已,它仍然沒有脫離.NET物件生命週期的規則,它將需要修改作用域的變數直接封裝到返回的類中變成類的一個屬性,從而保證了變數的生命週期不會隨函式呼叫結束而結束,因為變數n在這裡已經成了返回的類的一個屬性。

閉包的概念

Q:先丟擲第一個問題,什麼是閉包?閉包會出現在怎樣的場景中? A:在c#中,閉包是這樣定義的:內層的函式可以引用包含在它外層的函式的變數

,即使外層函式的執行已經終止。但該變數提供的值並非變數建立時的值,而是在父函式範圍內的最終值。閉包主要用在訪問外層函式定義的變數上。      那麼問題來了,筆者之前是學c和c++的,閉包的概念第一次接觸,很懵逼。怎麼辦?繼續查資料啊,筆者發現,閉包的概念首次提出是在Python語言中的,Python語言有一個很奇怪的地方,他可以巢狀定義函式,我們可以在定義函式a的時候,在a的內部定義函式b,且函式b只在函式a中有效。這就很神奇了,因為據筆者所知,c++和c是不支援函式巢狀定義的,只是支援函式的巢狀呼叫。巢狀定義和巢狀呼叫有個很大的區別,即巢狀定義中,內部函式可以訪問其外部函式的作用域,但是外部函式不能訪問內部函式的作用域。而c++中每個函式只可以使用自己內部棧中儲存的區域性變數、由入參傳入的引數以及全域性變數。Python中的函式巢狀定義的優勢是能夠對複雜程式碼進行劃片,使得單個函式只實現單個功能,而且內部函式可以減少私有函式的定義;但是會使得函式間的耦合性增大。      基於函式巢狀定義的需求,Python引入了閉包的概念。閉包(Closure)是詞法閉包的簡稱,是引用了自由變數的函式。這個被引用的自由變數將和這個函式一同存在,即使已經離開了創造它的環境也不例外
。所以,有另一種說法認為閉包是由函式和與其相關的引用環境組合而成的實體。這樣的說法可能有些晦澀難懂,相信讀者看了下面的例子應該就能對閉包的概念有個比較鮮明的認識了。

def funx(x):
	def funy(y):
		return x * y
    return funy

     上面的例子可以看出,函式funx裡面又定義了一個新函式funy,這個新函式裡面的一個變數正好是外部函式funx的引數。也就是說,外部傳遞過來的引數已經和funy函式繫結到一起了。我們可以把x看做函式funy的一個配置資訊,配置資訊不同,函式的功能就不一樣了,也就是能得到定製之後的函式。      相信經過上面的一番講解,大家應該都對閉包的概念有所瞭解了吧。接下來,我們繼續回到c#的閉包問題中。由於c#中有委託和lambda函式,所以c#其實也可以在函式的定義中利用lambda或者委託實現函式的巢狀定義, 此時閉包可以幫助我們輕鬆地訪問外層函式定義的變數

! Q:好,既然閉包這麼有用,我們直接使用這個概念就好了嘛,還有什麼需要注意的呢? A:就像我們在回答閉包的概念時就提出的問題一樣,內層函式引用外層函式的變數不是會隨外層函式變數的變化而變化的,只會引用外層函式的變數的最終值。 Q:那我們怎麼樣才能做到讓內層函式的引用值隨著外層函式變數的變化而變化呢?從而避免閉包陷阱呢? A:C#中普遍的做法是,將匿名函式引用的變數用一個臨時變數儲存下來,然後在匿名函式中使用臨時變數。

錯誤例子,產生了閉包陷阱:

List<UserModel> userList = new List<UserModel>
            {
                new UserModel{ UserName="jiejiep", UserAge = 26},
                new UserModel{ UserName="xiaoyi", UserAge = 25},
                new UserModel{ UserName="zhangzetian", UserAge=24}
            };

            for(int i = 0 ; i < 3; i++)
            {
                ThreadPool.QueueUserWorkItem((obj) =>
                {
                    Thread.Sleep(1000);
                    UserModel u = userList[i];//i永遠都是userList.Count
                    Console.WriteLine(u.UserName);
                });
            }

正確例子,解決了閉包陷阱:

List<UserModel> userList = new List<UserModel>
            {
                new UserModel{ UserName="jiejiep", UserAge = 26},
                new UserModel{ UserName="xiaoyi", UserAge = 25},
                new UserModel{ UserName="zhangzetian", UserAge=24}
            };

            for(int i = 0 ; i < 3; i++)
            {
                UserModel u = userList[i];
                ThreadPool.QueueUserWorkItem((obj) =>
                {
                    Thread.Sleep(1000);
                    Console.WriteLine(u.UserName);
                });
            }

A:但是為什麼用臨時變數就能夠避免閉包陷阱呢?臨時變數不也是外層函式的變數麼?他也會變化啊,不應該也是隻會引用他的最終值麼? Q:這是因為所謂的閉包,就如之前的定義所說,是引用了自由變數的函式,我們閉包的是“變數”,而不是“值”;而()=>v則是返回v的“當前值”,而不是建立該委託時v的“返回值”。所以在“for”迴圈中的新增的匿名函式,只是返回了變數i 而不是i的值。所以在第一個錯誤的例子中,當Lambda表示式被真正執行時,i已經是values.Count 值啦,所以會丟擲“超出索引範圍”。而在第二個正確的例子裡,在每一次迴圈中,會建立一個新的臨時變數u來儲存當前迴圈的i,當委託或Lambda建立時,閉包這個新的臨時變數u,且每個不同委託引用的u是互相獨立的,其到委託執行時都是不會變化的,所以使用臨時變數來儲存匿名函式想要引用的外部函式變數可以解決閉包陷阱。

     為了幫助讀者對閉包有更進一步的理解,這裡我引用下jujusharp大大的原文,他在c#與閉包這篇文章中對c#的閉包做了深刻的解釋,原文如下:      閉包其實就是使用的變數已經脫離其作用域,卻由於和作用域存在上下文關係,從而可以在當前環境中繼續使用其上文環境中所定義的一種函式物件。      你可能會好奇.net本身並不支援函式物件,那麼這樣的特性又是從何而來呢?答案是編譯器,我們一看IL程式碼便會明白了。      首先我給出c#程式碼:

public class TCloser {
        public Func<int> T1(){
            var n = 10;
            return () =>
            {
                return n;
            };
        }
 
        public Func<int> T4(){
            return () =>
            {
                var n = 10;
                return n;
            };
        }
    }

     這兩個返回的匿名函式的唯一區別就是返回的委託中變數n的作用域不一樣而已,T1中變數n是屬於T1的,而在T4中,n則是屬於匿名函式本身的。但我們看看IL程式碼就會發現這裡面的大不同了:

.method public hidebysig instance class [mscorlib]System.Func`1<int32> T1() cil managed{
    .maxstack 3
    .locals init (
        [0] class ConsoleApplication1.TCloser/<>c__DisplayClass1 CS$<>8__locals2,
        [1] class [mscorlib]System.Func`1<int32> CS$1$0000)
    L_0000: newobj instance void ConsoleApplication1.TCloser/<>c__DisplayClass1::.ctor()
    L_0005: stloc.0
    L_0006: nop
    L_0007: ldloc.0
    L_0008: ldc.i4.s 10
    L_000a: stfld int32 ConsoleApplication1.TCloser/<>c__DisplayClass1::n
    L_000f: ldloc.0
    L_0010: ldftn instance int32 ConsoleApplication1.TCloser/<>c__DisplayClass1::<T1>b__0()
    L_0016: newobj instance void [mscorlib]System.Func`1<int32>::.ctor(object, native int)
    L_001b: stloc.1
    L_001c: br.s L_001e
    L_001e: ldloc.1
    L_001f: ret
}
 
.method public hidebysig instance class [mscorlib]System.Func`1<int32> T4() cil managed
{
    .maxstack 3
    .locals init (
        [0] class [mscorlib]System.Func`1<int32> CS$1$0000)
    L_0000: nop
    L_0001: ldsfld class [mscorlib]System.Func`1<int32> ConsoleApplication1.TCloser::CS$<>9__CachedAnonymousMethodDelegate4
    L_0006: brtrue.s L_001b
    L_0008: ldnull
    L_0009: ldftn int32 ConsoleApplication1.TCloser::<T4>b__3()
    L_000f: newobj instance void [mscorlib]System.Func`1<int32>::.ctor(object, native int)
    L_0014: stsfld class [mscorlib]System.Func`1<int32> ConsoleApplication1.TCloser::CS$<>9__CachedAnonymousMethodDelegate4
    L_0019: br.s L_001b
    L_001b: ldsfld class [mscorlib]System.Func`1<int32> ConsoleApplication1.TCloser::CS$<>9__CachedAnonymousMethodDelegate4
    L_0020: stloc.0
    L_0021: br.s L_0023
    L_0023: ldloc.0
    L_0024: ret
}

     看IL程式碼你就會很容易發現其中究竟了,在T1中,函式對返回的匿名委託構造的是一個類,名稱為newobj instance void ConsoleApplication1.TCloser/<>c__DisplayClass1::.ctor(),而在T4中,則是仍然是一個普通的Func委託,只不過級別變為類級別了而已。      那我們接著看看T1中宣告的類c__DisplayClass1是何方神聖:

.class auto ansi sealed nested private beforefieldinit <>c__DisplayClass1
    extends [mscorlib]System.Object{
    .custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor()
    .method public hidebysig specialname rtspecialname instance void .ctor() cil managed{}
    .method public hidebysig instance int32 <T1>b__0() cil managed{}
    .field public int32 n
}

     看到這裡想必你已經明白了,在C#中,原來閉包只是編譯器玩的花招而已,它仍然沒有脫離.NET物件生命週期的規則,它將需要修改作用域的變數直接封裝到返回的類中變成類的一個屬性n,從而保證了變數的生命週期不會隨函式T1呼叫結束而結束,因為變數n在這裡已經成了返回的類的一個屬性了。      C#中,閉包其實和類中其他屬性、方法是一樣的,它們的原則都是下一層可以暢快的呼叫上一層定義的各種設定,但上一層則不具備訪問下一層設定的能力。即類中方法裡的變數可以自由訪問類中的所有屬性和方法,而閉包又可以訪問它的上一層即方法中的各種設定。但類不可以訪問方法的區域性變數,同理,方法也不可以訪問其內部定義的匿名函式所定義的區域性變數。      這正是C#中的閉包,它通過超越java語言的委託打下了閉包的第一步基礎,隨後又通過各種語法糖和編譯器來實現如今在.NET世界全面開花的Lamda和LINQ。也使得我們能夠編寫出更加簡潔優雅的程式碼。

注意點

     在c#5.0後,for和foreach在處理閉包問題上有了一些新的改變。為了適應不同的需求,微軟對佛 foreach做了調整,“foreach”的遍歷中定義的臨時迴圈變數會被邏輯上限制在迴圈內,“foreach”的每次迴圈都會是迴圈變數的一個拷貝,這樣閉包就看起來關閉了(沒有了)。但“for”迴圈沒有做修改。程式碼示例如下:

namespace Test1
{
    delegate void Func();
    public class TestFor
    {
        public void test()
        {
            List<Func> l = new List<Func>();
            for(int i = 0;i<5;i++)
            {
                l.Add(() =>
                {
                    Console.WriteLine(i);
                });
            }

            for(int i=0;i<5;i++)
            {
                l[i]();
            }
        }
    }

    public class TestForeach
    {
        public void test()
        {
            List<Func> l = new List<Func>();
            int[] a={0,1,2,3,4};
            foreach(int i in a)
            {
                l.Add(() =>
                {
                    Console.WriteLine(i);
                });
            }

            for (int i = 0; i < 5; i++)
            {
                l[i]();
            }
        }
    }

    public class main
    {
        public static void Main()
        {
            TestFor t1 = new TestFor();
            TestForeach t2 = new TestForeach();
            Console.WriteLine("TestFor");
            t1.test();
            Console.WriteLine("TestForeach");
            t2.test();
        }
    }
}

     程式碼結果如下:

TestFor
5
5
5
5
5
TestForeach
0
1
2
3
4

擴充套件認識

     筆者後來又想了下,既然閉包是由於函式巢狀定義引起的,c#中閉包存在於lambda和委託的情況下,那麼現在引入了lambda的c++中也應該存在閉包!      經過筆者的一番搜尋,發現c++中的確也存在閉包的概念。c++ 裡使用閉包有3個辦法:(1)operator();(2)lambda表示式;(3)boost::bind/std::bind。c++中的閉包和c#中的閉包大致相同,這裡就不做過多介紹了,感興趣的讀者可以訪問https://www.cnblogs.com/Aion/p/3449756.html來進行閱讀。

引用