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 写法会影响性能。能读懂这背后的机制,写出来的代码就会更稳。