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());以及禁止捕获变量的staticLambda。 - C# 10:“自然类型”,所以
var square = (int x) => x * x;会推断出Func<int, int>。在此之前,var f = x => x * 2;会失败;即使在 C# 10 中,不写参数类型也仍然会失败,报 CS8917The 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;。