Подтвердить что ты не робот

Изменения поведения экземпляра С# Struct при захвате в лямбда

У меня есть работа по этой проблеме, но я пытаюсь понять, почему она работает. В принципе, я перебираю список структур, используя foreach. Если я включаю оператор LINQ, который ссылается на текущую структуру, прежде чем я вызову метод struct, метод не сможет изменить членов структуры. Это происходит независимо от того, вызывается ли оператор 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();
        }
    }
}
4b9b3361

Ответ 1

Хорошо, мне удалось воспроизвести это с помощью более простой тестовой программы, как показано ниже, и теперь я это понимаю. По общему признанию, понимание этого не заставляет меня чувствовать себя менее тошнотворным, но эй... Объяснение после кода.

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

Разница между двумя циклами заключается в том, что во втором, переменная цикла захватывается лямбда-выражением. Второй цикл эффективно превращается в нечто вроде этого:

// 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% ясно, работает ли компилятор С# в соответствии со спецификацией здесь. Я подозреваю, что это так. Даже если это не так, я бы не хотел, чтобы команда приложила все усилия для его исправления. Код, подобный этому, просто умоляет сломаться тонким образом.

Ответ 2

Ok. У нас определенно есть проблемы здесь, но я подозреваю, что этот вопрос не с закрытием как таковым, а с внедрением foreach.

Указанная спецификация С# 4.0 (8.8.4 Оператор foreach): "Итерационная переменная соответствует локальной переменной только для чтения с областью действия, которая распространяется на встроенный оператор". Поэтому мы не можем изменить переменную цикла или увеличить его свойство (как уже сказал Джон):

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++;
}

В этом отношении переменные цикла только для чтения ведут себя почти точно так же, как и любое поле readonly (в терминах доступа к этой переменной вне конструктора):

  • Мы не можем изменить поле readonly вне конструктора
  • Мы не можем изменить свойство поля только для чтения типа значения
  • Мы обрабатываем поля readonly как значения, которые приводят к использованию временной копии каждый раз, когда мы обращаемся к полям readonly типа значения.

.

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);

Существует множество проблем, связанных с изменяемыми типами значений, и один из них связан с последним поведением: изменение readonly struct через метод mutator (например, ChangeX) приводит к неясному поведению, потому что мы модифицируем копию, но не сам объект readonly:

mr.M.ChangeX(10);

Является эквивалентным:

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

Если переменная цикла, обработанная компилятором С# как локальная переменная, доступная только для чтения, кажется разумной ожидать того же поведения для них, что и для полей только для чтения.

В настоящее время переменная цикла в простом цикле (без каких-либо замыканий) ведет себя почти так же, как поле только для чтения, кроме копирования его для каждого доступа. Но если происходит смена кода и закрытие, переменная цикла начинает вести себя как чистая переменная только для чтения:

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
}

К сожалению, я не могу найти строгие правила о том, как должны вести себя только локальные переменные только для чтения, но ясно, что это поведение отличается от тела цикла: мы не копируем локаторы для каждого доступа в простой петле, но мы делаем это если тело цикла закрывает переменную цикла.

Мы все знаем, что закрытая переменная цикла считается вредной, и эта реализация цикла была изменена в С# 5.0. Простой способ исправить эту старую проблему в эпоху pre С# 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
}

На самом деле это означает, что С# 5.0 имеет очень тонкое изменение, потому что никто больше не будет вводить локальную переменную (и даже такие инструменты, как ReSharper, останавливают предупреждение об этом в VS2012, потому что это не проблема).

Я в порядке с обоими поведением, но непоследовательность кажется странным.

Ответ 3

Я подозреваю, что это связано с тем, как вычисляются лямбда-выражения. См. этот вопрос и его ответ для более подробной информации.

Вопрос:

При использовании лямбда-выражений или анонимных методов в С# мы должны опасаться доступа к модифицированной ловушке закрытия. Например:

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

Из-за модифицированного закрытия вышеуказанный код приведет к тому, что все предложения Where в запросе будут основаны на конечном значении s.

Ответ:

Это одна из худших "gotchas" на С#, а мы собираемся принять исправление для исправления. В С# 5 переменная цикла foreach будет логически находиться внутри тела и поэтому замыкания получат новую копию каждый раз.

Ответ 4

Чтобы выполнить запись Сергея, я хочу добавить следующий пример с закрытием вручную, который демонстрирует поведение компилятора. Конечно, компилятор может иметь любую другую реализацию, которая удовлетворяет требованию readonly , записанному в переменной 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);
    }
}  

Ответ 5

Это может решить вашу проблему. Он меняет 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();
        }
    }
}