.NET Tutorial
Junior7 мин чтенияВопрос 4 из 25

Value types vs reference types: стек, куча, boxing/unboxing, struct vs class?

Как это выглядит в памяти

C#
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!
}

Управляемая куча

Стек метода Demo()

s1: X=1, Y=2

s2: X=100, Y=2

c1: ссылка

c2: ссылка

PointC
заголовок + MethodTable
X=100, Y=2

Управляемая куча

Стек метода Demo()

s1: X=1, Y=2

s2: X=100, Y=2

c1: ссылка

c2: ссылка

PointC
заголовок + MethodTable
X=100, Y=2

Копирование значения против копирования ссылки

Каждый объект в куче содержит служебный заголовок (object header, sync block index) и указатель на MethodTable (информацию о типе) — это +16 байт на 64-битной системе. Значимый тип такого оверхеда не имеет.

Где на самом деле живут данные

Ситуация Где хранится значение struct
Локальная переменная обычного метода Стек (или регистры процессора)
Поле класса Куча — внутри объекта-владельца
Элемент массива PointS[] Куча — внутри массива, подряд, без отдельных объектов
Локальная переменная в async-методе или захваченная лямбдой Куча — в поле сгенерированной state machine / closure-класса
После boxing (object o = s) Куча — отдельный объект-«коробка»

Boxing и unboxing

КучаСтекint x = 42объект Int32(заголовок + 42)object o = x — boxing:выделение объекта,копирование 42int y = (int)o — unboxing:проверка типа, копированиеобратно
КучаСтекint x = 42объект Int32(заголовок + 42)object o = x — boxing:выделение объекта,копирование 42int y = (int)o — unboxing:проверка типа, копированиеобратно
Упаковка и распаковка

Где boxing возникает незаметно:

C#
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)); // без boxing

struct или class: как выбирать

нет

да

нет

да

нет

да

да

нет

Представляет одно значение?
(как int, DateTime, Money)

class

Размер ≤ 16–24 байт?

Можно сделать неизменяемым?

Часто упаковывается
(object, интерфейсы)?

readonly struct

нет

да

нет

да

нет

да

да

нет

Представляет одно значение?
(как int, DateTime, Money)

class

Размер ≤ 16–24 байт?

Можно сделать неизменяемым?

Часто упаковывается
(object, интерфейсы)?

readonly struct

Выбор между struct и 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

Большую структуру дорого копировать. Её можно передавать по ссылке:

C#
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 в горячих местах.