C# 9 – Записи (record)

C# logoВ 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"
};

В следующей части разберемся в особенностях использования записей.

Добавить комментарий