Сообщений 0    Оценка 30        Оценить  
Система Orphus

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

Автор: Тепляков Сергей Владимирович
Опубликовано: 23.04.2012
Исправлено: 10.12.2016
Версия текста: 1.1
1. Изменяемый значимый тип в виде свойства объекта
2. Изменяемые значимые типы и модификатор readonly
3. Массивы и списки
4. И зачем мне все это?
Заключение

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

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

ПРИМЕЧАНИЕ

Замыкание – это не такой уж страшный зверь, как может показаться из замысловатого названия. И если вдруг, по какой-то причине вы не уверены в своих знаниях по этому поводу, то этот как раз отличный повод это исправить: “Замыкания в языке 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; }
}

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

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

Самое интересное, что этот код вообще не скомпилируется, поскольку вторая строка (a.Mutable.Y++;) является некорректной с точки зрения языка C#. Поскольку значение структуры Mutable копируется при возвращении из одноименного свойства, то компилятор уже на этапе компиляции понимает, что ничего хорошего от изменения временного объекта не будет, о чем и говорит красноречиво в сообщении об ошибке: “error CS1612: Cannot modify the return value of 'A.Mutable't' 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);

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

Опять-таки, это не 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);

Итак, что будет выведено в результате? 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);

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

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

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

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

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

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

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

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

В этом случае большинство разработчиков будет предполагать, что индексатор массива ведет себя аналогичным образом, возвращая копию элемента, который затем и изменяется в нашем коде. И поскольку язык 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(); // изменение временной переменной

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

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

ПРИМЕЧАНИЕ

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

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

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

Результат будет таким:

0
0
0
… (и так до бесконечности)

Заключение

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

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

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


Любой из материалов, опубликованных на этом сервере, не может быть воспроизведен в какой бы то ни было форме и какими бы то ни было средствами без письменного разрешения владельцев авторских прав.
    Сообщений 0    Оценка 30        Оценить