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. Это даёт четыре формы:
class: ссылочный тип, равенство по ссылке по умолчанию.record class(просто ключевое словоrecord): ссылочный тип, равенство по значению.struct: тип значения, равенство по значению полей (черезValueType.Equalsс рефлексией) — медленно, если вы его не переопределите.record struct: тип значения, равенство по значению (сгенерированное компилятором, без рефлексии).
readonly record struct — это наиболее распространённая форма структуры, которую вы действительно будете писать. Она помечает каждое поле как readonly и делает весь экземпляр неизменяемым, что вам нужно в 90 процентах случаев, когда вы тянетесь к структуре.
Матрица возможностей
| Возможность | class | record class | struct | record 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, когда:
- У типа есть идентичность (“это тот самый клиент, а не значение в форме клиента”).
- Тип изменяем по дизайну.
- Тип участвует в иерархии классов с базовыми классами, не являющимися records.
- Тип — сущность EF Core, которой нужно отслеживание изменений. EF Core 11 поддерживает records как сущности, но путь наименьшего сопротивления — по-прежнему
classс init-only свойствами и конструктором привязки. Смотрите как правильно использовать records с EF Core 11 для решения по каждому случаю.
// .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, когда:
- Тип — DTO, форма запроса/ответа, событие домена или снимок конфигурации.
- Вы будете использовать тип как ключ словаря или в
HashSet<T>, и равенство по значению — это контракт. - Вы будете часто создавать модифицированную копию:
var newer = original with { Status = "shipped" };. - Вы хотите, чтобы компилятор написал
ToStringза вас, чтобы структурированные журналы показывали каждое поле по умолчанию.
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, если вам не нужно равенство по значению), когда:
- Тип — маленькое неизменяемое значение: координата, денежная сумма, строго типизированный ID, временная метка фиксированной точности.
- Вы будете держать много таких в массиве или
Span<T>и итерироваться горячо. - Вы не будете их боксить. Приведение к
objectили к не-readonly интерфейсу боксит; приведение к интерфейсуref structв C# 13+ не боксит (когда JIT может это доказать). - Вам не нужно наследование.
// .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, но при этом позволяет переприсваивать поля. Два сценария имеют смысл:
- Аккумуляторы горячих циклов, где вы хотите сгенерированные компилятором равенство и
ToString, но также хотите изменять поля на месте, чтобы избежать копировальной суеты:state.Count++; state.Total += x;вrecord struct State, который живёт в одной локальной переменной. - Формы взаимодействия, где вы хотите семантику значения и возможность заполнять структуру поле за полем после конструкции.
Для всего остального предпочитайте readonly record struct. Изменяемая структура — знаменитая ловушка: присвоение её свойству создаёт копию, изменяя копию, и молча ничего не делая с оригиналом.
Матрица решений, которую можно повесить на стену
Три вопроса, по порядку. Останавливайтесь на первом, который куда-то указывает.
-
У этого типа есть идентичность, или он владеет меняющимся состоянием со временем? Да ->
class. Примеры:User,Order,HttpClient, сущности EF Core, всё в контейнере сервисов с временем жизни. -
Этот тип — неизменяемые данные, которые должны быть равны по значению и маленькие (16 байт или меньше, без ссылок на большие объекты)? Да ->
readonly record struct. Примеры:Money,Point, строго типизированные ID вродеUserId(Guid Value), ячейки(int Row, int Column). Порог 16 байт важнее всего, когда вы держите их в массивах, span или передаёте через горячие циклы. -
Иначе: тип — неизменяемые данные с равенством по значению? Да ->
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_ByVal | 0.31 ns | 0 B |
| Struct_ByIn | 0.28 ns | 0 B |
| Class_ByRef | 0.34 ns | 0 B |
Структура, переданная по значению, немного быстрее, чем класс, доступный по ссылке, и in экономит ещё чуть-чуть. Но разрыв — субнаносекундный. Структура решительно побеждает только когда вы выделяете класс миллионы раз — различается стоимость выделения, а не стоимость доступа. Выбирайте struct из-за давления на выделение, а не “более быстрого доступа”.
Когда структура растёт, паттерн переворачивается. 64-байтная изменяемая структура, переданная по значению через три кадра, — это измеримая регрессия по сравнению со ссылкой на class. Правило 16 байт существует потому, что это примерно тот размер, где битовая копия перестаёт быть бесплатной на AMD64.
Ловушки, которые выбирают за вас
Несколько вещей навязывают решение независимо от предпочтений.
-
Равенство с коллекциями в полезной нагрузке. Если ваш record содержит
List<int>, два record со структурно равными списками будут сравниваться как неравные, потому что равенство по значениюrecordиспользуетEqualityComparer<T>.Default, который дляList<T>откатывается к равенству по ссылке. ИспользуйтеImmutableArray<T>(у которого структурное равенство) или переопределитеEqualsвручную. -
Сущности EF Core и
record. EF Core 11 может отслеживать records как сущности, но выражениеwithпроизводит новый экземпляр, который change tracker никогда не видел. Если обработчик запроса делаетcustomer = customer with { Email = "..." }, change tracker всё ещё держит старую ссылку, что приводит к тому, чтоUPDATEне выдаётся. Оставайтесь сclassдля отслеживаемых сущностей. -
Default(struct) — это реальное значение.
structне может бытьnull.default(Money)— это экземплярMoneyс нулевой суммой и пустой строкой валюты, который система типов считает валидным. Если нулевое значение не имеет смысла для вашего типа, либо добавьте свойствоIsValid, либо используйтеrecord class, чтобыnullбыл вашим сигналом “нет значения”. -
Интерфейсы боксят типы значений. Приведение
MoneyкIEquatable<Money>боксит структуру в кучу, выделяя новый заголовок объекта и копируя полезную нагрузку. Если вы намерены обращаться к структуре через интерфейс в плотном цикле, вы либо выбрали неправильную форму, либо вам нужно обобщённое ограничение (where T : struct, IEquatable<T>), чтобы JIT мог специализировать без боксинга. -
Хеш-коды для отслеживаемых структур. Помещать изменяемую структуру в
DictionaryилиHashSet— это баг. Коллекция берёт хеш-код при вставке и сохраняет его; если вы измените поле, хеш значения изменится и коллекция не сможет его больше найти.readonly record structделает это невозможным по построению.
Мнение, переформулированное
По умолчанию class. Выбирайте record (record class) для неизменяемых данных с равенством по значению. Выбирайте readonly record struct для маленьких неизменяемых значений, которые вы держите в массе или передаёте через горячие циклы. Выбирайте простую struct только когда взаимодействие или мутация на месте в одной локальной переменной делают ловушку оправданной, и выбирайте не-record class для сущностей и типов, несущих идентичность.
Два следствия, которые стоит зафиксировать в мышечной памяти:
recordс первичным конструктором иsealed— это современный “класс данных”. Если вы ловите себя на написании класса только с init-only свойствами и переопределенииEqualsиGetHashCode, компилятор уже написал это за вас.readonly record structделает “сделать недопустимые состояния непредставимыми” практичным для маленьких значений. Строго типизированные ID (public readonly record struct UserId(Guid Value);) практически бесплатны во время выполнения и устраняют категорию багов типа “я передал ID заказа там, где ожидался ID пользователя” во время компиляции.
Связанное
- Как правильно использовать records с EF Core 11
- Как вернуть несколько значений из метода в C# 14
- async void vs async Task в C#: когда каждый корректен
- Как использовать новый тип System.Threading.Lock в .NET 11
- Как правильно использовать SearchValues в .NET 11
Comments
Sign in with GitHub to comment. Reactions and replies thread back to the comments repo.