前言
最近 Task.Run 相關的話題在園子里討論的比較熱鬧。其中有個比較重要的配角,傳給 Task.Run 的委托。而這個委托是通過 Lambda 表達式 來構建的。那 Lambda 表達式到底是個什么?
本文例子基於 .NET Core 3.1 的編譯結果反編譯得出結論,不同版本的編譯器的編譯結果可能不一致,因此本文僅供參考。為節省篇幅和便於閱讀,大部分例子只寫出編譯成的IL等效的C#代碼,不直接展示IL。
本文不討論的內容:
Lambda 表達式
如何構建表達式樹。- 閉包的概念。
Lambda 表達式
的好基友們匿名方法(delegate(int x){return x+1;} 這種)
以及Local Function
。
若需了解C#中如何引入閉包的概念以及Local Function和Lambda 表達式的區別,可參考我兩年前的一篇博客。
本文僅代表作者本人現階段的理解,若有不對的地方或不同的見解,歡迎留言。
預備知識,理解委托的構成
首先我們來看下一個委托是怎么被實例化的。
引用實例方法的委托
C# 代碼
public class Test
{
public Test()
{
Action action = Foo;
}
private void Foo()
{
}
}
為節約篇幅,只列出構造函數中的 IL代碼
.method public hidebysig specialname rtspecialname instance void
.ctor() cil managed
{
.maxstack 2
.locals init (
[0] class [System.Runtime]System.Action action
)
// [7 9 - 7 22]
IL_0000: ldarg.0 // this
IL_0001: call instance void [System.Runtime]System.Object::.ctor()
IL_0006: nop
// [8 9 - 8 10]
IL_0007: nop
// [9 13 - 9 33]
IL_0008: ldarg.0 // this
IL_0009: ldftn instance void TestApp.Test::Foo()
IL_000f: newobj instance void [System.Runtime]System.Action::.ctor(object, native int)
IL_0014: stloc.0 // action
// [10 9 - 10 10]
IL_0015: ret
} // end of method Test::.ctor
其中關鍵的部分是下面三行
// 加載 this 對象引用 到 evaluation stack
ldarg.0 // this
// 加載 Foo 方法指針 到 evaluation stack
ldftn instance void TestApp.Test::Foo()
// 將上述兩項傳入構造函數
newobj instance void [System.Runtime]System.Action::.ctor(object, native int)
簡單來說,就是調用委托的構造函數的時候傳入了兩個參數,第一個是實例方法當前實例的對象引用,第二個是實例方法指針。這個實例對象引用被維護在委托實例的 Target 屬性上。
簡單地通過在上述構造函數中加一行來說明。
public Test()
{
Action action = Foo;
// 走到這里時會輸出 True
Console.WriteLine(action.Target == this);
}
引用靜態方法的委托
那將上述的 Foo 方法改成靜態方法會發生什么呢?
public class Test
{
public Test()
{
Action action = Foo;
}
private static void Foo()
{
}
}
對應的 構造函數 IL 代碼
.method public hidebysig specialname rtspecialname instance void
.ctor() cil managed
{
.maxstack 2
.locals init (
[0] class [System.Runtime]System.Action action
)
// [7 9 - 7 22]
IL_0000: ldarg.0 // this
IL_0001: call instance void [System.Runtime]System.Object::.ctor()
IL_0006: nop
// [8 9 - 8 10]
IL_0007: nop
// [9 13 - 9 33]
IL_0008: ldnull // 注意這里,從 ldarg.0 變成了 ldnull。
IL_0009: ldftn void TestApp.Test::Foo()
IL_000f: newobj instance void [System.Runtime]System.Action::.ctor(object, native int)
IL_0014: stloc.0 // action
// [10 9 - 10 10]
IL_0015: ret
} // end of method Test::.ctor
和實例方法相比,構建委托的第一個參數從方法所關聯的實例變成了null。
為什么委托引用實例方法要維護一個this?因為實例方法中保不准會用到this。在 IL 層面,實例方法中,this 總是第一個參數。這也就是為什么 ldarg.0 是 this 的原因了。
為了證明后面委托執行的時候要用用到這個 Target,在做一個小實驗。
public class Test
{
private readonly int _id;
public Test(int id)
{
_id = id;
}
public void Foo()
{
Console.WriteLine(_id);
}
}
class Program
{
static void Main(string[] args)
{
var a = new Test(1);
var b = new Test(2);
Action action = a.Foo;
action(); // 輸出 1
Console.WriteLine(action.Target == a); // 輸出 True
var targetField =
typeof(Delegate)
.GetField("_target",
BindingFlags.Instance | BindingFlags.NonPublic);
// 將 action 的 Target 改成對象 b
targetField.SetValue(action, b);
action(); // 輸出 2
Console.WriteLine(action.Target == b); // 輸出 True
}
}
沒錯 Target 一變,方法所綁定的 實例 也變了。
Lambda 表達式的實際編譯結果
不同場景下創建的Lambda 表達式會有不同的實現方式,這里指語法糖被編譯成 IL 之后的真實形態。
為節省篇幅做出6個提前說明:
- 實例構造函數中Lambda 表達式的實現與普通實例方法實現一致。
- 靜態構造函數中Lambda 表達式的實現與普通的靜態方法實現一致。
- 靜態類型的靜態方法中Lambda 表達式的實現與非靜態類型的靜態方法實現一致。
- 不捕獲外部變量時,實例方法中的 Lambda 表達式的實現與靜態方法實現一致。
- 捕獲外部方法中的局部變量時,實例方法中的 Lambda 表達式的實現與靜態方法實現一致。
- Lambda 表達式,有無參數,有無返回值,實現一致。
去重后總結出下面4種基本CASE
CASE 1 沒有捕獲任何外部變量的Lambda 表達式
public class Test
{
public void Foo()
{
Func<int, int> func = x => x + 1;
}
}
編譯后等效 C# 代碼
public class Test
{
// 匿名內部類
private class AnonymousNestedClass
{
// 緩存匿名類單例
public static readonly AnonymousNestedClass _anonymousInstance;
// 緩存委托實例
public static Func<int, int> _func;
static AnonymousNestedClass()
{
_anonymousInstance = new AnonymousNestedClass();
}
internal int AnonymousMethod(int x)
{
return x + 1;
}
}
public void Foo()
{
// 這里是編譯器的一個優化,委托實例是單例
if (AnonymousNestedClass._func == null)
{
AnonymousNestedClass._func =
new Func<int, int>(AnonymousNestedClass._anonymousInstance.AnonymousMethod);
}
Func<int, int> func = AnonymousNestedClass._func;
}
}
我們的Lambda表達式實質上變成了匿名類型的實例方法。開篇講構建委托實例的例子的目的就在這了。
CASE 2 捕獲了外部方法局部變量的Lambda 表達式
public class Test
{
public void Foo()
{
int y = 1;
Func<int, int> func = x => x + y;
}
}
編譯后等效 C# 代碼
public class Test
{
// 匿名內部類
private class AnonymousNestedClass
{
// 局部變量變成了匿名類實例字段
public int _y;
internal int AnonymousMethod(int x)
{
return x + _y;
}
}
public void Foo()
{
AnonymousNestedClass anonymousInstance = new AnonymousNestedClass();
// 對局部變量的賦值變成了對匿名類型實例字段的賦值
anonymousInstance._y = 1;
// 委托沒有緩存了,每次都要重新實例化
Func<int, int> func = new Func<int, int>(anonymousInstance.AnonymousMethod);
}
}
CASE 3 實例方法中捕獲了實例字段的Lambda 表達式
public class Test
{
private int _y = 1;
public void Foo()
{
Func<int, int> func = x => x + _y;
}
}
編譯后等效 C# 代碼
public class Test
{
private int _y = 1;
public void Foo()
{
Func<int, int> func = new Func<int, int>(this.AnonymousMethod);
}
// Lambda 表達式 變成了當前類型的匿名實例方法
internal int AnonymousMethod(int x)
{
return x + _y;
}
}
插一句話,看到這里,相信你應該明白最近園子里討論比較多的所謂Task.Run
導致“內存泄漏”的真實原因了。
CASE 4 靜態方法中的捕獲了當前類型靜態字段的Lambda 表達式
public class Test
{
private static int _y = 1;
public static void Bar()
{
Func<int, int> func = x => x + _y;
}
}
編譯后等效 C# 代碼
public class Test
{
// 匿名內部類
private class AnonymousNestedClass
{
// 緩存匿名類單例
public static readonly AnonymousNestedClass _anonymousInstance;
// 緩存委托實例
public static Func<int, int> _func;
static AnonymousNestedClass()
{
_anonymousInstance = new AnonymousNestedClass();
}
internal int AnonymousMethod(int x)
{
// 實際使用原來的靜態字段
return x + Test._y;
}
}
private static int _y = 1;
public static void Bar()
{
if (AnonymousNestedClass._func == null)
{
AnonymousNestedClass._func =
new Func<int, int>(AnonymousNestedClass._anonymousInstance.AnonymousMethod);
}
Func<int, int> func = AnonymousNestedClass._func;
}
}
聊一聊循環中的Lambda 表達式
class Program
{
static void Main(string[] args)
{
List<Func<int>> list = new List<Func<int>>();
for (int i = 0; i < 3; i++)
{
list.Add(() => i);
}
for (int i = 0; i < 3; i++)
{
Console.WriteLine(list[i]());
}
Console.WriteLine(list.Distinct().Count());
}
}
這種場景下,類似於上述的 CASE 2。我們通過下面的編譯后等效代碼來理解下每次都輸出三的原因。
class Program
{
// 匿名內部類
private class AnonymousNestedClass
{
public int _i;
internal int AnonymousMethod()
{
return _i;
}
}
static void Main(string[] args)
{
List<Func<int>> list = new List<Func<int>>();
AnonymousNestedClass anonymousInstance = new AnonymousNestedClass();
for (anonymousInstance._i = 0;
anonymousInstance._i < 3;
anonymousInstance._i++)
{
// 退出循環時,anonymousInstance._i會變成3
// 每次委托實例的Target都是同一個對象
// 所以最后調用這三個委托的時候,都會得到相同的結果
list.Add(new Func<int>(anonymousInstance.AnonymousMethod));
}
for (int i = 0; i < 3; i++)
{
Console.WriteLine(list[i]());
}
}
}
那如果最后想要順利地輸出0 1 2,該怎么做呢。
class Program
{
static void Main(string[] args)
{
List<Func<int>> list = new List<Func<int>>();
for (int i = 0; i < 3; i++)
{
// 加個中間變量就可以了
int tmp = i;
list.Add(() => tmp);
}
for (int i = 0; i < 3; i++)
{
Console.WriteLine(list[i]());
}
Console.WriteLine(list.Distinct().Count());
}
}
相當於變成了這樣
class Program
{
// 匿名內部類
private class AnonymousNestedClass
{
public int _tmp;
internal int AnonymousMethod()
{
return _tmp;
}
}
static void Main(string[] args)
{
List<Func<int>> list = new List<Func<int>>();
for (int i = 0; i < 3; i++)
{
// 每個委托的Target不一樣,最后的執行結果也就不一樣了
AnonymousNestedClass anonymousInstance = new AnonymousNestedClass();
anonymousInstance._tmp = i;
list.Add(new Func<int>(anonymousInstance.AnonymousMethod));
}
for (int i = 0; i < 3; i++)
{
Console.WriteLine(list[i]());
}
}
}