在C#中的循环中捕获的变量


216

我遇到了一个有关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。但是,实际上它输出5个10s。

看来这是由于所有操作都引用了一个捕获的变量。结果,当它们被调用时,它们都具有相同的输出。

有没有办法解决这个限制,使每个动作实例都有自己的捕获变量?


15
另请参见Eric Lippert的Blog系列,主题:关闭有害的循环变量
Brian

10
而且,他们正在更改C#5以使其在foreach中按预期工作。(重大更改)
Neal Tibrewala 2012年


3
@Neal:尽管此示例在C#5中仍然无法正常工作,因为它仍然输出5个10s
Ian Oakes 2014年

6
它验证了在C#6.0(VS 2015)上到目前为止可以输出5个10s。我怀疑闭包变量的这种行为是否适合进行更改。Captured variables are always evaluated when the delegate is actually invoked, not when the variables were captured
RBT

Answers:


196

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

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节,我有关闭包的文章也提供了更多示例。

请注意,从C#5编译器及更高版本开始(即使在指定C#的早期版本时),其行为也已foreach更改,因此您不再需要进行本地复制。有关更多详细信息,请参见此答案


32
乔恩(Jon)的书对此也有很好的论述(乔恩,别谦虚!)
马克·格拉维

35
如果我让其他人插上它,看起来会更好一些;)(我承认我确实倾向于投票推荐它的答案。)
Jon Skeet

2
与往常一样,请将您的反馈意见发送给skeet@pobox.com :)
Jon Skeet,

7
对于C#5.0的行为是不同的(更合理)看到乔恩斯基特新的答案- stackoverflow.com/questions/16264289/...
阿列克谢Levenkov

1
@Florimond:那不是C#中闭包的工作方式。它们捕获变量,而不是。(不管循环如何,这都是正确的,并且可以通过捕获变量的lambda轻松演示,并在执行时仅打印当前值。)
Jon Skeet


11

在后台,编译器正在生成一个类,该类表示您的方法调用的结束。对于循环的每次迭代,它都使用闭包类的单个实例。该代码看起来像这样,这使得更容易了解错误发生的原因:

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;
    }
}

这实际上不是示例中的编译代码,但是我已经检查了自己的代码,这看起来很像编译器实际生成的代码。


8

解决方法是将所需的值存储在代理变量中,并捕获该变量。

IE浏览器

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

请参阅我编辑后的答案中的说明。我现在正在寻找规格的相关部分。
乔恩·斯基特

哈哈·乔恩,我实际上只是读了您的文章:csharpindepth.com/Articles/Chapter5/Closures.aspx 您的朋友做得很好。
tjlevine

@tjlevine:非常感谢。我将在答案中添加对此的引用。我忘记了!
乔恩·斯基特

另外,乔恩,我很想阅读您对各种Java 7闭包建议的想法。我见过你提到要写一个,但是我还没有看。
tjlevine

1
@tjlevine:好的,我保证在今年年底之前将它写出来:)
Jon Skeet

6

这与循环无关。

触发此行为的原因是,您使用的Lambda表达式() => variable * 2的外部作用域variable实际上未在Lambda的内部作用域中定义。

Lambda表达式(在C#3 +中以及在C#2中为匿名方法)仍会创建实际方法。将变量传递给这些方法会遇到一些难题(按值传递吗?按引用传递?C#随引用一起使用-但这带来了另一个问题,即引用可能会超出实际变量的寿命)。C#解决所有这些难题的方法是创建一个新的帮助器类(“ closure”),该类具有与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表达式相关的信息-尽管对循环的某些谨慎使用显示了闭包陷阱。


5

是的,您需要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();

5

在多线程(C#、. NET 4.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();
}

这似乎对我没有帮助。仍不确定。
Mladen Mihajlovic,2014年

0

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

10.4.4.10对于语句

确定赋值检查形式的形式:

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)。


-1

这被称为闭包问题,只需使用一个复制变量即可。

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());
}

4
您的答案与上方某人提供的答案有何不同?
Thangadurai
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.