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

283

我遇到了一个有趣的 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。

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

有没有一种方法可以解决这个限制,让每个动作实例都有自己的捕获变量?


16
请参阅Eric Lippert的博客系列:Closing over the Loop Variable Considered Harmful - Brian
11
另外,他们正在修改C# 5的代码,使其能够按照您在 foreach 循环中的预期工作。(破坏性变更) - Neal Tibrewala
4
@Neal:虽然这个例子在C# 5中仍然无法正常工作,因为它仍然输出了五个10。 - Ian Oakes
7
它验证了在C# 6.0(VS 2015)上至今输出了五个10。我怀疑闭包变量的这种行为是否需要更改。“捕获的变量总是在委托实际调用时评估,而不是在变量被捕获时评估”。 - RBT
2
Eric Lippert关于这个主题的博客系列现在在这里(https://ericlippert.com/2009/11/12/closing-over-the-loop-variable-considered-harmful-part-one/)和这里(https://ericlippert.com/2009/11/16/closing-over-the-loop-variable-considered-harmful-part-two/)。 - Luke Woodward
显示剩余4条评论
11个回答

263

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

while (variable < 5)
{
    int copy = variable;
    actions.Add(() => copy * 2);
    ++ variable;
}
你可以这样理解,当C#编译器遇到变量声明时,它会创建一个“新的”本地变量。实际上它将创建相应的新闭包对象,如果你在多个作用域中引用变量,则会变得复杂(在实现方面),但它能正常工作 :)
请注意,更常见的情况是使用`for`或`foreach`:
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的行为已经改变,因此您不再需要创建局部副本。有关详细信息,请参见这个答案


40
乔恩的书也有一章非常好的内容(不要谦虚,乔恩!) - Marc Gravell
47
如果让其他人来推荐它,效果会更好 ;) (不过我承认我倾向于投赞成票赞同推荐它的答案。) - Jon Skeet
2
一如既往,欢迎发送反馈至skeet@pobox.com :) - Jon Skeet
7
对于 C# 5.0,行为是不同的(更合理),请参考 Jon Skeet 的新回答 - https://dev59.com/JWQo5IYBdhLWcg3wKsxr - Alexei Levenkov
1
然而,由于int是值类型,我期望在声明闭包时该值会被复制。 - Florimond
2
@Florimond:这不是C#中闭包的工作方式。它们捕获变量,而不是。(无论循环如何,都是正确的,并且可以通过捕获一个变量并在每次执行时打印当前值的lambda轻松地进行演示。) - Jon Skeet

27

我认为你所经历的是一种称为闭包的东西 http://en.wikipedia.org/wiki/Closure_(computer_science)。你的lambda函数引用了一个在函数外部作用域的变量。只有在调用lambda函数时才会对其进行解释,此时它将获得变量在执行时的值。


16

在幕后,编译器正在生成一个表示您方法调用的闭包的类。它为循环的每次迭代使用该闭包类的单个实例。以下代码大致如此,可以更容易地看出为什么会出现 bug:

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

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


14

这与循环无关。

此行为是由于在 lambda 表达式 () => variable * 2 中使用了外部作用域中未在 lambda 的内部作用域中定义的变量 variable 所触发的。

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表达式使用本地范围变量有关-尽管一些不谨慎使用循环的情况会展示闭包陷阱。

11

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

例如:

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

请查看我编辑后的答案中的解释。我现在正在寻找规范中相关的部分。 - Jon Skeet
哈哈,Jon,我刚刚读了你的文章:http://csharpindepth.com/Articles/Chapter5/Closures.aspx你做得很好,我的朋友。 - Tyler Levine
@tjlevine:非常感谢。我会在我的答案中添加一个参考。我忘记了它! - Jon Skeet
另外,Jon,我很想听听你对各种Java 7闭包提案的看法。我看过你提到过想写一个,但我还没有看到它。 - Tyler Levine
1
@tjlevine:好的,我保证会在年底之前尝试写出来 :) - Jon Skeet

7

是的,你需要在循环内部限定变量并将其传递给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();

6

在多线程(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
这与你需要“重新声明”要捕获的变量无关。这仅仅是因为第二个线程在操作系统级别上可能更快地“准备好工作”,或者执行代码被提前调度。你的第二个示例也不会每次都输出1-5。在Debug中可能会出现,因为它比较慢,但在发布版本中肯定不会出现。 - Dennis19901

1
正如其他人所说,这与循环无关。这是C#中匿名函数体内的变量捕获机制的效果。 当你定义lambda作为你的例子时;
actions.Add(() => variable * 2);

编译器为lambda函数生成一个名为<>c__DisplayClass0_0的容器类。
在生成的类(容器)内部,它生成一个名为variable的字段,该字段捕获了同名的变量,并且方法b__0()包含了lambda的主体内容。
[CompilerGenerated]
private sealed class <>c__DisplayClass0_0
{
public int variable;

internal int <Main>b__0()
{
    return variable * 2;
}
}

然后名为variable的局部变量,成为容器类(<>c__DisplayClass0_0)的一个字段。
<>c__DisplayClass0_.variable = 0;
while (<>c__DisplayClass0_.variable < 5)
{
    list.Add(new Func<int>(<>c__DisplayClass0_.<Main>b__0));
    <>c__DisplayClass0_.variable++;
}

所以递增变量会导致容器类的字段递增,因为我们在while循环的所有迭代中获得容器类的一个实例,所以我们得到的输出是相同的,即10。

enter image description here

你可以通过在循环体内将捕获的变量重新分配给一个新的局部变量来防止这种情况发生。
while (variable < 5)
{
    var index = variable; // <= this line
    actions.Add(() => index * 2);
    ++ variable;
}

顺便说一下,这种行为在 .Net 8 预览版中仍然有效,我发现这种行为非常有问题和误导性。

-1
for (int n=0; n < 10; n++) //forloop syntax
foreach (string item in foo) foreach syntax

3
在代码示例中添加一些解释并不会造成任何损害 ;) - Maksym Rudenko
好的 @MaksymRudenko - Arshman Saleem
这个回答如何解答问题? - undefined

-2

这被称为闭包问题,只需使用一个复制变量,问题就解决了。

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

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接