在C中使用lambda表达式或匿名方法时,我们必须注意对修改后的闭包陷阱的访问。例如:
1 2 3 4 5
| foreach (var s in strings)
{
query = query.Where(i => i.Prop == s); // access to modified closure
...
} |
由于修改了闭包,上述代码将导致查询中所有Where子句都基于s的最终值。
正如这里所解释的,发生这种情况的原因是上面的foreach循环中声明的s变量在编译器中是这样翻译的:
1 2 3 4 5 6
| string s;
while (enumerator.MoveNext())
{
s = enumerator.Current;
...
} |
而不是这样:
1 2 3 4 5 6
| while (enumerator.MoveNext())
{
string s;
s = enumerator.Current;
...
} |
正如这里指出的,在循环外部声明变量没有性能优势,在正常情况下,我可以考虑这样做的唯一原因是,如果您计划在循环范围之外使用变量:
1 2 3 4 5 6 7
| string s;
while (enumerator.MoveNext())
{
s = enumerator.Current;
...
}
var finalString = s; |
但是,在foreach循环中定义的变量不能在循环之外使用:
1 2 3 4
| foreach(string s in strings)
{
}
var finalString = s; // won't work: you're outside the scope. |
因此,编译器声明变量的方式使得它极易出错,这通常很难找到和调试,但不会产生可感知的好处。
如果使用内部作用域变量编译foreach循环,您是否可以用这种方式处理它们,或者这只是在匿名方法和lambda表达式可用或常见之前所做的一种任意选择,并且从那时起就没有对其进行过修改?
- String s; foreach (s in strings) { ... }出了什么问题?
- @bradcristie the op并不是真的在谈论foreach,而是关于lamda表达式,它产生了类似于op所示的代码。
- @布莱德克里斯蒂:那是编译的吗?(错误:在foreach语句中,类型和标识符都是必需的)
- 我认为重要的是要注意,为您在这里提出的两种情况生成的asm可能是相等的;对字符串"s"的引用将占用堆栈的1个槽,无论它在两个范围中的哪个范围中声明。问题是编译器(还是jit'ter?)在第一种情况下要比在第二种情况下更好地重用堆栈槽。
- @Jakobbotschnielsen:它是一个封闭的lambda外部局部;为什么你假设它会在堆栈上?它的寿命比堆栈帧长!
- @埃里克:我很困惑。我理解lambda捕获了对foreach变量的引用(它在循环外部内部声明),因此您最终将其与最终值进行比较;我得到了。我不明白的是,在循环中声明变量是如何产生任何影响的。从编译器编写器的角度来看,无论声明是在循环内部还是在循环外部,我都只在堆栈上分配一个字符串引用(var's);我当然不想每次迭代都将一个新引用推送到堆栈上!
- @ericlippert:(续)这是否被视为一种特殊情况,即编译器发现通常是局部变量"s"的内容是由lambda表达式捕获的,因此在每次迭代期间将其从堆栈中"提升"并在堆中创建一个新变量?这是我唯一能看到这种效果的方法…
- 什么是终结?
- @Adityabokade:请参阅programmers.stackexchange.com/questions/40454/what-is-a-clos‌&8203;ure和stackoverflow.com/questions/36636/what-is-a-clos ure
- @安东尼:由于在编译时知道一个方法中变量的数量,所以为该方法分配的执行堆栈空间是恒定的——您不会在方法前进时推送和弹出值。另外,还有一些编译器优化,如果变量没有被闭包捕获,那么在哪里声明它就无关紧要。创建闭包会将变量的引用复制到堆中。变量的作用域将决定复制哪个引用。
The compiler declares the variable in a way that makes it highly prone to an error that is often difficult to find and debug, while producing no perceivable benefits.
你的批评是完全合理的。
我在这里详细讨论这个问题:
闭环变量被认为是有害的
Is there something you can do with foreach loops this way that you couldn't if they were compiled with an inner-scoped variable? or is this just an arbitrary choice that was made before anonymous methods and lambda expressions were available or common, and which hasn't been revised since then?
后者。C 1.0规范实际上没有说明循环变量是在循环体内部还是在循环体外部,因为它没有明显的区别。在C 2.0中引入闭包语义时,选择将循环变量放在循环之外,与"for"循环一致。
我认为公平地说,所有人都对这个决定感到遗憾。这是C中最糟糕的"gotchas"之一,我们将用破坏性的变化来修复它。在C 5中,foreach循环变量在逻辑上位于循环体内部,因此每次闭包都会得到一个新的副本。
for循环将不会被更改,更改也不会"返回"到以前的C版本。因此,在使用这个成语时应该继续小心。
- 所以,没有机会用句法来接近值?(当然,这有一个"没有人会使用它"的问题,因为关闭变量的语法更自然,90%的时间没有任何区别)
- @随机832:不太可能。但是,我们正在考虑为Roslyn添加一个静态分析器,该分析器确定在构造闭包之后是否曾经写入过关闭变量;如果不是,那么我们可以关闭该值而不是变量。
- 实际上,在1.x规范中有一个间接引用;如果你看一下明确的赋值规则,它给出了一个编译器解释的例子,并且IIRC在循环中声明。不过,这是间接和间接的。不是显式的spec语句。
- 事实上,我们确实推动了C 3和C 4的变化。当我们设计C 3时,我们确实意识到问题(已经存在于C 2中)会变得更糟,因为有了LINQ,前臂循环中会有如此多的lambda(以及查询理解,它们是伪装的lambda)。我很遗憾,我们等着问题变得足够糟糕,以至于需要这么晚才解决它,而不是在C 3中解决它。
- 现在我们必须记住,foreach是"安全的",但for不是。
- 我不会称之为破坏性的更改,而是一个启用性的更改:曾经细微错误的代码现在将开始按预期运行。当然,有些人可能依赖于过去的行为…
- @Michielwoo:这种变化在某种意义上是破坏性的,即它不是向后兼容的。使用旧的编译器时,新代码将无法在上正确运行。
- 我想我会把它改成编译器错误。微妙地改变语义对我来说太危险了。我想知道有多少代码取决于旧的行为。
- @这里也一样,但是没有工具来说明安全/不安全,我几乎总是选择使用一个明确的温度。创建bug的方法太多了,无法将细微的bug添加到组合中。
- @Matthew-闭包的关键是它们捕获变量,而不是值。这里面有巨大的力量,但是如果你不小心的话,你可能会被绊倒。这里的问题是,ops问题中的每个lambda都在循环外声明的一个变量s上关闭。因此,每个lambda都是在同一个s上关闭的。Eric说,在c 5中,s将在循环内声明,因此每个lambda将在一个新的、新的变量上关闭。
- @埃里克利珀特,只是出于好奇,你们有没有想出一些不完全古怪的场景,这些场景很可能会被这种"突破性"的变化打破?
- @不,这就是为什么我们愿意接受它。不过,乔恩·斯基特向我指出了一个重要的突破性变化场景,即有人用C 5编写代码,对其进行测试,然后将其与仍在使用C 4的人分享,而这些人则天真地相信它是正确的。希望受这种情况影响的人数很少。
- @codeinchaos:我们不能使它成为一个编译器错误来编写一个lambda,这个lambda在循环变量上是关闭的,因为这样就不能在foreach循环中使用linq!治疗方法要比疾病严重得多。
- @我假设这只影响代码,而不是生成的程序集?在这种情况下,它确实应该很小。
- 顺便提一句,Resharper总是捕捉到这一点,并将其报告为"访问修改后的闭包"。然后,按alt+enter,它会自动为您修复代码吗?jetbrains.com/resharper公司
- @Ericlippert,我认为基本的问题是lambda的实例是在循环外部创建的,即使在循环内部修改了封闭变量。考虑这样一种情况:我在循环外部声明一个变量(不是循环变量),然后在循环内部的lambda中使用它(没有到循环变量的连接),例如:string not_loop_var;foreach(var item in string s)not_loop_var=item;actions.add(()=>console.writeline(not_loop_var)))
- @random832 close on values vs variable:(一种观点)我认为,如果闭包遵循与函数调用相同的符号(pass-by-value/ref),就更自然了。例如,如果我声明public static action getlambda(int val)return()=>console.writeline(val);则getlambda(x)应通过"()=>console.writeline(x)"在任何地方替换
- 我不认为你可以无视这种行为"有害"或错误。它在许多语言中都是这样工作的,包括python和javascript。
- @Random832我知道这已经过时了,但是,如果您只对结束值时的行为(而不是性能)感兴趣,我相信简单的通用标识函数应该可以做到这一点。有点像公共静态t identity(t x)return x;我从未尝试过与c完全相同的方法,但我相信它适用于您的场景。
- 不,因为对标识(x)的调用发生在闭包中,当x更改时,它将返回不同的值。
- 我认为当前的行为是可以理解的,因为lambda将在对循环进行评估(或通过其评估的各个阶段)之后进行评估,但这并不直观。感谢您更改。
- @mho:我觉得奇怪的是,即使在设计vb.net时,微软似乎已经认识到参数传递应该默认为值语义,改变了他们的基本解释器中的一个长期传统(一直回到qbasic),他们将闭包默认为引用语义。使用引用语义的隐式闭包似乎会导致混淆。我想知道在什么情况下,要求在一个闭包中使用的所有变量都必须有一些特殊的声明是有问题的,明确地认识到它们的生存期将被延长?
- 哪个vb.net版本会修正这个问题?
- @Nikhalagrawal:Visual Studio 2012修复了C(C 5.0)和VB(VB 11.0)的这个问题。请参见Visual Studio 2012中的Visual Basic中断更改。
埃里克·利珀特在他的博客文章《封闭循环变量》(closing over-the-loop variable)中对你的要求进行了全面的阐述,该变量被认为是有害的,并且是其续集。
对我来说,最有说服力的论点是在每次迭代中都有新的变量将与for(;;)样式的循环不一致。您希望在每一次for (int i = 0; i < 10; i++)迭代中都有一个新的int i吗?
这种行为最常见的问题是在迭代变量上创建一个闭包,它有一个简单的解决方法:
1 2 3 4
| 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变量的闭包。
- 最终,当人们写这篇文章的时候,他们真正想要的不是多个变量,而是接近这个值。在一般情况下,很难想到一个有用的语法。
- 是的,不可能按值关闭,但是有一个非常简单的解决方法,我刚刚编辑了我的答案,将其包括在内。
- 在C关闭引用时关闭太糟糕了。如果它们默认关闭值,我们可以很容易地指定关闭变量,而不是使用ref。
- @克里兹,这是一个强制一致性比不一致性更有害的情况。它应该像人们所期望的那样"正常工作",而且很明显,人们在使用foreach时会期望一些不同的东西,而不是for循环,因为在我们了解修改后的闭包问题(如我自己)的访问之前,遇到问题的人的数量是多少。
- 这可能就是为什么微软的C团队决定做这个由埃里克在回答中宣布的突破性的改变的原因。
- @Random832不知道C,但在普通的Lisp中,有一种语法可以解释这一点,它认为任何具有可变变量和闭包的语言(不,必须)也会使用C。我们要么关闭对变化的地方的引用,要么关闭它在给定时间点的值(创建一个闭包)。本文讨论了python中的类似内容和方案(refs/vars的cut和cute用于在部分评估的闭包中保留评估值)。
- "您希望在for(int i=0;i<10;i++)的每次迭代中都有一个新的int i吗?"--在一种设计良好的语言中,我绝对希望一个循环内的闭包捕获具有循环范围的任何变量的当前迭代值。改变foreach而不是for的语义比在循环外限定foreach变量更糟糕。
- @Krizz链接到您的博客文章不起作用。
- @seanu不,在c close中对引用使用闭包也不错。如果它在值上关闭,首先您将消除闭包值的很大一部分!否则,如何更新lambda之外的变量?
- 嘿,我刚给你做了9999。所以我应该得到一点什么?:d:d没关系,只是开玩笑。
在被这个咬了之后,我有一个习惯,将本地定义的变量包含在最内部的作用域中,我使用它来传输到任何一个闭包。在您的示例中:
1 2 3
| foreach (var s in strings)
{
query = query.Where(i => i.Prop == s); // access to modified closure |
我愿意:
1 2 3 4
| foreach (var s in strings)
{
string search = s;
query = query.Where(i => i.Prop == search); // New definition ensures unique per iteration. |
一旦你有了这个习惯,你就可以避免它,在非常罕见的情况下,你实际上打算绑定到外部作用域。老实说,我想我从来没有这样做过。
- 这是典型的解决方法,谢谢你的贡献。Resharper足够聪明,能够识别这种模式并引起你的注意,这很好。我已经有一段时间没有被这种模式所困扰了,但是,用埃里克·利珀特的话说,"我们得到的唯一一个最常见的错误报告",我很好奇为什么要比如何避免它更多。
在C 5.0中,这个问题是固定的,您可以关闭循环变量并得到预期的结果。
语言规范规定:
8.8.4 The foreach statement
(...)
A foreach statement of the form
1
| foreach (V v in x) embedded-statement |
is then expanded to:
1 2 3 4 5 6 7 8 9 10 11 12
| {
E e = ((C)(x)).GetEnumerator();
try {
while (e.MoveNext()) {
V v = (V)(T)e.Current;
embedded-statement
}
}
finally {
… // Dispose e
}
} |
(...)
The placement of v inside the while loop is important for how it is
captured by any anonymous function occurring in the
embedded-statement. For example:
1 2 3 4 5 6 7
| int[] values = { 7, 9, 13 };
Action f = null;
foreach (var value in values)
{
if (f == null) f = () => Console.WriteLine("First value:" + value);
}
f(); |
If v was declared outside of the while loop, it would be shared
among all iterations, and its value after the for loop would be the
final value, 13, which is what the invocation of f would print.
Instead, because each iteration has its own variable v, the one
captured by f in the first iteration will continue to hold the value
7, which is what will be printed. (Note: earlier versions of C#
declared v outside of the while loop.)
- 为什么早期版本的C在while循环中声明了V?msdn.microsoft.com/en-gb/library/aa664754.aspx
- @Colinfang一定要阅读Eric的答案:C 1.0规范(在你的链接中,我们讨论的是vs 2003,即C 1.2)实际上没有说明循环变量是在循环体内部还是外部,因为它没有明显的区别。在C 2.0中引入闭包语义时,选择将循环变量放在循环之外,与"for"循环一致。
- 所以你是说链接中的例子在当时不是确定的规范?
- @科林方,他们是明确的规格。问题在于,我们讨论的是稍后(使用C 2.0)引入的特性(即函数闭包)。当C 2.0出现时,他们决定将循环变量放在循环之外。然后他们又改变了主意,用C 5.0:。
在我看来,这是个奇怪的问题。知道编译器是如何工作的很好,但那只是"很好知道"。
如果你写的代码依赖于编译器的算法,那是不好的做法,最好重写代码来排除这种依赖。
这是面试的好问题。但在现实生活中,我并没有遇到任何在面试中解决的问题。
for each的90%用于处理集合的每个元素(不用于选择或计算某些值)。有时需要在循环中计算一些值,但创建大循环并不是一个好的实践。
最好使用LINQ表达式来计算值。因为当你在循环中计算很多东西时,2-3个月后,当你(或其他任何人)将阅读这个代码时,人们将无法理解这是什么以及它应该如何工作。
- "如果你写的代码依赖于编译器的算法,那是一个糟糕的实践。"这不是关于编译器优化的问题。这是关于语言的实际行为,这是你无法摆脱的依赖。我承认,了解给定语言行为的原因更像是一种学术活动,但这显然是一个合理的问题,考虑到C小组决定对语言进行一次突破性的更改以纠正它。
- "最好使用LINQ表达式来计算值。"您会注意到我正在使用循环来构建一个LINQ表达式。是的,使用Aggregate一开始就可以避免这个问题,但是人们使用这样的循环是非常常见的。我在StackOverflow上一直看到JavaScript中出现的此类问题,在团队决定更改此语言行为之前,这些问题在C中同样常见。