СоХабр закрыт.
С 13.05.2019 изменения постов больше не отслеживаются, и новые посты не сохраняются.
С этой статьей я продолжаю публиковать целую серию статей, результатом которой будет книга по работе .NET CLR, и .NET в целом. Тема IDisposable была выбрана в качестве разгона, пробы пера. Теперь коснемся разныцы между типами. Вся книга будет доступна на GitHub: DotNetBook. Так что Issues и Pull Requests приветствуются :)
Давайте подумаем об особенностях обоих типов, об их достоинствах и недостатках и решим, где ими лучше пользоваться. Тут, конечно же стоит вспомнить классиков, дающих утверждение что выбор в сторону значимых типов, стоит дать если у нас тип не планирует быть наследуемым, он не станет меняться в течении своей жизни, а его размер не превышает 16 байт. Но не все так очевидно. Чтобы сделать полноценное сравнение нам необходимо задуматься о выборе типа с разных сторон, мысленно продумав сценарии его будущего использования. Разделить критерии выбора я предлагаю на три группы:
Каждая сущность, которая проектируется вами должна в полной мере отражать ее назначение. И это касается не только её названия или интерфейса взаимодействия (методы, свойства), но даже выбор между значимым и ссылочным типом может быть сделан из архитектурных соображений. Давайте порассуждаем, почему с точки зрения архитектуры системы типов может быть выбрана структура, а не класс:
1) Если наш проектируемый тип будет обладать инвариантностью по отношению к смысловой нагрузке своего состояния, то это будет значить что его состояние полностью отражает некоторый процесс или является значением чего-либо. Другими словами, экземпляр типа полностью константен и не может быть изменен по своей сути. Мы можем создать на основе этой константы другой экземпляр типа, указав некоторое смещение, либо создать с нуля, указав его свойства. Но изменять его мы не имеем права. Я прошу заметить, что я не имею ввиду что структура является неизменяемым типом. Вы можете менять поля, как хотите. Мало того вы можете отдать ссылку на структуру в метод через ref
параметр и получить измененные поля по выходу из метода. Однако, я про смысл с точки зрания архитектуры. Поясню на примерах:
uint
, однако предоставляет доступ к отдельным характеристикам момента времени. Например: год, месяц, день, час, минуты, секунды, миллисекунды и даже процессорные тики. Однако исходя из того что она инкапсулирует — она не может быть изменяемой по своей природе. Мы не можем изменить конкретный момент времени чтобы он стал другим. Я не могу прожить следующую минуту своей жизни в лучший день рождения своего детства. Время неизменно. Именно поэтому выбор для типа данных может стать либо класс с readonly интерфейсом взаимодействия (который на каждое изменение свойств отдает новый экземпляр) либо структура, которая несмотря на возможность изменения полей своих экземпляров делать этого не должна: описание момента времени является значением. Как число. Вы же не можете залезть в структру числа и поменять его? Если вы хотите получить другой момент времени, который является смещением относительно оригинального на один день, вы просто получаете новый экземпляр структуры;KeyValuePair<TKey, TValue> — это структура, инкапсулирующая в себе понятие связной пары ключ-значение. Замечу что эта структура используется только для выдачи пользователю при перечислении содержимого словаря. Почему выбрана структура с точки зрения архитектуры? Ответ прост: потому что в рамках Dictionary ключ и значение неразделимые понятия. Да, внутри все устроено иначе. Внутри мы имеем сложную структуру, где ключ лежит отдельно от значения. Однако для внешнего пользователя, с точки зрения интерфейса взаимодействия и смысла самой структуры данных пара ключ-значение является неразделимым понятием. Является значением целиком. Если мы по этому ключу расположили другое значение это значит, что изменилась вся пара. Для внешнего наблюдателя нет отдельно ключей, а отдельно — значений, они являются единым целым. Именно поэтому структура в данном случае — идеальный вариант
2) Если наш проектируемый тип является неотъемлимой частью внешнего типа. Но при этом он структурно неотъемлим. Т.е. было бы некорректным сказать, что внешний тип ссылается на экземпляр инкапсулируемого, но совершенно корректно — что инкапсулируемый является полноправной частью внешнего вместе со всеми своими свойствами. Как правило это используется при проектировании структур, которые являются частью другой структуры.
header.txt
. Это было бы уместно при вставке документа в некий другой, но не вживанием файла, а по относительной ссылке на файловой системе. Хороший пример — файл ярлыка ОС Windows. Однако если мы говорим о заголовке файла (например, о заголовке JPEG файла, в котором указаны размер изображения, методика сжатия, параметры съемки, коодинаты GPS и прочая метаинформация), то при проектировании типов, которые будут использованы для парсинга заголовка будет крайне полезно использовать структуры. Ведь, описав все заголовки в структурах вы получите в памяти абсолютно такое же положение всех полей как в файле. И через простое unsafe преобразование *(Header *)readedBuffer
без каких-либо десериализаций — полностью заполненные структуры данных.StackOverflowException
. Вторая причина — производительность. Чем больше копирований, тем медленнее все работает;Мы с вами прошли как может показаться и огонь и воду и можем пройти любое собеседование. Возможно даже в команду .NET CLR. Но давайте не будем спешить набирать microsoft.com и искать там раздел вакансий: успеем. Давайте лучше ответим на такой вопрос. Если значимые типы не содержат ни ссылки на SyncBlockIndex ни указателя на таблицу виртуальных методов… То, простите, как они наследуют тип object
? Ведь по всем канонам любой тип наследует именно его. Ответ на этот вопрос к сожалению не будет вмещен в одно предложение, но даст такое понимание о нашей системе типов, что последние кусочки пазла наконец встанут на свои места.
Итак, давайте еще раз вспомним про размещение значимых типов в памяти. Везде, где бы они не находились, они вживляются в то место, где находятся. Они становятся его частью. В отличии от ссылочных типов, для которых закон твердит быть в куче малых или больших объектов, а в место установки значения — всегда ставить ссылку на место в куче, где расположился наш объект.
Так вот если задуматься, то у любого значимого типа есть методы ToString
, Equals
и GetHashCode
, которые являются виртуальными, переопределяемыми, но нам не дают наследовать значимые типы, переопределяя методы. Почему? Потому что если значимые типы сделать с переопределяемыми методами, то им понадобится таблица виртуальных методов, через которую будет осуществляться роутинг вызовов. А это в свою очередь повлечет за собой проблемы проброса структур в unmanaged мир: туда уйдут лишние поля. В итоге получается что описание методов значимых типов где-то лежат, но к ним нет прямого доступа через таблицу виртуальных методов.
Это наводит на мысль что отсутствие наследования искусственно:
object
, вы все еще можете на полных правах вызывать ToString, Equals и GetHashCode. Method(ref structInstance, newInternalFieldValue)
. А это ведь по сути вызов с передачей this
за одним исключением: JIT должен собрать тело метода так чтобы не делать дополнительного смещения на поля структуры перепрыгивая через указатель на таблицу виртуальных методов, которой в самой структуре нет. Для значимых типов она находится в другом месте.var obj = (object)10;
То мы перестанем иметь дело с числом 10
. Произойдет так называемый boxing: упаковка. Т.е. мы начнем иметь возможность работать с ним через базовый класс. А если мы получили такие возможности это значит что нам стала доступна VMT (таблица виртуальных методов), через которую можно спокойно вызывать виртуальные методы ToString(), Equals и GetHashCode. Причем поскольку оригинальное значение у нас может храниться где угодно: хоть на стеке, хоть как поле класса, а приводя к типу object
мы получаем возможность хранить ссылку на это число веки вечные, то в реальности boxing создает копию значимого типа, а не делает указатель на оригинал. Т.е. когда происходит boxing, то:
struct Foo: IBoo
{
int x;
void Boo()
{
x = 666;
}
}
IBoo boo = new Foo();
boo.Boo();
Итак, когда создается экземпляр Foo, то его значение по сути нахдоится на стеке. После чего мы кладем эту переменную в переменную интерфейсного типа. Структуру - в переменную ссылочного типа. Происходит `boxing`. Хорошо. На выходе мы получили тип `object`. Но переменная у нас - интерфейсного типа. А это значит, что необходимо преобразование типа. Т.е. вызов, скорее, протсходит как-то так:
```csharp
IBoo boo = (IBoo)(box_to_object)new Foo();
boo.Boo();
Т.е. написание такого кода — это крайне не эффективно. Мало того что вы будете менять копию вместо оригинала:
void Main()
{
var foo = new Foo();
foo.a = 1;
Console.WriteLite(foo.a); // -> 1
IBoo boo = foo;
boo.Boo(); // выглядит как изменение foo.a на 10
Console.WriteLite(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>
{
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;
Мы получили новый инструмент, но пока не знаем что с ним делать. Давайте добьемся ответа рассуждениями:
Boxed<T>
по сути осуществляет все то же самое что и обычный: выделяет память в куче, отдает туда значение и позволяет его забрать, выполнив своеобразный unbox
;unboxing
, менять структуру на другую и делать boxing
обратно, раздав новую ссылку потребителям.
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 спокоен и не работает на износ;
Напоследок, давайте рассмотрим пример из мира совершенно не практичного кода
```csharp
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. Рассмотри его по шагам:
комментарии (17)