Lambda 運算式
早在 C# 1.0 時,C#中就引入了委託(delegate)類型的概念。通過使用這個類型,我們可以將函數作為參數進行傳遞。在某種意義上,委託可理解為一種託管的強型別的函數指標。
通常情況下,使用委託來傳遞函數需要一定的步驟:
定義一個委託,包含指定的參數類型和傳回值類型。
在需要接收函數參數的方法中,使用該委託類型定義方法的參數簽名。
為指定的被傳遞的函數建立一個委託執行個體。
可能這聽起來有些複雜,不過本質上說確實是這樣。上面的第 3 步通常不是必須的,C# 編譯器能夠完成這個步驟,但步驟 1 和 2 仍然是必須的。
幸運的是,在 C# 2.0 中引入了泛型。現在我們能夠編寫泛型類、泛型方法和最重要的:泛型委派。儘管如此,直到 .NET 3.5,微軟才意識到實際上僅通過兩種泛型委派就可以滿足 99% 的需求:
Action :無輸入參數,無傳回值
Action :支援1-16個輸入參數,無傳回值
Func :支援1-16個輸入參數,有傳回值
Action 委託返回 void 類型,Func 委託返回指定類型的值。通過使用這兩種委託,在絕大多數情況下,上述的步驟 1 可以省略了。但是步驟 2 仍然是必需的,但僅是需要使用 Action 和 Func。
那麼,如果我只是想執行一些代碼該怎麼辦?在 C# 2.0 中提供了一種方式,建立匿名函數。但可惜的是,這種文法並沒有流行起來。下面是一個簡單的匿名函數的樣本:
Func<double, double> square = delegate(double x){return x * x;};
為了改進這些文法,在 .NET 3.5 架構和 C# 3.0 中引入了Lambda 運算式。
首先我們先瞭解下 Lambda 運算式名字的由來。實際上這個名字來自微積分數學中的 λ,其涵義是聲明為了表達一個函數具體需要什麼。更確切的說,它描述了一個數學邏輯系統,通過變數結合和替換來表達計算。所以,基本上我們有 0-n 個輸入參數和一個傳回值。而在程式設計語言中,我們也提供了無傳回值的 void 支援。
讓我們來看一些 Lambda 運算式的樣本:
// The compiler cannot resolve this, which makes the usage of var impossible! // Therefore we need to specify the type. Action dummyLambda = () => { Console.WriteLine("Hello World from a Lambda expression!"); }; // Can be used as with double y = square(25); Func<double, double> square = x => x * x; // Can be used as with double z = product(9, 5); Func<double, double, double> product = (x, y) => x * y; // Can be used as with printProduct(9, 5); Action<double, double> printProduct = (x, y) => { Console.WriteLine(x * y); }; // Can be used as with // var sum = dotProduct(new double[] { 1, 2, 3 }, new double[] { 4, 5, 6 }); Func<double[], double[], double> dotProduct = (x, y) => { var dim = Math.Min(x.Length, y.Length); var sum = 0.0; for (var i = 0; i != dim; i++) sum += x[i] + y[i]; return sum; }; // Can be used as with var result = matrixVectorProductAsync(...); Func<double[,], double[], Task<double[]>> matrixVectorProductAsync = async (x, y) => { var sum = 0.0; /* do some stuff using await ... */ return sum; };
從這些語句中我們可以直接地瞭解到:
如果僅有一個入參,則可省略圓括弧。
如果僅有一行語句,並且在該語句中返回,則可省略大括弧,並且也可以省略 return 關鍵字。
通過使用 async 關鍵字,可以將 Lambda 運算式聲明為非同步執行。
大多數情況下,var 聲明可能無法使用,僅在一些特殊的情況下可以使用。
在使用 var 時,如果編譯器通過參數類型和傳回值類型推斷無法得出委託類型,將會拋出 “Cannot assign lambda expression to an implicitly-typed local variable.” 的錯誤提示。來看下如下這些樣本:
現在我們已經瞭解了大部分基礎知識,但一些 Lambda 運算式特別酷的部分還沒提及。
我們來看下這段代碼:
var a = 5;Funcint, int> multiplyWith = x => x * a;var result1 = multiplyWith(10); // 50a = 10;var result2 = multiplyWith(10); // 100
可以看到,在 Lambda 運算式中可以使用外圍的變數,也就是閉包。
static void DoSomeStuff() {var coeff = 10;Funcint, int> compute = x => coeff * x;Action modifier = () =>{ coeff = 5;};var result1 = DoMoreStuff(compute); // 50ModifyStuff(modifier);var result2 = DoMoreStuff(compute); // 25 } static int DoMoreStuff(Funcint, int> computer) {return computer(5); } static void ModifyStuff(Action modifier) {modifier(); }
這裡發生了什麼呢?首先我們建立了一個局部變數和兩個 Lambda 運算式。第一個 Lambda 運算式展示了其可以在其他範圍中訪問該局部變數,實際上這已經展現了強大的能力了。這意味著我們可以保護一個變數,但仍然可以在其他方法中訪問它,而不用關心那個方法是定義在當前類或者其他類中。
第二個 Lambda 運算式展示了在 Lambda 運算式中能夠修改外圍變數的能力。這就意味著通過在函數間傳遞 Lambda 運算式,我們能夠在其他方法中修改其他範圍中的局部變數。因此,我認為閉包是一種特彆強大的功能,但有時也可能引入一些非期望的結果。
var buttons = new Button[10]; for (var i = 0; i < buttons.Length; i++) {var button = new Button();button.Text = (i + 1) + ". Button - Click for Index!";button.OnClick += (s, e) => { Messagebox.Show(i.ToString()); };buttons[i] = button; } //What happens if we click ANY button?!
這個詭異的問題的結果是什麼呢?是 Button 0 顯示 0, Button 1 顯示 1 嗎?答案是:所有的 Button 都顯示 10!
因為隨著 for 迴圈的遍曆,局部變數 i 的值已經被更改為 buttons 的長度 10。一個簡單的解決辦法類似於:
var button = new Button();var index = i;button.Text = (i + 1) + ". Button - Click for Index!";button.OnClick += (s, e) => { Messagebox.Show(index.ToString()); };buttons[i] = button;
通過定義變數 index 來拷貝變數 i 中的值。
註:如果你使用 Visual Studio 2012 以上的版本進行測試,因為使用的編譯器與 Visual Studio 2010 的不同,此處測試的結果可能不同。可參考:Visual C# Breaking Changes in Visual Studio 2012
運算式樹狀架構
在使用 Lambda 運算式時,一個重要的問題是目標方法是怎麼知道如下這些資訊的:
我們傳遞的變數的名字是什嗎?
我們使用的運算式體的結構是什嗎?
在運算式體內我們用了哪些類型?
現在,運算式樹狀架構幫我們解決了問題。它允許我們深究具體編譯器是如何產生的運算式。此外,我們也可以執行給定的函數,就像使用 Func 和 Action 委託一樣。其也允許我們在運行時解析 Lambda 運算式。
我們來看一個樣本,描述如何使用 Expression 類型:
Expressionint>> expr = model => model.MyProperty;var member = expr.Body as MemberExpression;var propertyName = memberExpression.Member.Name; //only execute if member != null
上面是關於 Expression 用法的一個最簡單的樣本。其中的原理非常直接:通過形成一個 Expression 類型的對象,編譯器會根據運算式樹狀架構的解析產生中繼資料資訊。解析樹中包含了所有相關的資訊,例如參數和方法體等。
方法體包含了整個解析樹。通過它我們可以訪問操作符、操作對象以及完整的語句,最重要的是能訪問傳回值的名稱和類型。當然,返回變數的名稱可能為 null。儘管如此,大多數情況下我們仍然對錶達式的內容高度興趣。對於開發人員的益處在於,我們不再會拼錯屬性的名稱,因為每個拼字錯誤都會導致編譯錯誤。
如果程式員只是想知道調用屬性的名稱,有一個更簡單優雅的辦法。通過使用特殊的參數屬性 CallerMemberName 可以擷取到被呼叫者法或屬性的名稱。編譯器會自動記錄這些名稱。所以,如果我們僅是需要獲知這些名稱,而無需更多的類型資訊,則我們可以參考如下的代碼寫法:
string WhatsMyName([CallerMemberName] string callingName = null) { return callingName; }
Lambda 運算式的效能
有一個大問題是:Lambda 運算式到底有多快?當然,我們期待其應該與常規的函數一樣快,因為 Lambda 運算式也同樣是由編譯器產生的。在下一節中,我們會看到為 Lambda 運算式產生的 MSIL 與常規的函數並沒有太大的不同。
一個非常有趣的討論是關於在 Lambda 運算式中的閉包是否要比使用全域變數更快,而其中最有趣的地方就是是否當可用的變數都在本地範圍時是否會有效能影響。
讓我們來看一些代碼,用于衡量各種效能基準。通過這 4 種不同的基準測試,我們應該有足夠的證據來說明常規函數與 Lambda 運算式之間的不同了。
class StandardBenchmark : Benchmark{ static double[] A; static double[] B; public static void Test() {var me = new StandardBenchmark(); Init(); for (var i = 0; i 10; i++) { var lambda = LambdaBenchmark(); var normal = NormalBenchmark(); me.lambdaResults.Add(lambda); me.normalResults.Add(normal); } me.PrintTable(); } static void Init() { var r = new Random(); A = new double[LENGTH]; B = new double[LENGTH]; for (var i = 0; i ) { A[i] = r.NextDouble(); B[i] = r.NextDouble(); } } static long LambdaBenchmark() { Funcdouble> Perform = () => { var sum = 0.0; for (var i = 0; i ) sum += A[i] * B[i]; return sum; }; var iterations = new double[100]; var timing = new Stopwatch(); timing.Start(); for (var j = 0; j ) iterations[j] = Perform(); timing.Stop(); Console.WriteLine("Time for Lambda-Benchmark: t {0}ms", timing.ElapsedMilliseconds); return timing.ElapsedMilliseconds; } static long NormalBenchmark() { var iterations = new double[100]; var timing = new Stopwatch(); timing.Start(); for (var j = 0; j ) iterations[j] = NormalPerform(); timing.Stop(); Console.WriteLine("Time for Normal-Benchmark: t {0}ms", timing.ElapsedMilliseconds); return timing.ElapsedMilliseconds; } static double NormalPerform() { var sum = 0.0; for (var i = 0; i ) sum += A[i] * B[i]; return sum; }}
當然,利用 Lambda 運算式,我們可以把上面的代碼寫的更優雅一些,這麼寫的原因是防止幹擾最終的結果。所以我們僅提供了 3 個必要的方法,其中一個負責執行 Lambda 測試,一個負責常規函數測試,第三個方法則是在常規函數。而缺少的第四個方法就是我們的 Lambda 運算式,其已經在第一個方法中內嵌了。使用的計算方法並不重要,我們使用了隨機數,進而避免了編譯器的最佳化。最後,我們最感興趣的就是常規函數與 Lambda 運算式的不同。
在運行這些測試後,我們會發現,在通常情況下 Lambda 運算式不會表現的比常規函數更差。而其中的一個很奇怪的結果就是,Lambda 運算式實際上在某些情況下表現的要比常規方法還要好些。當然,如果是在使用閉包的條件下,結果就不一樣了。這個結果告訴我們,使用 Lambda 運算式無需再猶豫。但是我們仍然需要仔細的考慮當我們使用閉包時所丟失的效能。在這種情景下,我們通常會丟失一點效能,但或許仍然還能接受。關於效能丟失的原因將在下一節中揭開。
下面的表格中顯示了基準測試的結果:
無入參無閉包比較
含入參比較
含閉包比較
含入參含閉包比較
Test |
Lambda [ms] |
Normal [ms] |
0 |
45+-1 |
46+-1 |
1 |
44+-1 |
46+-2 |
2 |
49+-3 |
45+-2 |
3 |
48+-2 |
45+-2 |
註:測試結果根據機器硬體設定有所不同
下面的圖表中同樣展現了測試結果。我們可以看到,常規函數與 Lambda 運算式會有相同的限制。使用 Lambda 運算式並沒有顯著的效能損失。
MSIL揭秘Lambda運算式
使用著名的工具 LINQPad 我們可以查看 MSIL。
我們來看下第一個樣本:
void Main() { DoSomethingLambda("some example"); DoSomethingNormal("some example"); }
Lambda 運算式:
Actionstring> DoSomethingLambda = (s) => { Console.WriteLine(s);// + local };
相應的方法的代碼:
void DoSomethingNormal(string s) { Console.WriteLine(s); }
兩段代碼的 MSIL 代碼:
IL_0001: ldarg.0 IL_0002: ldfld UserQuery.DoSomethingLambda IL_0007: ldstr "some example" IL_000C: callvirt System.Action.Invoke IL_0011: nop IL_0012: ldarg.0 IL_0013: ldstr "some example" IL_0018: call UserQuery.DoSomethingNormal DoSomethingNormal: IL_0000: nop IL_0001: ldarg.1 IL_0002: call System.Console.WriteLine IL_0007: nop IL_0008: ret b__0: IL_0000: nop IL_0001: ldarg.0 IL_0002: call System.Console.WriteLine IL_0007: nop IL_0008: ret
此處最大的不同就是函數的命名和用法,而不是聲明方式,實際上聲明方式是相同的。編譯器會在當前類中建立一個新的方法,然後推斷該方法的用法。這沒什麼特別的,只是使用 Lambda 運算式方便了許多。從 MSIL 的角度來看,我們做了相同的事,也就是在當前的對象上調用了一個方法。
我們可以將這些分析放到一張圖中,來展現編譯器所做的更改。在下面這張圖中我們可以看到編譯器將 Lambda 運算式移到了一個單獨的方法中。
在第二個樣本中,我們將展現 Lambda 運算式真正神奇的地方。在這個例子中,我們使用了一個常規的方法來訪問全域變數,然後用一個 Lambda 運算式來捕獲局部變數。代碼如下:
void Main() { int local = 5; Actionstring> DoSomethingLambda = (s) => { Console.WriteLine(s + local); }; global = local; DoSomethingLambda("Test 1"); DoSomethingNormal("Test 2"); } int global; void DoSomethingNormal(string s) { Console.WriteLine(s + global); }
目前看來沒什麼特殊的。關鍵的問題是:編譯器是如何處理 Lambda 運算式的?
IL_0000: newobj UserQuery+c__DisplayClass1..ctor IL_0005: stloc.1 // CS$8__locals2 IL_0006: nop IL_0007: ldloc.1 // CS$8__locals2 IL_0008: ldc.i4.5 IL_0009: stfld UserQuery+c__DisplayClass1.local IL_000E: ldloc.1 // CS$8__locals2 IL_000F: ldftn UserQuery+c__DisplayClass1.b__0 IL_0015: newobj System.Action..ctor IL_001A: stloc.0 // DoSomethingLambda IL_001B: ldarg.0 IL_001C: ldloc.1 // CS$8__locals2 IL_001D: ldfld UserQuery+c__DisplayClass1.local IL_0022: stfld UserQuery.global IL_0027: ldloc.0 // DoSomethingLambda IL_0028: ldstr "Test 1" IL_002D: callvirt System.Action.Invoke IL_0032: nop IL_0033: ldarg.0 IL_0034: ldstr "Test 2" IL_0039: call UserQuery.DoSomethingNormal IL_003E: nop DoSomethingNormal: IL_0000: nop IL_0001: ldarg.1 IL_0002: ldarg.0 IL_0003: ldfld UserQuery.global IL_0008: box System.Int32 IL_000D: call System.String.Concat IL_0012: call System.Console.WriteLine IL_0017: nop IL_0018: ret c__DisplayClass1.b__0: IL_0000: nop IL_0001: ldarg.1 IL_0002: ldarg.0 IL_0003: ldfld UserQuery+c__DisplayClass1.local IL_0008: box System.Int32 IL_000D: call System.String.Concat IL_0012: call System.Console.WriteLine IL_0017: nop IL_0018: ret c__DisplayClass1..ctor: IL_0000: ldarg.0 IL_0001: call System.Object..ctor IL_0006: ret
還是一樣,兩個函數從調用語句上看是相同的,還是應用了與之前相同的機制。也就是說,編譯器為該函數產生了一個名字,並把它替換到代碼中。而此處最大的區別在於,編譯器同時產生了一個類,而編譯器產生的函數就被放到了這個類中。那麼,建立這個類的目的是什麼呢?它使變數具有了全域範圍範圍,而此之前其已被用於捕獲變數。通過這種方式,Lambda 運算式有能力訪問局部範圍的變數(因為從 MSIL 的觀點來看,其僅是類執行個體中的一個全域變數而已)。
然後,通過這個新產生的類的執行個體,所有的變數都從這個執行個體分配和讀取。這解決了變數間存在引用的問題(會對類添加一個額外的引用 – 確實是這樣)。編譯器已經足夠的聰明,可以將那些被捕獲變數放到這個類中。所以,我們可能會期待使用 Lambda 運算式並不會存在效能問題。然而,這裡我們必須提出一個警告,就是這種行為可能會引起記憶體流失,因為對象仍然被 Lambda 運算式引用著。只要這個函數還在,其作用範圍仍然有效(之前我們已經瞭解了這些,但現在我們知道了原因)。
像之前一樣,我們把這些分析放入一張圖中。我們可以看到,閉包並不是僅有的被移動的方法,被捕獲變數也被移動了。所有被移動的對象都會被放入一個編譯器產生的類中。最後,我們從一個未知的類執行個體化了一個對象。