Pull to refresh
0
Семинары Станислава Сидристого
CLRium #7: Parallel Computing Practice

[DotNetBook] Ссылочные и значимые типы данных, особенности выбора

Reading time 13 min
Views 15K
С этой статьей я продолжаю публиковать целую серию статей, результатом которой будет книга по работе .NET CLR, и .NET в целом. Тема IDisposable была выбрана в качестве разгона, пробы пера. Теперь коснемся разницы между типами. Вся книга будет доступна на GitHub: DotNetBook. Так что Issues и Pull Requests приветствуются :)

Это — выжимка из главы про Struct / Class и их разницу.

Особенности выбора между class/struct


Давайте подумаем об особенностях обоих типов, об их достоинствах и недостатках и решим, где ими лучше пользоваться. Тут, конечно же стоит вспомнить классиков, дающих утверждение что выбор в сторону значимых типов, стоит дать если у нас тип не планирует быть наследуемым, он не станет меняться в течении своей жизни, а его размер не превышает 16 байт. Но не все так очевидно. Чтобы сделать полноценное сравнение нам необходимо задуматься о выборе типа с разных сторон, мысленно продумав сценарии его будущего использования.

Примечание


Глава, опубликованная на Хабре не обновляется и возможно, уже несколько устарела. А потому, прошу обратиться за более свежим текстом к оригиналу:




Разделить критерии выбора я предлагаю на три группы:

  • с точки зрения архитектуры системы типов, в которой ваш тип будет взаимодействовать;
  • с точки зрения подхода вас как системного программиста: каков выбор будет оптимальным с точки зрения производительности;
  • по-другому просто невозможно.

Каждая сущность, которая проектируется вами должна в полной мере отражать ее назначение. И это касается не только её названия или интерфейса взаимодействия (методы, свойства), но даже выбор между значимым и ссылочным типом может быть сделан из архитектурных соображений. Давайте порассуждаем, почему с точки зрения архитектуры системы типов может быть выбрана структура, а не класс:

  1. Если наш проектируемый тип будет обладать инвариантностью по отношению к смысловой нагрузке своего состояния, то это будет значить что его состояние полностью отражает некоторый процесс или является значением чего-либо. Другими словами, экземпляр типа полностью константен и не может быть изменен по своей сути. Мы можем создать на основе этой константы другой экземпляр типа, указав некоторое смещение, либо создать с нуля, указав его свойства. Но изменять его мы не имеем права. Я прошу заметить, что я не имею ввиду что структура является неизменяемым типом. Вы можете менять поля, как хотите. Мало того вы можете отдать ссылку на структуру в метод через ref параметр и получить измененные поля по выходу из метода. Однако, я про смысл с точки зрания архитектуры. Поясню на примерах:
    • DateTime — это структура, которая инкапсулирует в себе понятие момента времени. Она хранит эти данные в виде UInt64, однако предоставляет доступ к отдельным характеристикам момента времени. Например: год, месяц, день, час, минуты, секунды, миллисекунды и даже процессорные тики. Однако исходя из того что она инкапсулирует — она не может быть изменяемой по своей природе. Мы не можем изменить конкретный момент времени чтобы он стал другим. Я не могу прожить следующую минуту своей жизни в лучший день рождения своего детства. Время неизменно. Именно поэтому выбор для типа данных может стать либо класс с readonly интерфейсом взаимодействия (который на каждое изменение свойств отдает новый экземпляр) либо структура, которая несмотря на возможность изменения полей своих экземпляров делать этого не должна: описание момента времени является *значением*. Как число. Вы же не можете залезть в структру числа и поменять его? Если вы хотите получить другой момент времени, который является смещением относительно оригинального на один день, вы просто получаете новый экземпляр структуры;
    • KeyValuePair<TKey, TValue> — это структура, инкапсулирующая в себе понятие связной пары ключ-значение. Замечу что эта структура используется только для выдачи пользователю при перечислении содержимого словаря. Почему выбрана структура с точки зрения архитектуры? Ответ прост: потому что в рамках Dictionary<T> ключ и значение неразделимые понятия. Да, внутри все устроено иначе. Внутри мы имеем сложную структуру, где ключ лежит отдельно от значения. Однако для внешнего пользователя, с точки зрения интерфейса взаимодействия и смысла самой структуры данных пара ключ-значение является неразделимым понятием. Является *значением* целиком. Если мы по этому ключу расположили другое значение это значит, что изменилась вся пара. Для внешнего наблюдателя нет отдельно ключей, а отдельно — значений, они являются единым целым. Именно поэтому структура в данном случае — идеальный вариант.
  2. Если наш проектируемый тип является неотъемлемой частью внешнего типа. Но при этом он структурно неотъемлим. Т.е. было бы некорректным сказать, что внешний тип ссылается на экземпляр инкапсулируемого, но совершенно корректно — что инкапсулируемый является полноправной частью внешнего вместе со всеми своими свойствами. Как правило это используется при проектировании структур, которые являются частью другой структуры.
    • Как, например, если взять структуру заголовка файла, было бы нечестно дать ссылку из одного файла в другой. Мол, заголовок находится в файле header.txt. Это было бы уместно при вставке документа в некий другой, но не вживанием файла, а по относительной ссылке на файловой системе. Хороший пример — файл ярлыка ОС Windows. Однако если мы говорим о заголовке файла (например, о заголовке JPEG файла, в котором указаны размер изображения, методика сжатия, параметры съемки, коодинаты GPS и прочая метаинформация), то при проектировании типов, которые будут использованы для парсинга заголовка будет крайне полезно использовать структуры. Ведь, описав все заголовки в структурах вы получите в памяти абсолютно такое же положение всех полей как в файле. И через простое unsafe преобразование *(Header *)readedBuffer без каких-либо десериализаций — полностью заполненные структуры данных.
  3. При этом заметьте, что каждый пример обладает следующим свойством: ни один из примеров не обладает свойством наследования поведения чего-либо. Мало того все эти примеры также показывают, что нет абсолютно никакого смысла наследовать поведение этих сущностей. Они полностью самодостаточны как единицы чего-либо.
    Если же мы взглянем на проблематику с точки зрения эффективности работы кода, то перед нами выбор предстанет с другой стороны:
    1. Структуры необходимо выбирать если необходимо забрать из неуправляемого кода какие-то структурированные данные. Либо отдать unsafe методу структуру данных. Ссылочный тип для этого совсем не подойдет;
    2. Если тип будет часто использоваться для передачи данных в вызовах методов (пусть в качестве возвращаемых значений или как параметр метода), но при этом нет никакой необходимости ссылаться на одно значение с разных мест, то ваш выбор — структура. Как пример я могу привести кортежи. Если метод через кортеж возвращает вам несколько значений, это значит, что возвращать он будет ValueTuple, который объявлен как структура. Т.е. при возврате метод не будет выделять память в куче, а использовать он будет стек потока, выделение памяти в котором не стоит вам абсолютно ничего;
    3. Если вы проектируете систему, которая создает некий больший трафик экземпляров проектируемого типа. При этом сами экземпляры имеют достаточно малый размер, а время жизни экземпляров очень короткое, то использование ссылочных типов приведет либо к использованию пула объектов, либо если без пула, то к неконтролируемому замусориванию кучи. При этом часть объектов перейдет в старшие поколения, чем вызовет проседание на GC. Использование значимых типов в таких местах (если это возможно) даст прирост производительности просто потому что в SOH ничего не уйдет, а это разгрузит GC и алгоритм отработает быстрее

Совмещая все выше сказанное, могу предложить некоторые советы и замечания в использовании структур:
  1. При выборе коллекций стоит избегать больших массивов, внутри которых находятся большие структуры. Это касается и тех структур данных, которые на массивах основаны (а их — большинство). Это может привести к уходу в Large Objects Heap и его фрагментации. Мало подсчитать, что, если у вашей структуры 4 поля типа byte, значит займет она 4 байта. Вовсе нет. Надо понимать, что для 32-разрядных систем, каждое поле структуры будет выровнено по 4 байтам (адрес каждого поля должен делиться на 4 без остатка), а на 64-разрядных системах — по 8 байтам. Т.е. размер массива должен зависеть от размера структуры и от платформы, на которой запущено приложение. В нашем примере с 4 байтами — 85К / (от 4 до 32 байт на поле * количество полей = 4) минус размер заголовка массива: примерно 2600 элементов на массив в зависимости от платформы (а брать понятное дело надо в меньшую сторону). Всего-то! Не так и много! А ведь могло показаться что магическая константа в 10,000 элементов вполне могла подойти!
  2. Также стоит отдавать себе отчет что если вы используете структуру, которая имеет некоторый достаточно большой размер как источник данных и размещаете ее в некотором классе как поле и при этом, например, одна и та же копия растиражирована на тысячу экземпляров (просто потому что вам удобно держать все под рукой), то вы тем самым увеличиваете каждый экземпляр класса на размер структуры что в конечном счете приведет к распуханию 0-го поколения и уходу в поколение 1 или даже 2. При этом если на самом деле экземпляры класса короткоживущие и вы рассчитываете на то что они будут собраны GC в нулевом поколении — за 1 мс, то будете сильно разочарованы тем что они на самом деле успели попасть в поколение 1 или даже второе. А какая, собственно, разница? Разница в том, что если поколение 0 собирается за 1 мс, то первое и второе — очень медленно и приведет к проседаниям на пустом месте;
  3. По примерно той же причине стоит избегать проброса больших структур через цепочку вызовов методов. Потому как если все начнет друг друга вызывать, то такие вызовы займут намного больше места в стеке подводя жизнь вашего приложения к смерти через StackOverflowException. Вторая причина — производительность. Чем больше копирований, тем медленнее все работает;


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

Базовый тип — Object и возможность реализации интерфейсов. Boxing.


Мы с вами прошли как может показаться и огонь и воду и можем пройти любое собеседование. Возможно даже в команду .NET CLR. Но давайте не будем спешить набирать microsoft.com и искать там раздел вакансий: успеем. Давайте лучше ответим на такой вопрос. Если значимые типы не содержат ни ссылки на SyncBlockIndex ни указателя на таблицу виртуальных методов… То, простите, как они наследуют тип object? Ведь по всем канонам любой тип наследует именно его. Ответ на этот вопрос к сожалению не будет вмещен в одно предложение, но даст такое понимание о нашей системе типов, что последние кусочки пазла наконец встанут на свои места.
Итак, давайте еще раз вспомним про размещение значимых типов в памяти. Везде, где бы они не находились, они вживляются в то место, где находятся. Они становятся его частью. В отличии от ссылочных типов, для которых закон твердит быть в куче малых или больших объектов, а в место установки значения — всегда ставить ссылку на место в куче, где расположился наш объект.
Так вот если задуматься, то у любого значимого типа есть методы ToString, Equals и GetHashCode, которые являются виртуальными, переопределяемыми, но нам не дают наследовать значимые типы, переопределяя методы. Почему? Потому что если значимые типы сделать с переопределяемыми методами, то им понадобится таблица виртуальных методов, через которую будет осуществляться роутинг вызовов. А это в свою очередь повлечет за собой проблемы проброса структур в unmanaged мир: туда уйдут лишние поля. В итоге получается что описание методов значимых типов где-то лежат, но к ним нет прямого доступа через таблицу виртуальных методов.
Это наводит на мысль что отсутствие наследования искусственно:
  • Наследование от object есть, хоть и не прямое;
  • В базовом типе есть ToString, Equals и GetHashCode, которые по-своему работают в значимых типах: у этих методов свое поведение в каждом из них. А значит что методы переопределены относительно object;
  • более того, если вы сделаете приведение типа в object, вы все еще можете на полных правах вызывать ToString, Equals и GetHashCode.
  • При вызове экземплярного метода над значимым типом не происходит копирования в метод. Т.е. вызов экземплярного метода аналогичен вызову статического метода: Method(ref structInstance, newInternalFieldValue). А это ведь по сути вызов с передачей this за одним исключением: JIT должен собрать тело метода так чтобы не делать дополнительного смещения на поля структуры перепрыгивая через указатель на таблицу виртуальных методов, которой в самой структуре нет. Для значимых типов она находится в другом месте.

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

var obj = (object)10;


То мы перестанем иметь дело с числом 10. Произойдет так называемый boxing: упаковка. Т.е. мы начнем иметь возможность работать с ним через базовый класс. А если мы получили такие возможности это значит что нам стала доступна VMT (таблица виртуальных методов), через которую можно спокойно вызывать виртуальные методы ToString(), Equals и GetHashCode. Причем поскольку оригинальное значение у нас может храниться где угодно: хоть на стеке, хоть как поле класса, а приводя к типу object мы получаем возможность хранить ссылку на это число веки вечные, то в реальности boxing создает копию значимого типа, а не делает указатель на оригинал. Т.е. когда происходит boxing, то:
  • CLR выделяет место в куче под структуру + SyncBlockIndex + VMT значимого типа (чтобы иметь возможность вызвать ToString, GetHashCode, Equals);
  • копирует туда экземпляр значимого типа.

Дамы и господа. В приличном обществе такое не принято говорить, но мы получили ссылочный вариант значимого типа. Я повторю еще раз: совершив boxing структура получила **абсолютно такой же набор системных полей, что и ссылочный тип**, став полноценным ссылочным типом. Структура стала классом. Давайте назовем это явление Кульбит Дотнетского. Мне кажется, это название будет достойным такого хитрого поворота дел.
Кстати, чтобы вы поверили в честность моих слов, достаточно разобраться что происходит если вы используете структуру, которая реализует некий интерфейс — по этому самому интерфейсу.


struct Foo : IBoo
{
	int x;
	void Boo() 
	{
		x = 666;
	}
}

IBoo boo = new Foo();

boo.Boo();


Итак, когда создается экземпляр Foo, то его значение по сути находится на стеке. После чего мы кладем эту переменную в переменную интерфейсного типа. Структуру — в переменную ссылочного типа. Происходит boxing. Хорошо. На выходе мы получили тип object. Но переменная у нас — интерфейсного типа. А это значит, что необходимо преобразование типа. Т.е. вызов, скорее, происходит как-то так:


IBoo boo = (IBoo)(box_to_object)new Foo();
boo.Boo();


Т.е. написание такого кода — это крайне не эффективно. Мало того что вы будете менять копию вместо оригинала:

void Main()
{
	var foo = new Foo();
	foo.a = 1;
	Console.WriteLine(foo.a);  // -> 1
	
	IBoo boo = foo;
	boo.Boo();                 // выглядит как изменение foo.a на 10
	Console.WriteLine(foo.a);  // -> 1
}

struct Foo : IBoo {
	public int a;

	public void Boo()
	{
		a = 10;
	}
}

interface IBoo {
	void Boo();
}


Выглядит как обман дважды. Первый раз — глядя на код мы не обязаны знать с чем имеем дело в чужом коде и видим ниже приведение к интерфейсу IBoo. Что фактически гарантированно наводит нас на мысль что Foo — класс, а не структура. Далее — полное отсутствие визуального разделения на структуры и классы дает полное ощущение что результаты модификации по интерфейсу обязаны попасть в foo, чего не происходит потому что boo — копия foo. Что фактически вводит нас в заблуждение. На мой взгляд, такой код стоит снабжать комментариями чтоб внешний разработчик смог бы в нем правильно разобратся.
Второе наблюдение, связанное с нашими более ранними рассуждениями связано с тем что мы можем сделать приведение типа из object в IBoo. Это — еще одно доказательство что boxed значимый тип это не что-то особенное, а на самом деле ссылочный вариант значимого типа. Либо если посмотреть с другого угла — все типы в системе типов являются ссылочными. Просто со структурами мы можем работать как со значимыми, «отгружая» их значение целиком. Как бы сказали в мире C++, разыменовывая указатель на объект.
Но вы можете возразить: дескать если бы все было именно так, как я говорю, то можно было бы написать как-то так:

var referenceToInteger = (IInt32)10;


И мы получили бы не просто object, а типизированную ссылку на упакованный значимый тип. Но тогда бы это разрушило всю идею значимых типов, друзья. А основная идея — это целостность их значения, позволяющее делать отличные оптимизации, основываясь на их свойствах. Так не будем сидеть сложа руки! Давайте разрушим эту идею!

public sealed class Boxed<T> where T : struct
{	
	public T Value;

	[MethodImpl(MethodImplOptions.AggressiveInlining)]
	public override bool Equals(object obj)
	{
		return Value.Equals(obj);
	}

	[MethodImpl(MethodImplOptions.AggressiveInlining)]
	public override string ToString()
	{
		return Value.ToString();
	}

	[MethodImpl(MethodImplOptions.AggressiveInlining)]
	public override int GetHashCode()
	{
		return Value.GetHashCode();
	}
}


Что мы только что получили? Мы получили абсолютно полный аналог боксинга. Но теперь у нас есть возможность менять его содержимое путем вызова его экземплярных методов. И эти изменения получат все, у кого будет ссылка на эту структуру данных.

var typedBoxing = new Boxed<int> { Value = 10 };
var pureBoxing = (object)10;


Первый вариант, согласитесь, выглядит несколько неуверенно. Вместо привычого приведения типа мы городим не пойми что. То ли дело вторая строчка. Лаконична как японский стих. Однако они на самом деле почти полностью идентичны. Разница состоит только в том что во время обычной упаковки после выделения памяти в куче не происходит очистки памяти нулями: память сразу занимается необходимой структурой. Тогда как в первом варианте очистка есть. Только из-за этого наш вариант медленнее обычной упаковки на 10%.
Зато теперь мы можем вызывать у нашего упакованного значения какие-то методы:

struct Foo
{
	public int x;

	public void ChangeTo(int newx)
	{
		x = newx;
	}
}

var boxed = new Boxed<Foo> { Value = new Foo { x = 5 } };
boxed.Value.ChangeTo(10);
var unboxed = boxed.Value;


Мы получили новый инструмент, но пока не знаем что с ним делать. Давайте добьемся ответа рассуждениями:
  1. Наш тип Boxed<T> по сути осуществляет все то же самое что и обычный: выделяет память в куче, отдает туда значение и позволяет его забрать, выполнив своеобразный unbox
  2. Точно также если потерять ссылку на упакованную структуру GC её соберет;
  3. Однако у нас теперь есть возможность работы с упакованным типом: вызывать у него методы;
  4. Также теперь мы имеем возможность подменить экземпляр значимого типа в SOH/LOH на другой. Этого мы не могли сделать раньше: нам пришлось бы делать unboxing, менять структуру на другую и делать boxing обратно, раздав новую ссылку потребителям.
  5. Также давайте подумаем какая основная проблема у упаковки? Создание траффика в памяти. Траффика непонятного количества объектов, часть из которых может выжить до первого поколения, где мы получим проблемы со сборкой мусора: он там будет, его там будет много и этого явно можно было избежать. А когда мы имеем траффик короткоживущих объектов первое решение, которое приходит в голову — пуллинг. Вот это будет отличным завершением Кульбита Дотнетского.



var pool = new Pool<Boxed<Foo>>(maxCount:1000);
var boxed = pool.Box(10);
boxed.Value=70;
// use boxed value here
pool.Free(boxed);


Т.е. мы получили возможность работы боксинга через пул, тем самым удалив траффик памяти по части боксинга до нуля. Шутки ради можно даже сделать чтобы в методе финализации объекты воскрешали бы себя, засовывая обратно в пул объектов. Это пригодилось бы для ситуаций, когда boxed структура уходит в чужой асинхронный код и нет возможности понять, когда она стала не нужна. В этом случае она сама себя вернет в пул во время GC.
А теперь давайте сделаем выводы:
  • Если упаковка — случайна и такого не должно было произойти, будьте аккуратны и не допускайте ее возникновения: она может привести к проблемам производительности;
  • Если упаковка — дань требованиям архитектуры той системы, которую вы делаете, то тут могут возникнуть варианты: если траффик упакованных структур мал и не заметен, можно не обращать никакого внимания и работать через упаковку. Если же траффик становится заметным, то возможно стоит сделать пуллинг боксинга через решение, указанное выше. Да, оно дает некоторые расходы на производительности пуллинга, зато GC спокоен и не работает на износ;


Напоследок, давайте рассмотрим пример из мира совершенно не практичного кода

static unsafe void Main()
{
	// делаем boxed int
    object boxed = 10;
    // забираем адрес указателя на VMT
    var address = (void**)EntityPtr.ToPointerWithOffset(boxed);
    unsafe
    {
        // забираем адрес Virtual Methods Table
        var structVmt = typeof(SimpleIntHolder).TypeHandle.Value.ToPointer();

        // меняем адрес VMT целого числа, ушедшего в Heap на VMT SimpleIntHolder, превратив Int в структуру
        *address = structVmt;
    }
    var structure = (IGetterByInterface)boxed;

   Console.WriteLine(structure.GetByInterface());
}

interface IGetterByInterface
{
    int GetByInterface();
}

struct SimpleIntHolder : IGetterByInterface
{
    public int value;

    int IGetterByInterface.GetByInterface()
    {
        return value;
    }
}


Этот код написан при помощи маленькой функции, которая умеет получать указатель из ссылки на объект. Библиотека находится по адресу на github. Этот код показывает что обычный boxing превращает int в типизированный reference type. Рассмотрим его по шагам:
  1. Делаем boxing для целого числа
  2. Достаем адрес полученного объекта (по этому адресу находится VMT Int32)
  3. Получаем VMT структуры SimpleIntHolder
  4. Подменяем VMT запакованного целого числа на VMT структуры
  5. Делаем unbox в тип структуры
  6. Выводим значение поля — на экран, доставая тем самым тот Int32, котрый был изначально запакован.

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

Ссылка на всю книгу





Tags:
Hubs:
+31
Comments 17
Comments Comments 17

Articles

Information

Website
clrium.ru
Registered
Founded
Employees
1 employee (me only)
Location
Россия
Representative
Stanislav Sidristij