c#閉包問題--踩過的一個大坑
引言
之前在做c#遊戲開發的過程中,遇到了一個大坑。當時為了實現動態載入圖鑑,用到了button的迴圈委託,誰知道這樣就踩了c#閉包問題的大坑!現在對這個問題進行總結,避免下次再踩入這樣的巨坑。 先貼下結論:在C#中,原來閉包只是編譯器玩的花招而已,它仍然沒有脫離.NET物件生命週期的規則,它將需要修改作用域的變數直接封裝到返回的類中變成類的一個屬性,從而保證了變數的生命週期不會隨函式呼叫結束而結束,因為變數n在這裡已經成了返回的類的一個屬性。
閉包的概念
Q:先丟擲第一個問題,什麼是閉包?閉包會出現在怎樣的場景中? A:在c#中,閉包是這樣定義的:內層的函式可以引用包含在它外層的函式的變數
def funx(x):
def funy(y):
return x * y
return funy
上面的例子可以看出,函式funx裡面又定義了一個新函式funy,這個新函式裡面的一個變數正好是外部函式funx的引數。也就是說,外部傳遞過來的引數已經和funy函式繫結到一起了。我們可以把x看做函式funy的一個配置資訊,配置資訊不同,函式的功能就不一樣了,也就是能得到定製之後的函式。 相信經過上面的一番講解,大家應該都對閉包的概念有所瞭解了吧。接下來,我們繼續回到c#的閉包問題中。由於c#中有委託和lambda函式,所以c#其實也可以在函式的定義中利用lambda或者委託實現函式的巢狀定義, 此時閉包可以幫助我們輕鬆地訪問外層函式定義的變數
錯誤例子,產生了閉包陷阱:
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來進行閱讀。