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


在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表达式可用或普通之前做出的任意选择,并且从那以后没有被修改过?


1484
2018-01-17 17:21


起源


怎么了? String s; foreach (s in strings) { ... }? - Brad Christie
@BradChristie OP并没有真正谈论 foreach但关于lamda表达式导致类似的代码如OP所示... - Yahia
@BradChristie:这会编译吗? (错误:foreach语句中都需要类型和标识符 为了我) - Austin Salonen
@JakobBotschNielsen:这是一个封闭的外部本地的lambda;你为什么假设它会在堆栈中呢?它的一生就是 比堆栈框架长! - Eric Lippert
@EricLippert:我很困惑。我理解lambda捕获对foreach变量的引用(在内部声明 外 循环)因此你最终会与最终价值进行比较;我得到了。我不明白的是如何声明变量 内 循环将完全不同。从编译器 - 编写者的角度来看,我只在堆栈上分配一个字符串引用(var's'),无论声明是在循环内部还是外部;我当然不希望每次迭代都将新的引用推送到堆栈中! - Anthony


答案:


编译器以一种方式声明变量,使其非常容易出现通常难以查找和调试的错误,同时不会产生可感知的好处。

你的批评是完全合理的。

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

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

有没有东西你可以用foreach循环这样做,如果它们是用内部范围的变量编译你不能?或者这只是在匿名方法和lambda表达式可用或普通之前做出的任意选择,并且从那以后还没有被修改过?

后者。 C#1.0规范实际上没有说明循环变量是在循环体内部还是外部,因为它没有产生可观察到的差异。当在C#2.0中引入闭包语义时,选择将循环变量放在循环之外,与“for”循环一致。

我认为所有人都对这一决定感到遗憾是公平的。这是C#中最糟糕的“陷阱”之一,而且 我们将采取突破性的改变来解决它。 在C#5中,foreach循环变量将是逻辑上的  循环的主体,因此闭合每次都会得到一个新的副本。

for 循环不会被更改,并且更改不会“反向移植”到以前版本的C#。因此,在使用这个习语时你应该继续小心。


1280
2018-01-17 17:56



事实上,我们确实在C#3和C#4中推迟了这一变化。当我们设计C#3时,我们确实意识到问题(已经存在于C#2中)会变得更糟,因为会有这么多的lambda(和查询)由于LINQ,foreach循环中的理解,这是伪装的lambdas。我很遗憾我们等待这个问题变得非常糟糕,以至于不能在很晚的时候修复它,而不是在C#3中修复它。 - Eric Lippert
现在我们必须记住 foreach 是'安全'但是 for 不是。 - leppie
@michielvoo:这种变化在不向后兼容的意义上是突破性的。使用旧编译器时,新代码无法正常运行。 - leppie
@Benjol:不,这就是为什么我们愿意接受它。 Jon Skeet向我指出了一个重要的突破性变化场景,即有人在C#5中编写代码,对其进行测试,然后与仍在使用C#4的人分享,然后他们天真地认为它是正确的。希望受这种情况影响的人数很少。 - Eric Lippert
顺便说一句,ReSharper总是抓住这个并将其报告为“访问修改后的封闭”。然后,通过按Alt + Enter,它甚至会自动为您修复代码。 jetbrains.com/resharper - Mike Chamberlain


Eric Lippert在他的博客文章中详细介绍了您的要求 关闭循环变量被认为是有害的 及其续集。

对我来说,最有说服力的论点是在每次迭代中使用新变量都会与之不一致 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变量


174
2018-01-17 17:39



最终,人们实际上是什么 想 当他们写这个不是有多个变量时,就要关闭了 值。在一般情况下,很难想到可用的语法。 - Random832
是的,不可能通过该值关闭,但是有一个非常简单的解决方法我刚刚编辑了我的答案。 - Krizz
在C#中关闭引用的关闭太糟糕了。如果它们默认关闭了值,我们可以轻松地指定关闭变量而不是 ref。 - Sean U
@Krizz,这是强制一致性比不一致更有害的情况。它应该像人们期望的那样“正常工作”,并且显然人们期望在使用foreach而不是for循环时会有不同的东西,因为在我们知道访问修改后的闭包问题之前遇到问题的人数(比如我自己) 。 - Andy
@ Random832不了解C#但是在Common LISP中有一个语法,它表明任何具有可变变量和闭包的语言都会(不, 必须也有它。我们要么关闭对变化的地方的引用,要么关闭它在给定时刻的价值(创建一个闭包)。 这个 讨论Python和Scheme中的类似内容(cut 对于refs / vars和 cute 保持 评估 部分评估闭包中的值)。 - Will Ness


受到这种困扰,我习惯在最里面的范围中包含本地定义的变量,我用它来转移到任何闭包。在你的例子中:

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.

一旦你有这种习惯,你可以避免它 非常 您实际上打算绑定到外部作用域的罕见情况。说实话,我不认为我曾经这样做过。


95
2018-01-17 17:47



这是典型的解决方法感谢您的贡献。 Resharper非常聪明,可以识别这种模式并引起你的注意,这很好。我有一段时间没有被这种模式所困扰,但是,正如Eric Lippert所说的那样,“我们得到的单一最常见的错误报告”,我很想知道 为什么 超过 如何避免它。 - StriplingWarrior


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

(......)

安置 v while循环内部对于它是如何重要的   被发现的任何匿名函数捕获   嵌入语句。例如:

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 在第一次迭代中将继续保持该值    7,这是将要打印的。 (注意:早期版本的C#   声明 v 在while循环之外。


52
2017-09-03 13:58



为什么这个早期版本的C#在while循环中声明了v?msdn.microsoft.com/en-GB/library/aa664754.aspx - colinfang
@colinfang一定要读 埃里克的回答:C#1.0规范(在你的链接中,我们谈论的是VS 2003,即C#1.2)实际上 没说 循环变量是在循环体内部还是外部,因为它没有可观察到的差异。当在C#2.0中引入闭包语义时,选择将循环变量放在循环之外,与“for”循环一致。 - Paolo Moretti
所以你是说链接中的例子当时不是明确的规范? - colinfang
@colinfang他们是明确的规范。问题是我们正在讨论稍后介绍的一个功能(即函数闭包)(使用C#2.0)。当C#2.0出现时,他们决定将循环变量放在循环之外。而且他们用C#5.0再次改变主意:) - Paolo Moretti


在我看来,这是一个奇怪的问题。很高兴知道编译器如何工作,但只知道“很有用”。

如果编写依赖于编译器算法的代码,这是不好的做法。最好重写代码以排除这种依赖。

这是面试的好问题。但在现实生活中,我没有遇到任何我在求职面试中遇到的问题。

90%的foreach用于处理每个集合元素(不用于选择或计算某些值)。有时您需要在循环内部计算一些值,但创建BIG循环并不是一个好习惯。

最好使用LINQ表达式来计算值。因为当你在循环中计算很多东西时,在你(或其他任何人)阅读此代码的2-3个月之后,人们将无法理解这是什么以及它应该如何工作。


0
2018-06-15 07:31



“如果编写依赖于编译器算法的代码,这是不好的做法。”这不是关于编译器优化的问题。这是关于语言的实际行为,这是你无法摆脱的东西。我承认知道 为什么 一个给定的语言行为更多是一个学术练习,但它显然是一个合理的问题,考虑到C#团队决定对语言进行重大改变以纠正它。 - StriplingWarrior
“最好使用LINQ表达式来计算值。”你会注意到我正在使用循环 构建LINQ表达式。 是的,使用 Aggregate 本来可以避免这个问题,但人们使用像这样的循环是非常普遍的。我在JavaScript中看到了这类问题引起的问题 每时每刻 在StackOverflow上,在团队决定改变这种语言行为之前,它们在C#中同样很常见。 - StriplingWarrior