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

2 つのループの違いは、2 番目のループでは、ループ変数がラムダ式によってキャプチャされることです。2 番目のループは、効果的に次のようになります。

// 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'

教訓:

  • 可変構造体は悪です
  • メソッドによって変更された構造体は二重に悪です
  • キャプチャされた反復変数のメソッド呼び出しを介して構造体を変更することは、破損の範囲で三重に悪です

C# コンパイラが仕様どおりに動作しているかどうかは、私には 100% 明らかではありません。そうだと思います。たとえそうでなくても、チームがそれを修正するために何らかの努力をするべきだと提案したくはありません. このようなコードは、微妙な方法で壊されることを懇願しています。

于 2012-11-28T17:32:27.927 に答える
4

Ok。ここには間違いなく問題がありますが、この問題はクロージャ自体ではなく、代わりに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);

可変値型に関連する問題はたくさんあり、そのうちの1つは最後の動作に関連しています。ミューテイタメソッド(のような)を介して読み取り専用構造体を変更すると、コピーを変更するが読み取り専用オブジェクト自体は変更しない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

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 に答える
1

これは、ラムダ式の評価方法に関係していると思われます。詳細については、この質問とその回答を参照してください。

質問:

C# でラムダ式または匿名メソッドを使用する場合、変更されたクロージャーへのアクセスの落とし穴に注意する必要があります。例えば:

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

クロージャが変更されているため、上記のコードにより、クエリのすべての Where 句が の最終値に基づくようになりますs

答え:

これは C# の最悪の "落とし穴" の 1 つであり、重大な変更を行って修正します。C# 5 では、foreach ループ変数は論理的にループの本体内にあるため、クロージャーは毎回新しいコピーを取得します。

于 2012-11-28T17:12:38.920 に答える
0

これで問題が解決する場合があります。foreachaに交換してforstruct不変にします。

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 に答える