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-параметрам. Это и корректнее, и быстрее.

Разница в двух таблицах

Свойствоstructclass
Семантика присваиваниякопируются все полякопируется ссылка
Где лежитвнутри владельца: в стеке, в массиве, в поле объектавсегда объект в куче
Наследованиенет (только реализация интерфейсов)да
Может быть nullнет, только через Nullable<T> (T?)да
Значение по умолчаниюэкземпляр со всеми полями в 0 / nullnull
Равенство по умолчаниюпо значениям полейпо ссылке
Нагрузка на 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 — осознанная оптимизация с аргументами.
Проверьте себя
1. struct Counter { public int Count; public void Increment() { Count++; } } Есть List<Counter> list с одним элементом. После вызова list[0].Increment() значение list[0].Count равно:
A1 — метод изменил элемент списка
B0 — индексатор вернул копию структуры, метод изменил копию
CКод не скомпилируется
DБудет выброшено исключение
2. В каком случае выбор struct вместо class действительно оправдан?
AТип большой, часто передаётся в методы и хранится в object-коллекции
BТипу нужно наследование и возможность быть null
CТип маленький (около 16 байт), неизменяемый, представляет одно значение и редко упаковывается
DТип часто мутируется через индексатор списка