C# lambda 表达式是怎么实现的:为什么捕获变量会这样工作
Table of Contents
C# lambda 表达式是怎么实现的:为什么捕获变量会这样工作
很多人第一次深入理解 C# lambda,不是因为它的语法,而是因为它的“行为”看起来有点反直觉。为什么一个局部变量在方法返回之后还能被委托访问?为什么循环里的 lambda 有时会捕获到不同结果?为什么看起来只是一个匿名函数,背后却会影响对象分配和执行结果?
这篇文章主要回答的就是这些问题。核心不在于记住语法,而在于理解编译器为了支持 lambda 和变量捕获,实际帮我们做了什么。
要通过 lambda 表达式来创建委托实例,编译器需要把 lambda 表达式中的代码转换成一个方法,之后在执行期创建委托就如同已经定义了方法组一样。下面主要编译器执行的转换过程。这样描述让人感觉编译器需要把一部分源码转译成另一些不包含 lambda 表达式的源码,但实际上编译器不会这么做,它可以直接生成相应的 IL 代码。
在 lambda 表达式中,可以像使用普通方法那样任意使用变量。这些变量可以是静态字段、实例字段(如果在实例方法中编写 lambda 表达式)、this 变量、方法参数或者局部变量。这些都输入捕获变量的范畴,因为它们都定义在 lambda 表达式所在的直接上下文之外。那些 lambda 表达式自带的参数或者定义在 lambda 表达式内部的局部变量,则不属于捕获变量。代码清单(如下) 展示了 lambda 表达式捕获的各种变量,之后会讲解编译器是如何处理这部分代码的。
代码清单 lambda 表达式捕获变量
public class CapturedVariablesDemo
{
private string instanceField = "instance field";
public Action<string> CreateAction(string methodParameter)
{
string methodLocal = "method local";
string uncaptured = "uncaptured local";
Action<string> action = lambdaParameter =>
{
string lambdaLocal = "lambda local";
Console.WriteLine($"Instance field:{instanceField}");
Console.WriteLine($"Method parameter:{methodParameter}");
Console.WriteLine($"Method local:{methodLocal}");
Console.WriteLine($"lambda parameter:{lambdaParameter}");
Console.WriteLine($"lambda local:{lambdaLocal}");
};
methodLocal = "modified method local";
return action;
}
}
var demo = new CapturedVariablesDemo();
Action<string> action = demo.CreateAction("method arguwent");
action("lambda argument");
其中涉及很多变量:
- instanceField 是 CapturedVariablesDemo 类的一个实例字段,为 lambda 表示式所捕获;
- methodParameter 是 CreateAction 方法的一个参数,为 lambda 表达式所捕获;
- methodLocal 是 CreateAction 方法中的一个局部变量,为 lambda 表达式所捕获;
- uncaptured 是 CreateAction 方法中的一个局部变量,因为没有被 lambda 表达式使用,所以不属于捕获变量;
- lambdaParameter 是 lambda 表达式自己的参数,不属于捕获变量;
- lambdaLocal 是 Lambda 表达式内部的局部变量,不属于捕获变量。
需要重点关注的是,这些 lambda 表达式捕获的是这些变量本身,而不是委托创建时这些变量的值。如果在委托定义后到委托调用前这一期间修改任何一个捕获变量,那么这些修改都会在输出结果中体现出来。同样,lambda 表达式自己也能够修改这些捕获的变量的值,那么编译器如何保证这些变量在委托调用时依然可用呢?
1、通过生成类来实现捕获变量
考虑如下 3 种普通情形。
- 如果没有捕获任何变量,那么编译器可以创建一个静态方法,不需要额外的上下文;
- 如果仅捕获了实例字段,那么编译器可以创建一个实例方法。在这种情况下,捕获 1 个实例字段和捕获 100 个没有什么差别,只需要一个 this 便可都访问到。
- 如果有局部变量或参数被捕获,编译器会创建一个私有的嵌套类来保存上下文信息,然后在当前类中创建一个实例方法来容纳原 lambda 表达式的内容。原先包含 lambda 表达式的方法会被修改为使用嵌套类来访问捕获变量。
具体实现细节因编译器而异
我们可能会遇到上述流程的不同变体。例如对于没有捕获变量的 lambda 表达式,编译器可能会创建一个包含一个实例方法的嵌套类,而不是创建一个静态方法。委托的执行效率会因创建方式的不同而略有差异。这里只描述编译器为访问捕获变量锁做的那些必要、基本的工作,其复杂度可能根据实际需要而增加。
显然,最后一种方法最为复杂,因此需要重点关注。先看如下代码清单。下面是创建 lambda 表达式的方法(其中省略了类声明部分):
public Action<string> CreateAction(string methodParameter)
{
string methodLocal = "method local";
string uncaptured = "uncaptured local";
Action<string> action = lambdaParameter =>
{
string lambdaLocal = "lambda local";
Console.WriteLine($"Instance field:{instanceField}");
Console.WriteLine($"Method parameter:{methodParameter}");
Console.WriteLine($"Method local:{methodLocal}");
Console.WriteLine($"lambda parameter:{lambdaParameter}");
Console.WriteLine($"lambda local:{lambdaLocal}");
};
methodLocal = "modified method local";
return action;
}
如前所述,编译器会创建一个私有的嵌套类来保存额外的上下文信息,然后在该类中创建一个实例方法用于容纳 lambda 表达式的代码。上下文信息被保存在嵌套类的实例变量中,在本例中就是:
- 一个 CaptureVariablesDemo 类实例的引用,用于之后访问 instanceField;
- 一个 string 变量来保存捕获的方法参数;
- 一个 string 变量来保存捕获的局部变量。
下面是嵌套类以及 CreateAction 方法使用该嵌套类的代码。
捕获变量的 lambda 表达式转译后的代码
private class LambdaContext
{
public CaptureVariablesDemo originalThis;
public string methodParameter;
public string methodLocal;
public void Method(string lambdaParameter)
{
string lambdaLocal = "lambda local";
Console.WriteLine($"Instance field:{originalThis.instanceField}");
Console.WriteLine($"Method parameter:{methodParameter}");
Console.WriteLine($"Method local:{methodLocal}");
Console.WriteLine($"lambda parameter:{lambdaParameter}");
Console.WriteLine($"lambda local:{lambdaLocal}");
}
}
public Action<string> CreateAction(string methodParameter)
{
LambdaContext context = new LambdaContext();
context.originalThis = this;
context.methodParameter = methodParameter;
context.methodLocal = "method local";
string uncaptured = "uncaptured local";
Action<string> action = context.Method;
context.methodLocal = "modified method local";
return action;
}
注意 CreateAction 方法末尾附近的 context.methodLocal 是如何被修改的。当委托最终被执行时,它能够知道该变量的修改情况。同样,如果委托自己修改了任何捕获的变量,那么每个委托的调用都会受到前一个调用的影响。再次强调:编译器捕获的是变量本身,而不是变量值的副本。
以上两个代码示例中为捕获变量仅创建了一个上下文。根据编程规范的要求,每个局部变量只能有一次实例化。下面丰富一下这个示例。
2、局部变量的多次实例化
简单起见,这次不捕获参数和实例字段,只捕获一个局部变量。请看如下代码清单:在 CreateActions 方法中创建了 action 的一个 list,然后依次执行这些 action ,其中每个 action 都会捕获 text 变量。
局部变量的多次实例化
static List<Action> CreateActions()
{
List<Action> actions = new List<Action>();
for (int i = 0; i < 5; i++)
{
string text = $"message {i}"; //在循环体内部声明局部变量
actions.Add(() => Console.WriteLine(text)); //在 lambda 表达式中捕获该变量
}
return actions;
}
List<Action> actions = CreateActions();
foreach (Action action in actions)
{
action();
}
在这段代码中, text 在循环中声明是非常关键的一点。每次声明 text 时,该变量就完成一次实例化,因此每个 lambda 表达式捕获的都是不同的变量实例,于是 5 个完全独立的 text 变量被分别捕获。虽然这段代码中变量初始化后没有任何修改操作,但实际上我们完全可以在循环内部或在 lambda 表达式内部修改该变量的值。无论修改哪个变量值,都不会影响其他变量。
编译器的做法是:每次初始化都创建一个不同的生成类型实例,因此代码中的 CreateAction 方法会转译成如下形式。
为每次初始化创建上下文实例
private class LambdaContent
{
public string text;
public void Method()
{
Console.WriteLine(text);
}
}
static List<Action> CreateActions()
{
List<Action> actions = new List<Action>();
for (int i = 0; i < 5; i++)
{
LambdaContent content = new LambdaContent(); //每次循环都创建一个新的上下文
content.text = $"message {i}";
actions.Add(content.Method); //使用上下文创建一个 action
}
return actions;
}
希望一直能够理解这段代码。我们是从 lambda 表达式的单一上下文,进阶到了循环的每次迭代都有一个新的上下文。接下来继续增加复杂度,把这两种情况混在一起。
3、多个作用域捕获变量
循环的每次迭代都要实例化一次变量,是因为变量作用域的缘故。一个方法内部可能存在多个作用域,每个作用域都可能包含局部变量的声明,而一个 lambda 表达式可以从多个作用域捕获变量,实例代码如下。这段代码创建了两个委托实例,每个委托分别捕获两个变量:它们捕获同一个 outerCounter 变量,又各自捕获一个 innerCounter 变量。委托的工作就是打印变量的当前值,并且执行加一操作。最后将每个委托各自执行两次,这样可以清楚地展现捕获变量之间的区别。
代码清单 从多个作用域捕获变量
static List<Action> CreateCountingActions()
{
List<Action> actions = new List<Action>();
int outerCounter = 0;
for (int i = 0; i < 2; i++)
{
int innerCounter = 0;
Action action = () =>
{
Console.WriteLine($"Outer:{outerCounter},Inner:{innerCounter}");
outerCounter++;
innerCounter++;
};
actions.Add(action);
}
return actions;
}
List<Action> actions = CreateCountingActions();
actions[0](); //每个委托调用两次
actions[0]();
actions[1]();
actions[1]();
上述代码输出结果为
Outer:0,Inner:0
Outer:1,Inner:1
Outer:2,Inner:0
Outer:3,Inner:1
前两行是第 1 个委托打印的结果,后两行是第 2 个委托打印的结果。如前所述, outerCounter 变量被两个委托共用,而 innerCounter 为两个委托分别所有。
每个委托都需要各自的上下文,但是各自的上下文还需要指向一个公共的上下文。编译器是如何处理这种情况的呢?答案是创建两个私有嵌套类。上述代码经过编译器处理后的结果如下。
从多个作用域捕获变量而创建多个类
// 外层作用域的上下文
private class OuterContext
{
public int outerCounter;
}
// 包含外层上下文引用的内层作用域上下文
private class InnerContext
{
public OuterContext outerContext;
public int innerCounter;
// 用于创建委托的方法
public void Method()
{
Console.WriteLine($"Outer:{outerContext.outerCounter},Inner:{innerCounter}");
outerContext.outerCounter++;
innerCounter++;
}
}
static List<Action> CreateCountingActions()
{
List<Action> actions = new List<Action>();
OuterContext outerContext = new OuterContext(); //创建一个外层上下文
outerContext.outerCounter = 0;
for (int i = 0; i < 2; i++)
{
//每次循环都创建一个内层上下文
InnerContext innerContext = new InnerContext();
innerContext.outerContext = outerContext;
innerContext.innerCounter = 0;
Action action = innerContext.Method;
actions.Add(action);
}
return actions;
}
大多数人很少需要查看这样的代码,但编译器生成代码的方式会对程序性能有不小的影响。如果在性能敏感的代码中使用 lambda 表达式,那么需要注意可能会因为变量捕获而创建过多对象,从而影响性能。
关于同一作用域下多个 lambda 表达式捕获不同的变量集合,或者在值类型方法中使用 lambda 表达式,还有很多示例可举。虽然我认为研究编译器生成的代码这件事很有趣,但恐怕有不少人不这么想。
实际开发里怎么理解 lambda 捕获
真正写业务代码时,大多数人不需要手动去“模拟编译器转译”,但理解这套机制依然很重要。因为只要代码里出现了变量捕获,你就需要意识到:lambda 并不只是把值复制进去,它经常是在延长变量的使用方式,甚至间接改变对象生命周期。
这会直接影响两件事:
- 代码行为是否符合预期,尤其是在循环、异步回调和延迟执行里。
- 是否引入额外分配和上下文对象,尤其是在性能敏感路径里。
总结
理解 C# lambda 的关键,不是把它当成一种“更短的函数写法”,而是理解它在捕获变量时,编译器实际上为你构造了额外的上下文。
一旦明白这一点,很多看似反直觉的现象都会变得合理:为什么变量在方法返回后还活着,为什么循环里每次迭代可能得到不同结果,为什么某些 lambda 写法会影响性能。能读懂这背后的机制,写出来的代码就会更稳。