首頁 >後端開發 >C#.Net教程 >詳細介紹C# Lambda表達式的前世今生(圖)

詳細介紹C# Lambda表達式的前世今生(圖)

黄舟
黄舟原創
2017-03-09 15:26:392466瀏覽

Lambda 表達式

早在 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 表达式引用着。只要这个函数还在,其作用范围仍然有效(之前我们已经了解了这些,但现在我们知道了原因)。

像之前一样,我们把这些分析放入一张图中。从图中我们可以看到,闭包并不是仅有的被移动的方法,被捕获变量也被移动了。所有被移动的对象都会被放入一个编译器生成的类中。最后,我们从一个未知的类实例化了一个对象。


以上是詳細介紹C# Lambda表達式的前世今生(圖)的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文內容由網友自願投稿,版權歸原作者所有。本站不承擔相應的法律責任。如發現涉嫌抄襲或侵權的內容,請聯絡admin@php.cn