当在lambda中捕获时,C#结构实例行为会发生变化



我有一个解决这个问题的方法,但我正在努力弄清楚它为什么有效。基本上,我使用foreach循环遍历结构列表。如果在调用结构的方法之前包含引用当前结构的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<ProcessedData> processedRecords = new List<ProcessedData>();
processedRecords.Add(new ProcessedData()
{
ID = 1
});

List<RawData> rawRecords = new List<RawData>();
rawRecords.Add(new RawData()
{
ID = 2
});

int i = 0;
foreach (RawData rawRec in rawRecords)
{
int id = rawRec.ID;
if (i < 0 || i > 20)
{
List<ProcessedData> 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<RawData>();
rawRecords.Add(new RawData()
{
ID = 2
});
i = 0;
foreach (RawData rawRec in rawRecords)
{
int id = rawRec.ID;
if (i < 0)
{
List<ProcessedData> 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<MutableStruct>()
{
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<Mutable>{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.M.X remains unchanged!
mr.M.ChangeX(10);

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

mr.M.ChangeX(10);

相当于:

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

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

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

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

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

我们都知道关闭循环变量被认为是有害的,并且在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(m.X));
Console.WriteLine("Before change: {0}", m.X); // 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}", m.X); // 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#中最糟糕的"gotchas"之一,我们将采取突破性的更改来修复它。在C#5中,foreach循环变量将在逻辑上位于循环体内部,因此闭包每次都会得到一个新的副本。

为了完成Sergey的文章,我想添加以下带有手动闭包的示例,演示编译器的行为。当然,编译器可能有任何其他实现来满足foreach语句变量中捕获的readonly要求。

static void Main()
{
var list = new List<MutableStruct>()
{
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);
}
}  

这可能会解决您的问题。它将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<ProcessedData> processedRecords = new List<ProcessedData>();
processedRecords.Add(new ProcessedData(1));

List<RawData> rawRecords = new List<RawData>();
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<ProcessedData> 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<RawData>();
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<ProcessedData> 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();
}
}
}

最新更新