Что такое value type c
Перейти к содержимому

Что такое value type c

  • автор:

Что такое value type c

Ранее мы рассматривали следующие элементарные типы данных: int, byte, double, string, object и др. Также есть сложные типы: структуры, перечисления, классы. Все эти типы данных можно разделить на типы значений, еще называемые значимыми типами, (value types) и ссылочные типы (reference types). Важно понимать между ними различия.

  • Целочисленные типы ( byte, sbyte, short, ushort, int, uint, long, ulong )
  • Типы с плавающей запятой ( float, double )
  • Тип decimal
  • Тип bool
  • Тип char
  • Перечисления enum
  • Структуры ( struct )
  • Тип object
  • Тип string
  • Классы ( class )
  • Интерфейсы ( interface )
  • Делегаты ( delegate )

В чем же между ними различия? Для этого надо понять организацию памяти в .NET. Здесь память делится на два типа: стек и куча (heap). Параметры и переменные метода, которые представляют типы значений, размещают свое значение в стеке. Стек представляет собой структуру данных, которая растет снизу вверх: каждый новый добавляемый элемент помещается поверх предыдущего. Время жизни переменных таких типов ограничено их контекстом. Физически стек — это некоторая область памяти в адресном пространстве.

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

class Program < static void Main(string[] args) < Calculate(5); >static void Calculate(int t) < int x = 6; int y = 7; int z = y + t; >>

При запуске такой программы в стеке будут определяться два фрейма — для метода Main (так как он вызывается при запуске программы) и для метода Calculate:

Структура стека в языке программирования C#

При вызове этого метода Calculate в его фрейм в стеке будут помещаться значения t, x, y и z. Они определяются в контексте данного метода. Когда метод отработает, область памяти, которая выделялась под стек, впоследствии может быть использована другими методами.

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

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

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

Так, в частности, если мы изменим метод Calculate следующим образом:

static void Calculate(int t)

То теперь значение переменной x будет храниться в куче, так как она представляет ссылочный тип object, а в стеке будет храниться ссылка на объект в куче.

Ссылочные типы в куче в языке программирования C#

Составные типы

Теперь рассмотим ситуацию, когда тип значений и ссылочный тип представляют составные типы — структуру и класс:

State state1 = new State(); // State — структура, ее данные размещены в стеке Country country1 = new Country(); // Country — класс, в стек помещается ссылка на адрес в хипе // а в хипе располагаются все данные объекта country1 struct State < public int x; public int y; >class Country

Здесь в методе Main в стеке выделяется память для объекта state1. Далее в стеке создается ссылка для объекта country1 ( Country country1 ), а с помощью вызова конструктора с ключевым словом new выделяется место в хипе ( new Country() ). Ссылка в стеке для объекта country1 будет представлять адрес на место в хипе, по которому размещен данный объект..

Ссылычные типы и типы значений в C#

Таким образом, в стеке окажутся все поля структуры state1 и ссылка на объект country1 в хипе.

Но, допустим, в структуре State также определена переменная ссылочного типа Country. Где она будет хранить свое значение, если она определена в типе значений?

State state1 = new State(); Country country1 = new Country(); struct State < public int x; public int y; public Country country; public State() < x = 0; y = 0; country = new Country(); >> class Country

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

Стек и куча в языке программирования C#

Копирование значений

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

State state1 = new State(); // Структура State State state2 = new State(); state2.x = 1; state2.y = 2; state1 = state2; state2.x = 5; // state1.x=1 по-прежнему Console.WriteLine(state1.x); // 1 Console.WriteLine(state2.x); // 5 Country country1 = new Country(); // Класс Country Country country2 = new Country(); country2.x = 1; country2.y = 4; country1 = country2; country2.x = 7; // теперь и country1.x = 7, так как обе ссылки и country1 и country2 // указывают на один объект в хипе Console.WriteLine(country1.x); // 7 Console.WriteLine(country2.x); // 7

Так как state1 — структура, то при присвоении state1 = state2 она получает копию структуры state2. А объект класса country1 при присвоении country1 = country2; получает ссылку на тот же объект, на который указывает country2. Поэтому с изменением country2, так же будет меняться и country1.

Ссылочные типы внутри типов значений

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

State state1 = new State(); State state2 = new State(); state2.country.x = 5; state1 = state2; state2.country.x = 8; // теперь и state1.country.x=8, так как state1.country и state2.country // указывают на один объект в хипе Console.WriteLine(state1.country.x); // 8 Console.WriteLine(state2.country.x); // 8 struct State < public int x; public int y; public Country country; public State() < x = 0; y = 0; country = new Country(); // выделение памяти для объекта Country >> class Country

Переменные ссылочных типов в структурах также сохраняют в стеке ссылку на объект в хипе. И при присвоении двух структур state1 = state2; структура state1 также получит ссылку на объект country в хипе. Поэтому изменение state2.country повлечет за собой также изменение state1.country.

Объекты классов как параметры методов

Организацию объектов в памяти следует учитывать при передаче параметров по значению и по ссылке. Если параметры методов представляют объекты классов, то использование параметров имеет некоторые особенности. Например, создадим метод, который в качестве параметра принимает объект Person:

Person p = new Person < name = "Tom", age = 23 >; ChangePerson(p); Console.WriteLine(p.name); // Alice Console.WriteLine(p.age); // 23 void ChangePerson(Person person) < // сработает person.name = "Alice"; // сработает только в рамках данного метода person = new Person < name = "Bill", age = 45 >; Console.WriteLine(person.name); // Bill > class Person

При передаче объекта класса по значению в метод передается копия ссылки на объект. Эта копия указывает на тот же объект, что и исходная ссылка, потому мы можем изменить отдельные поля и свойства объекта, но не можем изменить сам объект. Поэтому в примере выше сработает только строка person.name = «Alice» .

А другая строка person = new Person < name = "Bill", age = 45 >создаст новый объект в памяти, и person теперь будет указывать на новый объект в памяти. Даже если после этого мы его изменим, то это никак не повлияет на ссылку p в методе Main, поскольку ссылка p все еще указывает на старый объект в памяти.

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

Person p = new Person < name = "Tom", age = 23 >; ChangePerson(ref p); Console.WriteLine(p.name); // Bill Console.WriteLine(p.age); // 45 void ChangePerson(ref Person person) < // сработает person.name = "Alice"; // сработает person = new Person < name = "Bill", age = 45 >; > class Person

Операция new создаст новый объект в памяти, и теперь ссылка person (она же ссылка p из метода Main) будет указывать уже на новый объект в памяти.

C — Data Types

Data types in c refer to an extensive system used for declaring variables or functions of different types. The type of a variable determines how much space it occupies in storage and how the bit pattern stored is interpreted.

The types in C can be classified as follows −

Basic Types

They are arithmetic types and are further classified into: (a) integer types and (b) floating-point types.

Enumerated types

They are again arithmetic types and they are used to define variables that can only assign certain discrete integer values throughout the program.

The type void

The type specifier void indicates that no value is available.

Derived types

They include (a) Pointer types, (b) Array types, (c) Structure types, (d) Union types and (e) Function types.

The array types and structure types are referred collectively as the aggregate types. The type of a function specifies the type of the function’s return value. We will see the basic types in the following section, where as other types will be covered in the upcoming chapters.

Integer Types

The following table provides the details of standard integer types with their storage sizes and value ranges −

Type Storage size Value range
char 1 byte -128 to 127 or 0 to 255
unsigned char 1 byte 0 to 255
signed char 1 byte -128 to 127
int 2 or 4 bytes -32,768 to 32,767 or -2,147,483,648 to 2,147,483,647
unsigned int 2 or 4 bytes 0 to 65,535 or 0 to 4,294,967,295
short 2 bytes -32,768 to 32,767
unsigned short 2 bytes 0 to 65,535
long 8 bytes or (4bytes for 32 bit OS) -9223372036854775808 to 9223372036854775807
unsigned long 8 bytes 0 to 18446744073709551615

To get the exact size of a type or a variable on a particular platform, you can use the sizeof operator. The expressions sizeof(type) yields the storage size of the object or type in bytes. Given below is an example to get the size of various type on a machine using different constant defined in limits.h header file −

#include #include #include #include int main(int argc, char** argv)

When you compile and execute the above program, it produces the following result on Linux −

CHAR_BIT : 8 CHAR_MAX : 127 CHAR_MIN : -128 INT_MAX : 2147483647 INT_MIN : -2147483648 LONG_MAX : 9223372036854775807 LONG_MIN : -9223372036854775808 SCHAR_MAX : 127 SCHAR_MIN : -128 SHRT_MAX : 32767 SHRT_MIN : -32768 UCHAR_MAX : 255 UINT_MAX : 4294967295 ULONG_MAX : 18446744073709551615 USHRT_MAX : 65535

Floating-Point Types

The following table provide the details of standard floating-point types with storage sizes and value ranges and their precision −

Type Storage size Value range Precision
float 4 byte 1.2E-38 to 3.4E+38 6 decimal places
double 8 byte 2.3E-308 to 1.7E+308 15 decimal places
long double 10 byte 3.4E-4932 to 1.1E+4932 19 decimal places

The header file float.h defines macros that allow you to use these values and other details about the binary representation of real numbers in your programs. The following example prints the storage space taken by a float type and its range values −

#include #include #include #include int main(int argc, char** argv)

When you compile and execute the above program, it produces the following result on Linux −

Storage size for float : 4 FLT_MAX : 3.40282e+38 FLT_MIN : 1.17549e-38 -FLT_MAX : -3.40282e+38 -FLT_MIN : -1.17549e-38 DBL_MAX : 1.79769e+308 DBL_MIN : 2.22507e-308 -DBL_MAX : -1.79769e+308 Precision value: 6

The void Type

The void type specifies that no value is available. It is used in three kinds of situations −

Function returns as void

There are various functions in C which do not return any value or you can say they return void. A function with no return value has the return type as void. For example, void exit (int status);

Function arguments as void

There are various functions in C which do not accept any parameter. A function with no parameter can accept a void. For example, int rand(void);

Pointers to void

A pointer of type void * represents the address of an object, but not its type. For example, a memory allocation function void *malloc( size_t size ); returns a pointer to void which can be casted to any data type.

Kickstart Your Career

Get certified by completing the course

Value vs Reference Types In C#

Value vs Reference Types In C#

In C#, data types are divided into two categories Value Types and Reference Types. Understanding the difference between value types and reference types, and how they behave, is key to being proficient in C#, particularly when dealing with data structures, method parameters, and performance considerations.

Value Types

Value types in C# are stored in the Stack memory. Each value type variable holds its own copy of the data, and it is stored directly. If the data is assigned from one value type variable to another, then the system will create a separate copy of the data. Any changes made to one variable will not affect the other variable.

These are usually simple primitive types that include all numeric data types, Booleans, Characters, DateTime, TimeSpan, and Structs (if they only contain value types).

Value types directly contain their data which means that operations on the data are done directly on the stored values. Since these types are created on the stack, memory allocation and deallocation are more straightforward, leading to potentially more efficient memory usage.

One major feature of value types is that when they are assigned to a new variable or passed as a method parameter, the value is copied. This means that changing the value of one variable does not affect the other.

No alt text provided for this image

In this example, changing the value of b did not affect the value of a, even though b was initially set to a’s value.

Reference Types

Reference types in C# are stored in the Heap memory. When creating a reference type variable, the system allocates memory in the heap and then stores the address of the memory location in the variable stored in the stack. When the data is assigned from one reference type variable to another, both variables refer to the same memory location. So if the data is changed using one variable, it also gets changed for the other.

Reference types include Classes, Interfaces, Delegates, and Arrays. These types store references to the actual data, unlike value types which store the actual data. When you assign a reference type to another variable, the address is copied, not the actual data itself. Both variables then point to the same memory location — changes made through one variable are reflected in the other.

No alt text provided for this image

In this example, appending text to sb2 also affected sb1, because both reference the same memory location.

The Key Difference

The key difference between value types and reference types is that value types hold the actual data, while reference types hold a reference to the data’s location in memory. This fundamental difference leads to different behavior in the way these types are handled in the code, and it’s essential to understand this to avoid unintended side effects.

Whether to use value types or reference types depends on the specific needs of your program. Value types are generally more efficient in terms of memory and performance, but they lack the flexibility and functionality provided by reference types. Conversely, reference types are more flexible and powerful, but they can lead to potential pitfalls if not handled correctly, like unexpected side effects from changing data in one place that another part of your code is also referencing.

References

By understanding these nuances, you can choose the right type for your specific use case and write more efficient and effective code.

Что такое value type c

Давайте в первую очередь поговорим про 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  < 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  < 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

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

Если говорить про ссылочные типы, то, понятное дело, для них все обстоит иначе. Сам экземпляр находится в недосягаемом 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

В данном примере мы осуществляем недопустимую с точки зрения строгой типизации операцию: мы приводим один тип к несовместимому другому, который содержит одно лишнее поле. В методе 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

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

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(); Reference = new Holder(); > [FieldOffset(0)] public Holder Value; [FieldOffset(0)] public Holder Reference; > public class Holder  < 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

Выглядит как обман дважды. Первый раз — глядя на код мы не обязаны знать, с чем имеем дело в чужом коде, и видим ниже приведение к интерфейсу IBoo . Что фактически гарантированно наводит нас на мысль, что Foo — класс, а не структура. Далее — полное отсутствие визуального разделения на структуры и классы дает полное ощущение, что результаты модификации по интерфейсу обязаны попасть в foo, чего не происходит потому, что boo — копия foo. Что фактически вводит нас в заблуждение. На мой взгляд, такой код стоит снабжать комментариями, чтоб внешний разработчик смог бы в нем правильно разобраться.

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

Но вы можете возразить: дескать если бы все было именно так, как я говорю, то можно было бы написать как-то так:

var referenceToInteger = (IInt32)10; 

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

public sealed class Boxed  < 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 < Value = 10 >; var pureBoxing = (object)10; 

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

Зато теперь мы можем вызывать у нашего упакованного значения какие-то методы:

struct Foo < public int x; public void ChangeTo(int newx) < x = newx; >> var boxed = new Boxed  < Value = new Foo < x = 5 >>; boxed.Value.ChangeTo(10); var unboxed = boxed.Value; 

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

  • Наш тип Boxed по сути осуществляет все то же самое, что и обычный: выделяет память в куче, отдает туда значение и позволяет его забрать, выполнив своеобразный unbox ;
  • Точно также, если потерять ссылку на упакованную структуру, GC её соберет;
  • Однако у нас теперь есть возможность работы с упакованным типом: вызывать у него методы;
  • Также теперь мы имеем возможность подменить экземпляр значимого типа в SOH/LOH на другой. Этого мы не могли сделать раньше: нам пришлось бы делать unboxing , менять структуру на другую и делать boxing обратно, раздав новую ссылку потребителям.

Также давайте подумаем, какая основная проблема у упаковки? Создание траффика в памяти. Траффика непонятного количества объектов, часть из которых может выжить до первого поколения, где мы получим проблемы со сборкой мусора: он там будет, его там будет много, и этого явно можно было избежать. А когда мы имеем траффик короткоживущих объектов, первое решение, которое приходит в голову, — пуллинг. Вот это будет отличным завершением Кульбита Дотнетского.

var pool = new Pool>(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_BoxFastMP_InlineGetThread (AMD64 — многопроцессорный или Server GC, implicit Thread Local Storage)
    • JIT_BoxFastMP (AMD64 — многопроцессорный или Server GC)
    • JIT_BoxFastUP (AMD64 — однопроцессорный или Workstation GC)
    • JIT_TrialAlloc::GenBox(..) (x86), которая присоединяется через JitHelpers
    • И наконец вызывается CopyValueClassUnchecked(..), по коду которой прекрасно видно почему лучше всего выбирать размер структур до 8 байт включительно.

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

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

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *