C#在foreach中重用变量是否有原因?


1684

在C#中使用lambda表达式或匿名方法时,我们必须警惕对修改后的闭包陷阱的访问。例如:

foreach (var s in strings)
{
   query = query.Where(i => i.Prop == s); // access to modified closure
   ...
}

由于修改了闭包,因此上述代码将导致Where查询中的所有子句都基于的最终值s

正如解释在这里,这是因为该s变量在声明foreach环以上的编译器编译如下:

string s;
while (enumerator.MoveNext())
{
   s = enumerator.Current;
   ...
}

而不是像这样:

while (enumerator.MoveNext())
{
   string s;
   s = enumerator.Current;
   ...
}

如此处所指出的,循环外声明变量没有任何性能优势,在正常情况下,我能想到的唯一原因是如果您打算在循环范围外使用变量:

string s;
while (enumerator.MoveNext())
{
   s = enumerator.Current;
   ...
}
var finalString = s;

但是,foreach循环中定义的变量不能在循环外使用:

foreach(string s in strings)
{
}
var finalString = s; // won't work: you're outside the scope.

因此,编译器以某种方式声明该变量,使其极易出现通常难以查找和调试的错误,而不会产生明显的收益。

是否可以通过foreach这种方式对循环执行某些操作,如果它们是使用内部作用域变量进行编译则无法做到的,或者这只是在匿名方法和lambda表达式可用或通用之前做出的任意选择,并且没有从那以后就没有修改过?


4
这有什么错String s; foreach (s in strings) { ... }
布拉德·克里斯蒂

5
@BradChristie OP并不是真正在谈论foreach而是关于lamda表达式,其结果类似于OP所示的代码...
Yahia

22
@BradChristie:可以编译吗?(错误:对于我来说,在foreach语句中都需要类型和标识符
Austin Salonen 2012年

32
@JakobBotschNielsen:这是lambda的封闭局部。您为什么要假设它将完全堆叠?它的寿命比堆栈框架更长
埃里克·利珀特

3
@EricLippert:我很困惑。我了解到lambda捕获了对foreach变量的引用(该变量在循环外部内部声明),因此最终将其与最终值进行比较;我得到的。我不明白的是,在循环声明变量将如何产生任何区别。从编译器编写器的角度来看,无论声明是在循环内还是循环外,我都只在堆栈上分配一个字符串引用(var's')。我当然不想每次迭代都将新引用推入堆栈!
安东尼

Answers:


1407

编译器以一种很容易出错的方式声明该变量,该错误通常很难查找和调试,同时不会产生明显的好处。

您的批评是完全有道理的。

我在这里详细讨论这个问题:

关闭循环变量被认为是有害的

使用foreach循环,是否可以通过内部作用域变量进行编译而无法做到?还是这只是在匿名方法和lambda表达式可用或通用之前做出的任意选择,并且自那时以来没有进行过修改?

后者。实际上,C#1.0规范没有说明循环变量是在循环体内还是在循环体内,因为它没有明显的区别。在C#2.0中引入闭包语义时,选择将循环变量放入循环之外,与“ for”循环一致。

我认为可以说所有人都对该决定表示遗憾。这是C#中最糟糕的“陷阱”之一,我们将进行重大更改以对其进行修复。在C#5中,foreach循环变量在逻辑上将位于循环体内,因此闭包每次都会获得新的副本。

for循环将不会改变,并且改变不会是“向后移植”到C#的早期版本。因此,在使用此惯用语时,您应继续小心。


177
实际上,我们确实阻止了C#3和C#4的这一更改。当我们设计C#3时,我们确实意识到问题(在C#2中已经存在)将变得更糟,因为会有太多的lambda(和查询)。理解,这是伪装的lambdas),这要归功于LINQ。我很遗憾,我们等待的问题得到充分不好权证这么晚了固定它,而不是在C#3.固定
埃里克利珀

75
现在我们必须记住foreach“安全”,但for事实并非如此。
leppie 2012年

22
@michielvoo:从不向后兼容的意义上讲,这种变化正在打破。使用较旧的编译器时,新代码将无法正确运行。
leppie 2012年

41
@Benjol:不,这就是为什么我们愿意接受它。乔恩·斯凯特(Jon Skeet)向我指出了一个重要的突破性变化方案,即有人用C#5编写代码,然后对其进行测试,然后与仍在使用C#4的人共享,然后他们天真的认为它是正确的。希望受这种情况影响的人数很少。
埃里克·利珀特

29
顺便说一句,ReSharper始终抓住了这一点,并将其报告为“可以访问已修改的闭包”。然后,通过按Alt + Enter,它甚至会自动为您修复代码。jetbrains.com/resharper
Mike Chamberlain

191

你所问的是彻底埃里克利珀在他的博客盖关闭了循环变量认为是有害的和它的续集。

对我而言,最令人信服的论据是,每次迭代中都有新变量与for(;;)样式循环不一致。您是否希望int i在的每次迭代中都有一个新的for (int i = 0; i < 10; i++)

此行为最常见的问题是对迭代变量进行闭包,并且有一个简单的解决方法:

foreach (var s in strings)
{
    var s_for_closure = s;
    query = query.Where(i => i.Prop == s_for_closure); // access to modified closure

我的博客文章有关此问题:C#中的foreach变量关闭


18
最终,人们在写这篇文章时真正想要的不是拥有多个变量,而是要覆盖价值。在一般情况下,很难想到一种有用的语法。
Random832

1
是的,无法通过该值来关闭,但是有一个非常简单的解决方法,我刚刚编辑了要包含的答案。
Krizz 2012年

6
C#中对引用的关闭太糟糕了。如果默认情况下关闭值,我们可以轻松地指定关闭变量ref
肖恩·U'1

2
@Krizz,在这种情况下,强制一致性比不一致要有害得多。它应该像人们期望的那样“正常工作”,并且很明显,人们在使用foreach而不是for循环时期望有所不同,因为在我们知道访问修改后的闭包问题(例如我自己)之前遇到问题的人数。
安迪

2
@ Random832不了解C#,但是在Common LISP中有一种语法,它指出任何具有可变变量和闭包的语言也都可以(不,必须)。我们要么封闭对变更位置的引用,要么封闭它在给定时间点所具有的值(封闭的创造)。本文讨论了Python和Scheme中的类似内容(cut用于refs / vars和cute用于将评估值保留在部分评估的闭包中)。
Will Ness 2012年

103

被这个问题咬住之后,我有一个习惯,就是将局部定义的变量包含在我用来传递给任何闭包的最内层作用域中。在您的示例中:

foreach (var s in strings)
    query = query.Where(i => i.Prop == s); // access to modified closure

我做:

foreach (var s in strings)
{
    string search = s;
    query = query.Where(i => i.Prop == search); // New definition ensures unique per iteration.
}        

一旦有了这种习惯,在实际打算绑定到外部范围的少数情况下,就可以避免这种习惯。老实说,我认为我从未这样做过。


24
这是典型的解决方法。感谢您的贡献。Resharper非常聪明,可以识别这种模式并将其引起您的注意,这很好。我有一段时间没有被这种模式所困扰,但是既然用埃里克·利珀特(Eric Lippert)的话说,就是“我们得到的最常见的错误报告”,我很想知道为什么而不是如何避免它
StriplingWarrior 2012年

62

在C#5.0中,此问题已修复,您可以关闭循环变量并获得所需的结果。

语言规范说:

8.8.4 foreach语句

(...)

形式的foreach语句

foreach (V v in x) embedded-statement

然后扩展为:

{
  E e = ((C)(x)).GetEnumerator();
  try {
      while (e.MoveNext()) {
          V v = (V)(T)e.Current;
          embedded-statement
      }
  }
  finally {
       // Dispose e
  }
}

(...)

vwhile循环内部的位置对于嵌入式语句中发生的任何匿名函数如何捕获它很重要。例如:

int[] values = { 7, 9, 13 };
Action f = null;
foreach (var value in values)
{
    if (f == null) f = () => Console.WriteLine("First value: " + value);
}
f();

如果v在while循环之外声明,它将在所有迭代之间共享,并且for循环之后的值将是最终值13,这是调用的f结果。相反,由于每个迭代都有其自己的变量v,因此f在第一次迭代中捕获的变量将继续保留value 7,即将要打印的值。(注意:v在while循环外部声明的C#的早期版本。


1
为什么早期的C#版本在while循环中声明v?msdn.microsoft.com/zh-CN/library/aa664754.aspx
colinfang 2013年

4
@colinfang请确保阅读Eric的回答:C#1.0规范(在您的链接中,我们谈论的是VS 2003,即C#1.2)实际上并未说明循环变量是在循环体内还是在循环体内,因为它没有明显的区别。 。在C#2.0中引入闭包语义时,已做出选择,将循环变量放入循环之外,与“ for”循环一致。
Paolo Moretti

1
所以您是说链接中的示例当时不是确定的规范?
colinfang 2013年

4
@colinfang它们是确定的规范。问题在于我们正在谈论的是稍后(使用C#2.0)引入的功能(即函数闭包)。当C#2.0出现时,他们决定将循环变量放在循环之外。然后他们再次使用C#5.0改变了主意:)
Paolo Moretti
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.