购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

4.3 Lambda表达式

Lambda表达式是一种可以替代委托实例的匿名方法。编译器会立即将Lambda表达式转换为以下两种形式之一:

· 一个委托实例。

· 一个类型为 Expression<TDelegate> 的表达式树。该表达式树将Lambda表达式内部的代码表示为一个可遍历的对象模型,因此Lambda表达式的解释可以延迟到运行时进行(请参见8.10节)。

在以下示例中, x => x * x 是一个Lambda表达式:

编译器在内部将这种Lambda表达式编译为一个私有的方法,并将表达式代码转移到该方法中。

Lambda表达式拥有以下形式:

为了方便起见,在只有一个可推测类型的参数时,可以省略参数表外围的小括号。

在本例中,只有一个参数 x ,而表达式是 x * x

Lambda表达式的每一个参数对应委托的一个参数,而表达式的类型(可以是 void )对应着委托的返回类型。

在本例中, x 对应参数 i ,而表达式 x * x 的类型对应着返回值类型 int ,因此它和 Transformer 委托是兼容的:

Lambda表达式的代码除了表达式之外还可以是语句块,因此我们可以把上例改写成:

Lambda表达式通常与 Func Action 委托一起使用,因此前面的表达式通常写成如下形式:

以下是带有两个参数的表达式示例:

如果Lambda表达式中无须使用参数,(从C# 9开始)可以使用下划线丢弃该参数:

以下示例展示了一个无参的Lambda表达式:

从C# 10开始,若Lambda表达式可以由 Func Action 表示,则可以在该表达式上使用隐式类型声明。因此上述语句可以简写为:

4.3.1 显式指定Lambda参数和返回值的类型

编译器通常可以根据上下文推断出Lambda表达式的类型,但是当无法推断时则必须显式指定每一个参数的类型。请考虑如下方法:

以下代码无法通过编译,因为编译器无法推断 x 的类型:

我们可以通过显式指定 x 的类型来修正这个问题:

这个简单的例子还可以用如下两种方式修正:

以下示例展示了另一种使用显式指定参数类型的方式(适用于C# 10及后续版本):

编译器可以从上述代码中推断出 sqr 的类型为 Func<int,int> 。(如果不显式指定 int 参数类型而使用隐式参数类型,则编译会失败。这是因为编译器虽能推断出 sqr 的类型为 Func<T,T> 却无法得知 T 的具体类型。)

从C# 10开始,我们还能够指定Lambda表达式的返回类型:

指定返回类型可以改善编译器处理复杂的嵌套Lambda表达式时的性能。

4.3.2 捕获外部变量

Lambda表达式可以引用其定义所在之处可以访问的任何变量,这些变量称为外部变量(outer variable)。外部变量也包含局部变量、参数和字段:

Lambda表达式所引用的外部变量称为捕获变量(captured variable),含有捕获变量的表达式称为闭包(closure)。

变量也可以被匿名方法和局部方法捕获,捕获变量的规则都是一样的。

捕获的变量会在真正调用委托时赋值,而不是在捕获时赋值:

Lambda表达式也可以更新捕获的变量的值:

捕获变量的生命周期延伸到了和委托的生命周期一致。在以下例子中,局部变量 seed 本应该在 Natural 执行完毕后从作用域中消失,但由于 seed 被捕获,因此其生命周期已经和捕获它的委托 natural 保持一致了:

在Lambda表达式内实例化的局部变量对于每一次委托实例的调用都是唯一的。如果我们更改上述示例,在Lambda表达式内实例化 seed ,则程序的结果(当然这个结果不是我们期望的)将与之前不同:

内部捕获变量是通过将变量“提升”为私有类的字段的方式实现的。当调用方法时,实例化该私有类,并将其生命周期绑定在委托实例上。

4.3.2.1 静态Lambda

当Lambda表达式捕获局部变量、参数、实例字段或 this 引用时,编译器会根据需要创建或实例化一个私有类型以保存捕获的数据。由于这类操作需要分配内存(后续也需要回收),因此可能会造成微小的性能损失。在性能要求较高的场景下,一种微优化的方式即在代码的热点执行路径上减少内存分配或不进行内存分配,以降低垃圾收集器的工作负载。

从C# 9开始,我们可以使用 static 关键字来确保Lambda表达式、局部函数或匿名函数不会捕获任何状态。它适于用那些需要尽可能避免不必要内存分配的微优化场景中。例如,我们可以在以下Lambda表达式上添加静态修饰符:

如果之后修改Lambda代码时意外捕获了局部变量,则编译器将生成一个错误:

Lambda表达式本身会解析为一个委托实例,这也需要进行内存分配。但是如果Lambda不捕获变量,则编译器就可以在整个应用程序的生存期中重用缓存的单个委托实例。因此是没有额外损耗的。

该特性还可以用于局部方法。在以下示例中, Multiply 方法无法访问 factor 变量:

当然,即使使用了 static 修饰符,我们仍然可以在 Multiply 方法中调用 new 分配内存。静态Lambda防止的是不经意间的引用导致的潜在内存分配。同时, static 修饰符还可以作为一种文档工具,以表明耦合度的降低。

静态Lambda仍然可以访问静态变量和常量(这些访问并不会形成闭包)。

static 关键字仅仅作为检查手段存在,并不会影响编译期生成的IL。即使不添加 static 关键字,编译器也只会在需要的时候生成闭包(即使生成了闭包,也会使用各种技巧削减开销)。

4.3.2.2 捕获迭代变量

当捕获 for 循环中的迭代变量时,C#会认为该变量是在循环体外定义的。这意味着同一个变量在每一次迭代都被捕获了,因此程序输出 333 而非 012

每一个闭包(加粗的部分)都捕获了相同的变量 i (如果变量 i 在循环中保持不变,则非常有效,我们甚至可以在循环体中显式更改 i 的值),而这个后果是每一个委托只在调用的时候才看到 i 的值,而这时 i 已经是 3 了。将 for 展开更便于理解:

如果我们真的希望输出 012 ,那么需要将循环变量指定到循环内部的局部变量中:

由于 loopScopedi 对于每一次迭代都是新创建的,因此每一个闭包都将捕获不同的变量。

在C# 5.0之前, foreach 循环和 for 循环在闭包中的行为是相同的。这会引起很大的困惑,与 for 不同, foreach 循环中的迭代变量是不可变的,所以人们可以将它作为循环体的局部变量。当然,如今这个问题已经被修复,我们可以以预期的行为安全地捕获 foreach 循环的迭代变量。

4.3.3 Lambda表达式和局部方法的对比

局部方法(请参见3.1.3.2节)和Lambda表达式的相应功能是重叠的,而局部方法拥有以下三个优势:

· 局部方法无须使用奇怪的技巧就可以实现递归(调用自己)。

· 局部方法避免了定义杂乱的委托类型。

· 局部方法的开销更小。

局部方法更加高效,因为它不需要间接使用委托(委托会消耗更多的CPU时钟周期并使用更多的内存),而且当它们访问局部变量的时候不需要编译器像委托那样将捕获的变量放到一个隐藏的类中去。

但是,在许多情况下仍然需要使用委托,尤其是当需要调用高阶函数(即使用委托作为参数的方法)的时候:

(我们将在第8章中介绍更多的内容。)在这种情况下,就不得不使用委托了。特别是针对这种情况,Lambda表达式通常会显得更加简洁和清晰。 93MCXEqiXRpHwyoAFEUTy5yK1JSDxeUWlO7IgLCzUPrV7ogApKTSQfNodxfbG8Ve

点击中间区域
呼出菜单
上一章
目录
下一章
×