Pull to refresh

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

Reading time 8 min
Views 22K
Большинство программистов, которых нелегкая судьба свела с платформной.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.
Tags:
Hubs:
+61
Comments 27
Comments Comments 27

Articles