【问题标题】:Captured variable in a loop in C#在 C# 的循环中捕获的变量
【发布时间】:2008-11-07 07:26:42
【问题描述】:

我遇到了一个关于 C# 的有趣问题。我有如下代码。

List<Func<int>> actions = new List<Func<int>>();

int variable = 0;
while (variable < 5)
{
    actions.Add(() => variable * 2);
    ++ variable;
}

foreach (var act in actions)
{
    Console.WriteLine(act.Invoke());
}

我希望它输出 0、2、4、6、8。然而,它实际上输出了五个 10。

这似乎是由于所有操作都引用了一个捕获的变量。因此,当它们被调用时,它们都有相同的输出。

有没有办法绕过这个限制,让每个动作实例都有自己的捕获变量?

【问题讨论】:

  • 另见 Eric Lippert 的博客系列:Closing over the Loop Variable Considered Harmful
  • 此外,他们正在更改 C# 5 以在 foreach 中按预期工作。 (重大变化)
  • @Neal: 虽然这个例子在 C# 5 中仍然不能正常工作,因为它仍然输出五个 10s
  • 它验证了它在 C# 6.0 (VS 2015) 上直到今天输出了五个 10。我怀疑闭包变量的这种行为是改变的候选者。 Captured variables are always evaluated when the delegate is actually invoked, not when the variables were captured.

标签: c# closures captured-variable


【解决方案1】:

是的 - 在循环内获取变量的副本:

while (variable < 5)
{
    int copy = variable;
    actions.Add(() => copy * 2);
    ++ variable;
}

您可以将其想象为 C# 编译器每次遇到变量声明时都会创建一个“新”局部变量。事实上,它会创建适当的新闭包对象,如果您在多个范围内引用变量,它会变得复杂(在实现方面),但它可以工作:)

请注意,此问题更常见的情况是使用forforeach

for (int i=0; i < 10; i++) // Just one variable
foreach (string x in foo) // And again, despite how it reads out loud

有关更多详细信息,请参阅 C# 3.0 规范的第 7.14.4.2 节,我的 article on closures 也有更多示例。

请注意,从 C# 5 及更高版本开始(即使指定了早期版本的 C#),foreach 的行为发生了变化,因此您不再需要制作本地副本。详情请见this answer

【讨论】:

  • 乔恩的书中也有一个非常好的章节(不要谦虚,乔恩!)
  • 如果我让其他人插入它看起来会更好;)(我承认我确实倾向于投票推荐它的答案。)
  • 一如既往,我们将不胜感激 skeet@pobox.com 的反馈 :)
  • 对于 C# 5.0 的行为不同(更合理),请参阅 Jon Skeet 的更新答案 - stackoverflow.com/questions/16264289/…
  • @Florimond:这不是闭包在 C# 中的工作方式。它们捕获变量,而不是。 (无论循环如何,这都是正确的,并且可以使用捕获变量的 lambda 轻松演示,并且在执行时只打印当前值。)
【解决方案2】:

我相信您正在经历的是被称为 Closure http://en.wikipedia.org/wiki/Closure_(computer_science) 的事情。您的 lamba 引用了一个变量,该变量的范围在函数本身之外。在调用它之前,不会解释您的 Lamba,一旦调用它,它将获得变量在执行时的值。

【讨论】:

    【解决方案3】:

    在幕后,编译器正在为您的方法调用生成一个代表闭包的类。它为循环的每次迭代使用闭包类的单个实例。代码看起来像这样,这样更容易看出错误发生的原因:

    void Main()
    {
        List<Func<int>> actions = new List<Func<int>>();
    
        int variable = 0;
    
        var closure = new CompilerGeneratedClosure();
    
        Func<int> anonymousMethodAction = null;
    
        while (closure.variable < 5)
        {
            if(anonymousMethodAction == null)
                anonymousMethodAction = new Func<int>(closure.YourAnonymousMethod);
    
            //we're re-adding the same function 
            actions.Add(anonymousMethodAction);
    
            ++closure.variable;
        }
    
        foreach (var act in actions)
        {
            Console.WriteLine(act.Invoke());
        }
    }
    
    class CompilerGeneratedClosure
    {
        public int variable;
    
        public int YourAnonymousMethod()
        {
            return this.variable * 2;
        }
    }
    

    这实际上不是您示例中的编译代码,但我检查了我自己的代码,这看起来与编译器实际生成的非常相似。

    【讨论】:

      【解决方案4】:

      解决此问题的方法是将您需要的值存储在代理变量中,然后捕获该变量。

      I.E.

      while( variable < 5 )
      {
          int copy = variable;
          actions.Add( () => copy * 2 );
          ++variable;
      }
      

      【讨论】:

      • 查看我编辑的答案中的解释。我现在正在查找规范的相关部分。
      • 哈哈,乔恩,我其实刚看了你的文章:csharpindepth.com/Articles/Chapter5/Closures.aspx你做得很好我的朋友。
      • @tjlevine:非常感谢。我将在我的答案中添加对此的引用。我忘记了!
      • 另外,Jon,我很想了解您对各种 Java 7 闭包提案的看法。我看到你提到你想写一个,但我没有看到它。
      • @tjlevine: 好的,我保证会在年底前写完 :)
      【解决方案5】:

      这与循环无关。

      触发此行为是因为您使用了 lambda 表达式 () =&gt; variable * 2,其中外部范围 variable 实际上并未在 lambda 的内部范围中定义。

      Lambda 表达式(在 C#3+ 中,以及在 C#2 中的匿名方法)仍然创建实际方法。将变量传递给这些方法会遇到一些难题(按值传递?按引用传递?C# 通过引用进行 - 但这会引发另一个问题,即引用可能比实际变量的寿命更长)。 C# 解决所有这些困境的方法是创建一个新的辅助类(“闭包”),其中的字段对应于 lambda 表达式中使用的局部变量,方法对应于实际的 lambda 方法。代码中对 variable 的任何更改实际上都会转换为 ClosureClass.variable 中的更改

      因此,您的 while 循环会不断更新 ClosureClass.variable 直到达到 10,然后您的 for 循环执行操作,这些操作都在同一个 ClosureClass.variable 上运行。

      要获得预期的结果,您需要在循环变量和被关闭的变量之间创建一个分隔符。您可以通过引入另一个变量来做到这一点,即:

      List<Func<int>> actions = new List<Func<int>>();
      int variable = 0;
      while (variable < 5)
      {
          var t = variable; // now t will be closured (i.e. replaced by a field in the new class)
          actions.Add(() => t * 2);
          ++variable; // changing variable won't affect the closured variable t
      }
      foreach (var act in actions)
      {
          Console.WriteLine(act.Invoke());
      }
      

      您也可以将闭包移动到另一个方法来创建这种分隔:

      List<Func<int>> actions = new List<Func<int>>();
      
      int variable = 0;
      while (variable < 5)
      {
          actions.Add(Mult(variable));
          ++variable;
      }
      
      foreach (var act in actions)
      {
          Console.WriteLine(act.Invoke());
      }
      

      您可以将 Mult 实现为 lambda 表达式(隐式闭包)

      static Func<int> Mult(int i)
      {
          return () => i * 2;
      }
      

      或使用实际的帮助类:

      public class Helper
      {
          public int _i;
          public Helper(int i)
          {
              _i = i;
          }
          public int Method()
          {
              return _i * 2;
          }
      }
      
      static Func<int> Mult(int i)
      {
          Helper help = new Helper(i);
          return help.Method;
      }
      

      无论如何,“闭包”不是与循环相关的概念,而是与匿名方法/使用局部作用域变量的 lambda 表达式有关 - 尽管一些不谨慎的循环使用表明了闭包陷阱。

      【讨论】:

        【解决方案6】:

        是的,您需要在循环内限定 variable 并以这种方式将其传递给 lambda:

        List<Func<int>> actions = new List<Func<int>>();
        
        int variable = 0;
        while (variable < 5)
        {
            int variable1 = variable;
            actions.Add(() => variable1 * 2);
            ++variable;
        }
        
        foreach (var act in actions)
        {
            Console.WriteLine(act.Invoke());
        }
        
        Console.ReadLine();
        

        【讨论】:

          【解决方案7】:

          同样的情况也发生在多线程中(C#,.NET4.0]。

          见以下代码:

          目的是按顺序打印1,2,3,4,5。

          for (int counter = 1; counter <= 5; counter++)
          {
              new Thread (() => Console.Write (counter)).Start();
          }
          

          输出很有趣! (可能像 21334...)

          唯一的解决方案是使用局部变量。

          for (int counter = 1; counter <= 5; counter++)
          {
              int localVar= counter;
              new Thread (() => Console.Write (localVar)).Start();
          }
          

          【讨论】:

          • 这似乎对我没有帮助。仍然不确定。
          • 这与为什么需要“重新声明”要捕获的变量无关。这仅与第二个线程可能在操作系统级别上更快地“准备好工作”,或者执行代码被提前安排这一事实有关。您的第二个示例也不会每次都输出 1-5 。它可能在 Debug 中,因为速度要慢很多,但它绝对不会在 Release 版本中。
          【解决方案8】:
          for (int n=0; n < 10; n++) //forloop syntax
          foreach (string item in foo) foreach syntax
          

          【讨论】:

          • 在代码示例中添加一些解释行没有害处;)
          • 好的@MaksymRudenko
          【解决方案9】:

          称为闭包问题, 只需使用一个复制变量,就完成了。

          List<Func<int>> actions = new List<Func<int>>();
          
          int variable = 0;
          while (variable < 5)
          {
              int i = variable;
              actions.Add(() => i * 2);
              ++ variable;
          }
          
          foreach (var act in actions)
          {
              Console.WriteLine(act.Invoke());
          }
          

          【讨论】:

          • 你的答案与上面有人提供的答案有什么不同?
          【解决方案10】:

          由于这里没有人直接引用ECMA-334

          10.4.4.10 For 语句

          对表单的 for 语句进行明确的赋值检查:

          for (for-initializer; for-condition; for-iterator) embedded-statement
          

          就像语句被写一样完成:

          {
              for-initializer;
              while (for-condition) {
                  embedded-statement;
              LLoop: for-iterator;
              }
          }
          

          进一步在规范中,

          12.16.6.3 局部变量的实例化

          当执行进入变量的范围时,局部变量被认为是实例化的。

          [示例:例如,当调用以下方法时,局部变量x被实例化和初始化3次——循环的每次迭代一次。

          static void F() {
            for (int i = 0; i < 3; i++) {
              int x = i * 2 + 1;
              ...
            }
          }
          

          但是,将 x 的声明移到循环外会导致 x 的单个实例化:

          static void F() {
            int x;
            for (int i = 0; i < 3; i++) {
              x = i * 2 + 1;
              ...
            }
          }
          

          结束示例]

          如果未捕获,则无法准确地观察局部变量被实例化的频率——因为实例化的生命周期是不相交的,因此每个实例化可以简单地使用相同的存储位置。然而,当匿名函数捕获一个局部变量时,实例化的效果就变得很明显了。

          [例子:例子

          using System;
          
          delegate void D();
          
          class Test{
            static D[] F() {
              D[] result = new D[3];
              for (int i = 0; i < 3; i++) {
                int x = i * 2 + 1;
                result[i] = () => { Console.WriteLine(x); };
              }
            return result;
            }
            static void Main() {
              foreach (D d in F()) d();
            }
          }
          

          产生输出:

          1
          3
          5
          

          但是,当x 的声明移出循环时:

          static D[] F() {
            D[] result = new D[3];
            int x;
            for (int i = 0; i < 3; i++) {
              x = i * 2 + 1;
              result[i] = () => { Console.WriteLine(x); };
            }
            return result;
          }
          

          输出是:

          5
          5
          5
          

          请注意,允许(但不是必需)编译器将三个实例优化为单个委托实例(第 11.7.2 节)。

          如果 for 循环声明了一个迭代变量,则该变量本身被认为是在循环之外声明的。 [示例:因此,如果更改示例以捕获迭代变量本身:

          static D[] F() {
            D[] result = new D[3];
            for (int i = 0; i < 3; i++) {
              result[i] = () => { Console.WriteLine(i); };
            }
            return result;
          }
          

          只捕获迭代变量的一个实例,并产生输出:

          3
          3
          3
          

          结束示例]

          哦,是的,我想应该提到,在 C++ 中不会出现这个问题,因为您可以选择是通过值还是通过引用来捕获变量(参见:Lambda capture)。

          【讨论】:

            猜你喜欢
            • 1970-01-01
            相关资源
            最近更新 更多