在 C 循环中捕获的变量#

Captured variable in a loop in C#

提问人:Morgan Cheng 提问时间:11/7/2008 最后编辑:Peter MortensenMorgan Cheng 更新时间:10/20/2023 访问量:74382

问:

我遇到了一个关于 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。

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

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

捕获变量 C# 闭包

评论

16赞 Brian 11/12/2010
另请参阅 Eric Lippert 关于该主题的博客系列:关闭被认为有害的循环变量
11赞 Neal Tibrewala 3/5/2012
此外,他们正在更改 C# 5,使其在 foreach 中按预期工作。(中断性变更)
4赞 Ian Oakes 2/6/2014
@Neal:尽管此示例在 C# 5 中仍然无法正常工作,因为它仍然输出五个 10
7赞 RBT 4/22/2017
它验证了它在 C# 6.0 (VS 2015) 上输出五个 10。我怀疑闭包变量的这种行为是否适合改变。.Captured variables are always evaluated when the delegate is actually invoked, not when the variables were captured
2赞 Luke Woodward 6/12/2020
埃里克·利珀特(Eric Lippert)关于这个主题的博客系列现在在这里这里

答:

264赞 Jon Skeet 11/7/2008 #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 节,我关于闭包的文章也有更多示例。

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

评论

40赞 Marc Gravell 11/7/2008
乔恩的书也有很好的一章(别谦虚了,乔恩!
47赞 Jon Skeet 11/7/2008
如果我让其他人插上它,看起来会更好;)(我承认我确实倾向于投票推荐它的答案。
2赞 Jon Skeet 11/7/2008
一如既往,请向 [email protected] 提供反馈,我们将不胜感激:)
7赞 Alexei Levenkov 1/22/2016
对于 C# 5.0 行为不同(更合理),请参阅 Jon Skeet 的更新答案 - stackoverflow.com/questions/16264289/...
2赞 Jon Skeet 4/30/2019
@Florimond:这不是 C# 中闭包的工作方式。它们捕获变量,而不是。(无论循环如何,这都是正确的,并且很容易通过捕获变量的 lambda 来演示,并在执行时只打印当前值。
8赞 cfeduke 11/7/2008 #2

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

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();
12赞 Tyler Levine 11/7/2008 #3

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

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

评论

0赞 Jon Skeet 11/7/2008
请参阅我编辑的答案中的解释。我现在正在查找规范的相关部分。
0赞 Tyler Levine 11/7/2008
哈哈,乔恩,我实际上刚刚读了你的文章:csharpindepth.com/Articles/Chapter5/Closures.aspx 你做得很好,我的朋友。
0赞 Jon Skeet 11/7/2008
@tjlevine:非常感谢。我将在我的答案中添加对此的引用。我忘了!
0赞 Tyler Levine 11/7/2008
另外,Jon,我很想看看你对各种 Java 7 闭包提案的看法。我看到你提到你想写一个,但我没有看到。
1赞 Jon Skeet 11/7/2008
@tjlevine:好的,我保证在年底前写出来:)
28赞 TheCodeJunkie 11/7/2008 #4

我相信你正在经历的就是所谓的闭合 http://en.wikipedia.org/wiki/Closure_(computer_science)。你的 lamba 有一个对变量的引用,该变量的范围在函数本身之外。在你调用它之前,你的 lamba 不会被解释,一旦它被解释,它就会得到变量在执行时的值。

7赞 Sunil 1/28/2011 #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();
}

评论

0赞 Mladen Mihajlovic 1/31/2014
这似乎对我没有帮助。仍然是不确定的。
0赞 Dennis19901 10/7/2021
这与为什么需要“重新声明”要捕获的变量无关。这完全与以下事实有关:第二个线程可能在操作系统级别上更快地“准备好工作”,或者执行代码被提前调度。您的第二个示例也不会每次都输出 1-5。它可能会在调试中,因为速度会慢一些,但在发布版本中绝对不会。
17赞 gerrard00 3/30/2013 #6

在后台,编译器正在生成一个类,该类表示方法调用的闭包。它将闭包类的单个实例用于循环的每次迭代。代码如下所示,这样可以更容易地了解错误发生的原因:

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

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

15赞 Maverick Meerkat 6/13/2018 #7

这与循环无关。

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

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

所以你的 while 循环不断更新,直到它达到 10,然后你的 for 循环执行操作,这些操作都在同一 .ClosureClass.variableClosureClass.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 表达式有关——尽管一些不谨慎的循环使用表明了闭包陷阱。

-2赞 Junaid Pathan 12/12/2018 #8

它被称为闭包问题, 只需使用复制变量,即可完成。

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 1/30/2019
你的答案与上面的人提供的答案有什么不同?
-2赞 Nathan Chappell 3/1/2020 #9

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

10.4.4.10 For 语句

对表单的 for-statement 进行明确的赋值检查:

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

就像语句是写的那样完成的:

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

在规范中,

12.16.6.3 局部变量的实例化

当执行进入变量的作用域时,将认为局部变量已实例化。

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

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

但是,将 的声明移到循环之外会导致 :xx

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

-1赞 Arshman Saleem 12/24/2020 #10
for (int n=0; n < 10; n++) //forloop syntax
foreach (string item in foo) foreach syntax

评论

3赞 Maksym Rudenko 12/24/2020
在代码示例中添加一些解释行并没有什么坏处;)
0赞 Arshman Saleem 6/10/2021
好的@MaksymRudenko
0赞 Gert Arnold 9/10/2023
这如何回答这个问题?
2赞 erhan355 10/20/2023 #11

正如其他人所说,它与循环无关。它是 C# 中匿名函数主体中变量捕获机制的效果。 当您将 lambda 定义为示例时;

actions.Add(() => variable * 2);

编译器为 lambda 函数 () => () => 变量 * 2 生成一个类似 <>c__DisplayClass0_0 的容器类。

在生成的类(容器)中,它生成一个名为变量的字段,具有一个具有相同名称的捕获变量和包含 lambda 主体的方法 b__0()。

[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 Preview中仍然有效,我发现这种行为非常有缺陷和欺骗性。