Почему захват изменяемой структурной переменной внутри замыкания в операторе using изменяет ее локальное поведение?

Обновление: теперь я пошел и сделал это: I отправил отчет об ошибке в Microsoft, так как я серьезно сомневаюсь, что это правильное поведение. Тем не менее, я все еще не уверен на 100%, чему верить относительно этого вопроса; так что я вижу, что то, что «правильно», открыто для некоторого уровня интерпретации.

Мне кажется, что либо Microsoft согласится с тем, что это ошибка, либо ответит, что изменение переменной типа изменяемого значения в операторе using представляет собой неопределенное поведение.

Кроме того, как бы то ни было, у меня есть, по крайней мере, предположение о том, что здесь происходит. Я подозреваю, что компилятор создает класс для закрытия, «поднимая» локальную переменную до поля экземпляра этого класса; и поскольку он находится внутри блока using, он создает поле readonly. Как указал Люк Х в комментарий к другому вопросу, это помешает вызовам методов, таких как MoveNext, изменять само поле (вместо этого они повлияют на копию).


Примечание. Я сократил этот вопрос для удобства чтения, хотя он все еще не совсем короткий. Полный исходный (более длинный) вопрос см. В истории изменений.

Я прочитал то, что, по моему мнению, является соответствующими разделами ECMA-334, и не могу найти окончательного ответа на этот вопрос. Сначала я сформулирую вопрос, а затем дам ссылку на дополнительные комментарии для тех, кому это интересно.

Вопрос

Если у меня есть изменяемый тип значения, реализующий IDisposable, я могу (1) вызвать метод, который изменяет состояние значения локальной переменной в операторе using, и код ведет себя так, как я ожидал. Однако, как только я фиксирую рассматриваемую переменную внутри замыкания внутри оператора using, (2) изменения значения больше не видны в локальной области.

Такое поведение очевидно только в том случае, если переменная захвачена внутри замыкания и в операторе using; это не очевидно, когда присутствует только одно (using) или другое условие (закрытие).

Почему захват переменной изменяемого типа значения внутри замыкания в операторе using изменяет ее локальное поведение?

Ниже приведены примеры кода, иллюстрирующие пункты 1 и 2. В обоих примерах будет использоваться следующий демонстрационный Mutable тип значения:

struct Mutable : IDisposable
{
    int _value;
    public int Increment()
    {
        return _value++;
    }

    public void Dispose() { }
}

1. Изменение переменной типа значения в блоке using

using (var x = new Mutable())
{
    Console.WriteLine(x.Increment());
    Console.WriteLine(x.Increment());
}

Выходной код выводит:

0
1

2. Захват переменной типа значения внутри замыкания в блоке using

using (var x = new Mutable())
{
    // x is captured inside a closure.
    Func<int> closure = () => x.Increment();

    // Now the Increment method does not appear to affect the value
    // of local variable x.
    Console.WriteLine(x.Increment());
    Console.WriteLine(x.Increment());
}

Приведенный выше код выводит:

0
0

Дальнейшие комментарии

Было отмечено, что компилятор Mono обеспечивает ожидаемое мной поведение (изменения значения локальной переменной все еще видны в случае закрытия using +). Мне непонятно, правильно ли это поведение или нет.

Дополнительные мои мысли по этому поводу см. На странице здесь.


person Dan Tao    schedule 09.01.2011    source источник
comment
Интересно, что такое поведение не проявляется в Mono, где итерация по всем трем элементам происходит нормально.   -  person cdhowie    schedule 10.01.2011
comment
@cdhowie: Это это интересно. Дайте мне минутку, чтобы проверить на моей машине ...   -  person Dan Tao    schedule 10.01.2011
comment
Тот факт, что mono дает ожидаемый ответ, делает его похожим на ошибку в csc, но ожидаемый ответ может быть неправильным. Например, закрытие переменной итерации в цикле foreach обычно дает неожиданные, если они правильные, результаты.   -  person Gabe    schedule 10.01.2011
comment
@Gabe: Это правда, но закрытие переменной цикла не меняет семантику самого foreach.   -  person LukeH    schedule 10.01.2011
comment
LukeH: Вы правы; это был всего лишь пример того, как неожиданное поведение на самом деле является правильным в соответствии со спецификациями.   -  person Gabe    schedule 10.01.2011
comment
@Gabe: Я определенно согласен, и я пытаюсь тщательно подбирать слова, чтобы не утверждать, что одно поведение является правильным или неправильным (хотя я лично предпочитаю один). Но что меня сбивает с толку, так это то, что поведение, генерируемое csc, может быть правильным, если оно характерно для случая закрытия using plus. Мне кажется, он должен вести себя либо в одном направлении, при наличии замыкания, либо последовательно; или он должен постоянно вести себя наоборот. В нынешнем виде меня беспокоит непоследовательность. Тем не менее, я готов верить, что может быть объяснение, которое для меня просто неочевидно ...   -  person Dan Tao    schedule 10.01.2011
comment
Я удивлен, что Эрик Липперт не вмешался в это. Кажется, что на этот вопрос мало кто кроме него может ответить.   -  person Gabe    schedule 11.01.2011
comment
@Gabe: Я чувствовал, что, возможно, длина и плотность вопроса отталкивают потенциальных читателей. В несколько слабой попытке исправить это, я переписал его. Время покажет, получу ли я еще укусы, от Липперта или где-нибудь еще!   -  person Dan Tao    schedule 11.01.2011
comment
@Dan: Что касается вашего последнего обновления ... Этот тип-то-что-то-то-не описывает, что на самом деле происходит: поле, которое представляет захваченный локальный объект в сгенерированном компилятором классе , не помечено readonly, но, что интересно, IL, который обращается к полю в блоке using, точно такой же, как если бы поле было readonly (т. е. скопируйте поле в локальное и измените локальное вместо того, чтобы изменять само поле).   -  person LukeH    schedule 13.01.2011
comment
@Gabe: Я ем и сплю, знаете ли. Я не вижу всех вопросов на этом сайте!   -  person Eric Lippert    schedule 14.01.2011
comment
@Eric: Извините, я просто предположил, что вы - еще один экземпляр SkeetBot, просто запустите его с другими параметрами.   -  person Gabe    schedule 14.01.2011
comment
Однажды я прочитал совет Джона Скита: держитесь подальше от изменяемых структур, потому что они всего лишь проблемы. Этот вопрос, кажется, еще больше укрепляет это мнение.   -  person ShdNx    schedule 29.04.2011


Ответы (4)


Это известная ошибка; мы обнаружили это пару лет назад. Исправление могло бы привести к сбою, а проблема довольно неясна; это пункты против его исправления. Таким образом, это никогда не было настолько приоритетным, чтобы на самом деле это исправить.

Это было в моей очереди потенциальных тем для блога уже пару лет; возможно, мне стоит об этом написать.

И, между прочим, ваше предположение о механизме, объясняющем ошибку, совершенно верно; там хорошая психическая отладка.

Итак, да, известная ошибка, но несмотря ни на что, спасибо за отчет!

person Eric Lippert    schedule 14.01.2011
comment
Взрыв! Думал, что открыл для себя что-то новое! - person Dan Tao; 14.01.2011
comment
@Dan: Нет, но все равно это отличная работа. Кстати, я, кажется, припоминаю, что есть похожие ошибки, связанные с блокировкой. - person Eric Lippert; 14.01.2011
comment
@EricLippert: похоже, такая же проблема существует и с переменной цикла foreach - stackoverflow.com/questions/13610559/ - person Sergey Teplyakov; 06.12.2012
comment
@SergeyTeplyakov: Есть очень похожая, но не совсем идентичная проблема с переменной цикла foreach, да. - person Eric Lippert; 11.12.2012

Это связано со способом создания и использования типов замыканий. Кажется, есть небольшая ошибка в том, как csc использует эти типы. Например, вот IL, сгенерированный gmcs Mono при вызове MoveNext ():

      IL_0051:  ldloc.3
      IL_0052:  ldflda valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32> Foo/'<Main>c__AnonStorey0'::enumerator
      IL_0057:  call instance bool valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::MoveNext()

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

Вот что генерирует csc:

      IL_0068:  ldloc.3
      IL_0069:  ldfld valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32> Tinker.Form1/'<>c__DisplayClass3'::enumerator
      IL_006e:  stloc.s 5
      IL_0070:  ldloca.s 5
      IL_0072:  call instance bool valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::MoveNext()

Итак, в этом случае он берет копию экземпляра типа значения и вызывает метод для этой копии. Неудивительно, почему это ни к чему не приведет. Вызов get_Current () также неверен:

      IL_0052:  ldloc.3
      IL_0053:  ldfld valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32> Tinker.Form1/'<>c__DisplayClass3'::enumerator
      IL_0058:  stloc.s 5
      IL_005a:  ldloca.s 5
      IL_005c:  call instance !0 valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::get_Current()
      IL_0061:  call void class [mscorlib]System.Console::WriteLine(int32)

Поскольку состояние копируемого перечислителя не вызвало MoveNext (), get_Current (), по-видимому, возвращает default(int).

Вкратце: csc кажется глючным. Интересно, что Mono получил это право, а MS.NET - нет!

... Я хотел бы услышать комментарии Джона Скита по поводу этой странности.


В ходе обсуждения с brajkovic в #mono он определил, что спецификация языка C # на самом деле не детализирует, как должен быть реализован тип замыкания, а также то, как должны переводиться обращения локальных переменных, которые фиксируются в замыкании. Пример реализации в спецификации, кажется, использует метод «копирования», который использует csc. Следовательно, любой вывод компилятора можно считать правильным в соответствии со спецификацией языка, хотя я бы сказал, что csc должен по крайней мере скопировать локальный обратно в закрывающий объект после вызова метода.

person cdhowie    schedule 10.01.2011
comment
В документации для IEnumerator<T> конкретно указано, что поведение Current не определено до первого вызова MoveNext. Но вы правы, остальное похоже на ошибку кодогенерации в компиляторе MS. - person LukeH; 10.01.2011
comment
Да, мне немного странно, что свойство Current не генерирует исключение перед перечислением и для List<T> (на самом деле делает для других коллекций, таких как Queue<T>), хотя @LukeH прав, что это поведение специально задокументировано как неопределенное; так что М.С. по крайней мере прикрыли там свои спины. Меня беспокоит тот факт, что компилятор Mono ведет себя так, как я ожидал. Это определенно похоже на ошибку ... - person Dan Tao; 10.01.2011
comment
@LukeH: Ах, это полезно знать. Я всегда видел, как в этом случае счетчики генерируют исключения, поэтому я ожидал, что это будет частью контракта интерфейса. Еще одна причина проверить документы. :) - person cdhowie; 10.01.2011
comment
Лично такое поведение кажется правильным, исходя из того, что я знаю о типах значений. Но что я знаю. - person ChaosPandion; 10.01.2011
comment
@ChaosPandion: Технически вы можете назвать это правильным в любом случае. Используя ldflda, Mono устраняет проблему копирования в целом, используя структуру через ее адрес в объекте закрытия. Таким образом, тот факт, что пример кода работает в Mono, свидетельствует о том, что его можно заставить работать, и это поведение является правильным по ИМО, поскольку закрытие должно обрабатывать использование захваченных локальных переменных, как будто все они относятся к одной переменной. Использование типа значения по его адресу поля типа закрытия делает именно это, в то время как копирование значения локально крайне неинтуитивно и вызывает очень плохие побочные эффекты. - person cdhowie; 10.01.2011
comment
@ChaosPandion: Чтобы прояснить мой предыдущий комментарий, обратите внимание, что gmcs переводит ldloca для захваченных переменных в ldflda - это похоже на правильный путь. csc вместо этого переводит ldloca в ldfld; stloc; ldloca. gmcs сохраняет семантику локальных переменных при их захвате; csc нет. - person cdhowie; 10.01.2011
comment
Для всех, кто желает дублировать исследование, соответствующий раздел ECMA-334 - это §14.5.15.5, а спецификация находится в свободном доступе по адресу ecma-international.org/publications/files/ECMA-ST/Ecma-334.pdf. Предыдущие разделы (в общем, весь §14.5.15) посвящены анонимным методам, но соответствующий раздел - .5. - person Bojan Rajkovic; 10.01.2011
comment
Это самое странное; Я только что обнаружил, что такое поведение только возникает внутри using блока, когда переменная включена в замыкание. В using без закрытия все работает нормально. С закрытием not внутри using он также отлично работает. Это происходит только в случае обоих. Что ты думаешь об этом? Мне сложно представить, как это нельзя считать ошибкой. - person Dan Tao; 10.01.2011
comment
@Dan: Хм, это действительно интересно. Возможно, стоит сообщить об этом как об ошибке. - person cdhowie; 10.01.2011
comment
@cdhowie: Готово. - person Dan Tao; 12.01.2011
comment
Это кажется немного несправедливым ... вроде того, что Эрик Липперт получил галочку только потому, что только он мог точно знать, что он говорит (что это ошибка) ... но поскольку его ответ должен рассматриваться как авторитетный, я принял его. Хотя это тоже был отличный ответ. - person Dan Tao; 14.01.2011
comment
Основная проблема заключается в том, что C # и .net не имеют параметров const ref и не позволяют функциям-членам структуры объявлять, следует ли принимать аргумент как ref или const ref. Если кто-то пытается передать экземпляр структуры только для чтения как 'ref', компилятор не должен делать копию - он должен просто запретить поведение. По сути, если генерация кода потребует создания неявной копии структуры, конструкция генератора кода нарушится. К сожалению, учитывая отсутствие 'const ref', нет другого выбора, кроме как ужасно путаться в call-by-tempvalue-ref. - person supercat; 21.01.2012

ИЗМЕНИТЬ - Это неверно, я недостаточно внимательно прочитал вопрос.

Размещение структуры в замыкании вызывает присвоение. Присваивание типов значений приводит к созданию копии типа. Итак, что происходит: вы создаете новый Enumerator<int>, и Current в этом перечислителе вернет 0.

using System;
using System.Collections.Generic;

class Program
{
    static void Main(string[] args)
    {
        List<int> l = new List<int>();
        Console.WriteLine(l.GetEnumerator().Current);
    }
}

Результат: 0

person Martin Doms    schedule 10.01.2011

Проблема в том, что перечислитель хранится в другом классе, поэтому каждое действие работает с копией перечислителя.

[CompilerGenerated]
private sealed class <>c__DisplayClass3
{
    // Fields
    public List<int>.Enumerator enumerator;

    // Methods
    public int <Main>b__1()
    {
        return this.enumerator.Current;
    }
}

public static void Main(string[] args)
{
    List<int> <>g__initLocal0 = new List<int>();
    <>g__initLocal0.Add(1);
    <>g__initLocal0.Add(2);
    <>g__initLocal0.Add(3);
    List<int> list = <>g__initLocal0;
    Func<int> CS$<>9__CachedAnonymousMethodDelegate2 = null;
    <>c__DisplayClass3 CS$<>8__locals4 = new <>c__DisplayClass3();
    CS$<>8__locals4.enumerator = list.GetEnumerator();
    try
    {
        if (CS$<>9__CachedAnonymousMethodDelegate2 == null)
        {
            CS$<>9__CachedAnonymousMethodDelegate2 = new Func<int>(CS$<>8__locals4.<Main>b__1);
        }
        while (CS$<>8__locals4.enumerator.MoveNext())
        {
            Console.WriteLine(CS$<>8__locals4.enumerator.Current);
        }
    }
    finally
    {
        CS$<>8__locals4.enumerator.Dispose();
    }
}

Без лямбда код будет ближе к тому, что вы ожидаете.

public static void Main(string[] args)
{
    List<int> <>g__initLocal0 = new List<int>();
    <>g__initLocal0.Add(1);
    <>g__initLocal0.Add(2);
    <>g__initLocal0.Add(3);
    List<int> list = <>g__initLocal0;
    using (List<int>.Enumerator enumerator = list.GetEnumerator())
    {
        while (enumerator.MoveNext())
        {
            Console.WriteLine(enumerator.Current);
        }
    }
}

Конкретный Иллинойс

L_0058: ldfld valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32> Machete.Runtime.Environment/<>c__DisplayClass3::enumerator
L_005d: stloc.s CS$0$0001
L_005f: ldloca.s CS$0$0001
person ChaosPandion    schedule 10.01.2011
comment
Вывод (предположительно сгенерированный Reflector) там категорически неверен. while (CS$<>8__locals4.enumerator.MoveNext()) должно работать нормально. То, что на самом деле происходит, эффективно List<int>.Enumerator foo; while ((foo = CS$<>8__locals4.enumerator), foo.MoveNext()) (хотя это синтаксически неверно, вы поняли). Другими словами, вместо того, чтобы работать с полем закрываемого объекта по адресу, что могло бы работать, создается локальная копия. - person cdhowie; 10.01.2011
comment
@cdhowie - Вы правы, но для каждой итерации требуется копия поля. - person ChaosPandion; 10.01.2011
comment
@Chaos: Это именно то, что я сказал. Но код, сгенерированный Reflector, не копирует, и, следовательно, не является точной картиной того, что на самом деле происходит. - person cdhowie; 10.01.2011
comment
@cdhowie правильный. Если вы возьмете этот код, созданный с помощью Reflector, и вставите его обратно в свой собственный источник, он будет работать правильно. (Очевидно, сначала после переименования идентификаторов, сгенерированных компилятором, во что-то легальное.) - person LukeH; 10.01.2011
comment
@cdhowie - Копия - это весь процесс получения поля из экземпляра. Что именно вы пытаетесь мне сказать? - person ChaosPandion; 10.01.2011
comment
@ChaosPandion: строка кода while (CS$<>8__locals4.enumerator.MoveNext()), созданная Reflector, просто неверна. Если вы скомпилируете этот код, он выдаст последовательность ldflda; call;, а не последовательность ldfld; stloc; ldloca; call;. - person cdhowie; 10.01.2011