Menu

Записи record в C#: равенство по значению, выражения with и record struct

Что такое записи в C# (C# 9 и новее): позиционный синтаксис, члены, которые генерирует компилятор, равенство по значению, неразрушающие копии через with, встроенный ToString, record struct из C# 10, наследование между записями и эквивалентный класс, написанный вручную.

На этой странице есть исполняемые редакторы: меняйте, запускайте и сразу видите результат.

Запись (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 мешают отслеживанию изменений.

Coddy programming languages illustration

Учитесь программировать с Coddy

НАЧАТЬ