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