在lambda中捕获时,C#Struct实例行为会发生更改

我已经解决了这个问题,但我正在试图找出它的工作原理。 基本上,我使用foreach循环遍历结构列表。 如果我在调用struct的方法之前包含引用当前结构的LINQ语句,则该方法无法修改结构的成员。 无论是否甚至调用LINQ语句,都会发生这种情况。 我能够通过将我正在寻找的值分配给变量并在LINQ中使用它来解决这个问题,但我想知道导致这种情况的原因。 这是我创建的一个例子。

using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace WeirdnessExample { public struct RawData { private int id; public int ID { get{ return id;} set { id = value; } } public void AssignID(int newID) { id = newID; } } public class ProcessedData { public int ID { get; set; } } class Program { static void Main(string[] args) { List processedRecords = new List(); processedRecords.Add(new ProcessedData() { ID = 1 }); List rawRecords = new List(); rawRecords.Add(new RawData() { ID = 2 }); int i = 0; foreach (RawData rawRec in rawRecords) { int id = rawRec.ID; if (i  20) { List matchingRecs = processedRecords.FindAll(mr => mr.ID == rawRec.ID); } Console.Write(String.Format("With LINQ: ID Before Assignment = {0}, ", rawRec.ID)); //2 rawRec.AssignID(id + 8); Console.WriteLine(String.Format("ID After Assignment = {0}", rawRec.ID)); //2 i++; } rawRecords = new List(); rawRecords.Add(new RawData() { ID = 2 }); i = 0; foreach (RawData rawRec in rawRecords) { int id = rawRec.ID; if (i < 0) { List matchingRecs = processedRecords.FindAll(mr => mr.ID == id); } Console.Write(String.Format("With LINQ: ID Before Assignment = {0}, ", rawRec.ID)); //2 rawRec.AssignID(id + 8); Console.WriteLine(String.Format("ID After Assignment = {0}", rawRec.ID)); //10 i++; } Console.ReadLine(); } } } 

好吧,我已经设法用一个相当简单的测试程序重现这个,如下所示,我现在明白了。 无可否认地理解它不会让我觉得任何不那么恶心,但嘿……代码后的解释。

 using System; using System.Collections.Generic; struct MutableStruct { public int Value { get; set; } public void AssignValue(int newValue) { Value = newValue; } } class Test { static void Main() { var list = new List() { new MutableStruct { Value = 10 } }; Console.WriteLine("Without loop variable capture"); foreach (MutableStruct item in list) { Console.WriteLine("Before: {0}", item.Value); // 10 item.AssignValue(30); Console.WriteLine("After: {0}", item.Value); // 30 } // Reset... list[0] = new MutableStruct { Value = 10 }; Console.WriteLine("With loop variable capture"); foreach (MutableStruct item in list) { Action capture = () => Console.WriteLine(item.Value); Console.WriteLine("Before: {0}", item.Value); // 10 item.AssignValue(30); Console.WriteLine("After: {0}", item.Value); // Still 10! } } } 

两个循环之间的区别在于,在第二个循环中,循环变量由lambda表达式捕获 。 第二个循环实际上变成了这样的东西:

 // Nested class, would actually have an unspeakable name class CaptureHelper { public MutableStruct item; public void Execute() { Console.WriteLine(item.Value); } } ... // Second loop in main method foreach (MutableStruct item in list) { CaptureHelper helper = new CaptureHelper(); helper.item = item; Action capture = helper.Execute; MutableStruct tmp = helper.item; Console.WriteLine("Before: {0}", tmp.Value); tmp = helper.item; tmp.AssignValue(30); tmp = helper.item; Console.WriteLine("After: {0}", tmp.Value); } 

现在,当然每次我们从helper函数中复制变量时,我们都会获得结构的新副本。 这应该是正常的 – 迭代变量是只读的,所以我们期望它不会改变。 但是,您有一个方法可以更改结构的内容,从而导致意外行为。

请注意,如果您尝试更改属性 ,则会出现编译时错误:

 Test.cs(37,13): error CS1654: Cannot modify members of 'item' because it is a 'foreach iteration variable' 

教训:

  • 可变结构是邪恶的
  • 通过方法变异的结构是双重邪恶的
  • 通过对已经捕获的迭代变量的方法调用来对结构进行变换是破坏程度的三重恶魔

我不是100%清楚C#编译器是否按照此处的规范运行。 我怀疑是的。 即使不是,我也不想暗示团队应该付出任何努力来修复它。 像这样的代码只是乞求以微妙的方式被打破。

好。 我们肯定在这里有一些问题,但我怀疑这个问题不是关闭本身,而是与foreach实现相反。

C#4.0规范声明(8.8.4 foreach语句)“迭代变量对应于只读局部变量 ,其范围扩展到嵌入语句”。 这就是为什么我们不能改变循环变量或增加它的属性(如Jon所说):

 struct Mutable { public int X {get; set;} public void ChangeX(int x) { X = x; } } var mutables = new List{new Mutable{ X = 1 }}; foreach(var item in mutables) { // Illegal! item = new Mutable(); // Illegal as well! item.X++; } 

在这方面,只读循环变量的行为与任何只读字段几乎完全相同(在构造函数之外访问此变量):

  • 我们不能在构造函数之外更改只读字段
  • 我们不能更改值类型的只读字段的属性
  • 我们将只读字段视为每次访问值只读取字段时使用临时副本的值。

 class MutableReadonly { public readonly Mutable M = new Mutable {X = 1}; } // Somewhere in the code var mr = new MutableReadonly(); // Illegal! mr.M = new Mutable(); // Illegal as well! mr.M.X++; // Legal but lead to undesired behavior // becaues mr.MX remains unchanged! mr.M.ChangeX(10); 

有许多与可变值类型相关的问题,其中一个与最后一个行为有关:通过mutator方法(如ChangeX )更改readonly struct会导致模糊行为,因为我们将修改副本而不是readonly对象本身:

 mr.M.ChangeX(10); 

相当于:

 var tmp = mr.M; tmp.ChangeX(10); 

如果循环变量由C#编译器作为只读局部变量处理,那么对于它们来说,期望与只读字段相同的行为似乎是合理的。

现在,简单循环中的循环变量(没有任何闭包)与只读字段的行为几乎相同,只是为每次访问复制它。 但是如果代码更改和闭包发挥作用,循环变量开始表现得像纯只读变量:

 var mutables = new List { new Mutable { X = 1 } }; foreach (var m in mutables) { Console.WriteLine("Before change: {0}", mX); // X = 1 // We'll change loop variable directly without temporary variable m.ChangeX(10); Console.WriteLine("After change: {0}", mX); // X = 10 } foreach (var m in mutables) { // We start treating m as a pure read-only variable! Action a = () => Console.WriteLine(mX)); Console.WriteLine("Before change: {0}", mX); // X = 1 // We'll change a COPY instead of am variable! m.ChangeX(10); Console.WriteLine("After change: {0}", mX); // X = 1 } 

不幸的是,我无法找到严格的规则,只读局部变量应该如何表现,但很明显,这种行为因循环体而不同:我们不是在简单循环中为每次访问复制到本地,但是如果循环我们就这样做body关闭循环变量。

我们都知道Closing over loop变量被认为是有害的,并且在C#5.0中改变了循环实现。 在C#5.0时代之前修复旧问题的简单方法是引入局部变量,但有趣的是在我们的案例中引入局部变量也会改变行为:

 foreach (var mLoop in mutables) { // Introducing local variable! var m = mLoop; // We're capturing local variable instead of loop variable Action a = () => Console.WriteLine(mX)); Console.WriteLine("Before change: {0}", mX); // X = 1 // We'll roll back this behavior and will change // value type directly in the closure without making a copy! m.ChangeX(10); // X = 10 !! Console.WriteLine("After change: {0}", mX); // X = 1 } 

实际上这意味着C#5.0具有非常细微的突破性变化,因为没有人会再引入局部变量(甚至像ReSharper这样的工具也会在VS2012中停止对它的警告,因为它不是问题)。

我对两种行为都很好,但不一致似乎很奇怪。

我怀疑这与lambda表达式的计算方式有关。 有关详细信息,请参阅此问题及其答案。

题:

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

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

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

回答:

这是C#中最糟糕的“问题”之一, 我们将采取突破性的改变来解决它。 在C#5中,foreach循环变量将在逻辑上位于循环体内,因此闭包每次都会得到一个新的副本。

为了完成谢尔​​盖的post,我想用手动闭包添加以下示例,它演示了编译器的行为。 当然编译器可能有任何其他实现满足foreach语句变量中捕获的 只读要求。

 static void Main() { var list = new List() { new MutableStruct { Value = 10 } }; foreach (MutableStruct item in list) { var c = new Closure(item); Console.WriteLine(c.Item.Value); Console.WriteLine("Before: {0}", c.Item.Value); // 10 c.Item.AssignValue(30); Console.WriteLine("After: {0}", c.Item.Value); // Still 10! } } class Closure { public Closure(MutableStruct item){ Item = item; } //readonly modifier is mandatory public readonly MutableStruct Item; public void Foo() { Console.WriteLine(Item.Value); } } 

这可能会解决您的问题。 它将forach foreach for,并使struct不可变。

 using System; using System.Collections.Generic; using System.Linq; using System.Text; namespace WeirdnessExample { public struct RawData { private readonly int id; public int ID { get{ return id;} } public RawData(int newID) { id = newID; } } public class ProcessedData { private readonly int id; public int ID { get{ return id;} } public ProcessedData(int newID) { id = newID; } } class Program { static void Main(string[] args) { List processedRecords = new List(); processedRecords.Add(new ProcessedData(1)); List rawRecords = new List(); rawRecords.Add(new RawData(2)); for (int i = 0; i < rawRecords.Count; i++) { RawData rawRec = rawRecords[i]; int id = rawRec.ID; if (i < 0 || i > 20) { RawData rawRec2 = rawRec; List matchingRecs = processedRecords.FindAll(mr => mr.ID == rawRec2.ID); } Console.Write(String.Format("With LINQ: ID Before Assignment = {0}, ", rawRec.ID)); //2 rawRec = new RawData(rawRec.ID + 8); Console.WriteLine(String.Format("ID After Assignment = {0}", rawRec.ID)); //2 i++; } rawRecords = new List(); rawRecords.Add(new RawData(2)); for (int i = 0; i < rawRecords.Count; i++) { RawData rawRec = rawRecords[i]; int id = rawRec.ID; if (i < 0) { List matchingRecs = processedRecords.FindAll(mr => mr.ID == id); } Console.Write(String.Format("With LINQ: ID Before Assignment = {0}, ", rawRec.ID)); //2 rawRec = new RawData(rawRec.ID + 8); Console.WriteLine(String.Format("ID After Assignment = {0}", rawRec.ID)); //10 i++; } Console.ReadLine(); } } }