20

我已经解决了这个问题,但我试图弄清楚它为什么有效。基本上,我正在使用 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();
        }
    }
}
4

5 回答 5

39

好的,我已经设法用一个相当简单的测试程序重现了这一点,如下所示,我现在明白了。不可否认,理解它并没有让我感到恶心,但是嘿......代码后的解释。

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我们都会得到一个新的结构副本。这通常应该没问题 - 迭代变量是只读的,所以我们希望它不会改变。但是,您有一个方法可以更改结构的内容,从而导致意外行为。

请注意,如果您尝试更改property,则会收到编译时错误:

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

教训:

  • 可变结构是邪恶的
  • 通过方法变异的结构是双重邪恶的
  • 通过对已捕获的迭代变量的方法调用来改变结构在破坏的程度上是三重邪恶的

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

于 2012-11-28T17:32:27.927 回答
4

行。我们在这里肯定有问题,但我怀疑这个问题不是闭包本身,而是 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
}

不幸的是,我找不到严格的规则,只读局部变量的行为方式,但很明显,这种行为基于循环体是不同的:我们不会为简单循环中的每次访问复制到本地,但如果循环我们这样做body 关闭循环变量。

我们都知道关闭循环变量被认为是有害的,并且在 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 中也停止警告它,因为它不是问题)。

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

于 2012-11-29T11:12:32.357 回答
1

我怀疑这与如何评估 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 循环变量在逻辑上位于循环体中,因此闭包每次都会获得一个新副本。

于 2012-11-28T17:12:38.920 回答
1

只是为了完成 Sergey 的帖子,我想添加以下带有手动关闭的示例,以演示编译器的行为。当然,编译器可能具有满足在 foreach语句变量中捕获的只读要求的任何其他实现。

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);
    }
}  
于 2012-11-29T11:59:47.030 回答
0

这可能会解决您的问题。它换成foreachafor并使之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();
        }
    }
}
于 2012-11-28T18:05:54.203 回答