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

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

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

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

67377 次浏览

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

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的行为发生了变化,因此您不再需要进行本地复制。详见这个答案

是的,你需要在循环中限定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();

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

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

我相信你正在经历的是所谓的闭包http://en.wikipedia.org/wiki/Closure_ (computer_science)。你的lamba有一个对变量的引用,这个变量的作用域在函数本身之外。你的lamba直到你调用它才被解释,一旦它被解释,它将在执行时得到变量的值。

同样的情况也发生在多线程中(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();
}

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

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

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

这和循环没有关系。

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

Lambda表达式(在c# 3+中,以及c# 2中的匿名方法)仍然创建实际的方法。将变量传递给这些方法涉及到一些难题(通过值传递?通过引用传递?c#是通过引用来实现的——但这又带来了另一个问题,即引用可能比实际变量更长寿)。c#为解决所有这些难题所做的是创建一个新的helper类(“闭包”),其中的字段对应于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;
}

或者使用一个实际的helper类:

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表达式使用局部作用域变量-尽管一些不小心使用循环演示闭包陷阱。

它被称为闭包问题, 只需使用一个复制变量,就完成了
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());
}

因为这里没有人直接引用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将被实例化和初始化三次——每次循环迭代一次。

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++中这个问题不会发生,因为你可以选择是通过值捕获变量还是通过引用捕获变量(参见:λ捕获)。

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