Start Debugging

record vs class vs структура в C#: матрица решений

C# 14 даёт вам четыре формы типа данных -- class, record class, struct и record struct. Это матрица решений: когда каждая правильна, что каждая стоит, и правила, которые выбирают за вас.

Если вы выбираете между class, record и struct для нового типа в C# 14 / .NET 10, по умолчанию это class. Используйте record class (стандартный record), когда тип представляет неизменяемые данные и равенство по значению является контрактом. Используйте readonly record struct, когда тип маленький (16 байт или меньше), неизменяемый и копируется через горячие пути, где выделение в куче на каждый экземпляр было бы болезненным. Используйте простую структуру struct только для неуправляемого взаимодействия или когда вам действительно нужно изменять структуру фиксированного размера на месте. Используйте простой record (что есть record class), когда вы хотите неизменяемость и равенство по значению без борьбы с GC.

Этот пост — развёрнутая версия. Каждый пример нацелен на <TargetFramework>net10.0</TargetFramework> с <LangVersion>14.0</LangVersion>.

Четыре формы, которые у вас на самом деле есть

В C# есть два вида хранения (ссылочный тип, тип значения) и ортогональный модификатор record, который добавляет равенство по значению, первичный конструктор, поддержку выражения with и сгенерированный компилятором ToString. Это даёт четыре формы:

readonly record struct — это наиболее распространённая форма структуры, которую вы действительно будете писать. Она помечает каждое поле как readonly и делает весь экземпляр неизменяемым, что вам нужно в 90 процентах случаев, когда вы тянетесь к структуре.

Матрица возможностей

Возможностьclassrecord classstructrecord struct
Хранениекучакучаinline / стекinline / стек
Равенство по умолчаниюпо ссылкепо значению (комп.-ген.)по значению (рефлексия)по значению (комп.-ген.)
Выражение withнетданетда
Сгенерированный компилятором ToStringнетданетда
Наследованиедада (только records)нетнет
Изменяемость по умолчаниюизменяемыйinit-only (неизменяемый)изменяемыйизменяемый; readonly record struct неизменяем
Боксинг при приведении к object / интерфейсунетнетдада
Стоимость копированиякопия указателякопия указателяполная битовая копияполная битовая копия
null разрешён (NRT выкл.)даданет (используйте T?)нет (используйте T?)
Выделяет в кучекаждый экземпляркаждый экземпляртолько при боксингетолько при боксинге
Хорош как ключ словарятолько если реализуете Equals/GetHashCodeда, из коробкинет — равенство с рефлексией медленноеда, из коробки
Хорош как сущность EF Coreдада (с осторожностью)нетнет

Таблица — это пост. Всё ниже — это почему.

Почему class — значение по умолчанию

class выделяется в управляемой куче, доступ к нему по ссылке, и он равен другому экземпляру только когда обе ссылки указывают на один и тот же объект. Ссылочная семантика естественно подходит для вещей с идентичностью: User, Customer, HttpClient. Два объекта User с одинаковым именем и почтой — не один и тот же пользователь; это две записи, которые случайно делят данные. Равенство по ссылке соответствует этой ментальной модели.

class также единственная форма, которая поддерживает наследование с произвольными производными типами. record тоже поддерживает наследование, но только между другими records. struct и record struct не поддерживают никакого.

Выбирайте class, когда:

// .NET 10, C# 14
public class Customer
{
    public Guid Id { get; init; }
    public string Email { get; set; } = "";
    public DateTimeOffset CreatedAt { get; init; }
}

Это то место, которое владеет строкой в базе данных и которому разрешено меняться со временем.

Когда тянуться к record class

record (это record class — ключевое слово class подразумевается) — правильный ответ для неизменяемых носителей данных, где два экземпляра с одинаковыми значениями полей должны рассматриваться как равные. Компилятор генерирует Equals, GetHashCode, ToString по значению, и виртуальный метод EqualityContract, который заставляет наследование работать. Позиционный синтаксис public record Address(string City, string Zip); добавляет первичный конструктор и одно init-only свойство на каждый параметр.

Выбирайте record class, когда:

record class по-прежнему выделяется в куче и доступ к нему по ссылке, поэтому вся интуиция “это дёшево передавать” о class всё ещё применима. Вы платите одно выделение на экземпляр, но не платите битовую копию каждый раз, когда передаёте его в метод.

// .NET 10, C# 14
public sealed record OrderPlaced(Guid OrderId, decimal Total, DateTimeOffset At);

var evt = new OrderPlaced(orderId, 42.50m, DateTimeOffset.UtcNow);
var corrected = evt with { Total = 42.95m };

// evt != corrected
// Console.WriteLine(evt) prints OrderPlaced { OrderId = ..., Total = 42.50, At = ... }

Два предупреждения. Первое: объявляйте records как sealed, если только вам не нужна иерархия records. Компилятор выпускает косвенность EqualityContract на каждом record, чтобы производные records могли участвовать в равенстве по значению, и sealed позволяет JIT девиртуализировать вызовы. Второе: не помещайте изменяемые коллекции в свойства record. Равенство по значению record сравнивает ссылки для этих свойств, а не содержимое, что ведёт к сюрпризам типа “почему эти два record не равны”. Используйте ImmutableArray<T> или IReadOnlyList<T>, инициализированный один раз.

Когда тянуться к struct (и особенно readonly record struct)

struct — это тип значения. Его поля живут inline в том, что его содержит: в стеке для локальных переменных, внутри содержащего объекта в куче для полей, упакованные конец-к-концу в массивах. Каждое присваивание — это битовая копия всей структуры. Равенство, когда вы его предоставляете, может быть одним CPU-сравнением вместо виртуального вызова.

Это великолепно, когда данные маленькие и их у вас много. Структура из двух полей int может удерживаться в паре регистров, сравниваться одной ветвью и храниться в массиве как 8 байт на элемент без заголовка на элемент. Та же полезная нагрузка как class была бы 24-байтным заголовком объекта плюс 8-байтная ссылка на слот, что разрушает локальность кеша, как только массив больше линии L1.

Руководство Microsoft choose between class and struct перечисляет четыре условия для структуры: она логически представляет одно значение, имеет размер экземпляра меньше 16 байт, неизменяема, и не подвергается частому боксингу. Все четыре вместе, не три из четырёх.

Выбирайте readonly record struct (или readonly struct, если вам не нужно равенство по значению), когда:

// .NET 10, C# 14
public readonly record struct Money(decimal Amount, string Currency)
{
    public static Money Zero(string currency) => new(0m, currency);
    public Money Plus(Money other) =>
        other.Currency == Currency
            ? new(Amount + other.Amount, Currency)
            : throw new InvalidOperationException("currency mismatch");
}

Это компилируется в тип значения со встроенным равенством по значению, деконструктором, переопределением ToString и неизменяемой семантикой. Это современная замена для “я напишу struct и буду помнить не делать его изменяемым”.

Правило 16 байт — это эвристика, а не жёсткий потолок. JIT с удовольствием передаст 24-байтную структуру в регистрах на AMD64, если она помещается в соглашение о вызовах. Причина держать структуры маленькими — битовые копии. Каждое присваивание, каждая передача параметра без in, каждый шаг LINQ копирует всё целиком. 64-байтная структура, переданная по значению через пять кадров методов, — это 320 байт копирования.

Когда record struct (изменяемый) — правильный выбор

Простой record struct (без readonly) встречается редко, но легитимен. Он даёт равенство по значению, первичный конструктор и ToString, но при этом позволяет переприсваивать поля. Два сценария имеют смысл:

Для всего остального предпочитайте readonly record struct. Изменяемая структура — знаменитая ловушка: присвоение её свойству создаёт копию, изменяя копию, и молча ничего не делая с оригиналом.

Матрица решений, которую можно повесить на стену

Три вопроса, по порядку. Останавливайтесь на первом, который куда-то указывает.

  1. У этого типа есть идентичность, или он владеет меняющимся состоянием со временем? Да -> class. Примеры: User, Order, HttpClient, сущности EF Core, всё в контейнере сервисов с временем жизни.

  2. Этот тип — неизменяемые данные, которые должны быть равны по значению и маленькие (16 байт или меньше, без ссылок на большие объекты)? Да -> readonly record struct. Примеры: Money, Point, строго типизированные ID вроде UserId(Guid Value), ячейки (int Row, int Column). Порог 16 байт важнее всего, когда вы держите их в массивах, span или передаёте через горячие циклы.

  3. Иначе: тип — неизменяемые данные с равенством по значению? Да -> record (record class). Примеры: DTO, модели запроса/ответа, события домена, снимки конфигурации, типы сообщений в очереди. Это значение по умолчанию для “классов данных” в современном C#.

Если ничего из вышеперечисленного никуда не указало, вам почти наверняка нужен class. Оставшийся случай — “мне нужен тип значения, но он больше 16 байт”, что обычно означает реструктурировать тип, а не наклоняться сильнее к struct.

Бенчмарк: когда копии структур действительно болят

Распространённое утверждение — “структуры быстрее”. Иногда да, иногда стоимость копирования доминирует. Вот быстрое измерение для 24-байтной полезной нагрузки, переданной через пять кадров методов.

// .NET 10, C# 14, BenchmarkDotNet 0.14.0
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

BenchmarkRunner.Run<CopyCost>();

public readonly record struct PayloadStruct(long A, long B, long C); // 24 bytes
public sealed record PayloadClass(long A, long B, long C);           // pointer + 24 bytes on heap

[MemoryDiagnoser]
public class CopyCost
{
    private readonly PayloadStruct _s = new(1, 2, 3);
    private readonly PayloadClass _c = new(1, 2, 3);

    [Benchmark(Baseline = true)]
    public long Struct_ByVal()  => Sum1(_s);
    [Benchmark]
    public long Struct_ByIn()   => Sum2(in _s);
    [Benchmark]
    public long Class_ByRef()   => Sum3(_c);

    static long Sum1(PayloadStruct p)     => p.A + p.B + p.C;
    static long Sum2(in PayloadStruct p)  => p.A + p.B + p.C;
    static long Sum3(PayloadClass p)      => p.A + p.B + p.C;
}

Методология: BenchmarkDotNet 0.14.0, .NET 10.0.0 RTM, Windows 11 24H2, AMD Ryzen 9 7900X. Цифры из одного запуска; перезапустите на собственном железе, прежде чем делать на них ставку.

МетодСреднееВыделено
Struct_ByVal0.31 ns0 B
Struct_ByIn0.28 ns0 B
Class_ByRef0.34 ns0 B

Структура, переданная по значению, немного быстрее, чем класс, доступный по ссылке, и in экономит ещё чуть-чуть. Но разрыв — субнаносекундный. Структура решительно побеждает только когда вы выделяете класс миллионы раз — различается стоимость выделения, а не стоимость доступа. Выбирайте struct из-за давления на выделение, а не “более быстрого доступа”.

Когда структура растёт, паттерн переворачивается. 64-байтная изменяемая структура, переданная по значению через три кадра, — это измеримая регрессия по сравнению со ссылкой на class. Правило 16 байт существует потому, что это примерно тот размер, где битовая копия перестаёт быть бесплатной на AMD64.

Ловушки, которые выбирают за вас

Несколько вещей навязывают решение независимо от предпочтений.

Мнение, переформулированное

По умолчанию class. Выбирайте record (record class) для неизменяемых данных с равенством по значению. Выбирайте readonly record struct для маленьких неизменяемых значений, которые вы держите в массе или передаёте через горячие циклы. Выбирайте простую struct только когда взаимодействие или мутация на месте в одной локальной переменной делают ловушку оправданной, и выбирайте не-record class для сущностей и типов, несущих идентичность.

Два следствия, которые стоит зафиксировать в мышечной памяти:

Связанное

Источники

Comments

Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.

< Назад