C# Lambda表示式的前世今生

匠心十年發表於2016-05-21

早在 C# 1.0 時,C#中就引入了委託(delegate)型別的概念。通過使用這個型別,我們可以將函式作為引數進行傳遞。在某種意義上,委託可理解為一種託管的強型別的函式指標。

通常情況下,使用委託來傳遞函式需要一定的步驟:

  1. 定義一個委託,包含指定的引數型別和返回值型別。
  2. 在需要接收函式引數的方法中,使用該委託型別定義方法的引數簽名。
  3. 為指定的被傳遞的函式建立一個委託例項。

可能這聽起來有些複雜,不過本質上說確實是這樣。上面的第 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); // 50
a = 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); // 50

	ModifyStuff(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 表示式時,一個重要的問題是目標方法是怎麼知道如下這些資訊的:

  1. 我們傳遞的變數的名字是什麼?
  2. 我們使用的表示式體的結構是什麼?
  3. 在表示式體內我們用了哪些型別?

現在,表示式樹幫我們解決了問題。它允許我們深究具體編譯器是如何生成的表示式。此外,我們也可以執行給定的函式,就像使用 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 表示式引用著。只要這個函式還在,其作用範圍仍然有效(之前我們已經瞭解了這些,但現在我們知道了原因)。

像之前一樣,我們把這些分析放入一張圖中。從圖中我們可以看到,閉包並不是僅有的被移動的方法,被捕獲變數也被移動了。所有被移動的物件都會被放入一個編譯器生成的類中。最後,我們從一個未知的類例項化了一個物件。

文章內容翻譯並改編自 Way to Lambda ,章節和程式碼有很大的改動,未包含全部內容。

相關文章