1. Навіщо взагалі зʼявилися record
У C# (і .NET загалом) тривалий час основними будівельними блоками були класи (class) і структури (struct). Та кожен із них не ідеальний для певних завдань. Класи — посилальні, змінні та зазвичай порівнюються за посиланням. Структури — значимі типи (копіюються під час передавання), за замовчуванням порівнюються побайтово й найчастіше змінні (доки не зʼявилися readonly struct).
А якщо вам потрібен простий і природний запис даних, який зручно порівнювати за значенням, швидко клонувати і не перейматися тим, що ваш обʼєкт хтось десь змінив, — доводилося вигадувати чимало власних рішень або користуватися бібліотеками на кшталт ValueTuple чи навіть System.Tuple. Але всі вони не такі виразні та зручні, як хотілося б.
Тому в C# 9 зʼявився record — тип даних, що поєднує стислість оголошення, безпечну незмінність і поведінку, налаштовану під порівняння за значенням.
2. Усі чотири типи на одній сторінці
| class | struct | record | record struct | |
|---|---|---|---|---|
| Категорія | Посилальний тип | Значимий тип | Посилальний тип | Значимий тип |
| Змінність | За замовчуванням — так | За замовчуванням — так | За замовчуванням — ні (init) | За замовчуванням — ні (init) |
| Порівняння | За посиланням (==) | За значенням (поля) | За значенням (поля/властивості) | За значенням (поля) |
| Клонування | Тільки вручну | Тільки вручну | Є вбудована підтримка (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 }; // Працює! Створює копію з оновленими даними
Коли у вас великий проєкт і десятки сутностей, 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-типами, не з класами).
- record struct: не підтримує, як і звичайні struct.
record Animal(string Name);
record Dog(string Name, string Breed) : Animal(Name); // Добре!
class Vehicle { }
class Car : Vehicle { } // Добре!
// структура не може наслідувати структуру
Докладніше про наслідування — на наступному рівні.
8. Де які типи застосовуються на практиці
- class: великі обʼєкти з багатою поведінкою, довгим життєвим циклом, змінним станом та ієрархією (наприклад, представлення бізнес-логіки, UI-компоненти).
- struct: дрібні обʼєкти-значення, де важливі максимально швидке копіювання, відсутність участі GC і мінімальні накладні витрати (наприклад, координати, кольори, суми — те, що легко й швидко копіювати).
- record: DTO (Data Transfer Object), обʼєкти-параметри, параметри конфігурації, незмінні стани, результати обчислень, які зручно порівнювати за вмістом.
- record struct: значимі типи, де потрібні незмінність і поведінка, як у record, але без зайвих виділень у купі (heap).
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
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ