261

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

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

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

4

10 回答 10

239

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

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

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

请注意,此问题更常见的情况是使用foror 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变化,因此您不再需要制作本地副本。有关更多详细信息,请参阅此答案

于 2008-11-07T07:32:04.583 回答
25

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

于 2008-11-07T07:34:36.887 回答
14

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

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

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

于 2013-03-29T16:49:55.013 回答
9

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

IE

while( variable < 5 )
{
    int copy = variable;
    actions.Add( () => copy * 2 );
    ++variable;
}
于 2008-11-07T07:33:18.607 回答
8

这与循环无关。

触发此行为是因为您使用了 lambda 表达式() => 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 表达式有关——尽管循环的一些不小心使用证明了闭包陷阱。

于 2018-06-13T12:21:00.457 回答
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();
于 2008-11-07T07:32:55.470 回答
4

同样的情况也发生在多线程(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();
}
于 2011-01-28T14:00:53.497 回答
0
for (int n=0; n < 10; n++) //forloop syntax
foreach (string item in foo) foreach syntax
于 2020-12-24T14:21:10.133 回答
-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());
}
于 2018-12-12T13:52:08.753 回答
-2

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

10.4.4.10 对于语句

对形式的 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 捕获)。

于 2020-03-01T10:44:32.900 回答