Reference Types vs Value Types

Давайте в первую очередь поговорим про Reference Types и Value Types. И если говорить про разницу между ними и про полезность каждого из типов, то первое, о чем я бы упомянул - так это о своих мыслях об их названии. На мой скромный взгляд, если бы в русскоязычном сегменте их назвали ссылочные и значимые типы вместо проговаривания Value Types и Reference Types, то с пониманием разницы между ними все бы встало на свои места.

Очень часто при вопросе что такое ссылочные и значимые типы, люди отвечают, что ссылочные живут в куче, а значимые - в стеке. И это в корне неправильно. Это настолько маленькая часть правды, что правдой не может считаться в принципе

Также, чтобы понимать разницу между ними, давайте и будем изучать их с точки зрения разницы:

  • Значимый тип: значением является вся структура целиком. Для ссылочного типа значением является ссылка на объект;
  • По структуре в памяти: значимые типы содержат только те данные, которые вы указали. Ссылочные также содержат два системных поля. Первое необходимо для хранения SyncBlockIndex, второе - для хранения информации о типе: в том числе и о Virtual Methods Table (VMT)
  • Однако, ссылочные типы можно наследовать, переопределяя методы. Значимые типы лишены такой возможности;
  • Но чтобы выделить ссылочный тип, надо аллоцировать место в куче. Значимый тип может работать на стеке, не уходя в кучу, а может стать частью ссылочного типа. Это свойство может значительно повысить производительность для некоторых алгоритмов;

Однако, есть и общие черты:

  • Оба подкласса наследуют тип object, а значит - могут выступать как его представители - на полных правах

Рассмотрим каждую особенность в отдельности.

Копирование

Самую главную и основополагающую разницу между типами можно описать примерно так:

  • Любая переменная, поле класса/структуры или же параметр метода, которые принимают ссылочный тип, на самом деле хранят в себе ссылку на значение;
  • Тогда как любая переменная, поле класса/структуры или же параметр метода, которые принимают значимый тип, на самом деле хранят в себе именно значение. Т.е. всю структуру целиком;

Что это значит для нас? Это в частности значит, что любое присваивание или прокидывание через параметр метода вызовет копирование значения. А поменяв копию, оригинал изменен не будет. При этом если вы меняете поля ссылочного типа, изменения "получают" все, кто имеют ссылку на экземпляр типа. Давайте рассмотрим это на примере:


 DateTime dt = DateTime.Now;   // Здесь сначала при вызове метода будет выделено место под переменную DateTime,
                               // но заполнено оно будет нулями. Далее копируется все значение свойства Now в переменную dt
 DateTime dt2 = dt;            // Здесь значение копируется еще раз

 object obj = new object();    // Тут мы создаем объект, выделяя память в Small Object Heap, и размещаем указатель на объект в переменной obj
 object obj2 = obj;            // Тут мы копируем ссылку на этот объект. Т.е. объект - один, а ссылок - две

Это свойство рождает ряд двусмысленных на первый взгляд конструкций кода. Одна из них - изменение значений в коллекциях:

// Объявим структуру
struct ValueHolder
{
    public int Data;
}

// Создадим массив таких структур и проинициализируем поле Data = 5
var array = new [] { new ValueHolder { Data = 5 } };

// Заберем по индексу структуру и в поле Data выставим 4
array[0].Data = 4;

// Проверим значение
Console.WriteLine(array[0].Data);

В данном коде есть маленькая хитрость. Код выглядит так, будто мы сначала достаем экземпляр структуры, а затем у полученной копии выставляем поле Data в новое значение. А это значит, что при проверке мы снова должны получить 5. Однако все совсем не так. Все дело в том, что в MSIL есть отдельная инструкция для выставления значения полей структур, находящихся в массивах. Она была введена для повышения производительности. И этот код отработает именно так, как и было задумано его автором: программа выведет в консоль число 4.

Однако стоит изменить пример так:

// Объявим структуру
struct ValueHolder
{
    public int Data;
}

// Создадим список таких структур и проинициализируем поле Data = 5
var list = new List<ValueHolder> { new ValueHolder { Data = 5 } };

// Заберем по индексу структуру и в поле Data выставим 4
list[0].Data = 4;

// Проверим значение
Console.WriteLine(list[0].Data);

Так у нас ничего даже и не скомпилируется. А все потому, что когда вы пишете list[0].Data = 4, то сначала вы получаете именно копию структуры. Вы ведь на самом деле вызываете метод экземпляра типа List, который скрывается за доступом по индексу. И который в свою очередь забирает копию структуры из внутреннего массива (List хранит данные в массивах), которая возвращается из метода доступа по индексу - вам. После чего вы пытаетесь модифицировать копию, которая далее нигде не используется. Это - не то чтобы ошибка, но абсолютно бессмысленный код. А компилятор, зная, что люди путаются с Value Types, запрещает такое поведение. Поэтому пример должен быть переписан таким образом:

// Объявим структуру
struct ValueHolder
{
    public int Data;
}

// Создадим список таких структур и проинициализируем поле Data = 5
var list = new List<ValueHolder> { new ValueHolder { Data = 5 } };

// Заберем по индексу структуру и в поле Data выставим 4, после чего сохраним обратно
var copy = list[0];
copy.Data = 4;
list[0] = copy;

// Проверим значение
Console.WriteLine(list[0].Data);

Несмотря на кажущееся многословие, он корректен. Когда программа отработает, в консоль выведется число 4.

Вторым примером я хочу показать вам, что вообще понимается под "значением структуры является вся структура целиком"

// Вариант 1
struct PersonInfo
{
    pubilc int Height;
    pubilc int Width;
    pubilc int HairColor;
}

int x = 5;
PersonInfo person;
int y = 6;

// Вариант 2
int x = 5;
int Height;
int Width;
int HairColor;
int y = 6;

Фактически, по расположению данных в памяти оба примера идентичны. Потому как значением структуры является вся структура в целом. Куда она попала, там под себя память и определила.

// Вариант 1
struct PersonInfo
{
    pubilc int Height;
    pubilc int Width;
    pubilc int HairColor;
}

class Employee
{
    public int x;
    public PersonInfo person;
    public int y;
}

// Вариант 2
class Employee
{
    public int x;
    pubilc int Height;
    pubilc int Width;
    pubilc int HairColor;
    public int y;
}

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

Если говорить про ссылочные типы, то, понятное дело, для них все обстоит иначе. Сам экземпляр находится в недосягаемом Small Object Heap (SOH) или же в Large Object Heap (LOH), а в поле класса запишется лишь значение указателя на экземпляр: 32-х или 64-разрядное число.

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

// Вариант 1
struct PersonInfo
{
    pubilc int Height;
    pubilc int Width;
    pubilc int HairColor;
}

void Method(int x, PersonInfo person, int y);

// Вариант 2
void Method(int x, int HairColor, int Width, int Height, int y);

Вы меня поняли совершенно корректно: с точки зрения работы с памятью оба варианта будут работать одинаково (но не архитектурной! это вам не замена переменного числа аргументов!). Почему изменился порядок? Потому что параметры метода объявляются друг за другом и в этом порядке складываются в стек потока. Однако стек растет от старших адресов к младшим, а это значит, что порядок складывания по очереди будет отличаться от порядка складывания структуры целиком.

Переопределяемые методы и наследование

Вторая глобальная разница между ними - это отсутствие таблицы виртуальных методов в структурах. Это означает что:

  1. В структурах нельзя описать virtual методы, а также - переопределять их;
  2. Структуры в принципе нельзя наследовать друг от друга. Единственный способ сделать эмуляцию наследования - расположить структуру базового типа первым полем. Тогда по смещениям они будут совпадать, поля "унаследованной" структуры будут располагаться после полей "базовой", и логически вы сделаете наследование;
  3. Структуры в отличии от классов можно передавать в unmanaged код. Я имею ввиду именно значение. Информация о методах, естественно, будет утеряна. Ведь структура - это просто отрезок памяти, заполненный данными без информации о типе. А это значит, что ее можно без изменений отдавать в unmanaged методы, написанные, например, на C++.

Отсутствие таблицы виртуальных методов хоть и отнимает у структур часть "магии", которую вносит понятие наследования, но и наделяет рядом преимуществ. Первое и самое главное уже было оговорено: мы можем легко и просто отдать во внешний мир (за пределы .NET Framework) экземпляр такой структуры. Это ведь просто участок памяти! Либо мы можем принять из unmanaged кода некий участок памяти и сделать приведение типа к нашей структуре, чтобы сделать более удобный доступ к ее полям. С классами такое поведение не пройдет: у классов существует два поля, которые никому не доступны: это SyncBlockIndex и адрес таблицы виртуальных методов. Если эти два поля уйдут в unmanaged код, это станет очень опасным. Ведь с любой таблицей виртуальных методов можно умеючи достучаться до любого типа и поменять его, осуществив атаку на приложение.

Давайте докажем, что это просто участок памяти без какой-либо дополнительной логики:

unsafe void Main()
{
    int secret = 666;
    HeightHolder hh;
    hh.Height = 5;

    WidthHolder wh;
    unsafe
    {
        // Если бы у структур была информация о типе, это приведение не смогло бы работать:
        // CLR перед приведением типа проверила бы иерархию и, не найдя в ней WidthHolder,
        // выбросила бы InvalidCastException. Но поскольку структура - просто участок памяти,
        // в unsafe мире никто не мешает вам интерпретировать его какой угодно структурой
        wh = *(WidthHolder*)&hh;
    }
    Console.WriteLine("Width: " + wh.Width);
    Console.WriteLine("Secret: " + wh.Secret);
}

struct WidthHolder
{
    public int Width;
    public int Secret;
}

struct HeightHolder
{
    public int Height;
}

В данном примере мы осуществляем недопустимую с точки зрения строгой типизации операцию: мы приводим один тип к несовместимому другому, который содержит одно лишнее поле. В методе Main мы вводим дополнительную переменную, значение которой по-идее секретно и не должно быть считано. Однако это не так. Пример уверенно выводит на экран значение переменной метода Main(), которая не находится ни в одной из структур. Тут на вашем лице должна расплыться улыбка, а в голове промелькнуть фраза "ну ни черта себе дыра в безопасности!!!"... Но на самом деле все не так очевидно. Обезопасить свой код от вызываемого unmanaged практически невозможно. Все дело в первую очередь - в структуре стека потока, о котором мы поговорим чуть позже, и по которому можно легко уйти в вызываемый код, и похимичить с локальными переменными. Защита от такого рода атак строится другими путями. Например, на рандомизации размера кадра стека или на стирании информации о регистре EBP - для усложнения восстановления стекового кадра. Но не будем слишком углубляться: это тема отдельного разговора. Единственное, о чем стоит упомянуть в рамках этого примера, это почему же при том, что переменная secret находится перед определением переменной hh, а в структуре WidthHolder - после (т.е. по сути визуально - в разных местах), ее значение прекрасно считалось. А все потому, что стек растет не слева направо, а наоборот - справа налево. Т.е. переменные, объявленные первыми, будут находиться по более старшим адресам, а те, кто объявлены позднее - по более ранним.

Поведение при вызове экземплярных методов

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


// Пример с ссылочным типом
class FooClass 
{
    private int x;

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

// Пример с значимым типом
struct FooStruct
{
    private int x;

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

FooClass klass = new FooClass();
FooStruct strukt = new FooStruct();

klass.ChangeTo(10);
strukt.ChangeTo(10);

Если рассуждать логически, то можно легко и просто прийти к выводу, что тело у метода компилируется одно. Т.е. нет такого, что у каждого экземпляра типа компилируется свой набор методов, который при этом совершенно идентичен набору методов других экземпляров. Однако, вызванный метод прекрасно знает для какого экземпляра он вызван. Это достигается тем, что первым параметром передается ссылка на экземпляр типа. Можно наш пример легко переписать, и это будет совершенно идентично тому, что было написано выше (я намеренно не привожу пример с виртуальными методами. У них все по-другому):


// Пример с ссылочным типом
class FooClass 
{
    public int x;
}

// Пример с значимым типом
struct FooStruct
{
    public int x;
}

public void ChangeTo(FooClass klass, int val)
{
    klass.x = val;
}

public void ChangeTo(ref FooStruct strukt, int val)
{
    strukt.x = val;
}

FooClass klass = new FooClass();
FooStruct strukt = new FooStruct();

ChangeTo(klass, 10);
ChangeTo(ref strukt, 10);

Стоит пояснить, почему я использовал ключевое слово ref. Если бы я его не использовал, то получилась бы ситуация, в которой я получил параметром метода копию структуры вместо оригинала, поменял ее, а оригинал бы остался неизменным. Мне бы пришлось возвращать измененную копию из метода вызывающей стороне (еще одно копирование), а вызывающая сторона сохранила бы это значение обратно в переменной (еще одно копирование). Вместо этого в экземплярный метод отдается указатель на структуру, по которому она и меняется. Сразу оригинал. Заметьте, что передача по указателю никак не влияет на производительность, т.к. любые операции на уровне процессора итак происходят по указателям. Т.е. ref - это из мира C#, не более того.

Возможность указать положение элементов

Еще одной возможностью обоих классов типов является возможность точно указать, по какому смещению относительно начала структуры в памяти располагается то или иное поле. Это введено по нескольким причинам: - для работы с внешними API, которые располагаются в unmanaged world, чтобы не "отбивать" до нужного поля неиспользуемыми полями - чтобы, например, приказать компилятору расположить некоторое поле точно в самом начале типа ([FieldOffset(0)]). Тогда это ускорит работу с ним. А если это поле очень часто используется, то на этом можно неплохо сэкономить. Отмечу только одну важную деталь. Указанное справедливо только для значимых типов. Ведь в ссылочных по нулевому смещению располагается адрес таблицы виртуальных методов, который занимает 1 процессорное слово. Т.е. даже если вы обращаетесь к самому первому полю класса, обращение все равно будет идти по более сложной адресации (адрес + смещение). Кстати это сделано не просто так: самым часто-используемым полем класса является именно адрес таблицы виртуальных методов, т.к. именно через нее виртуальные методы и вызываются; - вы можете установить несколько полей по одному адресу. Тогда одно и то же значение может быть интерпретировано как различные типы данных. В C++ такой тип данных называется union; - также вы можете ничего не объявлять: компилятор будет размещать поля так, как ему покажется оптимальным. Т.е. конечный порядок полей может оказаться другим;

Общие положения

  • Auto: Среда выполнения автоматически выбирает расположение и упаковку для всех полей класса или структуры. Структуры, определенные с помощью члена этого перечисления, не могут быть предоставлены за пределами управляемого кода. Попытка это сделать приводит к возникновению исключения;
  • Explicit: Программист явным образом контролирует точное положение каждого поля объекта. Каждое поле должно использовать FieldOffsetAttribute для указания его точного расположения;
  • Sequential: Члены объекта располагаются последовательно в порядке, указанном при проектировании типа. Также они располагаются в соответствии с указанным StructLayoutAttribute.Pack значением шага упаковки.

Использование FieldOffset для пропуска неиспользуемых областей структуры

Тут конечно же может возникнуть вопрос, почему вообще могут возникнуть поля, которые не используются вообще. Структуры, идущие из unmanaged мира, могут содержать резервные поля, которые могут быть использованы в будущих версиях библиотеки. Если в мире C/C++ принято отбивать такие пропуски путем добавления полей reserved1, reserved2, .., то в .NET мы имеем прекрасную возможность просто задать смещение к началу поля при помощи атрибута FieldOffsetAttribute и [StructLayout(LayoutKind.Explicit)]:

[StructLayout(LayoutKind.Explicit)]
public struct SYSTEM_INFO
{
    [FieldOffset(0)] public ulong OemId;
    // 92 байта - резерв
    [FieldOffset(100)] public ulong PageSize;
    [FieldOffset(108)] public ulong ActiveProcessorMask;
    [FieldOffset(116)] public ulong NumberOfProcessors;
    [FieldOffset(124)] public ulong ProcessorType;
}

Прошу заметить, что пропуск - это тоже занятое, но не используемое пространство. Размер структуры будет равен 132 байта, а не 40, как может показаться изначально.

Union

При помощи FieldOffsetAttribute вы можете эмулировать такой тип из мира C/C++ как union. union – это специальный тип, который позволяет обращаться к одним и тем же данным как к разнотипным сущностям. Давайте посмотрим на примере его эмуляцию:

// Если прочитать RGBA.Value, мы прочитаем Int32 значение, которое будет аккумуляцией всех остальных полей.
// Однако если мы попробуем прочитать RGBA.R, RGBA.G, RGBA.B, RGBA.Alpha, то мы прочитаем отдельные компоненты Int32 числа
[StructLayout(LayoutKind.Explicit)]
public struct RGBA
{
    [FieldOffset(0)] public uint Value;
    [FieldOffset(0)] public byte R;
    [FieldOffset(1)] public byte G;
    [FieldOffset(2)] public byte B;
    [FieldOffset(3)] public byte Alpha;
}

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

class Program
{
    public static void Main()
    {
        Union x = new Union();
        x.Reference.Value = "Hello!";
        Console.WriteLine(x.Value.Value);
    }

    [StructLayout(LayoutKind.Explicit)]
    public class Union
    {
        public Union()
        {
            Value = new Holder<IntPtr>();
            Reference = new Holder<object>();
        }

        [FieldOffset(0)]
        public Holder<IntPtr> Value;

        [FieldOffset(0)]
        public Holder<object> Reference;
    }

    public class Holder<T>
    {
        public T Value;
    }
}

Заметьте что я намеренно перекрыл через Generic тип: при обычном перекрытии в момент загрузки данного типа в домен приложения будет сгенерировано исключение TypeLoadException. На самом деле это только внешне выглядит как брешь в безопасности приложения (особенно со стороны "плагинов" к приложению), однако если мы попробуем запустить этот код из-под защищенного домена, то мы получим все тот же самый TypeLoadException.

Разница в аллокации

Еще одним важным свойством, кардинально различающимся для обоих типов, является выделение памяти под объект/структуру. Все дело в том, что для того чтобы выделить память под объект, CLR обязана для начала ответить себе на ряд вопросов. Первый - какого размера объект? Меньше он или больше 85К байт? Если меньше, то является ли количество оставшегося места в Small Objects Heap достаточным, чтобы разместить объект? Если нет, запускается Garbage Collection, который для своей работы по сути должен сначала обойти граф объектов, а потом сжать их, переместив на освободившееся место. Если и после этой операции нет места в SOH (например, ничего не было освобождено), то инициируется процесс выделения дополнительных страниц виртуальной памяти, чтобы нарастить размер Small Objects Heap. И только после того как все срастется, выделяется место под объект, а выделенный участок памяти очищается от мусора (обнуляется), размечается SyncBlockIndex и VirtualMethodsTable, после чего ссылка на объект возвращается пользователю.

Если же выделяемый объект имеет размеры, превышающие 85K, то мы имеем дело с Large Objects Heap. Это, например, случай огромных строк и массивов. В этом случае мы должны найти максимально подходящий кусок памяти из списка освобожденных и, если таковых нет, выделить новый участок. Эти процедуры по умолчанию не быстрые, но мы предполагаем, что с объектами такого размера мы будем работать особенно осторожно и они вне контекста данной беседы

Т.е. для RefTypes мы имеем несколько случаев:

  • Размер RefType < 85K, место в SOH есть: выделение памяти идет достаточно быстро;
  • Размер RefType < 85K, место в SOH заканчивается: выделение памяти идет очень медленно;
  • Размер RefType > 85K, выделение памяти идет относительно медленно. А с учетом того, что данные операции редки и не могут ввиду своих размеров конкурировать с ValTypes, нас это сейчас не сильно волнует.

Каков же алгоритм выделения памяти под Value Types? А нет его. Выделение памяти под Value Types не стоит абсолютно ничего. Единственное, что происходит при его "выделении" - это обнуление полей. Давайте разберемся, почему так происходит:

  1. В случае объявления переменной в теле метода время на выделение места под структуру можно считать около нулевым. Ведь время на выделение места под локальные переменные почти не зависит от их количества;
  2. В случае размещения ValTypes в качестве полей RefTypes просто увеличит их размер. Значимый тип размещается целиком, становясь его частью;
  3. Если ValTypes передаются как параметры метода - тут, как и в случае копирования, возникнет некоторая разница - в зависимости от размера и положения параметра.

Но в любом случае это не дольше копирования из одной переменной в другую.

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

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

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

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

  1. Если наш проектируемый тип будет обладать инвариантностью по отношению к смысловой нагрузке своего состояния, то это будет значить что его состояние полностью отражает некоторый процесс или является значением чего-либо. Другими словами, экземпляр типа полностью константен и не может быть изменен по своей сути. Мы можем создать на основе этой константы другой экземпляр типа, указав некоторое смещение, либо создать с нуля, указав его свойства. Но изменять его мы не имеем права. Я прошу заметить, что я не имею ввиду что структура является неизменяемым типом. Вы можете менять поля, как хотите. Мало того вы можете отдать ссылку на структуру в метод через ref параметр и получить измененные поля по выходу из метода. Однако, я про смысл с точки зрения архитектуры. Поясню на примерах:

    • DateTime - это структура, которая инкапсулирует в себе понятие момента времени. Она хранит эти данные в виде uint, однако предоставляет доступ к отдельным характеристикам момента времени. Например: год, месяц, день, час, минуты, секунды, миллисекунды и даже процессорные тики. Однако, исходя из того что она инкапсулирует, она не может быть изменяемой по своей природе. Мы не можем изменить конкретный момент времени, чтобы он стал другим. Я не могу прожить следующую минуту своей жизни в лучший день рождения своего детства. Время неизменно. Именно поэтому выбор для типа данных может стать либо класс с readonly интерфейсом взаимодействия, который на каждое изменение свойств отдает новый экземпляр, либо структура, которая несмотря на возможность изменения полей своих экземпляров делать этого не должна: описание момента времени является значением. Как число. Вы же не можете залезть в структуру числа и поменять его? Если вы хотите получить другой момент времени, который является смещением относительно оригинального на один день, вы просто получаете новый экземпляр структуры;
    • KeyValuePair - это структура, инкапсулирующая в себе понятие связной пары ключ-значение. Замечу, что эта структура используется только для выдачи пользователю при перечислении содержимого словаря. Почему выбрана структура с точки зрения архитектуры? Ответ прост: потому что в рамках Dictionary ключ и значение неразделимые понятия. Да, внутри все устроено иначе. Внутри мы имеем сложную структуру, где ключ лежит отдельно от значения. Однако для внешнего пользователя, с точки зрения интерфейса взаимодействия и смысла самой структуры данных, пара ключ-значение является неразделимым понятием. Является значением целиком. Если мы по этому ключу расположили другое значение это значит, что изменилась вся пара. Для внешнего наблюдателя нет отдельно ключей, а отдельно - значений, они являются единым целым. Именно поэтому структура в данном случае - идеальный вариант.
  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 до 8 байт на поле * количество полей = 4) минус размер заголовка массива: примерно до 2600 элементов на массив в зависимости от платформы (а брать понятное дело надо в меньшую сторону). Всего-то! Не так и много! А ведь могло показаться, что магическая константа в 20,000 элементов вполне могла подойти!
  2. Также стоит отдавать себе отчет, что если вы используете структуру, которая имеет некоторый достаточно большой размер как источник данных, и размещаете ее в некотором классе как поле, и при этом, например, одна и та же копия растиражирована на тысячу экземпляров (просто потому, что вам удобно держать все под рукой), то вы тем самым увеличиваете каждый экземпляр класса на размер структуры, что в конечном счете приведет к распуханию 0-го поколения и уходу в поколение 1 или даже 2. При этом если на самом деле экземпляры класса короткоживущие, и вы рассчитываете на то, что они будут собраны GC в нулевом поколении - за 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.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;
  • Точно также, если потерять ссылку на упакованную структуру, GC её соберет;
  • Однако у нас теперь есть возможность работы с упакованным типом: вызывать у него методы;
  • Также теперь мы имеем возможность подменить экземпляр значимого типа в SOH/LOH на другой. Этого мы не могли сделать раньше: нам пришлось бы делать 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 спокоен и не работает на износ;

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

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, который был изначально запакован.

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

Nullable\

Также стоит сказать про то, как ведет себя boxing для Nullable значимых типов. Это очень приятная особенность nullable значимых типов, поскольку boxing значимого типа, который как-бы null - просто возвратит null:

int? x = 5;
int? y = null;

var boxedX = (object)x; // -> 5
var boxedY = (object)y; // -> null

Отсюда следует забавный вывод: т.к. null не имеет типа, то единственный вариант вытащить из boxing'a другой тип нежели был запакован, такой:

int? x = null;
var pseudoBoxed = (object)x;
double? y = (double?)pseudoBoxed;

Код рабочий просто потому что с null можно сделать приведение типа куда угодно. Но зато таким кодом можно запросто удивить своих коллег и скрасить вечер забавным фактом.

Погружаемся в boxing еще глубже

На закуску хочу вам рассказать про тип System.Enum. Не проходя по ссылке скажите, пожалуйста, является ли тип Value Type или же Reference Type? По всем канонам и логике тип просто обязан быть значимым. Ведь это обычное перечисление: набор aliases с чисел на названия в языке программирования. И мы бы закончили этот разговор словами: "вы все думаете абсолютно правильно! Так не будем терять времени и пойдем дальше!", - но нет. System.Enum, от которого наследуются все enum типы данных, определенных как в вашем коде, так и в .NET Framework являются ссылочными типами. Т.е. типом данных class. Мало того (!) класс этот абстрактный и наследуется от System.ValueType.

    [Serializable]
    [System.Runtime.InteropServices.ComVisible(true)]
    public abstract class Enum : ValueType, IComparable, IFormattable, IConvertible
    {
        // ...
    }

Значит ли это что все перечисления выделяются в куче SOH? Значит ли это что используя перечисления мы забиваем кучу, а вместе и с ней - GC? Ведь мы просто используем их. Нет, такого не может быть. Тогда, получается, что есть где-то пул перечислений, в которых они лежат и нам отдаются их экземпляры. И снова нет: перечисления можно использовать в структурах при маршаллинге. Это - обычные числа.

А правда заключается в том, что CLR при формировании структур типа данных прямо скажем подхачивает ее если встечается enum превращая класс в значимый тип:

// Check to see if the class is a valuetype; but we don't want to mark System.Enum
// as a ValueType. To accomplish this, the check takes advantage of the fact
// that System.ValueType and System.Enum are loaded one immediately after the
// other in that order, and so if the parent MethodTable is System.ValueType and
// the System.Enum MethodTable is unset, then we must be building System.Enum and
// so we don't mark it as a ValueType.
if(HasParent() &&
    ((g_pEnumClass != NULL && GetParentMethodTable() == g_pValueTypeClass) ||
    GetParentMethodTable() == g_pEnumClass))
{
    bmtProp->fIsValueClass = true;

    HRESULT hr = GetMDImport()->GetCustomAttributeByName(bmtInternal->pType->GetTypeDefToken(),
                                                            g_CompilerServicesUnsafeValueTypeAttribute,
                                                            NULL, NULL);
    IfFailThrow(hr);
    if (hr == S_OK)
    {
        SetUnsafeValueClass();
    }
}

Зачем это делается? В частности из-за идеи наследования: чтобы сделать пользовательский enum, необходимо снабдить его информацией об именах его возможных значений, например. А как сделать наследование у значимых типов? Никак. Вот и придумали они сделать его ссылочным, но на этапе работы JIT превращать его в значимый. Чтобы никто не догадался.

Что если хочется лично посмотреть как работает boxing?

В наше время для того чтобы посмотреть на реализацию упаковки значимых типов, к счастью, нет необходимости загружать дизассемблер и лезть в самые дебри не пойми чего. В наше прекрасное время у нас есть исходные тексты всего ядра платформы .NET и многие его части абсолютно идентичны между .NET Framework CLR и CoreCLR. Вы можете пройти по ссылкам ниже и посмотреть на реализации упаковки прямо в исходных кодах:

  • Существует отдельная группа оптимизаций, каждая из которых используется на отдельном типе процессора:
  • В общем случае JIT инлайнит вызов вспомогательной функции Compiler::impImportAndPushBox(..)
  • Generic-версия, менее использует менее оптимизированную MethodTable::Box(..)
    • И наконец вызывается CopyValueClassUnchecked(..), по коду которой прекрасно видно почему лучше всего выбирать размер структур до 8 байт включительно.

Для сравнения чтобы сделать распаковку реализован всего один метод: JIT_Unbox(..), который является оберткой вокруг JIT_Unbox_Helper(..).

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

Ключевое слово ref

TODO

Span\, Memory\

TODO

makeref, reftype, refvalue, arglist

TODO

Неявный Boxing

TODO

Раздел вопросов по теме

Почему .NET CLR не делает пуллинга для боксинга самостоятельно?

Если мы поговорим с любым Java разработчиком то мы с удивлением узнаем две вещи:

  • В Java все Value Types запакованы и по сути не являются значимыми. Т.е. целые числа - тоже запакованы;
  • Для оптимизации числа от -128 до 127 берутся из пула объектов.

Так почему же в .NET CLR во время упаковки не происходит того же самого? Ответ прост: потому что мы можем менять содержимое упакованного значимого типа. Т.е. другими словами, мы можем сделать так:

object x = 1;
x.GetType().GetField("m_value", BindingFlags.Instance | BindingFlags.NonPublic).SetValue(x, 138);
Console.WriteLine(x); // -> 138

или так (С++/CLI):

void ChangeValue(Object^ obj)
{
    Int32^ i = (Int32^)obj;
    *i = 138;
}

Т.е. если бы мы имели дело с пуллингом, то мы бы поменяли все единицы в приложении на 138! Согласитесь, ситуация не из приятных.

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

Почему при вызове метода, принимающего тип object, а по факту - значимый тип нет возможности сделать boxing на стеке, разгрузив кучу?

Потому что если произойдет упаковка значимого типа на стеке, после чего ссылка уйдет в кучу, то внутри метода ссылка может уйти в какое-либо другое место, например метод может записать пришедшую ссылку в поле какого-либо класса. Далее метод завершит работу, после чего завершит работу метод, который сделал упаковку. Как результат - с внешнего класса ссылка будет указывать на "вымерший" участок стека.

Почему нельзя использовать в качестве поля Value Type его самого?

Иногда возникает дерзкое желание в качестве поля структуры использовать другую структуру, которая использует первую. Или же еще проще: В качестве поля структуры использовать ее саму. Не задавайте мне вопросов, почему такое может понадобиться: на самом деле не может :) Но почему так сделать нельзя? Ответ прост: когда вы используете структуру в качестве поля ее же самой либо через зависимость от другой структуры, то фактически создаете рекурсию: вы создаете стрктуру бесконечных размеров. Однако, в .NET Framework есть места, в которых так сделано. Примером может служить структура System.Char, которая содержит саму себя:

public struct Char : IComparable, IConvertible
{
    // Member Variables
    internal char m_value;
    //...
}

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

Ссылки