Value types vs reference types: стек, куча, boxing/unboxing, struct vs class?
Как это выглядит в памяти
struct PointS { public int X, Y; }
class PointC { public int X, Y; }
void Demo()
{
PointS s1 = new PointS { X = 1, Y = 2 };
PointS s2 = s1; // копия данных
s2.X = 100; // s1.X по-прежнему 1
PointC c1 = new PointC { X = 1, Y = 2 };
PointC c2 = c1; // копия ссылки — один объект
c2.X = 100; // c1.X тоже 100!
}Каждый объект в куче содержит служебный заголовок (object header, sync block index) и указатель на MethodTable (информацию о типе) — это +16 байт на 64-битной системе. Значимый тип такого оверхеда не имеет.
Где на самом деле живут данные
| Ситуация | Где хранится значение struct |
|---|---|
| Локальная переменная обычного метода | Стек (или регистры процессора) |
| Поле класса | Куча — внутри объекта-владельца |
Элемент массива PointS[] |
Куча — внутри массива, подряд, без отдельных объектов |
Локальная переменная в async-методе или захваченная лямбдой |
Куча — в поле сгенерированной state machine / closure-класса |
После boxing (object o = s) |
Куча — отдельный объект-«коробка» |
Boxing и unboxing
Где boxing возникает незаметно:
int n = 5;
object o = n; // явный boxing
string s = string.Format("{0}", n); // params object[] → boxing
ArrayList list = new(); list.Add(n); // не-generic коллекции → boxing
IComparable c = n; // приведение к интерфейсу → boxing
Console.WriteLine($"{n}"); // C# 10+: DefaultInterpolatedStringHandler — БЕЗ boxing
enumValue.HasFlag(Flags.A); // до .NET Core 2.1 — boxing; сейчас JIT убирает
// Решение: generics сохраняют тип без упаковки
List<int> ints = new(); ints.Add(n); // без boxing
void Print<T>(T value) where T : IFormattable => Console.Write(value.ToString(null, null)); // без boxingstruct или class: как выбирать
struct |
class |
|
|---|---|---|
| Семантика | Значение, копируется | Ссылка, общий объект |
| Аллокация | Нет отдельной аллокации | Аллокация в куче + нагрузка на GC |
null |
Нет (только Nullable<T>) |
Может быть null |
| Наследование | Нет (только интерфейсы) | Да |
| Конструктор по умолчанию | Всегда есть (обнуление) | Если не объявлен другой |
| Равенство по умолчанию | По значениям полей (через reflection — медленно!) | По ссылке |
| Цена передачи | Копирование всех полей | Копирование 8 байт |
public readonly record struct Money(decimal Amount, string Currency)
{
public Money Add(Money other) =>
Currency == other.Currency
? this with { Amount = Amount + other.Amount }
: throw new InvalidOperationException("Разные валюты");
}readonly struct гарантирует неизменяемость и позволяет компилятору не делать защитные копии при вызове методов на readonly-полях и in-параметрах.
Передача по ссылке: ref, in, out
Большую структуру дорого копировать. Её можно передавать по ссылке:
void Update(ref LargeStruct s) { s.Value = 1; } // можно менять
double Length(in Vector3 v) => Math.Sqrt(v.X*v.X + v.Y*v.Y + v.Z*v.Z); // только чтение, без копии
bool TryParse(string s, out int result) { ... } // выходной параметрТипичные ошибки
- Мутабельная структура в
List<T>:list[0].X = 5не компилируется, так как индексатор возвращает копию. С массивом (arr[0].X = 5) работает — массив отдаёт ссылку на элемент. - Структура без переопределения
Equals/GetHashCodeв ключах словаря — используется медленное сравнение через reflection. Решение:record structилиIEquatable<T>. - Большие структуры (> 64 байт), передаваемые по значению в горячем цикле.
- Считать
stringзначимым типом, потому что он «ведёт себя как значение». Это неизменяемый ссылочный тип.
Вопросы на засыпку
Почему string — ссылочный тип, но ведёт себя как значимый?
Он неизменяемый: любая «модификация» создаёт новую строку. Плюс == перегружен для сравнения содержимого. Переменная всё равно хранит ссылку, а строка лежит в куче (литералы — в intern pool).
Может ли struct реализовать интерфейс? Будет ли boxing?
Может. При приведении к интерфейсу (IComparable c = myStruct) — будет boxing. При вызове через generic-ограничение (void M<T>(T x) where T : IComparable<T>) — boxing не будет: JIT генерирует отдельный код для каждого значимого типа.
Что такое Nullable<T> и как он устроен?
int? — это Nullable<int>, структура из двух полей: bool HasValue и T Value. При упаковке Nullable<T> без значения превращается в null, а со значением — в упакованный T, а не в «упакованный Nullable».
Что такое ref struct?
Структура, которая может жить только на стеке: её нельзя упаковать, положить в поле класса, захватить в лямбде или использовать в async-методе. Пример — Span<T>. Это позволяет ей безопасно ссылаться на стековую память.
Итог
Главное различие — семантика копирования: значение против ссылки. Где физически лежат данные, зависит от контекста. Используйте readonly struct для маленьких неизменяемых значений, class — для всего остального, и следите за скрытым boxing в горячих местах.