Menu

C# Lambda 表达式:语法、Func 与 Action、闭包与 LINQ

C# 中 Lambda 表达式的工作方式:=> 语法的各种写法,把 Lambda 存进 Func 和 Action,把它传给方法和 LINQ,语句 Lambda,捕获变量的闭包,以及 for 循环里会出现、而 foreach 没有的变量捕获 bug。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

Lambda 表达式是用 => 运算符(读作“goes to”)内联写出的小型匿名函数。参数写在左边,结果写在右边:

输出:

60.0
12
ORDER SHIPPED!
96.0, 180.0

Lambda 本身没有类型。它会变成上下文所需的那种委托(指向一个函数的对象):这里是 Func<decimal, decimal>,用于 List.FindAll 时是 Predicate<T>,用于事件时是 EventHandler。Func 委托有返回值,Action 委托没有;Func 与 Action 页面列出了完整的委托家族。

语法形式

x => x * x                         // one parameter: parentheses optional
(x, y) => x + y                    // several parameters
() => DateTime.Now.Year            // no parameters
(int x, string s) => s.Length > x  // explicit parameter types
order =>                           // statement lambda: a block with return
{
    decimal subtotal = order.Qty * order.Price;
    return subtotal > 100m ? subtotal * 0.9m : subtotal;
}

表达式 Lambda 在 => 后面只有一个表达式,它的值就是结果。语句 Lambda 是一个用花括号括起来的代码块,里面可以写任意语句;如果委托有返回值,那么每条路径都必须 return 一个值(否则会报 CS1643,Not all code paths return a value in lambda expression)。

较新的版本又加入了更多写法,全部是可选的:

  • C# 9:弃元参数,多个未使用的参数都可以写成 _((_, _) => Save());以及禁止捕获变量的 static Lambda。
  • C# 10:“自然类型”,所以 var square = (int x) => x * x; 会推断出 Func<int, int>。在此之前,var f = x => x * 2; 会失败;即使在 C# 10 中,不写参数类型也仍然会失败,报 CS8917 The delegate type could not be inferred。
  • C# 12:参数默认值,(int x, int y = 1) => x + y。

把 Lambda 传给方法

任何接受委托参数的方法都可以接受 Lambda。这样你传递的就不只是数据,还有行为:

输出:

report.pdf, scan.pdf
photo.jpg, cv.txt, scan.pdf
cv.txt, scan.pdf, photo.jpg, report.pdf

Filter 并不知道“保留”是什么意思;由调用方用 Lambda 来决定。List.Sort 也是同样的道理:这个 Lambda 是一个返回负数、零或正数的比较函数,Sort 需要调用多少次就调用多少次。

LINQ 中的 Lambda

大多数 C# 代码都是在 LINQ 中与 Lambda 打交道的。每个查询运算符都接受一个 Lambda:Where 接受条件,Select 接受投影,OrderBy 接受排序键,Sum 接受要累加的值:

输出:

Ben: 250
Ana: 60
Cy: 45
Ana spent 80
Ben spent 250
Cy spent 45

这些 Lambda 并不会在书写的地方运行。LINQ 会把它们存起来,等查询被 foreach、ToList() 或 Sum() 枚举时才调用,这叫作延迟执行。LINQ 页面介绍了这些运算符本身。

闭包:捕获的变量

Lambda 可以使用它所在方法的局部变量和参数。它捕获的是变量,而不是变量的当前值,并且只要 Lambda 还在,这个变量就一直存在:

输出:

1
2
3
1
50.0

每次调用 MakeCounter 都会创建一个新的 count,所以两个计数器互不影响。折扣 Lambda 是在运行时读取 rate 的,而不是在书写时,所以它用的是 50%。编译器的实现方式是把被捕获的变量移到一个隐藏的类中,由方法和 Lambda 共享。

循环变量捕获的陷阱

捕获循环变量是闭包最容易坑人的地方。for 循环在整个循环中只有一个变量,所以在循环里创建的每个 Lambda 都共享这个变量:

输出:

3 3 3 <- for loop, shared i
0 1 2 <- for loop, copied
0 1 2 <- foreach

第一个循环中的三个 Lambda 都是在循环结束后才运行的,那时 i 已经是 3 了。把 i 复制到循环体内声明的变量中,每个 Lambda 就有了自己的变量。foreach 自己就能处理好:从 C# 5 开始,它的迭代变量在每一轮都是一个新变量。C# 5 之前它也有同样的 bug,所以网上一些较早的回答仍然建议在 foreach 中也做复制。

同样的陷阱也适用于任何稍后才运行的东西:事件处理程序、计时器、Task.Run,以及在循环中构建、循环结束后才枚举的 LINQ 查询。

Lambda、匿名方法与本地函数

Lambda 取代了 C# 2 的匿名方法语法,不过后者仍然可以编译:

Func<int, bool> isEven = delegate (int n) { return n % 2 == 0; };   // C# 2 anonymous method
Func<int, bool> isEven2 = n => n % 2 == 0;                          // lambda

对于只在一个方法内部使用的辅助逻辑,C# 7.0 还提供了本地函数,它有名字,可以递归,而且除非你把它转换成委托,否则不会分配委托对象。当你要把行为传给别的东西(LINQ、Sort、事件)时用 Lambda;当你要自己调用它时,用本地函数或私有方法。

在 Entity Framework 这类 LINQ 提供程序中,还有一个区别:赋值给 Expression<Func<T, bool>> 的 Lambda 根本不会被编译成代码,而是编译成一个描述代码的数据结构,由提供程序把它翻译成 SQL。两者语法完全相同;你得到哪一种,取决于所调用方法的参数类型。

常见问题

C# 中的 Lambda 表达式是什么?

用 => 运算符写成的匿名函数:x => x * 2 接收 x 并返回 x * 2。Lambda 本身没有名字;它会被转换成某个委托类型,例如 Func<int, int> 或 Action<string>,可以存进变量、传给方法,也可以从方法中返回。

多个参数或没有参数的 Lambda 怎么写?

把参数放在括号里:两个参数写 (a, b) => a + b,没有参数写 () => DateTime.Now。只有单个参数时才能省略括号(x => x + 1)。你也可以显式写出类型:(int a, int b) => a + b。

C# Lambda 中的闭包是什么?

当 Lambda 用到外层方法的局部变量时,它捕获的是这个变量本身,而不是变量值的快照。这个变量的生命周期和 Lambda 一样长,之后无论哪一方修改了它,另一方都能看到变化。计数器和回调正是靠这一点工作的,经典的 for 循环捕获 bug 也是由它引起的。

为什么 for 循环里的 Lambda 都看到同一个值?

for 循环在整个循环中只有一个循环变量,所以在循环里创建的每个 Lambda 捕获的都是同一个 i,等它们稍后运行时,看到的都是它的最终值。在循环体内把它复制到一个局部变量(int copy = i;),然后捕获这个副本。从 C# 5 开始,foreach 没有这个问题,因为它每次迭代都会创建一个新变量。

为什么 var f = x => x * 2 无法编译?

编译器无法判断你想要哪种委托类型,也不知道 x 是什么类型,所以会报 CS8917 The delegate type could not be inferred(C# 10 之前是 CS0815)。请声明类型(Func<int, int> f = x => x * 2;),或者在 C# 10 及以后的版本中给参数写上类型:var f = (int x) => x * 2;。

Coddy 编程语言插图

和 Coddy 一起学编程

开始