Avatar ·

Когда использовать struct вместо class в C#: разбираемся с типами-значениями и ссылочными типами

Всем привет! Я пишу на C# около года, и меня постоянно мучает вопрос: когда же стоит использовать struct, а когда class? Вроде бы я понимаю разницу — struct это тип-значение, а class ссылочный тип. Но на практике я часто теряюсь. Например, у меня есть такой код: ```csharp public class PointClass { public int X { get; set; } public int Y { get; set; } public PointClass(int x, int y) { X = x; Y = y; } } public struct PointStruct { public int X { get; set; } public int Y { get; set; } public PointStruct(int x, int y) { X = x; Y = y; } } ``` Вроде бы оба делают одно и то же. Но я читал, что struct быстрее для маленьких объектов, потому что они хранятся в стеке, а class в куче. С другой стороны, я слышал, что struct не стоит использовать с коллекциями, LINQ или асинхронностью, потому что они начинают копироваться туда-сюда и производительность падает. Ещё я не понимаю, почему в struct нельзя объявить конструктор без параметров (до C# 10), и почему нельзя наследоваться от struct. И как быть с изменяемостью? Я видел рекомендации делать struct неизменяемыми, но зачем? Подскажите, пожалуйста, какие критерии использовать при выборе между struct и class? Может быть есть какие-то правила или чеклист? И приведите примеры, когда struct действительно оправдана, а когда лучше взять class. Заранее спасибо! <<>>

Основные критерии выбора между struct и class

Начну с главного правила, которое рекомендуют в документации Microsoft и которое подтверждено практикой тысяч разработчиков: по умолчанию используйте class. Struct — это инструмент для оптимизации в специфических сценариях, а не замена class на каждый день.

Вот чеклист, когда struct оправдана:

  • Размер данных ≤ 16-24 байта (обычно 2-3 поля простых типов)
  • Короткое время жизни — объект создаётся и быстро уничтожается в рамках одного метода
  • Неизменяемость — после создания поля не меняются (рекомендуется readonly struct)
  • Частое создание в циклах — например, миллионы структур в секунду
  • Не требуется наследование (struct не может наследовать от другого struct или class)
  • Логически это единое значение — например, координаты, цвет, комплексное число

Разберём на вашем примере Point. Предположим, мы обрабатываем 10 миллионов точек:

// Тест производительности
var sw = System.Diagnostics.Stopwatch.StartNew();
var points = new PointClass[10_000_000];
for (int i = 0; i < points.Length; i++)
    points[i] = new PointClass(i, i);
sw.Stop();
Console.WriteLine($"Class: {sw.ElapsedMilliseconds} ms");

sw.Restart();
var pointsStruct = new PointStruct[10_000_000];
for (int i = 0; i < pointsStruct.Length; i++)
    pointsStruct[i] = new PointStruct(i, i);
sw.Stop();
Console.WriteLine($"Struct: {sw.ElapsedMilliseconds} ms");

Здесь struct будет значительно быстрее, потому что массив PointStruct[] хранит данные прямо в массиве (в куче, но непрерывно), а массив PointClass[] хранит ссылки на объекты, разбросанные по куче. Struct даёт лучшую локальность данных (cache locality).

Почему struct должен быть неизменяемым?

Посмотрите на этот код:

var p1 = new PointStruct(1, 2);
var p2 = p1; // Копирование!
p2.X = 10;
Console.WriteLine(p1.X); // 1 — p1 не изменился, всё ок

Но если вы передадите struct в метод:

void Mutate(PointStruct p) { p.X = 100; } // Работает с копией!
var p = new PointStruct(1, 2);
Mutate(p);
Console.WriteLine(p.X); // 1 — оригинал не изменился, это сбивает с толку

А если сделать struct изменяемым и использовать его в коллекции:

var list = new List<PointStruct> { new PointStruct(1, 2) };
list[0].X = 10; // Ошибка компиляции! list[0] — это копия, менять копию бессмысленно

Поэтому с C# 7.2 появился модификатор readonly:

public readonly struct PointStruct
{
    public int X { get; }
    public int Y { get; }
    
    public PointStruct(int x, int y) { X = x; Y = y; }
}

Теперь компилятор гарантирует неизменяемость, и вы избегаете неожиданного копирования.

Когда НЕ стоит использовать struct

  • Размер > 16-24 байт — копирование станет дороже, чем работа со ссылкой
  • Частое использование в LINQ — методы вроде Where() или Select() создают много копий
  • Асинхронные методы — async/await перемещает struct в кучу (boxing), теряя преимущества
  • Наследование или полиморфизм — struct не поддерживает наследование
  • Объекты с изменяемым состоянием — например, банковский счёт с балансом

Практический пример: когда struct — идеальный выбор

Допустим, вы пишете графический движок и работаете с цветами в формате RGBA:

public readonly struct Color
{
    public byte R { get; }
    public byte G { get; }
    public byte B { get; }
    public byte A { get; }
    
    public Color(byte r, byte g, byte b, byte a = 255)
    {
        R = r; G = g; B = b; A = a;
    }
    
    // Методы, возвращающие новые экземпляры
    public Color WithAlpha(byte alpha) => new Color(R, G, B, alpha);
    public static Color Blend(Color a, Color b) { /* ... */ }
}

Цвет — это 4 байта (32 бита), идеально подходит под struct. Он неизменяем, часто создаётся в циклах обработки пикселей, и копирование 4 байт тривиально.

Итог: используйте struct только для маленьких, неизменяемых, логически единых значений, которые создаются в большом количестве. Во всех остальных случаях — class.

<<>>

Дополнение: производительность и подводные камни

Согласен с основным ответом, но хочу добавить несколько важных нюансов, которые часто упускают новички.

1. Boxing — главный враг struct

Когда вы передаёте struct в метод, ожидающий object, или используете её в негенериковой коллекции (например, ArrayList), происходит упаковка (boxing) — struct копируется в кучу, что снижает производительность. Всегда используйте generic-коллекции (List<T>, Dictionary<K,V>), чтобы избежать этого.

2. Struct в качестве поля класса

Если у вас есть class, который содержит struct-поля, то при копировании класса копируется и struct (если это value-тип). Это может быть как преимуществом (глубокое копирование), так и неожиданностью. Например:

public class Container
{
    public PointStruct Location; // поле-структура
}

var c1 = new Container { Location = new PointStruct(1, 2) };
var c2 = c1;
c2.Location.X = 10; // Ошибка! Location — это копия, но c1.Location не изменится

3. Используйте ref return для больших struct

Если вам нужно работать с большой struct без копирования, используйте ref и in параметры:

public void Process(in LargeStruct data) // in — передача по ссылке без копирования
{
    // data — readonly, копирования не происходит
}

4. Когда struct всё же может быть больше 16 байт?

Если struct содержит только значимые типы и используется исключительно в локальных вычислениях (не передаётся между методами), то 32-48 байт тоже может быть оправдано. Но это уже уровень микрооптимизаций, который стоит применять только после профилирования.

Мой совет: начните с class, и только если профилировщик показал, что создание объектов занимает >5% времени, и struct действительно подходит по критериям — тогда заменяйте. Не оптимизируйте заранее!