struct или class: что выбрать
Вопрос-фильтр для мидла: «Когда бы вы выбрали struct вместо class?» Правильный ответ начинается не с «структуры быстрее», а с семантики.
struct — тип значения: копируется целиком при каждом присваивании и передаче. class — ссылочный тип: копируется только адрес объекта в куче. Всё остальное — наследование,
null, равенство, поведение GC — следствия этого одного различия.
Вопрос: почему изменение «не сохранилось»?
Реальный баг из практики, который любят приносить на собеседование:
using System;
using System.Collections.Generic;
struct Counter
{
public int Count;
public void Increment()
{
Count++;
}
}
class Program
{
static void Main()
{
Counter[] array = new Counter[1];
array[0].Increment();
Console.WriteLine($"array[0].Count = {array[0].Count}");
List<Counter> list = new List<Counter> { new Counter() };
list[0].Increment();
Console.WriteLine($"list[0].Count = {list[0].Count}");
}
}
Результат:
array[0].Count = 1
list[0].Count = 0
Одинаковый на вид код даёт разный результат — и компилятор не сказал ни слова.
- Массив хранит структуры «на месте», внутри своего блока памяти.
array[0]— это прямой доступ к элементу, поэтомуIncrement()увеличивает счётчик там, где он лежит. List<T>отдаёт элемент через индексатор, то есть через свойство. Свойство возвращает копию структуры.Increment()честно увеличивает счётчик — у копии, которая тут же выбрасывается. Оригинал в списке остаётся нулём.
Показательная деталь: строка list[0].Count = 5; вообще не скомпилировалась бы — компилятор выдаёт ошибку «Cannot modify the return value». Но вызов метода, который мутирует структуру, он разрешает. Отсюда золотое правило: изменяемых (mutable) структур быть не должно. Именно они порождают «изменение молча потерялось» — самый неприятный класс багов.
Тот же капкан в других местах
// 1. readonly-поле: компилятор делает защитную копию перед вызовом метода
class Service
{
private readonly Counter counter;
public void Tick()
{
counter.Increment(); // мутируется копия, поле не меняется
}
}
// 2. foreach выдаёт копии — мутировать элементы нельзя
foreach (Counter c in list)
{
c.Increment(); // бесполезно: c — копия
}
// 3. Свойство вместо поля
public Counter Current { get; set; }
obj.Current.Increment(); // тоже уходит в копию
// Лечение: сделать структуру неизменяемой
public readonly struct Counter
{
public int Count { get; }
public Counter(int count) => Count = count;
public Counter Increment() => new Counter(Count + 1);
}
Ключевое слово readonly struct (C# 7.2) не просто документирует намерение: компилятор перестаёт создавать защитные копии при обращении к readonly-полям и in-параметрам. Это и корректнее, и быстрее.
Разница в двух таблицах
| Свойство | struct | class |
| Семантика присваивания | копируются все поля | копируется ссылка |
| Где лежит | внутри владельца: в стеке, в массиве, в поле объекта | всегда объект в куче |
| Наследование | нет (только реализация интерфейсов) | да |
Может быть null | нет, только через Nullable<T> (T?) | да |
| Значение по умолчанию | экземпляр со всеми полями в 0 / null | null |
| Равенство по умолчанию | по значениям полей | по ссылке |
| Нагрузка на GC | сама по себе не создаёт мусора | каждый экземпляр — работа для GC |
Разницу «нет null / всё по нулям» видно невооружённым глазом:
using System;
struct PointS
{
public int X;
public int Y;
}
class PointC
{
public int X;
public int Y;
}
class Program
{
static void Main()
{
PointS[] structs = new PointS[3];
PointC[] classes = new PointC[3];
Console.WriteLine($"structs[0].X = {structs[0].X}");
Console.WriteLine($"classes[0] == null: {classes[0] == null}");
}
}
Результат:
structs[0].X = 0
classes[0] == null: True
Массив структур сразу «заполнен» готовыми экземплярами — конструктор при этом не вызывается вовсе. Массив классов заполнен пустыми ссылками, и каждый объект придётся создавать отдельно (три аллокации вместо нуля).
Как это работает под капотом и когда struct оправдан
Официальная рекомендация Microsoft — выбирать struct, если тип одновременно:
- логически представляет одно значение, как число: точка, деньги, отрезок времени, координата;
- маленький — ориентир 16 байт и меньше;
- неизменяемый;
- редко упаковывается в
objectили интерфейс.
Хватает нарушить один пункт — и выгода испаряется. Структура на 100 байт копируется при каждой передаче в метод; копировать 100 байт дороже, чем 8-байтную ссылку. Если такой тип всё же нужен (интероп, буфер), передавайте его через in-параметр или ref readonly — тогда уедет адрес, а не содержимое.
Второй подводный камень — упаковка. Стоит присвоить структуру интерфейсной переменной или положить в object-коллекцию, и вы получаете ровно тот же объект в куче, от которого убегали, плюс лишнее копирование. «Быстрая» структура становится медленнее класса.
Третий нюанс — Equals и GetHashCode. У структуры реализация по умолчанию сравнивает поля, но у обычного struct она делает это через рефлексию, если поля не «плоские». В горячем пути (например, структура-ключ словаря) это сюрприз на порядок. Решение: переопределять Equals/GetHashCode вручную, реализовывать IEquatable<T> или использовать record struct (C# 10) — он генерирует всё это за вас.
Где структуры реально выигрывают, видно по самому .NET: int, DateTime, TimeSpan, Guid, decimal, Span<T>, ValueTuple, перечислитель List<T>.Enumerator. Все они маленькие, значениеподобные и неизменяемые. Всё остальное в библиотеке — классы. Хороший ориентир: по умолчанию пишите class; struct — осознанное исключение, за которое вы можете аргументировать.
Частые ошибки на собеседовании
- «struct быстрее класса». Быстрее — только маленький и неупаковываемый. Большой struct с копированием и boxing легко проигрывает классу.
- Мутабельные структуры. Изменения теряются в
List<T>, вforeach, в свойствах, вreadonly-полях. Всегдаreadonly struct. - «struct всегда в стеке». Поле-структура внутри класса живёт в куче — вместе с объектом-владельцем.
- «struct не может иметь методы и конструкторы». Может: и методы, и свойства, и интерфейсы. Нельзя только наследоваться и (до C# 10) объявлять безпараметрический конструктор.
- Забывать про упаковку при работе через интерфейс.
IComparable c = myStruct;— уже коробка в куче. - Не знать про
in,ref readonly,record struct. Это ровно тот словарь, по которому отличают мидла от джуна.
Итоги-шпаргалка
- Выбор начинается с семантики: это «значение» (копировать нормально) или «сущность с идентичностью» (нужна ссылка)?
structуместен, когда тип маленький (≈16 байт), неизменяемый, значениеподобный и не боксится.- Мутабельная структура — источник тихих багов: индексатор
List<T>,foreach, свойства иreadonly-поля отдают копию. - Массив структур — ноль аллокаций и данные вплотную; массив классов — ссылки плюс объект на каждый элемент.
- Большие структуры передавайте через
in/ref readonly, чтобы не копировать содержимое. - Для структур-ключей реализуйте
IEquatable<T>или беритеrecord struct. - По умолчанию —
class.struct— осознанная оптимизация с аргументами.