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

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


由于修改了闭包,以上代码将导致查询中的所有Where子句都基于s的最终值。 br />
如此处所述,这是因为在编译器中将上述s循环中声明的foreach变量在编译器中进行了如下翻译:

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


而不是像this:

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表达式可用之前做出的任意选择还是通用,并且自那时以来没有进行过修订?

评论

String s怎么了? foreach(以字符串形式){...}?

OP的@BradChristie并不是真正在谈论foreach,而是关于lamda表达式导致与OP所示代码相似的代码。

@BradChristie:可以编译吗? (错误:对于我来说,在foreach语句中都必须同时输入类型和标识符)

@JakobBotschNielsen:这是lambda的封闭局部。您为什么要假设它完全要放在堆栈中?它的寿命比堆栈框架更长!

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

#1 楼


编译器声明该变量的方式使其很容易出错,而该错误通常很难查找和调试,并且不会产生明显的收益。


您的批评是完全有道理。

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

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


可以用这种方式处理foreach循环,如果它们是使用内部作用域变量编译的,则无法实现?还是这只是在匿名方法和lambda表达式可用或通用之前做出的任意选择,并且此后从未进行过修改?


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

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

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

评论


实际上,我们确实推迟了C#3和C#4的更改。当我们设计C#3时,我们确实意识到问题(在C#2中已经存在)会变得更糟,因为会有太多的lambda(和查询)理解,这是变相的lambdas),这要归功于LINQ。我感到遗憾的是,我们等待该问题变得很严重,以至于不能及时解决该问题,而不是在C#3中进行修复。

–埃里克·利珀特
2012年1月17日19:21



现在,我们将不得不记住,foreach是“安全的”,但是for不是。

–嬉皮士
2012年1月18日下午5:58

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

–嬉皮士
2012年1月18日9:00

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

–埃里克·利珀特
2012年1月19日14:20

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

–迈克·张伯伦
2012年3月21日在22:57



#2 楼

埃里克·利珀特(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变量。

评论


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

–Random832
2012年1月17日17:44

是的,无法按该值结束,但是有一个非常简单的解决方法,我刚刚编辑了要包含的答案。

– Krizz
2012年1月17日17:50



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

– Sean U
2012年1月17日17:53

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

–安迪
2012年1月17日19:41

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

–尼斯
2012年1月18日在18:09

#3 楼

被这个问题咬伤后,我有一个习惯,就是将局部定义的变量包含在最里面的范围内,该范围用于传递给任何闭包。在您的示例中:

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


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

评论


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

–StriplingWarrior
2012年1月17日17:53

#4 楼

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

语言规范中说:


8.8.4 foreach语句

(...)

然后扩展形式为

foreach (V v in x) embedded-statement


的foreach语句to:

{
  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();


如果在while循环之外声明了v,它将在所有迭代之间共享
,并且它在for循环之后的值将是
最终值13,这是f的调用将打印的内容。
因为每个迭代都有自己的变量v,所以f在第一次迭代中捕获的变量
将继续保留值
7,它将被打印。 (注意:较早版本的C#
在while循环外声明了v。)


评论


为什么此早期C#版本在while循环内声明v?msdn.microsoft.com/zh-CN/library/aa664754.aspx

– colinfang
13年4月29日在16:00

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

–保罗·莫雷蒂(Paolo Moretti)
13年4月29日在18:35



因此,您是说链接中的示例当时不是确定的规范?

– colinfang
13年4月29日在18:47



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

–保罗·莫雷蒂(Paolo Moretti)
13年4月29日在22:01