首页 > 编程笔记 > C#笔记 阅读:3

C# Lambda表达式用法详解(附带实例)

Lambda 表达式是一种可以替代委托实例的匿名方法。编译器会立即将 Lambda 表达式转换为以下两种形式之一:
在以下示例中,x => x * x 是一个 Lambda 表达式:
Transformer sqr = x => x * x;
Console.WriteLine(sqr(3));  // 9

delegate int Transformer(int i);
注意,编译器在内部将这种 Lambda 表达式编译为一个私有的方法,并将表达式代码转移到该方法中。

Lambda 表达式拥有以下形式:
(parameters) => expression-or-statement-block
为了方便起见,在只有一个可推测类型的参数时,可以省略参数表外围的小括号。

在本例中,只有一个参数 x,而表达式是 x * x:
x => x * x;
Lambda 表达式的每一个参数对应委托的一个参数,而表达式的类型(可以是 void)对应着委托的返回类型。

在本例中,x 对应参数 i,而表达式 x * x 的类型对应着返回值类型 int,因此它和 Transformer 委托是兼容的:
delegate int Transformer(int i);
Lambda 表达式的代码除了表达式之外还可以是语句块,因此我们可以把上例改写成:
x => { return x * x; };

Lambda 表达式通常与 Func 和 Action 委托一起使用,因此前面的表达式通常写成如下形式:
Func<int, int> sqr = x => x * x;
以下是带有两个参数的表达式示例:
Func<string, string, int> totalLength = (s1, s2) => s1.Length + s2.Length;
int total = totalLength("hello", "world"); // total is 10

如果 Lambda 表达式中无须使用参数,(从 C# 9 开始)可以使用下划线丢弃该参数:
Func<string, string, int> totalLength = (_, _) => ...;
以下示例展示了一个无参的 Lambda 表达式:
Func<string> greeter = () => "Hello, world";

从 C# 10 开始,若 Lambda 表达式可以由 Func 和 Action 表示,则可以在该表达式上使用隐式类型声明。因此上述语句可以简写为:
var greeter = () => "Hello, world";

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

编译器通常可以根据上下文推断出 Lambda 表达式的类型,但是当无法推断时则必须显式指定每一个参数的类型。请考虑如下方法:
void Foo<T>(T x) { }
void Bar<T>(Action<T> a) { }

以下代码无法通过编译,因为编译器无法推断 x 的类型:
Bar(x => Foo(x)); // What type is x?

我们可以通过显式指定 x 的类型来修正这个问题:
Bar((int x) => Foo(x));
这个简单的例子还可以用如下两种方式修正:
Bar<int>(x => Foo(x));         // Specify type parameter for Bar
Bar<int>(Foo);                 // As above, but with method group

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

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

从 C# 10 开始,我们还能够指定 Lambda 表达式的返回类型:
var sqr = int (int x) => x;
指定返回类型可以改善编译器处理复杂的嵌套 Lambda 表达式时的性能。

C#捕获外部变量

Lambda 表达式可以引用其定义所在之处可以访问的任何变量,这些变量称为外部变量(outer variable)。外部变量也包含局部变量、参数和字段:
int factor = 2;
Func<int, int> multiplier = n => n * factor;
Console.WriteLine(multiplier(3)); // 6
Lambda 表达式所引用的外部变量称为捕获变量(captured variable),含有捕获变量的表达式称为闭包(closure)。

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

捕获的变量会在真正调用委托时赋值,而不是在捕获时赋值:
int factor = 2;
Func<int, int> multiplier = n => n * factor;
factor = 10;
Console.WriteLine(multiplier(3)); // 30

Lambda 表达式也可以更新捕获的变量的值:
int seed = 0;
Func<int> natural = () => seed++;
Console.WriteLine(natural()); // 0
Console.WriteLine(natural()); // 1
Console.WriteLine(seed);      // 2

捕获变量的生命周期延伸到了和委托的生命周期一致。在以下例子中,局部变量 seed 本应该在 Natural 执行完毕后从作用域中消失,但由于 seed 被捕获,因此其生命周期已经和捕获它的委托 natural 保持一致了:
static Func<int> Natural()
{
    int seed = 0;
    return () => seed++; // Returns a closure
}

static void Main()
{
    Func<int> natural = Natural();
    Console.WriteLine(natural()); // 0
    Console.WriteLine(natural()); // 1
}

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

static void Main()
{
    Func<int> natural = Natural();
    Console.WriteLine(natural()); // 0
    Console.WriteLine(natural()); // 0
}
注意,内部捕获变量是通过将变量“提升”为私有类的字段的方式实现的。当调用方法时,实例化该私有类,并将其生命周期绑定在委托实例上。

C++静态Lambda

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

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

例如,我们可以在以下 Lambda 表达式上添加静态修饰符:
Func<int, int> multiplier = static n => n * 2;

如果之后修改 Lambda 代码时意外捕获了局部变量,则编译器将生成一个错误:
int factor = 2;
Func<int, int> multiplier = static n => n * factor; // will not compile

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

该特性还可以用于局部方法。在以下示例中,Multiply 方法无法访问 factor 变量:
void Foo()
{
    int factor = 123;
    static int Multiply(int x) => x * 2; // Local static method
}

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

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

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

C#捕获迭代变量

当捕获 for 循环中的迭代变量时,C# 会认为该变量是在循环体外定义的。这意味着同一个变量在每一次迭代都被捕获了,因此程序输出 333 而非 012:
Action[] actions = new Action[3];
for (int i = 0; i < 3; i++)
    actions[i] = () => Console.Write(i);
foreach (Action a in actions) a(); // 333

每一个闭包(加粗的部分)都捕获了相同的变量 i(如果变量 i 在循环中保持不变,则非常有效,我们甚至可以在循环体中显式更改 i 的值),而这个后果是每一个委托只在调用的时候才看到i的值,而这时 i 已经是 3 了。将 for 展开更便于理解:
Action[] actions = new Action[3];
int i = 0;
actions[0] = () => Console.Write(i);
i = 1;
actions[1] = () => Console.Write(i);
i = 2;
actions[2] = () => Console.Write(i);
i = 3;
foreach (Action a in actions) a(); // 333

如果我们真的希望输出 012,那么需要将循环变量指定到循环内部的局部变量中:
Action[] actions = new Action[3];
for (int i = 0; i < 3; i++)
{
    int loopScopedi = i;
    actions[i] = () => Console.Write(loopScopedi);
}
foreach (Action a in actions) a(); // 012
由于 loopScopedi 对于每一次迭代都是新创建的,因此每一个闭包都将捕获不同的变量。

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

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

局部方法和 Lambda 表达式的相应功能是重叠的,而局部方法拥有以下三个优势:
局部方法更加高效,因为它不需要间接使用委托(委托会消耗更多的CPU时钟周期并使用更多的内存),而且当它们访问局部变量的时候不需要编译器像委托那样将捕获的变量放到一个隐藏的类中去。

但是,在许多情况下仍然需要使用委托,尤其是当需要调用高阶函数(即使用委托作为参数的方法)的时候:
public void Foo(Func<int, bool> predicate) { ... }
在这种情况下,就不得不使用委托了。特别是针对这种情况,Lambda 表达式通常会显得更加简洁和清晰。

相关文章