Давайте представим, что вам нужно создать класс для хранения данных пользователя. Сколько строк кода вы напишете? Конструктор, свойства, метод ToString(), сравнение объектов... А если добавится новое поле? Придется обновлять конструктор, метод Equals, GetHashCode — утомительная работа, которая не добавляет бизнес‑ценности вашему приложению.

Records — это один из самых мощных и недооцененных новичками инструментов в C#. Он автоматически генерирует за вас тонны бойлерплейт‑кода (это повторяющийся программный код, который программисту приходится писать из‑за требований языка программирования, операционной системы, библиотеки подпрограмм и прочего), который раньше приходилось писать вручную. С приходом C# 9 и C# 10 работа с данными (DTO, Value Objects, модели) стала на порядок проще и безопаснее.

В этой статье мы разберем, как records меняют подход к написанию кода, и почему они должны стать вашим стандартным выбором для представления данных.

Чем record class отличается от class?

Начнем с самого простого примера. Вот как выглядит обычный класс:

// Старый подход: много кода для простых данных

public class User
{
    public string FirstName { get; init; }
    public string LastName { get; init; }
    public int Age { get; init; }

    public User(string firstName, string lastName, int age)
    {
        FirstName = firstName;
        LastName = lastName;
        Age = age;
    }
}

А вот как это же выглядит с record:

// Новый подход: одна строка!
public record User(string FirstName, string LastName, int Age);

Вот и все! Компилятор автоматически создает все необходимое: конструктор с параметрами, свойства FirstName, LastName, Age и многое другое. Как видно, удобство на лицо. Теперь давайте разберемся с тем, как все это работает.

Семантика

Начнем с того, что класс — это ссылочный тип с семантикой идентичности. Два объекта класса считаются равными, если это один и тот же объект в памяти (или если вы переопределили Equals).

Record — это ссылочный тип с семантикой значения. Два record‑объекта считаются равными, если их данные совпадают.

Посмотрим пример:

var user1 = new User("Иван", "Петров", 30);
var user2 = new User("Иван", "Петров", 30);

Console.WriteLine(user1 == user2); // True! (даже если это разные объекты)
Console.WriteLine(ReferenceEquals(user1, user2)); // False

Теперь давайте посмотрим на примере консольного приложения, что на самом деле генерирует компилятор по шагам.

// Определяем record

public record Person(string Name, string Email, int Age);

class Program
{
    static void Main()
    {
        // Создаем экземпляры
        var person1 = new Person("Анна Смирнова", "anna@example.com", 28);
        var person2 = new Person("Анна Смирнова", "anna@example.com", 28);
        var person3 = new Person("Иван Петров", "ivan@example.com", 35);

        // 1. Автоматический ToString()
        Console.WriteLine("=== ToString() ===");
        Console.WriteLine(person1);
        // Вывод: Person { Name = Анна Смирнова, Email = anna@example.com, Age = 28 }   

        // 2. Сравнение по значениям
        Console.WriteLine("\n=== Сравнение ===");
        Console.WriteLine($"person1 == person2: {person1 == person2}"); // True
        Console.WriteLine($"person1 == person3: {person1 == person3}"); // False        

        // 3. Автоматическая реализация IEquatable<T>
        Console.WriteLine($"\n=== Equals ===");
        Console.WriteLine($"person1.Equals(person2): {person1.Equals(person2)}"); // True

        // 4. Получение хеш-кода на основе данных
        Console.WriteLine($"\n=== GetHashCode() ===");
        Console.WriteLine($"person1 Hash: {person1.GetHashCode()}");
        Console.WriteLine($"person2 Hash: {person2.GetHashCode()}");
        Console.WriteLine($"person3 Hash: {person3.GetHashCode()}");

        // 5. Использование в коллекциях (работает как Value Object)

        Console.WriteLine($"\n=== Работа со словарем ===");
        var dictionary = new Dictionary<Person, string>();
        dictionary[person1] = "Первый пользователь";

        // Поиск по значению, а не по ссылке!
        Console.WriteLine($"Поиск person2: {dictionary[person2]}"); // "Первый пользователь"

        // 6. Деконструкция (разбор на составляющие)
        Console.WriteLine($"\n=== Деконструкция ===");
        var (name, email, age) = person1;
        Console.WriteLine($"Имя: {name}, Email: {email}, Возраст: {age}");
    }
}

Запустите этот код и вы увидите, как много функциональности работает «из коробки» без единой дополнительной строки кода.

Иммутабельное копирование

Records по умолчанию иммутабельны, то есть неизменяемы. Это огромное преимущество для многопоточных приложений и для предотвращения случайных изменений данных. Но как создать копию с небольшими изменениями? Для этого существуют with‑выражения.

public record Person(string Name, string Email, int Age);

class Program
{
    static void Main()
    {
        var original = new Person("Анна Смирнова", "anna@example.com", 28);
        // Создаем копию с новым возрастом
        var older = original with { Age = 30 };

        // Создаем копию с новым именем и email
        var renamed = original with { Name = "Анна Петрова", Email = "anna.petrova@example.com" };

        Console.WriteLine($"Оригинал: {original}");
        Console.WriteLine($"Старше: {older}");
        Console.WriteLine($"Переименован: {renamed}");
        // Исходный объект не изменился!
        Console.WriteLine($"\nОригинал не изменился: {original}");
    }
}

При этом важно понимать, что with‑выражения создают глубокую копию только для свойств значимых типов и record‑типов. Для обычных классов (ссылочных типов) копируется только ссылка.

Посмотрим, как это работает с вложенными records.

public record Address(string City, string Street);
public record Employee(string Name, Address Address);

class Program
{
    static void Main()
    {
        var original = new Employee(
            "Иван", 
            new Address("Москва", "Тверская")
        );

        // Меняем только улицу во вложенном объекте
        var updated = original with
        {
            Address = original.Address with { Street = "Новый Арбат" }
        };

        Console.WriteLine($"Оригинал: {original}");
        // Employee { Name = Иван, Address = Address { City = Москва, Street = Тверская } }

        Console.WriteLine($"Обновлен: {updated}");
        // Employee { Name = Иван, Address = Address { City = Москва, Street = Новый Арбат } }
    }
}

Record struct вместо класса

C# 10 добавил возможность создавать records как структуры. Это важно для сценариев, где критична производительность и где объекты маленькие и создаются часто.

Вот пример record struct

// Record struct — значимый тип
public record struct Point(int X, int Y);

// Record class — ссылочный тип
public record class Rectangle(Point TopLeft, Point BottomRight);

class Program
{
    static void Main()
    {
        // Record struct копируется по значению
        var point1 = new Point(10, 20);
        var point2 = point1; // Создается копия!

        point2 = point2 with { X = 30 };
        Console.WriteLine($"Point1: {point1}"); // Point { X = 10, Y = 20 }
        Console.WriteLine($"Point2: {point2}"); // Point { X = 30, Y = 20 }

        // Record class копируется по ссылке
        var rect1 = new Rectangle(new Point(0, 0), new Point(100, 100));
        var rect2 = rect1; // Копируется ссылка, объект один
        Console.WriteLine($"\nrect1 == rect2: {rect1 == rect2}"); // True (один объект)
        Console.WriteLine($"ReferenceEquals(rect1, rect2): {ReferenceEquals(rect1, rect2)}"); // True
    }
}

Соответственно, возникает вопрос, когда что использовать? Используйте record class, когда создаете DTO для передачи данных между слоями, работаете с ORM (Object‑Relational Mapping), вам нужно наследование или объекты относительно крупные.

А record struct используйте, когда объект маленький (до 16–24 байт), создается много экземпляров (сотни тысяч), нужна максимальная производительность или объект представляет простую пару координат, диапазон и так далее

Расширенное использование: позиционные vs непозиционные records

Теперь давайте разберемся с еще одним важным моментом: позиционными и непозиционными records. Вот пример позиционного record с параметрами конструктора:

// Все свойства генерируются автоматически

public record Product(string Name, decimal Price, int Quantity);

А непозиционный record с явным определением свойств будет иметь следующий вид:

// Более гибкий вариант
public record Order
{
    public int Id { get; init; }
    public DateTime CreatedAt { get; init; }
    public List<Product> Items { get; init; } = new();

    // Можно добавлять свои методы
    public decimal GetTotal() => Items.Sum(p => p.Price * p.Quantity);

    // Можно переопределять стандартные методы
    public override string ToString() => $"Order #{Id} from {CreatedAt:d}";
}

Таким образом, позиционный record это лаконичный способ объявления, где компилятор автоматически генерирует первичный конструктор, свойства init, методы ToString(), Equals(), GetHashCode() и деконструкцию — идеально подходит для простых DTO и Value Objects с обязательными полями, где важна минимальная длина кода и максимальная производительность.

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

Также, можно использовать смешанный подход, как в примере ниже:

// Комбинируем позиционный синтаксис с дополнительными свойствами

public record Employee(
    string Name,
    string Position
)

{
    public DateTime HireDate { get; init; }
    public decimal Salary { get; init; }
    public int YearsOfService() => DateTime.Now.Year - HireDate.Year;
}

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

Таким образом, выбор зависит от сценария: позиционные records — для 80% случаев работы с данными, непозиционные — когда нужна гибкость инициализации или сложная логика.

Полный рабочий пример: Система управления заказами

В завершении статьи давайте соберем все вместе в реальном сценарии. Напишем приложение для системы управления заказами.

using System;
using System.Collections.Generic;
using System.Linq;

// Базовый record для человека
public record Person(string Name, string Email);

// Наследование record class

public record Customer(string Name, string Email, string CustomerLevel) 
    : Person(Name, Email);

// Record struct для адреса (маленький, часто создается)
public record struct Address(string City, string Street, string Building);

// Record для товара
public record Product(string Id, string Name, decimal Price); 

// Record для позиции заказа

public record OrderItem(Product Product, int Quantity)
{
    public decimal Total => Product.Price * Quantity;
}

// Record для заказа

public record Order(
    int Id,
    Customer Customer,
    Address ShippingAddress,
    List<OrderItem> Items,
    DateTime CreatedAt
)

{
    public decimal Total => Items.Sum(i => i.Total);
    public string Status { get; init; } = "New";

    public override string ToString() => 
        $"Order #{Id} for {Customer.Name} - Total: {Total:C} ({Status})";
}

class Program
{
    static void Main()
    {
        // Создаем продукты
        var laptop = new Product("L001", "Ноутбук", 75000m);
        var mouse = new Product("M001", "Мышь", 1500m);

        // Создаем клиента
        var customer = new Customer("Алексей Иванов", "alex@example.com", "Gold");

        // Создаем адрес
        var address = new Address("Москва", "Тверская", "15");

        // Создаем заказ
        var order = new Order(
            Id: 1001,
            Customer: customer,
            ShippingAddress: address,
            Items: new List<OrderItem>
            {
                new(laptop, 1),
                new(mouse, 2)
            },
            CreatedAt: DateTime.Now
        );

        Console.WriteLine("=== Исходный заказ ===");
        Console.WriteLine(order);
        Console.WriteLine($"Количество позиций: {order.Items.Count}");
        Console.WriteLine($"Сумма: {order.Total:C}");

        // Иммутабельное изменение статуса
        var updatedOrder = order with { Status = "Processing" };

        Console.WriteLine("\n=== Обновленный заказ ===");
        Console.WriteLine(updatedOrder);

        // Проверка иммутабельности
        Console.WriteLine("\n=== Исходный заказ не изменился ===");
        Console.WriteLine(order);

        // Сравнение заказов
        var duplicateOrder = order with { Id = 1002 };
        Console.WriteLine($"\n=== Сравнение ===");
        Console.WriteLine($"order == duplicateOrder: {order == duplicateOrder}"); // False (разные Id)
        Console.WriteLine($"order.GetHashCode(): {order.GetHashCode()}");
        Console.WriteLine($"duplicateOrder.GetHashCode(): {duplicateOrder.GetHashCode()}");

        // Использование в словаре
        var orderDictionary = new Dictionary<Order, string>();
        orderDictionary[order] = "Активный заказ";

        // Поиск по значению
        var searchOrder = order with { }; // Создаем копию
        Console.WriteLine($"\nПоиск по копии: {orderDictionary[searchOrder]}"); // Работает!

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

        var (id, cust, shipAddr, items, date) = order;
        Console.WriteLine($"\nДеконструкция: Заказ #{id} для {cust.Name}");

        // Работа с вложенными records и with
        var newAddress = address with { Building = "20", Street = "Петровка" };
        var orderWithNewAddress = order with { ShippingAddress = newAddress };

        Console.WriteLine($"\n=== Новый адрес доставки ===");
        Console.WriteLine(orderWithNewAddress.ShippingAddress);
    }
}

Запустите этот код и проанализируйте вывод. Вы увидите, как records делают код чистым, безопасным и лаконичным.

Заключение

В этой статье мы рассмотрели использование Records в C#. Важно понимать, что records это не просто «синтаксический сахар», а это фундаментальное изменение подхода к работе с данными в C#. С помощью records мы можем устранить тонны бойлерплейт‑кода, а также сделать код более читаемым и поддерживаемым. Помимо этого, данный инструмент позволяет предотвратить целый класс ошибок благодаря иммутабельности.

В итоге, если вы до сих пор пишете обычные классы для DTO и моделей данных — самое время перейти на records. Ваш код станет короче, чище и безопаснее.

Освоить C# с нуля проще, когда обучение выстроено последовательно, а темы не разваливаются на отдельные куски. Курс «C#-разработчик. Базовый уровень» помогает собрать прочную базу, разобраться в логике языка и перейти от знакомства с синтаксисом к нормальной практике разработки.льно можно опираться в работе.

Задача не просто выбрать курс, а войти в обучение в момент, когда это и полезно, и финансово разумно. 30–31 марта в честь дня рождения OTUS это можно сделать выгоднее: действует дополнительная скидка 10% по промокоду birthday на любые курсы, и она суммируется с другими скидками. Так что тем, кто давно планировал покупку, лучше не переносить решение. ☛ [Забрать курс со скидкой]

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

13 апреля в 20:00 — «Как работают структуры данных C# под капотом».
☛ [На урок по структурам данных C#]

21 апреля в 20:00 — «Производительность кода на примере алгоритмов сортировки».
☛ [На урок по алгоритмам сортировки]