О вреде изменяемых значимых типов

    Большинство программистов, которых нелегкая судьба свела с платформной.Net знают о существовании значимых типов (value types) и ссылочных типов (reference types). И довольно многие из них прекрасно знают, что помимо названия, эти типы имеют и другие различия, такие как расположение объектов оных типов в памяти, а также в семантике.

    Что касается первого различия (о котором стоит упомянуть как минимум ради полноты изложения), то оно заключается в том, что экземпляры ссылочных типов всегда располагаются в управляемой куче, в то время как экземпляры значимых типов по умолчанию располагаются в стеке, но могут мигрировать в управляемую кучу вследствие упаковки, будучи членами ссылочных типов, а также при использовании их в хитрых экзотических конструкциях языка C#, типа замыканий (*).

    Хотя это отличие является очень важным и именно благодаря нему значимые типы существуют и используются, у этой пары типов есть еще одно, не менее важное семантическое различие. Значимые типы, как подсказывает название, являются значениями, которое копируется каждый раз при передаче в функцию или при возвращении из нее. А поскольку при копировании, как, опять же, подсказывает название, передается и возвращается не исходный вариант, а копия, то все попытки изменений приведут к изменению копии, а не исходного экземпляра.


    В теории, последнее заявление кажется настолько простым и очевидным, что оно кажется недостойным внимания, однако в языке C# существует ряд моментов, когда копирование происходит настолько неявно, что преводит к копированию совершенно не того экземпляра, о котором думает разработчик, что приводит его (разработчика) в легкое замешательство.

    Давайте рассмотрим некоторые из таких примеров.

    1. Изменяемый значимый тип в виде свойства объекта



    Давайте начнем с относительно простого примера, в котором копирование происходит достаточно явно. Предположим у нас есть некоторый изменяемый значимый тип (который, кстати, нам пригодится не только для этого, но и для всех последующих примеров) под названием Mutable и некоторый класс A, который содержит свойство указанного типа:

    struct Mutable
    {
      public Mutable(int x, int y)
        : this()
      {
        X = x;
        Y = y;
      }
      public void IncrementX() { X++; }
      public int X { get; private set; }
      public int Y { get; set; }
    }
    class A
    {
      public A() { Mutable = new Mutable(x: 5, y: 5); }
      public Mutable Mutable { get; private set; }
    }


    * This source code was highlighted with Source Code Highlighter.


    Пока, вроде бы, ничего интересного, но давайте посмотрим на следующий пример:

    A a = new A();
    a.Mutable.Y++;


    * This source code was highlighted with Source Code Highlighter.


    Самое интересное, что этот код вообще не скомпилируется, поскольку вторая строка (a.Mutable.Y++;) является некорректной с точки зрения языка C#. Поскольку значение структуры Mutable копируется при возвращении из одноименного свойства, то компилятор уже на этапе компиляции понимает, что ничего хорошего от изменения временного объекта не будет, о чем и говорит красноречиво в сообщении об ошибке: “error CS1612: Cannot modify the return value of 'System.Collections.Generic.IList<MutableValueTypes.Mutable>.this[int]' because it is not a variable”. Всем, кто более или менее знаком с языком С++, такое поведение будет достаточно понятным, поскольку в этой строке кода мы пытаемся сделать не что иное, как изменить значение, не являющееся l-value.

    Хотя компилятор понимает семантику оператора ++, в общем случае он понятия не имеет о том, что делает конкретная функция с текущим объектом, в частности, изменяет ли она его или нет. И хотя мы не можем вызвать оператор ++ свойства Y в предыдущем фрагменте кода, мы спокойно сможем вызвать метод IncrementX свойства X:

    Console.WriteLine("Исходное значение Mutable.X: {0}", a.Mutable.X);
    a.Mutable.IncrementX();
    Console.WriteLine("Mutable.X после вызова IncrementX(): {0}", a.Mutable.X);


    * This source code was highlighted with Source Code Highlighter.


    Хотя предыдущий код ведет себя некорректно, заметить ошибку невооруженным взглядом не всегда просто. Каждый раз при обращении к свойству Mutable класса создается новая копия, для которой и вызывается метод IncrementX, но поскольку изменение копии никакого отношения к изменению исходного объекта не имеет, то и вывод на консоль, при выполнении предыдущего фрагмента кода будет соответствующий:

    Исходное значение Mutable.X: 5

    Mutable.X после вызова IncrementX(): 5


    «Хм… ничего сверхъестественного», скажите вы и будете правы… до тех пор, пока мы не рассмотрим другие, более интересные случаи.

    2. Изменяемые значимые типы и модификатор readonly



    Давайте рассмотрим класс B, который в качестве readonly поля содержит нашу изменяемую структуру Mutable:

    class B
    {
      public readonly Mutable M = new Mutable(x: 5, y: 5);
    }


    * This source code was highlighted with Source Code Highlighter.


    Опять-таки, это не rocket science, а самый простой класс, единственным недостатком которого является использование открытого поля. Но поскольку открытость этого поля обусловлена простой примера и удобством, а не ошибками дизайна, то обращать внимание на эту мелочь не стоит. Вместо этого, стоит обратить внимание на простой пример использования этого класса и на получаемые результаты.

    B b = new B();
    Console.WriteLine("Исходное значение M.X: {0}", b.M.X);
    b.M.IncrementX();
    b.M.IncrementX();
    b.M.IncrementX();
    Console.WriteLine("M.X после трех вызовов IncrementX: {0}", b.M.X);


    * This source code was highlighted with Source Code Highlighter.


    Итак, что будет выведено в результате? 8? (Напомню, что исходное значение свойства X равно 5, а 5 + 3, как известно, равно 8; 7 возможно, было бы лучше, но, увы, получается аж 8) Или, может быть -8? Шутка.

    Вроде бы M – это не свойство, которое будет копироваться каждый раз при его возвращении, так что ответ 8 кажется вполне логичным. Однако, компилятор (и спецификация языка C#, кстати, тоже) с нами не согласятся и, в результате выполнения этого кода, M.X все еще будет равен 5:

    Исходное значение M.X: 5

    M.X после трех вызовов IncrementX(): 5


    Все дело здесь в том, что согласно спецификации, при обращении к полю только для чтения вне конструктора, генерируется временная переменная, для которой и вызывается метод IncrementX. По сути, предыдущий фрагмент кода компилятором переписывается таким образом:

    Console.WriteLine("Исходное значение M.X: {0}", b.M.X);
    Mutable tmp1 = b.M;
    tmp1.IncrementX();
    Mutable tmp2 = b.M;
    tmp2.IncrementX();
    Mutable tmp3 = b.M;
    tmp3.IncrementX();
    Console.WriteLine("M.X после трех вызовов IncrementX: {0}", b.M.X);


    * This source code was highlighted with Source Code Highlighter.


    (Да, если вы уберете модификатор readonly, то вы получите ожидаемый результат; после трех вызовов метода IncrementX значение свойства X переменной M будет равно 8.)

    3. Массивы и списки



    Очередным, но явно не последним, моментом неочевидного поведения изменяемых значимых типов является их использование в массивах и списках. Итак, давайте поместим один элемент изменяемого значимого типа в коллекцию, например в список List<T>.

    List<Mutable> lm = new List<Mutable> { new Mutable(x: 5, y: 5) };

    * This source code was highlighted with Source Code Highlighter.


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

    lm[0].Y++; // Ошибка компиляции
    lm[0].IncrementX(); // ведет к изменению временной переменной


    * This source code was highlighted with Source Code Highlighter.


    Теперь давайте попробуем проделать ту же самую операцию с массивом:

    Mutable[] am = new Mutable[] { new Mutable(x: 5, y: 5) };
    Console.WriteLine("Исходные значения X: {0}, Y: {1}", am[0].X, am[0].Y);
    am[0].Y++;
    am[0].IncrementX();
    Console.WriteLine("Новые значения X: {0}, Y: {1}", am[0].X, am[0].Y);


    * This source code was highlighted with Source Code Highlighter.


    В этом случае большинство разработчиков будут предполагать, что индексатор массива ведет себя аналогичным образом, возвращая копию элемента, который затем и изменяется в нашем коде. И поскольку язык C# не поддерживает такую возможность, как возвращение «управляемых указателей» (managed pointers) из функции, то других вариантов, вроде бы и нет. Ведь все, что мы можем, так это создавать синонимы нашей переменной (alias) и передать ее в другую функцию с помощью ключевых слов ref или out, но мы не можем написать функцию, возвращающую ссылку на одно из полей объекта.

    Но хотя язык C# и не поддерживает возвращение управляемых ссылок в общем случае, существует особая оптимизация в виде специальной инструкции IL-кода, которая позволяет получить не просто копию элемента массива, а ссылку на него (для любознательных, эта инструкция называется ldelema). Благодаря этой возможности, предыдущий фрагмент не только полностью корректен (включая строку am[0].Y++;), но и позволяет изменить непосредственно элементы массива, а не их копии. И если вы запустите предыдущий фрагмент кода, то увидите, что он компилируется, запускается, и напрямую изменяет нулевой объект массива.

    Исходные значения X: 5, Y: 5

    Новые значения X:6, Y:6


    Однако если рассматриваемый выше массив привести к одному из его интерфейсов, такому как IList<T>, то вся уличная магия в виде генерации особых IL-инструкций останутся за бортом, и мы получим поведение, описанное в начале этого раздела.

    Mutable[] am = new Mutable[] { new Mutable(x: 5, y: 5) };
    IList<Mutable> lst = am;
    lst[0].Y++; // Ошибка компиляции
    lst[0].IncrementX(); // изменение временной переменной


    * This source code was highlighted with Source Code Highlighter.


    4. И зачем мне все это?



    Вопрос резонный, особенно если вспомнить, насколько часто вы создаете свои собственные значимые типы и уж тем более, насколько часто вы их делаете изменяемыми. Но польза от этих знаний есть. Во-первых, мы с вами не единственные программисты на свете, как не сложно догадаться, существует много других «гавриков», которые клепают код со страшной силой и создают свои собственные изменяемые структуры. И даже если лично в вашей команде таких «гавриков» нет, то они есть в других командах, например в команде разработчиков .Net Framework. Да, в составе .Net Framework есть достаточное количество изменяемых значимых типов, неосмотрительное использование которых может привести к дорогостоящим сюрпризам (**).

    Классическим примером изменяемого значимого типа является структура Point, а также енумераторы, например ListEnumerator. И если в первом случае отпилить себе ногу весьма сложно, то во втором случае – будь здоров:

    var x = new { Items = new List<int> { 1, 2, 3 }.GetEnumerator() };
    while (x.Items.MoveNext())
    {
      Console.WriteLine(x.Items.Current);
    }


    * This source code was highlighted with Source Code Highlighter.


    (Скопируйте этот код в LINQPad или в метод Main и запустите.)

    Заключение



    Говорить категорично о том, что изменяемые значимые типы являются полным злом точно также неверно, как и говорить о всеобъемлющем зле оператора goto. Известно, что использование оператора goto программистом напрямую в крупной промышленной системе может привести к сложному для понимания и сопровождения коду, к скрытым ошибкам и головной боли при поиске ошибок. По этой же причине нужно остерегаться и изменяемых значимых типов: если вы умеете их готовить, то аккуратное их применение может быть неплохой оптимизацией производительности. Но эта эффективность вполне может вам аукнуться позднее, когда за дело возьмется ваш сосед, который еще не выучил спецификацию языка C# на зубок и все еще не знает, что использование конструкции using со значимыми типами приводит к очистке копии (***).

    Использование значимых типов уже является оптимизацией, поэтому вам обязательно нужно доказать, что их использование того стоит и вы получите повышение производительности. Использование же изменяемых значимых типов является оптимизацией в квадрате (ведь вы экономите на копии при их изменении), поэтому прежде делать свои значимые типы изменяемыми стоит подумать не n раз, а n раз в квадрате.

    -----------------------------

    (*) Замыкание – это не такой уж страшный зверь, как может показаться из замысловатого названия. И если вдруг, по какой-то причине вы не уверены в своих знаниях по этому поводу, то этот как раз отличный повод это исправить: “Замыкания в языке C#”.

    (**) Что самое интересное, изменяемые значимые типы – это далеко не единственное сомнительное решение, проявление которого легко можно найти в составе .Net Framework. Другим, не менее сомнительным дизайнерским решением является поведение виртуальных событий (о которых я писал ранее), и при всем своем неоднозначном поведении, они также присутствуют в .Net Framework (например, события PropertyChanged и CollectionChanged класса ObservableCollection являются виртуальными)

    (***) Это тонкий намек на одну из статей Эрика Липперта (который считает изменяемые значимые типы самым большим вселенским злом), в которой он показывает «не совсем очевидное» поведение при использовании изменяемых значимых типов, реализующих интерфейс IDisposable: To box or not to box, that is a question.

    Комментарии 27

      +1
      Помню в бытность изучения платформы наткнулся на такое, когда пытался поправить Location у контрола в WinForms: someControl.Location.X++; (где Location типа Point, который, ессесно, struct).
      А так всё описанное должен знать любой дотнетчик. (хотя бы для прохождения собеседования).
        +3
        Ну, можно провести опрос, но мне как-то сомнительно, что много людей скажут точное поведение для 2-го и 3-го случаев, т.е. для readonly и массива и тем более за трюк с анонимным классом.

        И я никого не знаю, кто такую жесть спрашивает на собеседовании:) (помимо первого случая).
          0
          ой там где я бывал — всегда любили попытаться опустить кандидата — к примеру, на знание замыканий, дабы он (кандидат) уж слишком много не требовал
            0
            Ну, хз. Мне часто говорят, что я задаю сложные вопросы на собеседовании, но про устройство замыканий я никогда не спрашивал, и подобный хард-кор, как приведенные выше примеры (кроме первого случая) — тоже.
              0
              ну в любом случае, да, кроме первого (и второго собсно) вашего пункта в моём комменте «должен» надо заменить на «не плохо бы знать, что такое есть».
        +1
        Хотя про большие угрозы со стороны изменяемых типов-значений я наслышан, было интересно посмотреть ещё несколько примеров, где могут всплыть проблемы. Спасибо!
          +1
          Ага, не за что!
          Проблемы, связанные с изменением структуры через интерфейс довольно известны (спасибо дядьке Рихтеру), а вот многие другие — известны не так уж и многим…
            +1
            Да. Именно от Рихтера узнал про такие неприятности. А примеры действительно толковые, сам не напарывался на такое. И тут с одной стороны «слава богу», а с другой плохо, так как не обратил бы внимание на опасный код. Собственно, в первом примере из статьи я долго не мог заметить проблемы. Открыл VS, накатал пример (не копипастом), всё равно компилился. И только потом заметил, в чём мой код отличался от кода из статьи… Ну а уже после включения внимательности в остальных примерах угрозы я заметил.

            На самом деле, я стал себе делать памятки о том, как какие единицы кода реализовывать. Для структур в частности, записано, что они должны быть неизменяемыми (read-only поля спасают) в общем случае (ну и кое-что ещё типа небольшого размера, переопределение Equals и прочего).
          +5
          Большое спасибо, отличная статья!
            +1
            Спасибо, годная статья. По поводу опасности при реализации IDisposable в значимом типе, к своему стыду не знал.
              –5
              Значимый тип (Value Type) называется таковым именно потому, что передается по значению. Размещение в памяти вторично, например, если у ссылочного типа (reference type) будет поле типа int (value type), то это поле будет находится в куче вместе со ссылочным типом.
                +4
                Не в обиду будет сказано: а вы статью читали? Там ведь все именно так и сказано, причем практически такими же словами:)))
                  +1
                  Читал, там написанно что одним из свойств значимых типов является то, что о ни передаются по значению. А на самом деле это определение значимых типов. Кроме того по умолчанию в стэке располагаются только локальные переменные значимых типов.

                  Эрика Липперт (один из людей, который работает над C#) об этом этом писал в своем блоге. (осторожно, английский)
                    0
                    * Эрик
                      +3
                      Да, я не особенно боюсь блога Эрика, будучи его переводчиком на русский язык (сори, задержался с последними статьями, скоро догоню:) ).

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

                      Вопрос в том, для чего комментарий?
                        0
                        На самом деле, о вопросе размещения значимых и референс типов действительно нужно останавливаться более подробно. Я часто провожу собеседования, и всегда задаю вопрос о различии структур и классов. Так вот, все как один отвечают что «структуры хранятся в стеке, а классы — в куче». Когда им приводишь такой пример
                        class A
                        {
                        int i;
                        }
                        и спрашиваешь, где находится i — большинство кандидатов впадают в ступор.
                          0
                          Да с этим никто не спорит, но статья-то не об этом. А раз не об этом, то максимум, что можно (и нужно сделать), это упомянуть об особенностях месторасположения экземпляров тех и других типов.
                            +2
                            Ну, это вопрос на «поговорить за жизнь», практического значения это знание ИМХО не имеет.
                    0
                    var x = new { Items = new List<int> { 1, 2, 3 }.GetEnumerator() };
                    while (x.Items.MoveNext())
                     {
                       Console.WriteLine(x.Items.Current);
                     }
                    

                    Ух ты, не знал, что Current по умолчанию ноль возвращает :).

                    За статью спасибо, очень правильная!
                      +2
                      Ну, по умолчанию свойство Current енумератора блока итератора возвращает не 0, а default(T), а поскольку для целого числа — это 0, вот он 0 и возвращает.

                      А если формально, то в данном случае проявляются любимое всеми С++-никами неопределенное поведение:)

                      When an enumerator object is in the suspended state, the value of Current is the value set
                      by the previous call to MoveNext. When an enumerator object is in the before, running, or
                      after states, the result of accessing Current is unspecified.


                      Например, в некоторых ручных реализациях итераторов (а не в блоках итераторов) бросается InvalidOperationException.
                        0
                        Ну с итераторами бывают сюрпризы, особенно с замыканиями, а уж тем более в ручных реализациях.
                          +1
                          Хм, а какая связь между итераторами и замыканиями?
                            0
                            Не столько именно связь между итераторами и замыканиями, сколько могут быть просто неочевидные ситуации с использованием обоих. Даже если мы знаем, что замыкания, эмм.., собственно, замыкаются над переменными, а не их значениями, некоторый интуитивно написанный код может иметь непредсказуемый результат:

                            var closures = new List<Func<int>>();
                            var items = new List<int> { 1, 2, 3 };
                            
                            foreach (var item in items)
                            {
                                closures.Add(() => item);
                            }
                            
                            closures.ForEach(c => Console.WriteLine(c.Invoke()));
                            
                              +1
                              Это да, возможно ты (я надеюсь можно на ты?:) ) в предыдущем комментарии выразился не совсем ясно. Было впечатление, что речь идет о замыкании итеоратов или о замыкании в итераторах. А то, что в обоих этих случаях нужно ясно представлять, что происходит — это да, я согласен.
                                +1
                                Конечно, можно на ты :)

                                Да, я, пожалуй, не вполне ясно выразился. Речь именно о том, что некая синтаксическая конструкция требует знания того, как это происходит «за кулисами», чтобы ее нормально использовать. Вот вроде, foreach и foreach себе — отличный и часто используемый синтаксический подсластитель, а на практике надо понимать, что переменная «item» объявляется за пределами цикла, что может быть не вполне очевидно. Прекрасно помню свои «WTF» в попытках найти подобные проблемы (в обнимку с дебаггерами и рефлекторами), код вроде верный, а тесты падают.

                                Конечно, сейчас уже ReSharper подсвечивает такую конструкцию, но он, опять же, подсвечивает ее и для замыканий, которые не живут за пределами итерации цикла, то есть которые полностью валидны сами по себе.
                      –1
                      Спасибо автору. На мой взгляд код какой-то синтетический. Я правильно понимаю, что если в первом примере в классе А убрать private, т.е. строчку public Mutable Mutable { get; private set; } заменить на public Mutable Mutable { get; set; }, то «ошибка» исчезнет? Во втором примере использовано public field, что уже как бэ намекает)
                      Думаю, что подобные казусы могут появиться либо у совсем неопытных разработчиков, либо в результате глубоких оптимизаций в 4:30 утра, а если идти по пути наименьшего сопростивления, то и проблем вообще не возникнет.
                        +3
                        Нет, в первом случае ошибка не исчезнет, поскольку дело не в сеттере, а в геттере.

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

                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                      Самое читаемое