Запись (record) это тип, главная задача которого хранить данные и равенство которого определяется этими данными. Записи появились в C# 9. Вы пишете одну строку, а компилятор генерирует члены, нужные классу данных: свойства, конструктор, Equals и == по значению, GetHashCode, читаемый ToString, Deconstruct и поддержку копий через with.
Записям нужен C# 9 или новее (.NET 5+), поэтому код с записями на этой странице показан как обычный C# с выводом в комментариях. В последнем разделе те же члены написаны вручную на C# 7, и этот код можно запустить.
Позиционные записи
Самая короткая форма перечисляет свойства в скобках после имени. Каждый параметр становится открытым свойством только для инициализации с тем же именем:
public record Product(string Sku, string Name, decimal Price);
var mug = new Product("MUG-01", "Mug", 8.50m);
Console.WriteLine(mug.Name); // Mug
Console.WriteLine(mug); // Product { Sku = MUG-01, Name = Mug, Price = 8.50 }
// mug.Price = 4m; // error CS8852: init-only property
var (sku, name, price) = mug; // generated Deconstruct
Console.WriteLine($"{sku} {price}"); // MUG-01 8.50
Из этой одной строки компилятор генерирует:
- конструктор, принимающий
(string Sku, string Name, decimal Price); - три свойства
public ... { get; init; }; Equals(object),Equals(Product),GetHashCode()и операторы==и!=, которые сравнивают все три свойства;ToString(), печатающий имя типа и каждое открытое свойство;Deconstruct(out string Sku, out string Name, out decimal Price);- конструктор копирования (защищённый, а в запечатанной записи закрытый) и скрытый метод клонирования, который использует
with.
Запись можно написать и с обычным телом, что удобно, когда свойствам нужны значения по умолчанию или проверка:
public record Customer
{
public required string Email { get; init; } // required: C# 11
public string Name { get; init; } = "";
}
var c = new Customer { Email = "ana@example.com" };
А обе формы можно сочетать: позиционные параметры плюс дополнительные члены в фигурных скобках.
public record Order(string Id, decimal Subtotal)
{
public decimal Tax => Subtotal * 0.23m;
public decimal Total => Subtotal + Tax;
}
Равенство по значению
Для обычного класса == спрашивает «это один и тот же объект?». Для записи он спрашивает «у них одинаковые значения?»:
var a = new Product("MUG-01", "Mug", 8.50m);
var b = new Product("MUG-01", "Mug", 8.50m);
Console.WriteLine(a == b); // True
Console.WriteLine(a.Equals(b)); // True
Console.WriteLine(ReferenceEquals(a, b)); // False: still two objects
GetHashCode генерируется согласованно, поэтому записи правильно работают как ключи словаря и в HashSet<T>: вторая запись с теми же значениями находит элемент первой.
Равенство сравнивает каждое поле (для позиционных записей поле за каждым свойством) через EqualityComparer<T>.Default, который вызывает собственный Equals типа. Для свойства-коллекции это равенство ссылок, что удивляет людей:
public record Basket(string Owner, List<string> Items);
var x = new Basket("Ana", new List<string> { "tea" });
var y = new Basket("Ana", new List<string> { "tea" });
Console.WriteLine(x == y); // False: two different List objects
Если запись хранит коллекцию и должна сравниваться по её содержимому, переопределите Equals(Basket other) и GetHashCode() или используйте неизменяемую коллекцию с собственной семантикой значений.
Выражения with: неразрушающие изменения
Записи обычно неизменяемы, поэтому «изменить» запись значит создать изменённую копию. with копирует все свойства, затем применяет присваивания в фигурных скобках:
var mug = new Product("MUG-01", "Mug", 8.50m);
var sale = mug with { Price = 6.00m };
Console.WriteLine(sale); // Product { Sku = MUG-01, Name = Mug, Price = 6.00 }
Console.WriteLine(mug.Price); // 8.50: the original is untouched
Копия поверхностная. Свойство ссылочного типа копируется как ссылка, поэтому обе записи разделяют объект:
public record Customer { public List<string> Tags { get; init; } = new(); /* ... */ }
var c1 = new Customer { Email = "ana@example.com", Tags = { "vip" } };
var c2 = c1 with { Name = "Ana" };
c2.Tags.Add("newsletter");
Console.WriteLine(string.Join(",", c1.Tags)); // vip,newsletter
Либо держите свойства записи неизменяемыми до самого дна (IReadOnlyList<T>, заполненный один раз, или ImmutableList<T>), либо создавайте новый список в with: c1 with { Tags = new List<string>(c1.Tags) }.
ToString
Сгенерированный ToString печатает имя типа и каждое открытое свойство, поэтому с записями приятно работать в логах и отладчике:
Console.WriteLine(new Product("MUG-01", "Mug", 8.50m));
// Product { Sku = MUG-01, Name = Mug, Price = 8.50 }
Коллекции печатаются как имя их типа (System.Collections.Generic.List`1[System.String]), а вложенные записи печатаются рекурсивно. Весь вывод можно заменить, переопределив ToString:
public record Money(decimal Amount, string Currency)
{
public override string ToString() => $"{Amount:F2} {Currency}";
}
record struct (C# 10)
record сам по себе означает record class: ссылочный тип. C# 10 добавил record struct, тип значения с теми же сгенерированными членами:
public readonly record struct Point(int X, int Y);
var p = new Point(3, 4);
var q = p with { Y = 10 };
Console.WriteLine(p == new Point(3, 4)); // True
Console.WriteLine(q); // Point { X = 3, Y = 10 }
Различие в значениях по умолчанию стоит запомнить: у позиционного record struct свойства изменяемые ({ get; set; }), как обычно у структур, а у readonly record struct и record class они только для инициализации. Выбирайте между ними так же, как между структурой и классом: небольшим значениям, которые свободно копируются, подходит readonly record struct; всему остальному record.
Наследование
Запись может наследоваться от другой записи (но не от класса, и класс не может наследоваться от записи). Позиционные параметры передаются базовой записи как аргументы конструктора:
public abstract record Shape(string Color);
public record Circle(string Color, double Radius) : Shape(Color);
public record Square(string Color, double Side) : Shape(Color);
Shape a = new Circle("red", 2);
Shape b = new Circle("red", 2);
Shape c = new Square("red", 2);
Console.WriteLine(a == b); // True
Console.WriteLine(a == c); // False: different runtime types are never equal
Console.WriteLine(a); // Circle { Color = red, Radius = 2 }
Равенство учитывает тип во время выполнения через сгенерированное свойство EqualityContract. Поэтому Circle никогда не равен Square с тем же Color, хотя оба сравниваются через Shape, и поэтому ToString и with работают с производным типом, даже когда переменная имеет базовый тип.
То же самое на C# 7: класс с равенством по значению
Записи генерируют код, который можно написать самому, и если его увидеть, становится понятно их поведение. Вот класс на C# 7, эквивалентный public record Point(int X, int Y);: свойства только для чтения, конструктор, Deconstruct, равенство по значению, согласованный хеш-код, ==, ToString и метод With, заменяющий выражение with.
Вывод:
True
False
Point { X = 3, Y = 10 }
Point { X = 3, Y = 4 }
x=3, y=10
True
False
Около 30 строк на два свойства, и каждое новое свойство требует снова трогать конструктор, Deconstruct, Equals, GetHashCode и ToString. Забыть один из них это классическая ошибка (две точки, которые равны по ==, но имеют разный хеш, поэтому HashSet теряет их). Именно это сопровождение и убирают записи.
Класс намеренно sealed: равенство по значению вместе с наследованием требует дополнительной проверки типа, которую записи генерируют через EqualityContract, а запечатывание обходит эту проблему.
Когда использовать записи
Записи подходят для данных, которые определяются своими значениями и не меняются после создания:
- модели запросов и ответов веб-API;
- сообщения, команды и события, передаваемые между частями системы;
- объекты конфигурации и настроек;
- составные ключи словарей (
record CacheKey(string Region, int Year)); - результаты вычислений (
record PriceQuote(decimal Net, decimal Tax)).
Они плохо подходят там, где идентичность важнее значений: сущность Entity Framework это «клиент 42» даже после смены имени, а отслеживание изменений в EF Core опирается на идентичность ссылок. Там используйте класс.
Частые ошибки
- Ожидание глубокого равенства для коллекций. Свойство
List<T>сравнивается по ссылке. Две записи с одинаковыми на вид списками не равны. - Ожидание, что
withделает глубокую копию. Вложенные объекты и коллекции общие для оригинала и копии. - Случайно изменяемый позиционный
record struct. Добавьтеreadonly, если не нужны изменяемые свойства. - Записи в роли сущностей EF Core. Равенство по значению и копирование конфликтуют с отслеживанием изменений.
- Добавление записи в проект на C# 8. Записям нужен C# 9 (значение по умолчанию для .NET 5 и новее). Для старых целевых платформ пишите класс вручную, как показано выше.
Часто задаваемые вопросы
Что такое запись (record) в C#?
Запись (C# 9) это класс, а в случае record struct (C# 10) структура, для которой компилятор генерирует равенство по значению, читаемый ToString(), метод Deconstruct и поддержку копий через with. public record Product(string Sku, decimal Price); это полноценный тип с двумя свойствами только для инициализации. Две записи с равными значениями свойств равны, хотя это разные объекты.
Чем запись отличается от класса в C#?
record внутри это класс, поэтому он ссылочный тип и может наследоваться от других записей. Различия в сгенерированных членах: записи сравниваются по значению (== и Equals проверяют каждое поле), печатают свои свойства из ToString() и поддерживают with. Обычный класс сравнивается по ссылке и печатает имя своего типа, если вы сами не напишете эти члены.
Что делает выражение with в C#?
var sale = product with { Price = 6.00m }; создаёт новую запись, которая копирует все свойства product, а затем задаёт перечисленные. Оригинал не меняется. Копия поверхностная: свойство List<T> общее для обеих записей, поэтому добавление через одну видно через другую.
Что такое record struct в C#?
record struct (C# 10) это тип значения с теми же сгенерированными членами, что и у класса-записи: равенство по значению, ToString, Deconstruct и with. В отличие от класса-записи, его позиционные свойства по умолчанию изменяемые; объявите его как readonly record struct, чтобы сделать их доступными только для инициализации. Используйте его для небольших значений вроде координат или денежных сумм.
Когда использовать записи в C#?
Для данных, идентичность которых определяется их значениями: DTO, модели запросов и ответов API, сообщения и события, конфигурация и ключи словарей. Избегайте записей для сущностей, которые меняются со временем и идентифицируются по id, например сущностей Entity Framework, потому что равенство по значению и копии через with мешают отслеживанию изменений.