Вопрос «чем struct отличается от class» звучит почти на каждом .NET-собесе. Хороший ответ начинается не со стека и кучи, а с семантики копирования.
Два вида типов
Типы значений (int, double, bool, decimal, DateTime, любые struct и enum): переменная содержит сами данные. Присваивание копирует их целиком.
Ссылочные типы (class, string, массивы, делегаты, record): переменная содержит ссылку на объект в куче. Присваивание копирует ссылку, и обе переменные указывают на один объект.
struct PointS { public int X; }
class PointC { public int X; }
var a = new PointS { X = 1 };
var b = a; // копия данных
b.X = 5; // a.X по-прежнему 1
var c = new PointC { X = 1 };
var d = c; // копия ссылки
d.X = 5; // c.X тоже 5
Где что хранится
Объекты классов всегда в управляемой куче. Тип значения хранится там, где лежит его переменная: локальная переменная обычно на стеке (или в регистре), поле класса внутри объекта в куче, элемент массива внутри массива в куче. Поэтому фраза «структуры живут на стеке» неточна, и интервьюер это любит проверять.
Локальные переменные тоже могут уехать в кучу: если их захватила лямбда (появится объект-замыкание) или они живут через await (станут полями машины состояний).
Передача параметров
По умолчанию всё передаётся по значению: для структуры копируются данные, для класса копируется ссылка. Метод может менять поля переданного объекта, но присваивание параметру p = new Person() вызывающий не увидит.
ref: ссылка на саму переменную, нужна инициализация до вызова.out: метод обязан присвоить значение.in: ссылка только для чтения, удобно для больших структур без копирования.
Boxing и unboxing
Упаковка происходит, когда тип значения приводится к object или интерфейсу: в куче создаётся объект-копия. Распаковка требует точного типа.
object o = 42; // boxing
int x = (int)o; // unboxing
long y = (long)o; // InvalidCastException: упакован int, а не long
Минусы: аллокации и нагрузка на GC, а изменения копии не видны оригиналу. Источники скрытой упаковки: ArrayList, вызов метода интерфейса у структуры через переменную интерфейсного типа, == у переменных типа object. Дженерики (List<int>) появились во многом ради того, чтобы упаковки не было.
Nullable<T>
int? это структура Nullable<int> с полями HasValue и Value. Арифметика «поднятая»: если хотя бы один операнд null, результат null. Сравнения <, > с null всегда false. .Value у пустого значения бросает InvalidOperationException, безопаснее ?? и GetValueOrDefault().
Не путайте с nullable reference types (string?): это лишь аннотации для анализатора компилятора, во время выполнения string и string? одинаковы.
record
record (класс) и record struct генерируют равенство по значению, ToString, Deconstruct и поддерживают with:
record User(string Name, int Age);
var a = new User("Ира", 30);
var b = a with { Age = 31 }; // новая копия
Console.WriteLine(a == new User("Ира", 30)); // True
Когда struct
Структура подходит для маленького (ориентир до 16-24 байт) неизменяемого значения без идентичности: координата, деньги, диапазон дат. Делайте её readonly struct. Изменяемые структуры коварны: list[0].X = 1 не компилируется, потому что индексатор возвращает копию, а у readonly-поля метод вызовется на защитной копии.
Частые ошибки
- Считать, что присваивание структуры даёт «ту же» структуру.
- Сравнивать упакованные значения через
==уobject: сравниваются ссылки. - Ожидать, что
default(S)илиnew S[10]вызовет конструктор структуры без параметров. Не вызовет.
Что спросят на собесе
- Чем тип значения отличается от ссылочного и где они хранятся?
- Что такое boxing, где он происходит незаметно и чем вреден?
- Как передать ссылочный тип так, чтобы метод мог заменить объект у вызывающего?
- Чем
recordотличается отclassи когда выбратьstruct? - Что такое защитная копия и зачем
readonly struct?