有朋友好奇為什麼將 閉包 歸於語法糖,這裡簡單聲明下,C# 中的所有閉包最終都會歸結於 類 和 方法,為什麼這麼說,因為 C# 的基因就已經決定了,如果大家瞭解 CLR 的話應該知道, C#中的類最終都會用 MethodTable 來承載,方法都會用 MethodDesc 來承載, 所以不管你怎麼玩 ...
有朋友好奇為什麼將 閉包
歸於語法糖,這裡簡單聲明下,C# 中的所有閉包最終都會歸結於 類
和 方法
,為什麼這麼說,因為 C# 的基因就已經決定了,如果大家瞭解 CLR 的話應該知道, C#中的類
最終都會用 MethodTable
來承載,方法都會用 MethodDesc
來承載, 所以不管你怎麼玩都逃不出這三界之內。
這篇我們就來聊聊C#中的閉包底層原理及玩法,錶面上的概念就不說了哈。
一:普通閉包玩法
1. 案例演示
放了方便說明,先上一段測試代碼:
static void Main(string[] args)
{
int y = 10;
Func<int, int> sum = x =>
{
return x + y;
};
Console.WriteLine(sum(11));
}
剛纔也說了,C#的基因決定了最終會用 class
和 method
對 閉包
進行面向對象改造,那如何改造呢? 這裡有兩個問題:
- 匿名方法如何面向對象改造
方法
不能脫離 類
而獨立存在,所以 編譯器
必須要為其生成一個類,然後再給匿名方法配一個名字即可。
- 捕獲到的 y 怎麼辦
捕獲是一個很抽象
的詞,一點都不接底氣,這裡我用 面向對象
的角度來解讀一下,這個問題本質上就是 棧變數
和 堆變數
混在一起的一次行為衝突,什麼意思呢?
- 棧變數
大家應該知道 棧變數
所在的幀空間是由 esp
和 ebp
進行控制,一旦方法結束,esp 會往回收縮造成局部變數從棧中移除。
- 堆變數
委托是一個引用類型,它是由 GC 進行管理回收,只要它還被人牽著,自然就不會被回收。
到這裡我相信你肯定發現了一個嚴重的問題, 一旦 sum
委托逃出了方法,這時局部變數 y 肯定會被銷毀,如果真的被銷毀了, 後續再執行 sum
委托自然就是一個巨大的bug,那怎麼辦呢?
編譯器自然早就考慮到了這種情況,它在進行面向對象改造的時候,特意為 類
定義了一個 public
類型的欄位,用這個欄位來承載這個局部變數。
2. 手工改造
有了這些多前置知識,我相信你肯定會知道如何改造了,參考代碼如下:
class Program
{
static void Main(string[] args)
{
int y = 10;
//Func<int, int> sum = x =>
//{
// return x + y;
//};
//面向對象改造
FuncClass funcClass = new FuncClass() { y = y };
Func<int, int> sum = funcClass.Run;
Console.WriteLine(sum(11));
}
}
public class FuncClass
{
public int y;
public int Run(int x)
{
return x + y;
}
}
如果你不相信的話,可以看下 MSIL
代碼。
.method private hidebysig static
void Main (
string[] args
) cil managed
{
// Method begins at RVA 0x2050
// Code size 43 (0x2b)
.maxstack 2
.entrypoint
.locals init (
[0] class ConsoleApp1.Program/'<>c__DisplayClass0_0' 'CS$<>8__locals0',
[1] class [System.Runtime]System.Func`2<int32, int32> sum
)
IL_0000: newobj instance void ConsoleApp1.Program/'<>c__DisplayClass0_0'::.ctor()
IL_0005: stloc.0
IL_0006: nop
IL_0007: ldloc.0
IL_0008: ldc.i4.s 10
IL_000a: stfld int32 ConsoleApp1.Program/'<>c__DisplayClass0_0'::y
IL_000f: ldloc.0
IL_0010: ldftn instance int32 ConsoleApp1.Program/'<>c__DisplayClass0_0'::'<Main>b__0'(int32)
IL_0016: newobj instance void class [System.Runtime]System.Func`2<int32, int32>::.ctor(object, native int)
IL_001b: stloc.1
IL_001c: ldloc.1
IL_001d: ldc.i4.s 11
IL_001f: callvirt instance !1 class [System.Runtime]System.Func`2<int32, int32>::Invoke(!0)
IL_0024: call void [System.Console]System.Console::WriteLine(int32)
IL_0029: nop
IL_002a: ret
} // end of method Program::Main
.class nested private auto ansi sealed beforefieldinit '<>c__DisplayClass0_0'
extends [System.Runtime]System.Object
{
.custom instance void [System.Runtime]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = (
01 00 00 00
)
// Fields
.field public int32 y
// Methods
.method public hidebysig specialname rtspecialname
instance void .ctor () cil managed
{
// Method begins at RVA 0x2090
// Code size 8 (0x8)
.maxstack 8
IL_0000: ldarg.0
IL_0001: call instance void [System.Runtime]System.Object::.ctor()
IL_0006: nop
IL_0007: ret
} // end of method '<>c__DisplayClass0_0'::.ctor
.method assembly hidebysig
instance int32 '<Main>b__0' (
int32 x
) cil managed
{
// Method begins at RVA 0x209c
// Code size 14 (0xe)
.maxstack 2
.locals init (
[0] int32
)
IL_0000: nop
IL_0001: ldarg.1
IL_0002: ldarg.0
IL_0003: ldfld int32 ConsoleApp1.Program/'<>c__DisplayClass0_0'::y
IL_0008: add
IL_0009: stloc.0
IL_000a: br.s IL_000c
IL_000c: ldloc.0
IL_000d: ret
} // end of method '<>c__DisplayClass0_0'::'<Main>b__0'
} // end of class <>c__DisplayClass0_0
二:迴圈下閉包玩法
為了方便說明,還是先上一段代碼。
static void Main(string[] args)
{
var actions = new Action[10];
for (int i = 0; i < actions.Length; i++)
{
actions[i] = () => Console.WriteLine(i);
}
foreach (var item in actions) item();
}
然後把代碼跑起來:
我相信有非常多的朋友都踩過這個坑,那為什麼會出現這樣的結果呢? 我試著從原理上解讀一下。
1. 原理解讀
根據前面所學的 面向對象
改造法,我相信大家肯定會很快改造出來,參考代碼如下:
class Program
{
static void Main(string[] args)
{
var actions = new Action[10];
for (int i = 0; i < actions.Length; i++)
{
//actions[i] = () => Console.WriteLine(i);
//改造後
var funcClass = new FuncClass() { i = i };
actions[i] = funcClass.Run;
}
foreach (var item in actions) item();
}
}
public class FuncClass
{
public int i;
public void Run()
{
Console.WriteLine(i);
}
}
然後跑一下結果:
真奇葩,我們的改造方案一點問題都沒有,咋 編譯器
就弄不對呢?要想找到案例,只能看 MSIL 啦,簡化後如下:
IL_0001: ldc.i4.s 10
IL_0003: newarr [System.Runtime]System.Action
IL_0008: stloc.0
IL_0009: newobj instance void ConsoleApp1.Program/'<>c__DisplayClass0_0'::.ctor()
IL_000e: stloc.1
IL_000f: ldloc.1
IL_0010: ldc.i4.0
IL_0011: stfld int32 ConsoleApp1.Program/'<>c__DisplayClass0_0'::i
IL_0016: br.s IL_003e
// loop start (head: IL_003e)
IL_0018: nop
IL_0019: ldloc.0
...
// end loop
如果有興趣大家可以看下完整版,它的實現方式大概是這樣的。
static void Main(string[] args)
{
var actions = new Action[10];
var funcClass = new FuncClass();
for (int i = 0; i < actions.Length; i++)
{
actions[i] = funcClass.Run;
funcClass.i = i + 1;
}
foreach (var item in actions) item();
}
原來問題就出在了它只 new 了一次,同時 for 迴圈中只是對 i
進行了賦值,導致了問題的發生。
2. 編譯器的想法
為什麼編譯器會這麼改造代碼,我覺得可能基於下麵兩點。
- 不想 new 太多的類實例
new一個對象,其實並沒有大家想象的那麼簡單,在 clr 內部會分 快速路徑
和 慢速路徑
,同時還為此導致 GC 回收,為了保存一個變數
需要專門 new 一個實例,這代價真的太大了。。。
- 有更好的解決辦法
更好的辦法就是用 方法參數
,方法的位元組碼是放置在 CLR 的 codeheap 上,獨此一份,同時方法參數只是在棧
上多了一個存儲空間而已,這代價就非常小了。
三: 代碼改造
知道編譯器的苦衷後,改造起來就很簡單了,大概有如下兩種。
1. 強制 new 實例
這種改造法就是強制在每次 for 中 new 一個實例來承載 i
變數,參考代碼如下:
static void Main(string[] args)
{
var actions = new Action[10];
for (int i = 0; i < actions.Length; i++)
{
var j = i;
actions[i] = () => Console.WriteLine(j);
}
foreach (var item in actions) item();
}
2. 採用方法參數
為了能夠讓 i 作為方法參數,只能將 Action
改成 Action<int>
,雖然你可能要為此掉頭髮,但對程式性能來說是巨大的,參考代碼如下:
static void Main(string[] args)
{
var actions = new Action<int>[10];
for (int i = 0; i < actions.Length; i++)
{
actions[i] = (j) => Console.WriteLine(j);
}
for (int i = 0; i < actions.Length; i++)
{
actions[i](i);
}
}
好了,洋洋灑灑寫了這麼多,希望對大家有幫助。