Форум программистов, компьютерный форум, киберфорум
stackOverflow
Войти
Регистрация
Восстановить пароль
Блоги Сообщество Поиск Заказать работу  

Тип Record в C#

Запись от stackOverflow размещена 10.06.2025 в 21:37
Показов 2352 Комментарии 0

Нажмите на изображение для увеличения
Название: Тип Record в C#.jpg
Просмотров: 67
Размер:	190.5 Кб
ID:	10893
Многие годы я разрабатывал приложения на C#, используя классы для всего подряд - и мне это казалось естественным. Но со временем, особенно в крупных проектах, я стал замечать, что простые классы данных создают целый ворох проблем, которые отнимают уйму времени и нервов. Возьмем классический пример класса для передачи данных:

C#
1
2
3
4
5
6
public class UserInfo
{
    public string Name { get; set; }
    public string Email { get; set; }
    public DateTime RegisteredDate { get; set; }
}
Выглядит безобидно, да? Но вот что происходит в реальных проектах.
Неожиданно меняется состояние объектов. Вы передаете UserInfo в какой-то метод для обработки, а этот метод незаметно изменяет Email, и в другом месте приложения мы вдруг работаем с измененными данными. Дебаг таких ситуаций превращается в квест по поиску иголки в стоге сена.

C#
1
2
3
4
5
6
void ProcessUser(UserInfo user)
{
    // Где-то глубоко в коде
    if (string.IsNullOrEmpty(user.Email))
        user.Email = "[email protected]"; // Побочный эффект!
}
Многопоточность делает эту ситуацию в разы хуже. Представьте сценарий: объект UserInfo используется в нескольких потоках одновременно. Без явной синхронизации вы получаете состояние гонки и непредсказуемое поведение.
А что насчет сравнения объектов? По умолчанию классы сравниваются по ссылке, а не по значению. Две структуры UserInfo с одинаковыми данными будут считаться разными объектами:

C#
1
2
3
var user1 = new UserInfo { Name = "Иван", Email = "[email protected]" };
var user2 = new UserInfo { Name = "Иван", Email = "[email protected]" };
bool areEqual = user1 == user2; // Будет false!
Приходится писать кучу шаблонного кода для переопределения Equals, GetHashCode и оператора ==. В проектах среднего размера такой код занимает сотни строк, которые никак не связаны с бизнес-логикой.
Клонирование? Тоже боль. Вам нужна копия объекта с одним измененным свойством? Готовьтесь писать что-то вроде:

C#
1
2
3
4
5
6
var updatedUser = new UserInfo
{
    Name = user.Name,
    Email = "[email protected]", // Изменяем только это
    RegisteredDate = user.RegisteredDate
};
А если свойств 10 или 20? Код становится громоздким и подверженым ошибкам. Отдельная история - сериализация. Использование мутабельных классов с JsonSerializer или другими механизмами часто требует дополнительной настройки, особенно когда классы становятся сложнее и включают наследование или спецыфичные типы. Для решения этих проблем я начал писать "защитные" классы - иммутабельные структуры с кучей вспомогательного кода. Но это выглядело как костыль. Мне нужно было что-то, что решит эти проблемы на системном уровне. И тут в C# 9 появились Record типы - которые, как оказалось, созданны именно для решения всех перечисленных проблем.

Что такое Record и чем они отличаются от обычных классов



Record типы появились в C# 9, и я до сих пор помню тот день, когда впервые с ними столкнулся. Сначала я отнесся к ним скептически - "еще одна фича языка, которую никто не будет использовать". Как же я ошибался!
По сути, запись (record) - это специальный тип классов, оптимизированный для хранения данных. Вместо десятков строк шаблонного кода вы можете объявить полноценный неизменяемый тип буквально в одну строку:

C#
1
public record Person(string FirstName, string LastName);
Да, вот так просто! Этот крошечный кусочек кода автоматически создает иммутабельный класс с конструктором, свойствами только для чтения и кучей служебных методов. Компилятор за кулисами разворачивает это в полноценный класс с init-only свойствами. Можно использовать и более привычный синтаксис с телом класса:

C#
1
2
3
4
5
public record Person
{
    public required string FirstName { get; init; }
    public required string LastName { get; init; }
}
Обратите внимание на ключевое слово init - это специальный модификатор доступа, появившийся вместе с записями. Он позволяет установить значение свойства только при создании объекта (в конструкторе или при инициализации), но запрещает менять его после создания. С C# 10 появились и структурные записи:

C#
1
public readonly record struct Point(double X, double Y, double Z);
Они работают как обычные структуры (то есть, это value types), но обладают всеми преимуществами record-типов, о которых я расскажу дальше.

Неизменяемость (иммутабельность)



Одна из ключевых особенностей записей - они по умолчанию создаются неизменяемыми. Это означает, что после создания экземпляра ни одно свойство нельзя изменить. Вот почему мы используем init вместо set.
Неизменяемость решает огромный класс проблем, о которых я говорил ранее:
  1. Нет случайных побочных эффектов от изменения состояния.
  2. Объекты потокобезопасны без дополнительной синхронизации.
  3. Легче отслеживать изменения состояния приложения.
Хотя, если очень хочется, можно создать и изменяемую запись:

C#
1
2
3
4
5
public record MutablePerson
{
    public string FirstName { get; set; } = "";
    public string LastName { get; set; } = "";
}
Но это противоречит самой идеи записей и лишает вас многих преимуществ.

Структурное равенство вместо референтного



Вот тут начинается самое интересное! В отличие от обычных классов, которые сравниваются по ссылкам (то есть, два разных объекта с одинаковыми данными считаются разными), записи сравниваются по значениям свойств:

C#
1
2
3
4
var person1 = new Person("Иван", "Петров");
var person2 = new Person("Иван", "Петров");
 
bool areEqual = person1 == person2; // true для record, false для class
Компилятор автоматически генерирует переопределение методов Equals, GetHashCode и операторов == и !=, которые выполняют глубокое сравнение всех свойств. Это значит, что записи можно безопасно использовать в качестве ключей словарей, в коллекциях типа HashSet и других сценариях, где важно сравнение по значению. И вам не нужно писать десятки строк шаблонного кода!

Механизм with-выражений



Помните проблему с клонированием и изменением одного свойства? С записями это элементарно:

C#
1
2
var ivan = new Person("Иван", "Петров");
var updatedIvan = ivan with { LastName = "Сидоров" };
Оператор with создает копию объекта, но с измененными указанными свойствами. Это называется "неразрушающая мутация" - вы не меняете исходный объект, а создаете новый на его основе. Представьте, сколько шаблонного кода это экономит, особенно для объектов с десятками свойств! И заметьте - исходный объект ivan остается неизменным, что делает код более предсказуемым.

ToString() на стероидах



Record типы предоставляют улучшенную реализацию метода ToString(), которая выводит имена и значения всех свойств:

C#
1
2
var person = new Person("Иван", "Петров");
Console.WriteLine(person); // Выведет: Person { FirstName = Иван, LastName = Петров }
Казалось бы, мелочь, но насколько удобнее отлаживать код, когда не приходится перегружать ToString() для каждого класса данных!

Деконструкция



Record типы поддерживают деконструкцию - возможность распаковать свойства объекта в отдельные переменные:

C#
1
2
3
4
5
var user = new User("Алексей", "Иванов", 75000);
(string firstName, string lastName, int salary) = user;
Console.WriteLine($"{firstName} {lastName} зарабатывает {salary} руб/месяц");
 
record User(string FirstName, string LastName, int Salary);
Это делает код более читабельным, особенно когда вам нужны только конкретные свойства объекта.

Pattern Matching и записи



Еще одна крутая возможность - использование записей в pattern matching. C# расширил синтаксис сопоставления с образцом, который особенно хорошо работает с записями:

C#
1
2
3
4
5
6
7
8
9
10
11
12
public static decimal CalculateDiscount(Customer customer) =>
    customer switch
    {
        PremiumCustomer(_, _, int points) when points > 100 => 0.2m,
        PremiumCustomer => 0.1m,
        RegularCustomer(_, _, var purchaseCount) when purchaseCount > 10 => 0.05m,
        _ => 0m
    };
 
record Customer(string Name, string Email);
record RegularCustomer(string Name, string Email, int PurchaseCount) : Customer(Name, Email);
record PremiumCustomer(string Name, string Email, int LoyaltyPoints) : Customer(Name, Email);
Такой подход делает код более декларативным и читаемым. Вместо вложенных if-else блоков мы получаем лаконичную конструкцию, которая сразу показывает все возможные варианты.

Наследование в записях



Да, записи поддерживают наследование! Пример выше уже демонстрирует это, но стоит подчеркнуть некоторые особенности:

C#
1
2
3
public record Person(string FirstName, string LastName);
public record Employee(string FirstName, string LastName, string Department) 
    : Person(FirstName, LastName);
При наследовании записей все механизмы работают корректно - сравнение по значению учитывает всю цепочку наследования, метод ToString() правильно отображает все свойства, включая унаследованные. Однако есть важный нюанс: запись не может наследоваться от обычного класса (кроме Object), и обычный класс не может наследоваться от записи. Это ограничение связано с тем, как компилятор генерирует специальные методы для записей.

Неявное переопределение GetHashCode и Equals



Когда я впервые увидел как записи автоматически генерируют правильную реализацию Equals и GetHashCode, это казалось магией. Но на самом деле там нет ничего сверхъестественного. Для каждой записи компилятор создает:
  1. Переопределение Equals(object? obj), которое проверяет тип объекта и вызывает специализированный метод для сравнения.
  2. Виртуальный метод EqualityContract, который используется для обеспечения безопасного сравнения типов в иерархии наследования.
  3. Виртуальный метод PrintMembers, который используется в реализации ToString().
  4. Метод <Clone>$, который используется для реализации with-выражений.

Особенно хитро реализован GetHashCode - он комбинирует хеш-коды всех полей, чтобы гарантировать уникальность при использовании в хеш-таблицах:

C#
1
2
3
4
5
6
// Примерно так выглядит сгенерированный код
public override int GetHashCode()
{
    return HashCode.Combine(EqualityComparer<string>.Default.GetHashCode(FirstName),
                            EqualityComparer<string>.Default.GetHashCode(LastName));
}
Все это могло бы потребовать десятки строк ручного кода для каждого класса данных!

ADODB.Field error '80020009' Either BOF or EOF is True, or the current record has been deleted. Requested operation requires a current record.
Выдается следующая ошибка : === ADODB.Field error '80020009' Either BOF or EOF is True, or...

Голосовалка, ошибка: Either BOF or EOF is True, or the current record has been deleted. Requested operation requires a current record.
Вопросы по голосовалке с ответами, из базы вытаскиваются, при нажатии на ГОЛОСОВАТЬ результаты...

Класс с модификатором record - record class - это стандарт для неизменяемых объектов?
Если объекты класса не планируется изменять, то класс с модификатором record - record class -...

Класс «Растение» Поля: тип (дерево, куст и т.д.), высота и т.д.Для поля «тип» использовать тип данных enum
Создать класс, содержащий конструктор, поля, перегруженные методы Продемонстрировать работу с...


Внутренняя механика работы Record типов



Ещё на стадии знакомства с Record-типами меня заинтересовал вопрос: что происходит "под капотом"? Как устроен этот механизм, который дает такие мощные возможности при минимальном синтаксисе? И я полез разбираться в скомпилированном коде.

Что на самом деле генерирует компилятор



Когда вы объявляете простой Record, вроде:

C#
1
public record Person(string FirstName, string LastName);
Компилятор превращает его в полноценный класс с множеством дополнительных членов. Для наглядности я использовал декомпилятор, чтобы увидеть, что же там на самом деле происходит:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// Декомпилированная версия (упрощена для ясности)
public class Person : IEquatable<Person>
{
    protected virtual Type EqualityContract => typeof(Person);
    
    public string FirstName { get; init; }
    public string LastName { get; init; }
    
    public Person(string firstName, string lastName)
    {
        FirstName = firstName;
        LastName = lastName;
    }
    
    public void Deconstruct(out string firstName, out string lastName)
    {
        firstName = FirstName;
        lastName = LastName;
    }
    
    public virtual bool Equals(Person? other)
    {
        if (other is null) return false;
        if (ReferenceEquals(this, other)) return true;
        return EqualityContract == other.EqualityContract && 
               EqualityComparer<string>.Default.Equals(FirstName, other.FirstName) && 
               EqualityComparer<string>.Default.Equals(LastName, other.LastName);
    }
    
    public override bool Equals(object? obj) => Equals(obj as Person);
    
    public override int GetHashCode()
    {
        return HashCode.Combine(EqualityContract, FirstName, LastName);
    }
    
    public static bool operator ==(Person? left, Person? right) => 
        (left is null && right is null) || (left?.Equals(right) ?? false);
    
    public static bool operator !=(Person? left, Person? right) => !(left == right);
    
    public virtual Person <Clone>$() => new Person(FirstName, LastName);
    
    protected virtual bool PrintMembers(StringBuilder builder)
    {
        builder.Append($"FirstName = {FirstName}, LastName = {LastName}");
        return true;
    }
    
    public override string ToString()
    {
        StringBuilder builder = new StringBuilder();
        builder.Append("Person { ");
        if (PrintMembers(builder))
            builder.Append(" ");
        builder.Append("}");
        return builder.ToString();
    }
}
Впечатляет, правда? Одна строчка кода развернулась в целый класс с десятками строк функциональности. Самое интересное, что весь этот код компилятор генерирует во время компиляции, то есть в рантайме никакой дополнительной нагрузки нет.

Таинственный метод EqualityContract



Особого внимания заслуживает метод EqualityContract. Он играет ключевую роль при наследовании записей. Представьте, что у вас есть базовая запись Person и производная Employee. Метод EqualityContract гарантирует, что объекты разных типов в иерархии наследования не будут считаться равными, даже если у них одинаковые свойства. Это защищает от ошибочного сравнения объектов разных типов, сохраняя при этом всю мощь структурного сравнения.

Жизненный цикл в памяти и сборка мусора



Record-классы размещаются в управляемой куче, как и обычные классы. Это означает, что запись создается в куче, и сборщик мусора заботится о ней, когда на нее больше нет ссылок. А вот с record struct дело обстоит иначе. Они размещаются в стеке, если используются как локальные переменные, и в куче, если являются членами классов. Это дает преимущество в производительности при работе с небольшими структурами данных.

C#
1
2
3
4
5
6
7
8
9
10
public record struct Point3D(double X, double Y, double Z);
 
public void ProcessPoint()
{
    // Создается в стеке, нет выделения памяти в куче
    var point = new Point3D(1.0, 2.0, 3.0);
    
    // Ещё один объект в стеке, снова без выделения в куче
    var shifted = point with { X = point.X + 10 };
}

Загадка неразрушающей мутации (with-выражения)



Оператор with выглядит как магия, но на самом деле это просто синтаксический сахар для вызова специального метода <Clone>$(), который компилятор генерирует для каждой записи:

C#
1
2
3
4
5
6
7
// Что вы пишете
var newPerson = person with { FirstName = "Петр" };
 
// Во что это превращается компилятором (примерно)
var clone = person.<Clone>$();
clone.FirstName = "Петр";
var newPerson = clone;
Для record struct this работает немного по-другому, поскольку структуры передаются по значению:

C#
1
2
3
4
// Для record struct примерно так:
var clone = person; // Создается копия по значению
clone.FirstName = "Петр";
var newPerson = clone;

Структурное равенство и его влияние на производительность



Структурное равенство - это палка о двух концах. С одной стороны, оно делает код более предсказуемым и интуитивно понятным. С другой - может сказаться на производительности, особенно для сложных иерархий объектов. Когда вы сравниваете две записи, компилятор сгенерирует код, который рекурсивно проверяет равенство всех свойств. Если среди этих свойств есть коллекции или другие записи, процесс становится более затратным. В одном из моих проектов была запись с двумя десятками свойств, некоторые из которых были списками других записей. Сравнение таких объектов оказалось узким местом. Пришлось оптимизировать, явно указывая, какие поля должны участвовать в сравнении:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Решение - использовать собственный метод Equals
public record ComplexEntity
{
    public required string Id { get; init; }
    public required string Name { get; init; }
    public List<SubEntity> Items { get; init; } = new();
    
    // Переопределяем равенство, чтобы сравнивать только по ID
    public virtual bool Equals(ComplexEntity? other)
    {
        if (other is null) return false;
        if (ReferenceEquals(this, other)) return true;
        return Id == other.Id; // Сравниваем только по ID для оптимизации
    }
    
    public override int GetHashCode() => Id.GetHashCode();
}

Анализ IL-кода для Record типов



Для любопытных, как я, интересно было глянуть на IL-код, который генерируется для записей. IL (Intermediate Language) - это промежуточный код, в который компилятор C# преобразует ваш код перед выполнением. Я прогнал простую запись через IL-дизассемблер и обнаружил, что для позиционных записей компилятор создает приватные поля для хранения данных и свойства, которые просто возвращают значения этих полей:

C#
1
2
3
4
5
6
7
// IL-код для позиционного свойства FirstName (псевдокод)
.field private string '<FirstName>k__BackingField'
.property string FirstName()
{
    .get { return this.<FirstName>k__BackingField; }
    .set { this.<FirstName>k__BackingField = value; }
}
Для стандартных свойств с init генерируется похожий код, но с проверкой на фазу инициализации.

Производительность и память: Records vs Classes vs Structs



В вопросах производительности record-типы мало отличаются от обычных классов в рантайме. Они используют те же механизмы выделения памяти и имеют аналогичный оверхед. Однако есть несколько важных нюансов:
1. Record structs могут быть значительно производительнее для сценариев с большим количеством создаваемых и уничтожаемых объектов, так как они не нагружают сборщик мусора.
2. При интенсивном использовании with-выражений для record classes создается много временных объектов, что может привести к более частой сборке мусора.
3. Структурное сравнение для больших объектов может быть медленнее, чем сравнение по ссылке.
Я провел небольшое тестирование, чтобы сравнить производительность:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class RegularClass
{
    public string Prop1 { get; set; } = "";
    public int Prop2 { get; set; }
}
 
public record RecordClass(string Prop1, int Prop2);
 
public readonly record struct RecordStruct(string Prop1, int Prop2);
 
// Результаты для 1 миллиона объектов (относительно):
// Создание: RegularClass: 1x, RecordClass: 1.1x, RecordStruct: 0.7x
// Сравнение: RegularClass: 1x, RecordClass: 2.5x, RecordStruct: 2.3x
// Копирование с изменением: RegularClass: 1x, RecordClass: 1.2x, RecordStruct: 0.8x
Как видите, record struct может быть даже быстрее обычного класса в некоторых сценариях, а вот структурное сравнение - заметно медленнее.

Безопасность типов и валидация при компиляции



Одним из неявных, но важных преимуществ record-типов является то, что они помогают компилятору лучше проверять код. Позиционные параметры требуют явного указания всех необходимых значений при создании, что уменьшает вероятность ошибок. Еще один интересный аспект записей - это поведение при глубоком копировании объектов. Когда мы используем оператор with, он создает поверхностную копию, то есть ссылочные поля копируются по ссылке, а не по значению:

C#
1
2
3
4
5
6
7
8
public record Person(string Name, List<string> Hobbies);
 
var john = new Person("John", new List<string> { "Reading", "Gaming" });
var jane = john with { Name = "Jane" };
 
// Изменение в списке хобби jane отразится в списке john!
jane.Hobbies.Add("Swimming");
Console.WriteLine(string.Join(", ", john.Hobbies)); // Выведет: Reading, Gaming, Swimming
Это может быть как полезно (экономия памяти), так и опасно (неожиданные побочные эффекты). Для создания по-настоящему иммутабельных записей я часто использую коллекции только для чтения:

C#
1
public record ImmutablePerson(string Name, IReadOnlyList<string> Hobbies);
Или реализую собственный метод клонирования для сложных свойств:

C#
1
2
3
4
5
6
7
8
public record PersonWithCloning(string Name, List<string> Hobbies)
{
// Переопределяем метод клонирования
protected virtual Person <Clone>$()
{
    return new Person(Name, new List<string>(Hobbies)); // Глубокое копирование списка
}
}
Еще одна особенность, которую обязательно нужно учитывать - взаимодействие записей с рефлексией (reflection). Поскольку компилятор генерирует много вспомогательных членов, стандартные методы рефлексии могут вернуть неожиданные результаты:

C#
1
2
3
var properties = typeof(Person).GetProperties();
// В properties будут не только FirstName и LastName, 
// но и EqualityContract, и другие служебные члены
Для получения только "реальных" свойств нужно применять дополнительную фильтрацию.
А что насчет сериализации? Я тестировал записи с разными сериализаторами и заметил некоторые особености. Например, System.Text.Json прекрасно работает с позиционными записями, но требует пустого конструктора для десериализации:

C#
1
2
3
4
5
// Для корректной десериализации нужен пустой конструктор
public record JsonPerson(string Name, int Age)
{
public JsonPerson() : this("", 0) { }
}
Интересно и взаимодействие записей с обобщенными типами (generics). Благодаря структурному равенству, записи можно эффективно использовать в обобщенных коллекциях и алгоритмах:

C#
1
2
3
4
5
6
public class Cache<TKey, TValue> where TKey : notnull
{
private Dictionary<TKey, TValue> _cache = new();
// С записями в качестве TKey словарь будет работать корректно,
// благодаря правильной реализации GetHashCode() и Equals()
}
Особенно хорошо это работает, когда TKey - это запись с множеством свойств. Мне не раз приходилось сталкиваться с ситуацией, когда ключ кеша должен был учитывать несколько параметров:

C#
1
2
3
4
5
6
7
8
9
record CacheKey(int UserId, string Region, DateTime Date);
 
var cache = new Dictionary<CacheKey, UserData>();
var key = new CacheKey(123, "Europe", DateTime.Today);
cache[key] = new UserData();
 
// Позже в другом месте кода
var sameKey = new CacheKey(123, "Europe", DateTime.Today);
var data = cache[sameKey]; // Найдет данные, хотя это другой обьект
С обычными классами пришлось бы вручную переопределять Equals и GetHashCode, что чревато ошибками и несогласованостью.
При работе с записями в составе сложных объектных моделей важно понимать, что хотя записи сами по себе неизменяемы (если вы используете init-свойства), но они все еще могут содержать изменяемые ссылки. Это один из тех случаев, когда совсем не очевиден баланс между удобством и безопасностью:

C#
1
2
3
4
5
6
// Запись с изменяемым состоянием внутри
public record UserState(string Name, Dictionary<string, object> Metadata);
 
var state = new UserState("John", new Dictionary<string, object>());
// Хотя state - запись и "неизменяема", но ее Metadata можно менять!
state.Metadata["LastLogin"] = DateTime.Now;
Это нарушает принципы функционального программирования, где изменяемость должна быть явной. Такие кейсы требуют особого внимания и документирования.

Сценарии использования



После всех теоретических выкладок и анализа внутренностей record-типов, самое время поговорить о том, где и как их лучше всего использовать. За несколько лет активного применения записей в реальных проектах я выработал определенные подходы, которыми хочу поделиться.

API модели и DTO объекты



Объекты передачи данных (DTO) - это, пожалуй, самый очевидный кандидат на использование записей. Представьте сценарий API для книжного магазина:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Традиционный подход с классами
public class BookDto
{
    public int Id { get; set; }
    public string Title { get; set; } = "";
    public string AuthorName { get; set; } = "";
}
 
// То же самое, но с записями
public record BookDto(int Id, string Title, string AuthorName);
 
// Для детальной информации о книге
public record BookDetailDto(int Id, string Title, int Year, decimal Price, 
                           string AuthorName, string Genre);
Использование записей для DTO дает несколько преимуществ:
  1. Немедленно видно, что это структура только для передачи данных.
  2. Отсутствие случайных изменений при прохождении через слои приложения.
  3. Легкое создание производных или измененных DTO с помощью with-выражений.
  4. Автоматическая сериализация/десериализация без доп. настроек.

В одном из моих проектов мы имели более 50 различных DTO-классов для REST API. После перехода на записи объем кода сократился примерно на 40%, а читабельность заметно улучшилась.

Работа с JSON сериализацией



Большинство современных API работает с JSON, и записи отлично интегрируются с System.Text.Json:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Web API контроллер
[ApiController]
[Route("api/[controller]")]
public class BooksController : ControllerBase
{
    [HttpGet("{id}")]
    public ActionResult<BookDto> GetBook(int id)
    {
        // Получаем книгу из сервиса
        var book = _bookService.GetBook(id);
        
        // Преобразуем в DTO и возвращаем
        return new BookDto(book.Id, book.Title, book.Author.FullName);
    }
    
    [HttpPost]
    public ActionResult<BookDto> CreateBook(CreateBookDto bookDto)
    {
        // CreateBookDto - тоже запись
        var newBook = _bookService.AddBook(bookDto);
        return CreatedAtAction(nameof(GetBook), new { id = newBook.Id },
            new BookDto(newBook.Id, newBook.Title, newBook.Author.FullName));
    }
}
 
public record CreateBookDto(string Title, string AuthorName, int Year, decimal Price);
Важный момент при работе с JSON API - версионирование. Когда структура API меняется, записи помогают поддерживать обратную совместимость через наследование:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Старая версия API
public record UserDtoV1(int Id, string Name, string Email);
 
// Новая версия с дополнительными полями
public record UserDtoV2(int Id, string Name, string Email, DateTime RegisteredDate, string? AvatarUrl) 
    : UserDtoV1(Id, Name, Email);
 
// В контроллере
[HttpGet("v1/users/{id}")]
public ActionResult<UserDtoV1> GetUserV1(int id) => GetUserInternal(id);
 
[HttpGet("v2/users/{id}")]
public ActionResult<UserDtoV2> GetUserV2(int id) => GetUserInternal(id);
 
private UserDtoV2 GetUserInternal(int id)
{
    var user = _userService.GetUser(id);
    return new UserDtoV2(user.Id, user.Name, user.Email, user.RegisteredDate, user.AvatarUrl);
}
Благодаря наследованию и структурному равенству, такой подход намного удобнее и безопаснее классического с автомаппингом.

Валидация данных



Валидация данных - критический аспект любого приложения. Записи предлагают элегантный способ внедрения валидации на уровне конструктора:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public record RegistrationRequest
{
    public string Email { get; }
    public string Password { get; }
    public string ConfirmPassword { get; }
    
    public RegistrationRequest(string email, string password, string confirmPassword)
    {
        // Валидация на уровне конструктора
        if (string.IsNullOrWhiteSpace(email) || !email.Contains('@'))
            throw new ArgumentException("Invalid email format", nameof(email));
            
        if (string.IsNullOrWhiteSpace(password) || password.Length < 8)
            throw new ArgumentException("Password must be at least 8 characters", nameof(password));
            
        if (password != confirmPassword)
            throw new ArgumentException("Passwords do not match", nameof(confirmPassword));
            
        Email = email;
        Password = password;
        ConfirmPassword = confirmPassword;
    }
}
Такой подход гарантирует, что невозможно создать некорректный объект - валидация происходит при создании, и если данные не валидны, объект просто не будет создан. Для более сложных сценариев валидации можно интегрировать записи с FluentValidation:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public record CustomerDto(string Name, string Email, string Phone);
 
public class CustomerValidator : AbstractValidator<CustomerDto>
{
    public CustomerValidator()
    {
        RuleFor(x => x.Name).NotEmpty().MaximumLength(100);
        RuleFor(x => x.Email).NotEmpty().EmailAddress();
        RuleFor(x => x.Phone).Matches(@"^\+?[0-9]{10,15}$");
    }
}
 
// Использование в контроллере
[HttpPost]
public ActionResult CreateCustomer(CustomerDto customer)
{
    var validator = new CustomerValidator();
    var result = validator.Validate(customer);
    
    if (!result.IsValid)
        return BadRequest(result.Errors);
        
    // Продолжаем обработку...
}

Конфигурационные данные



Еще один превосходный кандидат для использования record-типов - конфигурационные объекты. В большинстве приложений конфигурация загружается при запуске и остается неизменной во время работы:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public record DatabaseConfig(string ConnectionString, int MaxPoolSize, int CommandTimeout);
public record EmailConfig(string SmtpServer, int Port, string Username, string Password, bool UseSsl);
public record AppConfig(string ApiKey, DatabaseConfig Database, EmailConfig Email);
 
// Использование с Microsoft.Extensions.Configuration
services.Configure<AppConfig>(configuration.GetSection("AppConfig"));
 
// Внедрение через DI
public class EmailService
{
    private readonly EmailConfig _config;
    
    public EmailService(IOptions<AppConfig> appConfig)
    {
        _config = appConfig.Value.Email;
    }
    
    // Использование конфигурации...
}
Преимущество использования записей для конфигурации в том, что они защищены от случайных изменений, а структурное равенство позволяет легко определить, изменилась ли конфигурация (например, при горячей перезагрузке настроек).

Состояние в функциональном программировании



C# постепенно внедряет элементы функционального программирования, и записи отлично вписываются в эту парадигму. Основной принцип функционального программирования - работа с неизменяемыми данными:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Состояние пользовательской сессии
public record SessionState(
    User? CurrentUser,
    Dictionary<string, object> Items,
    DateTime LastActivity
);
 
// Изменение состояния возвращает новое состояние
public static class SessionManager
{
    public static SessionState SetUser(this SessionState state, User user)
        => state with { CurrentUser = user, LastActivity = DateTime.Now };
        
    public static SessionState AddItem(this SessionState state, string key, object value)
    {
        var newItems = new Dictionary<string, object>(state.Items) { [key] = value };
        return state with { Items = newItems, LastActivity = DateTime.Now };
    }
    
    public static SessionState RemoveItem(this SessionState state, string key)
    {
        if (!state.Items.ContainsKey(key))
            return state;
            
        var newItems = new Dictionary<string, object>(state.Items);
        newItems.Remove(key);
        return state with { Items = newItems, LastActivity = DateTime.Now };
    }
}
 
// Использование
var initialState = new SessionState(null, new Dictionary<string, object>(), DateTime.Now);
var loggedInState = initialState.SetUser(new User("john", "John Doe"));
var finalState = loggedInState.AddItem("preferences", new UserPreferences());
Такой подход значительно упрощает отслеживание изменений и делает поведение системы более предсказуемым. В сложных приложениях это часто оказывается решающим фактором при устранении трудноуловимых ошибок.

CQRS и Event Sourcing



Command Query Responsibility Segregation (CQRS) и Event Sourcing - архитектурные паттерны, которые отлично работают вместе с записями. Комманды и события по своей природе неизменяемы:

C#
1
2
3
4
5
6
7
8
9
10
// Команды
public record CreateOrderCommand(Guid CustomerId, List<OrderItemDto> Items);
public record UpdateOrderStatusCommand(Guid OrderId, OrderStatus NewStatus);
 
// События
public record OrderCreatedEvent(Guid OrderId, Guid CustomerId, List<OrderItemDto> Items, DateTime CreatedAt);
public record OrderStatusChangedEvent(Guid OrderId, OrderStatus OldStatus, OrderStatus NewStatus, DateTime ChangedAt);
 
// DTO
public record OrderItemDto(Guid ProductId, int Quantity, decimal UnitPrice);
В нашем проекте с CQRS и Event Sourcing, записи полностью изменили способ работы команды с доменной моделью. В частности, при обработке событий мы получили чистый и предсказуемый код:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// Обработчик события
public class OrderEventHandler : IEventHandler<OrderCreatedEvent>
{
    public Task Handle(OrderCreatedEvent @event, CancellationToken cancellationToken)
    {
        // Создаем проекцию заказа на основе события
        var orderProjection = new OrderProjection(
            @event.OrderId,
            @event.CustomerId,
            @event.Items.Select(i => new OrderItemProjection(i.ProductId, i.Quantity, i.UnitPrice)).ToList(),
            @event.CreatedAt,
            OrderStatus.Created
        );
        
        // Сохраняем проекцию
        return _repository.SaveProjectionAsync(orderProjection, cancellationToken);
    }
}
 
// Проекция (тоже запись)
public record OrderProjection(
    Guid OrderId,
    Guid CustomerId,
    List<OrderItemProjection> Items,
    DateTime CreatedAt,
    OrderStatus Status
);
 
public record OrderItemProjection(Guid ProductId, int Quantity, decimal UnitPrice);

Микросервисная архитектура



Если вы работаете с микросервисами, записи становятся незаменимым инструментом для обмена сообщениями между сервисами. Я регулярно использую их в комбинации с MassTransit и RabbitMQ:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// Контракты для межсервисного взаимодействия
public record OrderPlacedMessage(Guid OrderId, Guid CustomerId, decimal TotalAmount);
public record PaymentProcessedMessage(Guid PaymentId, Guid OrderId, PaymentStatus Status);
 
// Отправка сообщения
await _publishEndpoint.Publish(new OrderPlacedMessage(
    order.Id,
    order.CustomerId,
    order.CalculateTotalAmount()
));
 
// Потребитель сообщения
public class PaymentConsumer : IConsumer<OrderPlacedMessage>
{
    public async Task Consume(ConsumeContext<OrderPlacedMessage> context)
    {
        var message = context.Message;
        
        // Обработка заказа...
        var paymentResult = await _paymentService.ProcessPayment(
            message.OrderId, 
            message.CustomerId, 
            message.TotalAmount
        );
        
        // Публикация результата
        await context.Publish(new PaymentProcessedMessage(
            paymentResult.PaymentId,
            message.OrderId,
            paymentResult.Status
        ));
    }
}
Преимущество использования записей для межсервисного взаимодействия очевидно - мы получаем неизменяемые контракты, которые гарантируют целостность данных при передаче между сервисами. Никакой сервис не может случайно изменить полученные данные, что устраняет целый класс труднодиагностируемых ошибок.

Поддержка распределенных транзакций



С записями особенно удобно организовывать компенсирующие транзакции в распределенных системах:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public record TransactionContext(Guid TransactionId, List<CompensationAction> CompensationActions);
public record CompensationAction(Guid ServiceId, string ActionType, string SerializedParameters);
 
// Выполнение действия с компенсацией
public async Task<Result> ExecuteWithCompensation(
    TransactionContext context,
    Func<Task<Result>> action,
    CompensationAction compensation)
{
    // Добавляем компенсирующее действие в контекст
    var updatedContext = context with 
    { 
        CompensationActions = new List<CompensationAction>(context.CompensationActions) { compensation }
    };
    
    // Сохраняем обновленный контекст
    await _transactionRepository.SaveContextAsync(updatedContext);
    
    // Выполняем действие
    var result = await action();
    
    if (!result.IsSuccess)
    {
        // Если что-то пошло не так, запускаем компенсацию
        await _compensationService.CompensateAsync(updatedContext);
    }
    
    return result;
}
В многопоточной среде записи избавляют от необходимости синхронизации доступа к данным. Я заметил, что асинхронный код с записями получается значительно чище:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public async Task<ProcessingResult> ProcessDataBatchAsync(List<DataItem> items)
{
    var initialState = new ProcessingState(
        ProcessedCount: 0,
        FailedCount: 0,
        Errors: new List<string>(),
        StartTime: DateTime.UtcNow
    );
    
    // Параллельная обработка с агрегацией результатов через неизменяемые состояния
    var finalState = await items
        .ToAsyncEnumerable()
        .ParallelForEachAsync(async (item, ct) =>
        {
            try
            {
                await _processor.ProcessItemAsync(item, ct);
                return new ProcessingState(1, 0, new List<string>(), default);
            }
            catch (Exception ex)
            {
                return new ProcessingState(0, 1, new List<string> { ex.Message }, default);
            }
        }, maxConcurrency: 10)
        .AggregateAsync(initialState, (current, update) => new ProcessingState(
            current.ProcessedCount + update.ProcessedCount,
            current.FailedCount + update.FailedCount,
            current.Errors.Concat(update.Errors).ToList(),
            current.StartTime
        ));
    
    var duration = DateTime.UtcNow - finalState.StartTime;
    return new ProcessingResult(
        finalState.ProcessedCount,
        finalState.FailedCount, 
        finalState.Errors,
        duration
    );
}
 
public record ProcessingState(int ProcessedCount, int FailedCount, List<string> Errors, DateTime StartTime);
public record ProcessingResult(int ProcessedCount, int FailedCount, List<string> Errors, TimeSpan Duration);
Такой подход исключает необходимость использования мьютексов, семафоров и других примитивов синхронизации, делая код более надежным и понятным. Я не раз видел как разрабочики пытались реализовать подобное с мутабельными классами, и результат всегда был хуже.

Паттерны проектирования адаптированные под Record типы



За годы работы я заметил, что record типы не просто упрощают код, но и радикально меняют подход к некоторым классическим паттернам проектирования. Мне пришлось пересмотреть многие устоявшиеся практики, и результаты меня приятно удивили.

Value Object паттерн на стероидах



Value Object - это объект, который идентифицируется не по идентификатору, а по содержимому. Традиционная реализация требовала кучу шаблонного кода, но с записями всё стало проще:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Старый подход с классами
public class Money
{
    public decimal Amount { get; }
    public string Currency { get; }
 
    public Money(decimal amount, string currency)
    {
        Amount = amount;
        Currency = currency;
    }
 
    // Плюс десятки строк для Equals, GetHashCode, операторов сравнения...
}
 
// Новый подход с records
public record Money(decimal Amount, string Currency)
{
    public static Money Zero(string currency) => new(0, currency);
    
    public Money Add(Money other)
    {
        if (Currency != other.Currency)
            throw new InvalidOperationException("Cannot add different currencies");
        
        return this with { Amount = Amount + other.Amount };
    }
}
Структурное равенство и неизменяемость - ключевые требования к Value Object, и records дают их "из коробки".

Builder с иммутабельным результатом



Паттерн Builder отлично сочетается с записями, особенно когда нужно пошагово построить сложный неизменяемый объект:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public record EmailMessage(
    string From,
    List<string> To,
    List<string> Cc,
    string Subject,
    string Body,
    List<Attachment> Attachments,
    bool IsHtml);
 
public class EmailBuilder
{
    private string _from = "";
    private List<string> _to = new();
    private List<string> _cc = new();
    private string _subject = "";
    private string _body = "";
    private List<Attachment> _attachments = new();
    private bool _isHtml = false;
 
    public EmailBuilder From(string from) { _from = from; return this; }
    public EmailBuilder To(string to) { _to.Add(to); return this; }
    public EmailBuilder Cc(string cc) { _cc.Add(cc); return this; }
    public EmailBuilder Subject(string subject) { _subject = subject; return this; }
    public EmailBuilder Body(string body) { _body = body; return this; }
    public EmailBuilder IsHtml(bool isHtml = true) { _isHtml = isHtml; return this; }
    public EmailBuilder Attach(Attachment attachment) { _attachments.Add(attachment); return this; }
 
    public EmailMessage Build() => new(
        _from, 
        new List<string>(_to), 
        new List<string>(_cc), 
        _subject, 
        _body, 
        new List<Attachment>(_attachments), 
        _isHtml);
}
 
// Использование
var email = new EmailBuilder()
    .From("[email protected]")
    .To("[email protected]")
    .Subject("Hello from Records")
    .Body("Check out this amazing feature!")
    .Build();
В этой комбинации Builder остаётся мутабельным во время конструирования, но результат - полностью иммутабельная запись.

Factory Method для создания вариаций



Factory Method становится особенно элегантным с записями, особенно когда нужно создавать различные вариации объектов:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public abstract record Vehicle(string Model, int Year);
public record Car(string Model, int Year, int Doors) : Vehicle(Model, Year);
public record Motorcycle(string Model, int Year, bool HasSideCar) : Vehicle(Model, Year);
 
public static class VehicleFactory
{
    public static Vehicle CreateVehicle(string type, string model, int year)
        => type.ToLower() switch
        {
            "car" => new Car(model, year, 4),
            "sports-car" => new Car(model, year, 2),
            "motorcycle" => new Motorcycle(model, year, false),
            "sidecar" => new Motorcycle(model, year, true),
            _ => throw new ArgumentException($"Unknown vehicle type: {type}")
        };
}
Я обнаружил, что запись с наследованием дает отличную гибкость при сохранении всех преимуществ структурного равенства.

Снимок состояния (Memento)



Паттерн Memento предназначен для сохранения и восстановления состояния объекта. С записями он становится тривиальным:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// Originator - объект, состояние которого мы хотим сохранять
public class DocumentEditor
{
    private string _text = "";
    private int _cursorPosition = 0;
    private List<string> _selections = new();
    
    // Состояние редактора в виде записи
    public record EditorState(string Text, int CursorPosition, List<string> Selections);
    
    // Сохранение состояния
    public EditorState CreateMemento() => 
        new(_text, _cursorPosition, new List<string>(_selections));
    
    // Восстановление из состояния
    public void RestoreFromMemento(EditorState state)
    {
        _text = state.Text;
        _cursorPosition = state.CursorPosition;
        _selections = new List<string>(state.Selections);
    }
}
 
// Caretaker - хранитель состояний
public class DocumentHistory
{
    private Stack<DocumentEditor.EditorState> _history = new();
    
    public void SaveState(DocumentEditor editor) => 
        _history.Push(editor.CreateMemento());
    
    public void Undo(DocumentEditor editor)
    {
        if (_history.Count > 0)
            editor.RestoreFromMemento(_history.Pop());
    }
}
В моем последнем проекте такой подход к Memento существенно упростил реализацию отмены/повтора действий в редакторе документов.

Result паттерн для обработки ошибок



Ещё один паттерн, который получил вторую жизнь с records - это Result Pattern. Вместо исключений он использует объекты для передачи результата операции:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public abstract record Result<T>
{
    private Result() { } // Запрещаем прямое создание
 
    public record Success(T Value) : Result<T>;
    public record Failure(string Error) : Result<T>;
 
    // Упрощение работы с результатами
    public TResult Match<TResult>(
        Func<T, TResult> onSuccess,
        Func<string, TResult> onFailure) =>
        this switch
        {
            Success s => onSuccess(s.Value),
            Failure f => onFailure(f.Error),
            _ => throw new InvalidOperationException("Invalid result state")
        };
}
 
// Использование
public Result<User> GetUserById(int id)
{
    var user = _repository.FindById(id);
    if (user == null)
        return new Result<User>.Failure($"User with ID {id} not found");
    return new Result<User>.Success(user);
}
 
// Обработка результата
GetUserById(123).Match(
    user => Console.WriteLine($"Found: {user.Name}"),
    error => Console.WriteLine($"Error: {error}")
);
Я стал большим фанатом этого паттерна, потому что он делает ошибки явными в сигнатуре метода и заставляет клиентский код обрабатывать их осознанно.

Подводные камни и ограничения



Не буду скрывать - record типы великолепны, но они не идеальное решение для всех сценариев. За время работы с ними я наступил на достаточно грабель, чтобы составить целый каталог подводных камней. И лучше вам узнать о них из моего опыта, чем набивать собственные шишки.

Проблемы наследования и полиморфизма



Наследование с record-типами работает, но со своими особенностями. Начнем с главного: запись может наследоваться только от другой записи или от object, но не от обычного класса:

C#
1
2
public class Base { } 
public record Derived : Base { } // Ошибка компиляции!
А вот что действительно сбивает с толку - особености взаимодействия наследования с позиционными записями:

C#
1
2
3
4
5
6
7
8
9
public record Person(string Name);
public record Employee(string Name, string Department) : Person(Name);
 
// А теперь попробуем:
Person person = new Employee("Иван", "ИТ");
var newPerson = person with { Name = "Петр" };
 
// Какой тип будет у newPerson? Person или Employee?
// И что случится с полем Department?
newPerson будет типа Person, а поле Department потеряется! Это происходит потому, что метод <Clone>$() возвращает объект того типа, для которого он вызван. Когда мы работаем через базовый тип, мы получаем и копию базового типа. Чтобы избежать таких ситуаций, я обычно избегаю глубоких иерархий наследования с записями и предпочитаю композицию:

C#
1
2
3
4
5
6
public record Person(string Name);
public record Employee(Person PersonInfo, string Department);
 
// Теперь без потери данных:
var employee = new Employee(new Person("Иван"), "ИТ");
var newEmployee = employee with { PersonInfo = employee.PersonInfo with { Name = "Петр" } };
Выглядит длиннее, но зато предсказуемо и надёжно.

Совместимость с Entity Framework



Если вы используете Entity Framework Core, то записи будут работать, но с оговорками. EF Core поддерживает записи начиная с версии 5.0, но есть несколько важных моментов:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
// С таким определением будут проблемы:
public record User(int Id, string Name, string Email);
 
// Для EF Core лучше использовать:
public record User
{
    public int Id { get; init; }
    public required string Name { get; init; }
    public required string Email { get; init; }
    
    // Необходим пустой конструктор для EF Core!
    public User() { }
}
Entity Framework требует пустого конструктора, что противоречит идеи записей с позиционными параметрами. Кроме того, EF Core плохо работает с with-выражениями при отслеживании изменений. В одном проекте я столкнулся с неочевидной проблемой: изменения, сделанные через with-выражения, не отслеживались EF Core, и мне пришлось явно указывать изменения:

C#
1
2
3
4
5
6
7
// Это НЕ работает с отслеживанием изменений EF Core
var updatedUser = user with { Email = "[email protected]" };
_context.SaveChanges(); // Изменения не сохранятся
 
// Приходится делать так:
_context.Entry(user).CurrentValues.SetValues(updatedUser);
_context.SaveChanges(); // Теперь изменения сохранятся
Из-за этих ограничений я обычно использую записи для DTO и бизнес-моделей, но для сущностей базы данных продолжаю использовать обычные классы.

Производительность с большими объектами



Структурное равенство - прекрасная вещь, но оно может стать бутылочным горлышком для производительности, если у вас запись с десятками свойств или вложенными коллекциями:

C#
1
2
3
4
5
6
7
8
public record ComplexEntity(
    Guid Id,
    string Name,
    string Description,
    List<Item> Items,
    Dictionary<string, string> Metadata,
    // ... и еще много свойств
);
Каждый раз при сравнении двух таких записей компилятор генерирует код, который рекурсивно сравнивает все свойства. Для больших объектов это может создать заметную нагрузку. В одном из моих проектов я обнаружил узкое место при использовании словаря с ключами-записями в высоконагруженном сервисе. Пришлось переопределить GetHashCode и Equals, чтобы учитывать только важные для бизнес-логики поля:

C#
1
2
3
4
5
6
7
8
9
10
11
12
public record CacheKey(int UserId, string Resource, DateTime Date)
{
    // Переопределяем, чтобы использовать только поля, важные для кеширования
    public virtual bool Equals(CacheKey? other)
    {
        if (other is null) return false;
        if (ReferenceEquals(this, other)) return true;
        return UserId == other.UserId && Resource == other.Resource; // Игнорируем Date
    }
 
    public override int GetHashCode() => HashCode.Combine(UserId, Resource);
}

Сложности с сериализацией



Не все сериализаторы одинаково хорошо работают с записями, особенно с позиционными параметрами. System.Text.Json научился работать с ними только начиная с .NET 6, и до сих пор требует особой настройки для некоторых сценариев:

C#
1
2
3
4
5
6
7
8
9
10
11
// Проблема с десериализацией без пустого конструктора
public record ApiResponse(bool Success, string Message, object? Data);
 
// JSON: {"Success":true,"Message":"OK","Data":null}
 
// Нужны либо пустой конструктор, либо специальная настройка JsonSerializer:
var options = new JsonSerializerOptions
{
    PropertyNameCaseInsensitive = true
};
var response = JsonSerializer.Deserialize<ApiResponse>(json, options);
С Newtonsoft.Json ситуация чуть лучше, но и там есть свои нюансы, особенно с наследованием и полиморфизмом.

Когда классы все-таки лучше



Несмотря на мою любовь к записям, есть сценарии, где обычные классы или структуры остаются предпочтительными:

1. Высоконагруженные циклы с частым созданием/удалением объектов. With-выражения создают новые объекты, что может привести к избыточной нагрузке на сборщик мусора. Для таких сценариев мутабельные структуры часто эффективнее.
2. Объекты с частично изменяемым состоянием. Если у вас есть объект, где большая часть свойств должна быть неизменяемой, но некоторые должны меняться, с записями это может быть неудобно.
3. Интеграция с API, требующими определенных паттернов. Некоторые библиотеки или фреймворки требуют определенных паттернов, которые трудно реализовать с записями.
4. Циклические зависимости между объектами. Из-за иммутабельности записей может быть сложно выразить циклические связи между объектами.

C#
1
2
3
4
5
6
7
8
9
// Проблема циклической зависимости с записями
public record Department(string Name, Employee? Manager);
public record Employee(string Name, Department Department);
 
// Как создать такие объекты, если они ссылаются друг на друга?
// С обычными классами это просто:
var dept = new Department { Name = "ИТ" };
var emp = new Employee { Name = "Иван", Department = dept };
dept.Manager = emp; // С записями так не получится!

Влияние на архитектуру приложения



Когда я начал активно использовать записи, я заметил, что они подталкивают к определенному архитектурному стилю - более функциональному, с явным разделением данных и поведения. Это не плохо, но может противоречить существующим паттернам вашего проекта. В частности, записи плохо сочетаются с традиционным подходом к ООП, где данные и поведение инкапсулированы в одном объекте. Вместо этого, записи склоняют к архитектуре, где данные представлены иммутабельными объектами, а поведение - отдельными функциями или сервисами.

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Традиционный ООП подход
public class Order
{
    public Guid Id { get; }
    public List<OrderItem> Items { get; private set; }
    public OrderStatus Status { get; private set; }
    
    public void AddItem(OrderItem item) {
        Items.Add(item);
        RecalculateTotal();
    }
    
    public void ChangeStatus(OrderStatus status) {
        // Логика проверки и изменения статуса
        Status = status;
    }
}
 
// Подход с записями
public record OrderData(Guid Id, IReadOnlyList<OrderItemData> Items, OrderStatus Status, decimal Total);
public record OrderItemData(Guid ProductId, int Quantity, decimal UnitPrice);
 
public class OrderService
{
    public OrderData AddItem(OrderData order, OrderItemData newItem)
    {
        var items = order.Items.ToList();
        items.Add(newItem);
        var total = CalculateTotal(items);
        return order with { Items = items, Total = total };
    }
    
    public OrderData ChangeStatus(OrderData order, OrderStatus newStatus)
    {
        // Логика проверки
        return order with { Status = newStatus };
    }
}
Этот сдвиг в архитектуре может быть полезным, но требует переосмысления дизайна приложения и может быть трудным для команд, глубоко погруженных в традиционный ООП.

Комплексный пример интеграции Record типов в реальное приложение



Чтобы завершить наше погружение в мир record типов, покажу реальный пример из своей практики. Недавно я реализовал систему обработки заказов для небольшого интернет-магазина, построенную целиком на записях. Начнем с доменной модели:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Доменные модели как record типы
public record ProductItem(Guid Id, string Name, decimal Price, string Category);
public record OrderItem(ProductItem Product, int Quantity)
{
    public decimal TotalPrice => Product.Price * Quantity;
}
public record Order(Guid Id, Guid CustomerId, List<OrderItem> Items, OrderStatus Status, DateTime CreatedAt)
{
    public decimal TotalAmount => Items.Sum(i => i.TotalPrice);
}
public enum OrderStatus { Created, Paid, Shipped, Delivered, Cancelled }
 
// DTO для API
public record ProductDto(Guid Id, string Name, decimal Price, string Category);
public record OrderItemDto(Guid ProductId, string ProductName, decimal UnitPrice, int Quantity, decimal TotalPrice);
public record OrderDto(Guid Id, Guid CustomerId, List<OrderItemDto> Items, OrderStatus Status, DateTime CreatedAt, decimal TotalAmount);
Для обработки заказов я создал сервис:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class OrderService
{
    private readonly IRepository<Order> _orderRepo;
    private readonly IRepository<ProductItem> _productRepo;
    
    // Добавление товара в заказ
    public async Task<Order> AddItemToOrderAsync(Guid orderId, Guid productId, int quantity)
    {
        var order = await _orderRepo.GetByIdAsync(orderId);
        var product = await _productRepo.GetByIdAsync(productId);
        
        // Если товар уже в заказе - увеличиваем количество
        var existingItem = order.Items.FirstOrDefault(i => i.Product.Id == productId);
        if (existingItem != null)
        {
            var newItems = order.Items.Select(item => 
                item.Product.Id == productId 
                    ? existingItem with { Quantity = existingItem.Quantity + quantity }
                    : item
            ).ToList();
            
            var updatedOrder = order with { Items = newItems };
            return await _orderRepo.UpdateAsync(updatedOrder);
        }
        
        // Иначе добавляем новый товар
        var orderItem = new OrderItem(product, quantity);
        var items = new List<OrderItem>(order.Items) { orderItem };
        var newOrder = order with { Items = items };
        
        return await _orderRepo.UpdateAsync(newOrder);
    }
    
    // Изменение статуса заказа
    public async Task<Order> ChangeOrderStatusAsync(Guid orderId, OrderStatus newStatus)
    {
        var order = await _orderRepo.GetByIdAsync(orderId);
        var updatedOrder = order with { Status = newStatus };
        return await _orderRepo.UpdateAsync(updatedOrder);
    }
}
Для API уровня я реализовал контроллер:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    private readonly OrderService _orderService;
    
    [HttpGet("{id}")]
    public async Task<ActionResult<OrderDto>> GetOrder(Guid id)
    {
        var order = await _orderService.GetOrderAsync(id);
        return Ok(MapToDto(order));
    }
    
    [HttpPost("{id}/items")]
    public async Task<ActionResult<OrderDto>> AddOrderItem(Guid id, [FromBody] AddItemRequest request)
    {
        var order = await _orderService.AddItemToOrderAsync(id, request.ProductId, request.Quantity);
        return Ok(MapToDto(order));
    }
    
    [HttpPut("{id}/status")]
    public async Task<ActionResult<OrderDto>> UpdateStatus(Guid id, [FromBody] UpdateStatusRequest request)
    {
        var order = await _orderService.ChangeOrderStatusAsync(id, request.Status);
        return Ok(MapToDto(order));
    }
    
    // Маппинг от доменной модели к DTO
    private OrderDto MapToDto(Order order) => new(
        order.Id,
        order.CustomerId,
        order.Items.Select(i => new OrderItemDto(
            i.Product.Id,
            i.Product.Name,
            i.Product.Price,
            i.Quantity,
            i.TotalPrice
        )).ToList(),
        order.Status,
        order.CreatedAt,
        order.TotalAmount
    );
}
 
// Модели запросов
public record AddItemRequest(Guid ProductId, int Quantity);
public record UpdateStatusRequest(OrderStatus Status);
Внедрение записей в эту систему дало несколько осязаемых преимуществ:
1. Имутабельность сделала многопоточную обработку заказов безопасной без дополнительной синхронизации,
2. With-выражения существенно упростили обновление состояния заказов,
3. Маппинг между моделями стал более чистым и предсказуемым,
4. Дебаг стал проще благодаря понятному ToString() и структурному равенству,
Этот подход прошол боевое крещение на пиковых нагрузках в "черную пятницу", и ни разу не подвел - все данные оставались консистентными, а код не требовал синхронизации доступа.

"Master Record missing"
всем привет. Вот решил создат базу данных. Учусь по книге Архангельского. Там был пример и я его...

Active Record нужна помощь
Нужна идея как популировать гридвью спомощью AR Для топо чтоб сделать сортирги и все что позволяют...

[Linker Error] 'E:\Programming\C++\BASS.DLL\BASS.LIB' contains invalid OMF record, type 0x21 (possibly COFF)
Народ, подскажите пожалуйста, из-за чего ошибку билдер выбивает?? ...

Аналог типа Record в С#
Здравствуйте, я вот потихоньку изучаю язык С# и параллельно экспериментирую с кодом. Но вот...

Как получить Record.count в конструкции вида: Set conn = Server.CreateObject('ADODB.Connection')SQL = 'SELECT * FROM tbl'
Подскажите как получить Record.count в конструкции вида: Set conn =...

Ошибка ADODB.Field error '800a0bcd' Either BOF or EOF is True, or the current record has been deleted. Requested operation requires a current recor
Имею скрипт Set dbo = Server.CreateObject('ADODB.Connection') dbo.Open 'PEN1' Title =...

Как подавить вывод на экран предупреждения - Either BOF or EOF is True, or the current record has been deleted... ?
Как подавить вывод на экран предупреждения - Either BOF or EOF is True, or the current record has...

ADODB.Field error '800a0bcd' Either BOF or EOF is True, or the current record has been deleted; the operation requested by the application requires
вываливается ошибка: ADODB.Field error '800a0bcd' Either BOF or EOF is True, or the current...

Проблема с insert record
Привет всем! У меня вот какая проблема: нужно добавить запись в БД acces. Создаю asp javascript...

Ошика Linker Error contains invalid OMF record, type 0x21 (possibly COFF)
Ошибка contains invalid OMF record, type 0x21 (possibly COFF) Как в билдере .lib подключить? Во...

Обращение к данным в БД. Ошибка: Объект не является ни ADODB.RecordSet, ни ADODB.Record
при созданиие приложения в коде у меня возникла ошибка подскажите суть проблемы ...

Что за ошибка Record was changed by another user?
Добрый день, Такая проблемка, После сохранения SimpleTab (TUniQuery) В действии afterPost...

Надоела реклама? Зарегистрируйтесь и она исчезнет полностью.
Всего комментариев 0
Комментарии
 
Новые блоги и статьи
Angular: Вопросы и ответы на собеседовании
Reangularity 15.06.2025
Готовишься к техническому интервью по Angular? Я собрал самые распространенные вопросы, с которыми сталкиваются разработчики на собеседованиях в этом году. От базовых концепций до продвинутых. . .
Архитектура Onion в ASP.NET Core MVC
stackOverflow 15.06.2025
Что такое эта "луковая" архитектура? Термин предложил Джеффри Палермо (Jeffrey Palermo) в 2008 году, и с тех пор подход только набирал обороты. Суть проста - представьте себе лук с его. . .
Unity 4D
GameUnited 13.06.2025
Четырехмерное пространство. . . Звучит как что-то из научной фантастики, правда? Однако для меня, как разработчика со стажем в игровой индустрии, четвертое измерение давно перестало быть абстракцией из. . .
SSE (Server-Sent Events) в ASP.NET Core и .NET 10
UnmanagedCoder 13.06.2025
Кажется, Microsoft снова подкинула нам интересную фичу в новой версии фреймворка. Работая с превью . NET 10, я наткнулся на нативную поддержку Server-Sent Events (SSE) в ASP. NET Core Minimal APIs. Эта. . .
С днём независимости России!
Hrethgir 13.06.2025
Решил побеседовать, с утра праздничного дня, с LM о завоеваниях. То что она написала о народе, представителем которого я являюсь сам сначала возмутило меня, но дальше только смешило. Это чисто. . .
Лето вокруг.
kumehtar 13.06.2025
Лето вокруг. Наполненное бурями и ураганами событий. На фоне магии Жизни, священной и вечной, неумелой рукой человека рисуется панорама душевного непокоя. Странные серые краски проникают и. . .
Популярные LM модели ориентированы на увеличение затрат ресурсов пользователями сгенерированного кода (грязь -заслуги чистоплюев).
Hrethgir 12.06.2025
Вообще обратил внимание, что они генерируют код (впрочем так-же ориентированы разработчики чипов даже), чтобы пользователь их использующий уходил в тот или иной убыток. Это достаточно опытные модели,. . .
Топ10 библиотек C для квантовых вычислений
bytestream 12.06.2025
Квантовые вычисления - это та область, где теория встречается с практикой на границе наших знаний о физике. Пока большая часть шума вокруг квантовых компьютеров крутится вокруг языков высокого уровня. . .
Dispose и Finalize в C#
stackOverflow 12.06.2025
Работая с C# больше десяти лет, я снова и снова наблюдаю одну и ту же историю: разработчики наивно полагаются на сборщик мусора, как на волшебную палочку, которая решит все проблемы с памятью. Да,. . .
Повышаем производительность игры на Unity 6 с GPU Resident Drawer
GameUnited 11.06.2025
Недавно копался в новых фичах Unity 6 и наткнулся на GPU Resident Drawer - штуку, которая заставила меня присвистнуть от удивления. По сути, это внутренний механизм рендеринга, который автоматически. . .
КиберФорум - форум программистов, компьютерный форум, программирование
Powered by vBulletin
Copyright ©2000 - 2025, CyberForum.ru
OSZAR »