闭包如何在幕后工作? (C#)

时间:2009-12-18 14:47:40

标签: c# .net closures

我觉得我对闭包有很好的理解,如何使用它们,以及它们何时有用。但我不明白的是他们实际上是如何在幕后的幕后工作的。一些示例代码:

public Action Counter()
{
    int count = 0;
    Action counter = () =>
    {
        count++;
    };

    return counter;
}

通常情况下,如果闭包没有捕获{count},它的生命周期将限定为Counter()方法,并且在它完成之后它将消除Counter()的其余堆栈分配。什么时候关闭会发生什么?这个Counter()调用的整个堆栈分配是否存在?它会将{count}复制到堆中吗?它是否从未实际分配到堆栈中,但是被编译器识别为关闭,因此总是存在于堆上?

对于这个特殊问题,我主要关注它在C#中是如何工作的,但不会反对与支持闭包的其他语言进行比较。

4 个答案:

答案 0 :(得分:46)

你的第三个猜测是正确的。编译器将生成如下代码:

private class Locals
{
  public int count;
  public void Anonymous()
  {
    this.count++;
  }
}

public Action Counter()
{
  Locals locals = new Locals();
  locals.count = 0;
  Action counter = new Action(locals.Anonymous);
  return counter;
}

有意义吗?

另外,您要求进行比较。 VB和JScript都以完全相同的方式创建闭包。

答案 1 :(得分:33)

编译器(与运行时相对)创建另一个类/类型。您的闭包函数以及您关闭/提升/捕获的任何变量将作为该类的成员在您的代码中重写。 .Net中的闭包实现为此隐藏类的一个实例。

这意味着你的count变量完全是一个不同类的成员,并且该类的生命周期与任何其他clr对象一样。在它不再生根之前,它不符合垃圾收集的条件。这意味着只要你有一个可调用的方法引用它就不会去任何地方。

答案 2 :(得分:0)

谢谢@HenkHolterman。由于已经由Eric解释过,我添加了链接只是为了显示编译器为闭包生成的实际类。我想补充一点,C#编译器创建显示类可能会导致内存泄漏。例如,在函数内部有一个由lambda表达式捕获的int变量,另一个局部变量只保存对大字节数组的引用。编译器将创建一个显示类实例,它将保存对两个变量的引用,即int和byte数组。但是,在引用lambda之前,字节数组不会被垃圾收集。

答案 3 :(得分:0)

Eric Lippert的回答确实很明显。然而,建立一个堆栈帧和捕获如何工作的图片会很好。要做到这一点,有助于查看稍微复杂的示例。

以下是捕获代码:

public class Scorekeeper { 
   int swish = 7; 

   public Action Counter(int start)
   {
      int count = 0;
      Action counter = () => { count += start + swish; }
      return counter;
   }
}

以下是我认为相同的内容(如果我们幸运的话,Eric Lippert会评论这是否真的正确):

private class Locals
{
  public Locals( Scorekeeper sk, int st)
  { 
      this.scorekeeper = sk;
      this.start = st;
  } 

  private Scorekeeper scorekeeper;
  private int start;

  public int count;

  public void Anonymous()
  {
    this.count += start + scorekeeper.swish;
  }
}

public class Scorekeeper {
    int swish = 7;

    public Action Counter(int start)
    {
      Locals locals = new Locals(this, start);
      locals.count = 0;
      Action counter = new Action(locals.Anonymous);
      return counter;
    }
}

重点是本地类替换整个堆栈帧,并在每次调用Counter方法时进行相应的初始化。通常,堆栈帧包括对“this”的引用,加上方法参数以及局部变量。 (进入控制块时,堆栈帧也会有效扩展。)

因此,我们没有一个对应于捕获的上下文的对象,而是每个捕获的堆栈帧实际上有一个对象。

基于此,我们可以使用以下心理模型:堆栈帧保存在堆上(而不是堆栈上),而堆栈本身只包含指向堆上堆栈帧的指针。 Lambda方法包含指向堆栈帧的指针。这是使用托管内存完成的,因此框架会粘在堆上,直到不再需要它为止。

显然,当需要堆对象支持lambda闭包时,编译器只能使用堆来实现它。

我喜欢这个模型的是它提供了“收益率回报”的综合图片。我们可以想到一个迭代器方法(使用yield return),好像它的堆栈帧是在堆上创建的,而引用指针存储在调用者的局部变量中,以便在迭代期间使用。