1. Почему вообще понадобились record
В C# (и .NET в целом) долгое время основными строительными блоками были классы (class) и структуры (struct). Но каждый из них неполон для ряда задач. Классы — ссылочные, мутируемые, и сравнение у них по ссылке (за очень редким исключением). Структуры — значимые типы (копируются при передаче), по умолчанию сравниваются побайтово и чаще всего мутируемы (пока не появились readonly struct).
А вот если вам хотелось бы простую и натуральную запись данных, которую удобно сравнивать по значению, быстро клонировать и не париться о том, что ваш объект кто-то где-то изменил — приходилось городить кучу велосипедов или прибегать к библиотекам наподобие ValueTuple или даже System.Tuple. Но все они не так красивы и выразительны, как хотелось бы.
Поэтому в C# 9 появился record — тип данных, который сочетает краткость объявления, безопасность неизменяемости и поведение, ориентированное на сравнение по значению.
2. Все четыре типа на одной страничке
| class | struct | record | record struct | |
|---|---|---|---|---|
| Категория | Ссылочный тип | Значимый тип | Ссылочный тип | Значимый тип |
| Мутируемость | По умолчанию — да | По умолчанию — да | По умолчанию — нет (init) | По умолчанию — нет (init) |
| Сравнение | По ссылке (==) | По значению (поля) | По значению (fields/properties) | По значению (fields) |
| Клонирование | Только вручную | Только вручную | Есть встроенная поддержка (with) | Есть встроенная поддержка (with) |
| Наследование | Да | Нет | Да | Нет |
| Неизменяемость | Нужно реализовать | Нужно реализовать | Очень просто реализовать | Очень просто реализовать |
| Синтаксис | Самый длинный | Короткий | Самый короткий (позиционный) | Довольно короткий |
| Использование в коллекциях | По ссылке | Копии | По ссылке | Копии |
Визуальная схема
+----------------+ +----------------+ +--------------------+
| class | | struct | | record |
+----------------+ +----------------+ +--------------------+
| Reference Type | | Value Type | | Reference Type |
| Mutable | | Mutable | | Immutable (init) |
| == : Reference | | == : By Fields | | == : By Value |
+----------------+ +----------------+ +--------------------+
3. Суть различий между record, class и struct
Поведение в памяти: ссылка или значение?
- class и record — это ссылочные типы. Когда вы их передаёте в функцию, копируется ссылка на объект.
- struct и record struct — значимые типы. Они всегда копируются побайтово (если только вы не передаёте их по ссылке явно).
class PointClass { public int X; public int Y; }
struct PointStruct { public int X; public int Y; }
record PointRecord(int X, int Y);
record struct PointRecordStruct(int X, int Y);
void Demo()
{
var pc = new PointClass { X = 1, Y = 2 };
var ps = new PointStruct { X = 1, Y = 2 };
var pr = new PointRecord(1, 2);
var prs = new PointRecordStruct(1, 2);
ChangeY(pc); // pc.Y изменится!
ChangeY(ps); // ps.Y не изменится — копия!
ChangeY(pr); // pr.Y изменится!
ChangeY(prs); // prs.Y не изменится — копия!
}
void ChangeY(dynamic p) { p.Y = 99; }
Если вам кажется, что dynamic тут это магия — да, это просто для примера, чтобы вы увидели, что при работе со структурами значение не изменится, а при работе с классом или record (ссылочным) — изменится.
Сравнение: как узнать, что два объекта равны?
- class: сравниваются по ссылке (== — true, только если это один и тот же объект в памяти), если не переопределить Equals.
- struct: сравниваются по значению всех полей (по умолчанию).
- record: сравниваются по значению всех полей/свойств, которые заданы в первичном конструкторе.
class Foo { public int A; public int B; }
record Bar(int A, int B);
var foo1 = new Foo { A = 42, B = 1 };
var foo2 = new Foo { A = 42, B = 1 };
var bar1 = new Bar(42, 1);
var bar2 = new Bar(42, 1);
Console.WriteLine(foo1 == foo2); // False! Разные объекты
Console.WriteLine(bar1 == bar2); // True! Значения совпали
Интересный факт:
С record struct всё ещё круче: у них сравнение "по значению", как у обычных struct, но с синтаксисом и возможностями record.
4. Неизменяемость: кто сколько готов гарантировать?
Сравниваем безопасность объектов:
- class: По умолчанию легко изменяем, если явно не сделать все поля readonly.
- struct: То же самое, но можно объявить как readonly struct, тогда ни поля, ни свойства нельзя менять.
- record: Обычно поля объявляются с модификатором init, то есть задать их можно только в конструкторе или в инициализаторе объекта (with). Это удобно для безопасной передачи данных.
- record struct: То же, можно сделать readonly record struct и получать неизменяемость значимых типов с бесплатными плюшками record.
record Person(string Name, int Age);
var p1 = new Person("Алексей", 23);
// p1.Age = 24; // Ошибка! Только init
var p2 = p1 with { Age = 24 }; // Работает! Создаёт копию с новыми данными
Когда у вас большой проект и десятки entity-объектов, record позволят избежать кучи багов, связанных с "кто-то где-то поменял поле — и всё сломалось".
5. Синтаксис: как выглядят объявления и как не запутаться
// class
public class Product
{
public int Id { get; init; }
public string Name { get; init; }
}
// struct
public struct Point
{
public int X { get; set; }
public int Y { get; set; }
}
// record
public record Product(int Id, string Name);
// record struct
public record struct Point(int X, int Y);
Как видите, запись максимально короткая у record-типа. Получаете конструктор, деструктор, равенство, ToString() и многое другое "из коробки".
| Синтаксис | Получаете бесплатно | Можно ли наследовать |
|---|---|---|
| class | ничего (почти) | Да |
| struct | ничего (почти) | Нет |
| record | ToString, Equals, Deconstruct, with | Да |
| record struct | ToString, Equals, Deconstruct, with | Нет |
6. Клонирование и оператор with
Только у record и record struct есть специальный оператор with, который позволяет без боли копировать объект с изменением отдельных свойств.
record User(string Name, int Age);
var user1 = new User("Ирина", 28);
var user2 = user1 with { Age = 29 }; // user2.Name == "Ирина", user2.Age == 29
В случае класса (class) придётся реализовывать метод копирования вручную, а если забыть скопировать одно из полей — привет, баги.
7. Наследование: кто кого
- class: Поддерживает стандартное наследование (иерархии классов, виртуальные методы, абстракции и т.д.).
- struct: Не поддерживает (может только реализовывать интерфейсы).
- record: Поддерживает наследование, но с некоторыми ограничениями (например, наследование возможно только между record-типами, не с class).
- record struct: Не поддерживает, как и обычные struct.
record Animal(string Name);
record Dog(string Name, string Breed) : Animal(Name); // Окей!
class Vehicle { }
class Car : Vehicle { } // Окей!
// struct не может унаследоваться от struct
Подробнее о наследовании в следующем уровне :P
8. Где какие типы применяются на практике
- class: Большие объекты с богатым поведением, длинным жизненным циклом, изменяемым состоянием, иерархией (например, представление бизнес-логики, UI-компоненты).
- struct: Мелкие объекты-значения, где важно максимально быстрое копирование, отсутствие GC и минимальный оверхед (например, координаты, цвета, суммы — то, что легко и быстро клонировать).
- record: DTO (Data Transfer Object), объекты-параметры, параметры конфигурации, неизменяемые состояния, результаты вычислений, которым удобно сравниваться по содержимому.
- record struct: Значимые типы, где нужна неизменяемость и поведение, как у records, но без лишних аллокаций в куче.
9. Типичные ошибки и подводные камни
Иногда программисты думают, что record — это просто "замена класса". Это не так! Если вы пишете объект, который должен менять своё состояние в процессе жизни — используйте class.
Если вы хотите сравнивать объекты по ссылке (например, в паттерне "одиночка" или когда жизненный цикл объекта критичен) — используйте class.
Если вы делаете объект-значение, который должен вести себя как число или точка на координатной сетке — struct или record struct.
Если вы работаете с неизменяемыми, легко сравниваемыми объектами, которые часто передаются между слоями приложения, хранятся в коллекциях, логируются и сериализуются — ваш выбор record.
Также помните: если вы объявили record-класс с только-читабельными свойствами, но забыли про вложенные объекты — вложенные поля всё равно могут быть изменены, если они сами мутируемы.
record Student(string Name, List<int> Grades);
var s1 = new Student("Антон", new List<int>() {5,5,5});
var s2 = s1 with { };
s2.Grades.Add(2); // Оба объекта "прокляты" двойкой! s1.Grades == s2.Grades
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ