В C# 9 появился новый тип – записи (record). Рассмотрим для чего они нужны, чем отличается от других ссылочных (reference) и значимых (value) типов. |
Минимальная платформа с полной поддержкой: .NET 5
Возможность использования на предыдущих платформах: для компактной формы объявления записи требуется поддержка метода init. Объявления записей без свойств с методом доступа init можно использовать на любых платформах.
Что такое запись
Запись это новый ссылочный тип (reference type), который:
- автоматически поддерживает сравнение экземпляров по значениям их свойств.
- по умолчанию является неизменяемым (immutable). Для модификации значения любого свойства необходимо создать копию исходного экземпляра (с помощью специального выражения with).
- позволяет использовать очень компактную форму определения типа.
Синтаксис
Для объявления записи используется ключевое слово record. При этом само объявление может быть записано по разному. Начнём с компактной формы:
[public | internal] record [имя типа]([список свойств]);
Например:
public record User (int Id, string Name);
В результате у записи User будет два неизменяемых свойства: Id и Name, для каждого из которых будут определены методы доступа init и get.
Кроме того, для каждого типа записи компилятор автоматически добавляет следующий стандартный набор конструкторов и методов:
- наследование от интерфейса IEquatable<User> и его реализацию;
- параметризированный конструктор для инициализации всех свойств записи;
- защищенный (protected) конструктор копирования;
- операции сравнения == и !=;
- методы Equals(…) и GetHashCode();
- метод ToString(), который возвращает имя типа записи и содержимое записи в формате JSON;
- деконструктор объекта;
- специальные методы и свойства, доступные только компилятору:
- метод <Clone>$() для поддержки выражения with.
- свойство Type EqualityContract, содержащее тип записи и используемое при сравнении и вычислении хэш-кода экземпляра.
Для приведенной в качестве примера записи User будет сгенерирован код, аналогичный следующему определению класса:
public class User : IEquatable<User>
{
// Свойства
public int Id { get; init; }
public string Name { get; init; }
// Тип записи. Используется для сравнения и вычисления хеш-кода.
private Type EqualityContract => typeof(User);
// Параметризированный конструктор
public User(int id, string name)
{
Id = id;
Name = name;
}
// Деконструктор
public void Deconstruct(out int Id, out string Name) { … }
// Реализация IEquatable<T>
public bool Equals(User? other) { … }
// Методы
public override bool Equals(object? obj) { … }
public override int GetHashCode() { … }
public override string ToString() { … }
// Операции сравнения
public static bool operator ==(User a, User b) => a.Equals(b);
public static bool operator !=(User a, User b) => !a.Equals(b);
// Конструктор копирования
protected User(User original) { … }
// Поддержка выражения with
public virtual User <Clone>$() => new User(this);
}
В случае необходимости, реализацию любого из приведенных выше методов (за исключением <Clone>$()) разработчик может определить самостоятельно. Достаточно просто добавить его в запись. При этом остальные методы из списка по-прежнему будут добавлены автоматически.
Например, в следующем примере у User будет свой вариант метода ToString():
public record User(int Id, string Name)
{
public override string ToString() { … }
}
При таком формате, если нужно задать свой конструктор, то он обязательно должен вызывать автоматически сгенерированный:
public record Customer(int Id, string Name, string Address)
{
public Customer(User user, string address)
: this(user.Id, user.Name, address)
{
}
}
В данном примере Customer получит 2 конструктора: определенный разработчиком и созданный компилятором.
Максимальную гибкость можно получить при объявлении записи с использованием синтаксиса, который аналогичен объявлению класса. При этом разработчик самостоятельно определяет конструкторы, правила доступа к свойствам и любые нужные методы. В свою очередь, компилятор добавит все недостающие методы, входящие в состав стандартного набора методов для записи.
Перепишем в этом стиле объявление User, разрешив изменение поля Id только через конструктор:
public record User
{
public int Id { get; }
public string Name { get; init; }
public User(int id, string name)
{
this.Id = id;
this.Name = name;
}
}
Выражение with – копирование с изменением
Как уже было сказано, записи по умолчанию являются неизменяемыми объектами. В этом случае единственный способ получить запись с измененными свойствами – создать её модифицированную копию. Это можно сделать используя конструктор. Однако такой код будет выглядеть громоздко, особенно если у записи много свойств.
Для удобства в C# 9 существует специальное выражение для создания модифицированной копии записи – with. Оно позволяет перечислить только свойства с новыми значениями. Все остальные будут скопированы один в один.
[var | тип] [имя новой переменной] = [имя исходной переменной] with
{
[имя свойства] = [новое значение],
…
};
Например:
User updatedUser = user with
{
Name = "John Doe"
};
Выражение with может модифицировать только свойства с методом доступа init или set. В случае использования только метода get, значение свойства может быть задано только через конструктор.
При компиляции выражение with преобразуется в создание копии и изменение ее свойств при помощи инициализатора объекта:
User updatedUser = user.<Clone>$()
{
Name = "John Doe"
};
В следующей части разберемся в особенностях использования записей.