Записи (справочник по C#)

Начиная с C# версии 9 вы можете использовать ключевое слово record для определения ссылочного типа, который предоставляет встроенные возможности для инкапсуляции данных. Вы можете создавать типы записей с неизменяемыми свойствами, используя позиционные параметры или стандартный синтаксис свойств:

public record Person(string FirstName, string LastName);
public record Person
{
    public string FirstName { get; init; }
    public string LastName { get; init; }
};

Кроме того, можно создавать типы записей с изменяемыми свойствами и полями:

public record Person
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
};

Несмотря на поддержку изменения, записи предназначены в первую очередь для неизменяемых моделей данных. Тип записи предоставляет следующие возможности:

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

  • Они не поддерживают наследование.
  • Они менее эффективны при определении равенства значений. Для типов значений метод ValueType.Equals использует отражение для поиска всех полей. Для записей компилятор создает метод Equals. На практике реализация равенства значений в записях работает заметно быстрее.
  • В некоторых сценариях они используют больше памяти, так как каждый экземпляр содержит полную копию всех данных. Типы записей являются ссылочными типами, то есть каждый экземпляр записи содержит только ссылку на данные.

Позиционный синтаксис для определения свойств

Позиционные параметры позволяют объявить свойства записи и инициализировать значения свойств при создании экземпляра:

public record Person(string FirstName, string LastName);

public static void Main()
{
    Person person = new("Nancy", "Davolio");
    Console.WriteLine(person);
    // output: Person { FirstName = Nancy, LastName = Davolio }
}

При использовании позиционного синтаксиса для определения свойства компилятор создает следующие элементы:

  • Открытое автоматически реализуемое свойство "только init" создается для каждого позиционного параметра, предоставленного в объявлении записи. Свойство только init может быть задано только в конструкторе или с помощью инициализатора свойств.
  • Основной конструктор, параметры которого соответствуют позиционным параметрам в объявлении записи.
  • Метод Deconstruct с параметром out создается для каждого позиционного параметра, предоставленного в объявлении записи. Этот метод предоставляется только в том случае, если предоставлено два или более позиционных параметров. Этот метод деконструирует свойства, определенные с помощью позиционного синтаксиса, и игнорирует любые свойства, определенные с помощью стандартного синтаксиса.

Вы можете добавить атрибуты в любой из этих элементов, создаваемых компилятором из определения записи. Вы можете добавить целевой объект к любому атрибуту, который применяется к свойствам позиционной записи. В следующем примере System.Text.Json.Serialization.JsonPropertyNameAttribute применяется к каждому свойству записи Person. Целевой объект property: указывает, что атрибут применяется к свойству, созданному компилятором. Другие значения — field: для применения атрибута к полю и param: для применения атрибута к параметру.

/// <summary>
/// Person record type
/// </summary>
/// <param name="FirstName">First Name</param>
/// <param name="LastName">Last Name</param>
/// <remarks>
/// The person type is a positional record containing the
/// properties for the first and last name. Those properties
/// map to the JSON elements "firstName" and "lastName" when
/// serialized or deserialized.
/// </remarks>
public record Person([property: JsonPropertyName("firstName")]string FirstName, 
    [property: JsonPropertyName("lastName")]string LastName);

В примере выше также показано, как создавать для записи комментарии XML-документации. Вы можете указать тег <param>, чтобы добавить документацию для параметров первичного конструктора.

Если вам не подходит созданное определение автоматически реализуемого свойства, вы можете определить собственное свойство с тем же именем. В этом случае созданные конструктор и деконструктор будут использовать предоставленное вами определение свойства. Например, в следующем примере позиционное свойство FirstName получает атрибут internal вместо public.

public record Person(string FirstName, string LastName)
{
    internal string FirstName { get; init; } = FirstName;
}

public static void Main()
{
    Person person = new("Nancy", "Davolio");
    Console.WriteLine(person.FirstName); //output: Nancy
}

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

public record Person(string FirstName, string LastName)
{
    public string[] PhoneNumbers { get; init; }
};

Если вы определите свойства с использованием стандартного синтаксиса свойств, но опустите модификатор доступа, эти свойства неявно становятся public.

Неизменяемость

Тип записи не обязательно является неизменяемым. Вы можете объявить свойства с методами доступа set и полями без атрибута readonly. Но несмотря на поддержку изменения, записи лучше всего подходят для создания неизменяемых моделей данных.

Неизменяемость может быть полезной, если требуется обеспечить потокобезопасность для типа, ориентированного на данные, или существует необходимость сохранять хэш-код в неизменном виде в хэш-таблице. Но все же неизменяемость пригодна не для всех сценариев работы с данными. Например, Entity Framework Core не поддерживает обновление с неизменяемыми типами сущностей.

Свойства "только init", созданные на основе позиционных параметров или путем указания методов доступа init, имеют неполную неизменяемость. После инициализации вы не сможете изменить значения свойств с типом значения или ссылки на свойства ссылочного типа. Но вы можете изменить сами данные, на которые ссылается свойство ссылочного типа. В следующем примере показано, что содержимое неизменяемого свойства ссылочного типа (в нашем примере это массив) является по сути изменяемым:

public record Person(string FirstName, string LastName, string[] PhoneNumbers);

public static void Main()
{
    Person person = new("Nancy", "Davolio", new string[1] { "555-1234" });
    Console.WriteLine(person.PhoneNumbers[0]); // output: 555-1234

    person.PhoneNumbers[0] = "555-6789";
    Console.WriteLine(person.PhoneNumbers[0]); // output: 555-6789
}

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

Равенство значений

Равенство значений означает, что две переменные типа записи считаются равными, если совпадают типы и значения всех свойств и полей. Для других ссылочных типов равенство означает полную идентичность. То есть две переменные ссылочного типа равны только в том случае, если они ссылаются на один и тот же объект.

Для некоторых моделей данных требуется ссылочное равенство. Например, Entity Framework Core использует ссылочное равенство, чтобы гарантировать использование только одного экземпляра типа сущности в том случае, когда разные объекты концептуально являются одной сущностью. По этой причине типы записей не подходят для использования в качестве типов сущностей в Entity Framework Core.

Следующий пример демонстрирует равенство значений для типов записей:

public record Person(string FirstName, string LastName, string[] PhoneNumbers);

public static void Main()
{
    var phoneNumbers = new string[2];
    Person person1 = new("Nancy", "Davolio", phoneNumbers);
    Person person2 = new("Nancy", "Davolio", phoneNumbers);
    Console.WriteLine(person1 == person2); // output: True

    person1.PhoneNumbers[0] = "555-1234";
    Console.WriteLine(person1 == person2); // output: True

    Console.WriteLine(ReferenceEquals(person1, person2)); // output: False
}

Чтобы реализовать равенство значений, компилятор синтезирует следующие методы:

  • Переопределение Object.Equals(Object).

    Этот метод используется как основа для статического метода Object.Equals(Object, Object), если оба параметра имеют отличное от NULL значение.

  • Виртуальный метод Equals, параметр которого является типом записи. Этот метод реализует IEquatable<T>.

  • Переопределение Object.GetHashCode().

  • Переопределения операторов == и !=.

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

Вы можете написать собственные реализации и заменить ими любой из синтезированных методов. Если тип записи имеет метод, совпадающий с сигнатурой какого-либо синтезируемого метода, компилятор будет его синтезировать.

Если вы предоставляете в типе записи собственную реализацию Equals, предоставьте также реализацию GetHashCode.

Обратимое изменение

Если нужно изменить неизменяемые свойства экземпляра записи, вы можете с помощью выражения with выполнить обратимое изменение. Выражение with создает новый экземпляр записи, который является копией существующего экземпляра записи, и изменяет в этой копии указанные свойства и поля. Для указания требуемых изменений используется синтаксис инициализатора объектов, как показано в следующем примере:

public record Person(string FirstName, string LastName)
{
    public string[] PhoneNumbers { get; init; }
}

public static void Main()
{
    Person person1 = new("Nancy", "Davolio") { PhoneNumbers = new string[1] };
    Console.WriteLine(person1);
    // output: Person { FirstName = Nancy, LastName = Davolio, PhoneNumbers = System.String[] }

    Person person2 = person1 with { FirstName = "John" };
    Console.WriteLine(person2);
    // output: Person { FirstName = John, LastName = Davolio, PhoneNumbers = System.String[] }
    Console.WriteLine(person1 == person2); // output: False

    person2 = person1 with { PhoneNumbers = new string[1] };
    Console.WriteLine(person2);
    // output: Person { FirstName = Nancy, LastName = Davolio, PhoneNumbers = System.String[] }
    Console.WriteLine(person1 == person2); // output: False

    person2 = person1 with { };
    Console.WriteLine(person1 == person2); // output: True
}

Выражение with может задавать позиционные свойства или свойства, созданные с помощью стандартного синтаксиса свойств. Непозиционные свойства должны иметь метод доступа init или set, чтобы их можно было изменять с помощью выражения with.

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

Для реализации такой возможности компилятор синтезирует метод клонирования и конструктор копии. Этот конструктор принимает экземпляр записи для копирования и вызывает метод клонирования (clone). При использовании выражения with компилятор создает код, который вызывает конструктор копии, а затем устанавливает указанные в выражении with свойства.

Если требуется другое поведение копирования, вы можете написать собственный конструктор копирования. В этом случае компилятор не будет синтезировать конструктор. Присвойте конструктору атрибут private, если запись является sealed, или protected в противном случае.

Вы не можете переопределить метод клонирования или создать другой элемент с именем Clone. Фактическое имя метода клонирования создается компилятором.

Встроенное форматирование для отображения

Типы записей имеют создаваемый компилятором метод ToString, который отображает имена и значения открытых свойств и полей. Метод ToString возвращает строку в следующем формате:

<record type name> { <property name> = <value>, <property name> = <value>, ...}

Для ссылочных типов вместо значения свойства отображается имя типа того объекта, на который ссылается это свойство. В следующем примере массив имеет ссылочный тип, поэтому отображается System.String[] вместо фактических значений элементов массива:

Person { FirstName = Nancy, LastName = Davolio, ChildNames = System.String[] }

Для реализации этой функциональной возможности компилятор синтезирует виртуальный метод PrintMembers и переопределение ToString. Переопределение ToString создает объект StringBuilder с именем типа, за которым следует открывающая квадратная скобка. Затем оно вызывает метод PrintMembers, который добавляет имена и значения свойств, а затем добавляет закрывающую скобку. В следующем примере показан код, аналогичный синтезированному переопределению:

public override string ToString()
{
    StringBuilder stringBuilder = new StringBuilder();
    stringBuilder.Append("Teacher"); // type name
    stringBuilder.Append(" { ");
    if (PrintMembers(stringBuilder))
    {
        stringBuilder.Append(" ");
    }
    stringBuilder.Append("}");
    return stringBuilder.ToString();
}

Вы можете предоставить собственную реализацию PrintMembers или переопределения ToString. Примеры можно изучить в разделе Форматирование PrintMembers в производных записях далее в этой статье.

Наследование

Запись может наследовать от другой записи. Но запись не может наследовать от класса, а класс не может наследовать от записи.

Позиционные параметры в производных типах записей

Производная запись объявляет позиционные параметры для всех параметров, определенных в основном конструкторе базовой записи. Базовая запись объявляет и инициализирует эти свойства. Производная запись не скрывает их, но создает и инициализирует только свойства для параметров, которые не объявлены в базовой записи.

Следующий пример демонстрирует наследование с использованием синтаксиса позиционных свойств:

public abstract record Person(string FirstName, string LastName);
public record Teacher(string FirstName, string LastName, int Grade)
    : Person(FirstName, LastName);
public static void Main()
{
    Person teacher = new Teacher("Nancy", "Davolio", 3);
    Console.WriteLine(teacher);
    // output: Teacher { FirstName = Nancy, LastName = Davolio, Grade = 3 }
}

Равенство в иерархиях наследования

Чтобы две переменные записи считались равными, у них должен совпадать тип времени выполнения. При этом типы содержащихся в них переменных могут отличаться. Это демонстрируется в следующем примере кода:

public abstract record Person(string FirstName, string LastName);
public record Teacher(string FirstName, string LastName, int Grade)
    : Person(FirstName, LastName);
public record Student(string FirstName, string LastName, int Grade)
    : Person(FirstName, LastName);
public static void Main()
{
    Person teacher = new Teacher("Nancy", "Davolio", 3);
    Person student = new Student("Nancy", "Davolio", 3);
    Console.WriteLine(teacher == student); // output: False

    Student student2 = new Student("Nancy", "Davolio", 3);
    Console.WriteLine(student2 == student); // output: True
}

В этом примере все экземпляры имеют одинаковые свойства и одинаковые значения этих свойств. Но student == teacher возвращает False, хотя обе переменные имеют тип Person, а student == student2 возвращает True, хотя одна переменная имеет тип Person, а другая — Student.

Чтобы реализовать такое поведение, компилятор синтезирует свойство EqualityContract, которое возвращает объект Type, соответствующий типу записи. Это позволяет методам равенства учитывать тип времени выполнения при сравнении объектов. Если запись имеет базовый тип object, это свойство получает атрибут virtual. Если базовый тип имеет другой тип записи, это свойство становится переопределением. Если тип записи имеет атрибут sealed, свойство также получает атрибут sealed.

При сравнении двух экземпляров производного типа синтезированные методы равенства проверяют на равенство все свойства базового и производного типов. Синтезированный метод GetHashCode использует метод GetHashCode всех свойств и полей, объявленных в базовом типе и в производном типе записи.

Выражения with в производных записях

Так как синтезированной метод клонирования использует ковариантный возвращаемый тип, тип времени выполнения у результата выражения with будет таким же, как у операнда выражения. Копируются все свойства с типом времени выполнения, но изменять вы можете только свойства с типом времени компиляции, как показано в следующем примере:

public record Point(int X, int Y)
{
    public int Zbase { get; set; }
};
public record NamedPoint(string Name, int X, int Y) : Point(X, Y)
{
    public int Zderived { get; set; }
};

public static void Main()
{
    Point p1 = new NamedPoint("A", 1, 2) { Zbase = 3, Zderived = 4 };

    Point p2 = p1 with { X = 5, Y = 6, Zbase = 7 }; // Can't set Name or Zderived
    Console.WriteLine(p2 is NamedPoint);  // output: True
    Console.WriteLine(p2);
    // output: NamedPoint { X = 5, Y = 6, Zbase = 7, Name = A, Zderived = 4 }

    Point p3 = (NamedPoint)p1 with { Name = "B", X = 5, Y = 6, Zbase = 7, Zderived = 8 };
    Console.WriteLine(p3);
    // output: NamedPoint { X = 5, Y = 6, Zbase = 7, Name = B, Zderived = 8 }
}

Форматирование PrintMembers в производных записях

Синтезированный метод PrintMembers из производного типа записи вызывает базовую реализацию. Это означает, что в выходные данные ToString включаются все свойства и поля с атрибутом public, как в производных, так и базовых типах, как показано в следующем примере:

public abstract record Person(string FirstName, string LastName);
public record Teacher(string FirstName, string LastName, int Grade)
    : Person(FirstName, LastName);
public record Student(string FirstName, string LastName, int Grade)
    : Person(FirstName, LastName);

public static void Main()
{
    Person teacher = new Teacher("Nancy", "Davolio", 3);
    Console.WriteLine(teacher);
    // output: Teacher { FirstName = Nancy, LastName = Davolio, Grade = 3 }
}

Вы можете предоставить собственную реализацию метода PrintMembers. В этом случае используйте следующую сигнатуру:

  • Для записи sealed, которая является производной от object (не объявляет базовую запись): private bool PrintMembers(StringBuilder builder).
  • Для записи sealed, которая является производной от другой записи: protected sealed override bool PrintMembers(StringBuilder builder).
  • Для записи, которая не является sealed и наследует от объекта: protected virtual bool PrintMembers(StringBuilder builder);.
  • Для записи, которая не является sealed и наследует от другой записи: protected override bool PrintMembers(StringBuilder builder);.

Ниже приведен пример кода, который заменяет синтезированные методы PrintMembers: один пример для типа записи, которая наследует от объекта, и другой для типа записи, которая наследует от другой записи:

public abstract record Person(string FirstName, string LastName, string[] PhoneNumbers)
{
    protected virtual bool PrintMembers(StringBuilder stringBuilder)
    {
        stringBuilder.Append($"FirstName = {FirstName}, LastName = {LastName}, ");
        stringBuilder.Append($"PhoneNumber1 = {PhoneNumbers[0]}, PhoneNumber2 = {PhoneNumbers[1]}");
        return true;
    }
}

public record Teacher(string FirstName, string LastName, string[] PhoneNumbers, int Grade)
    : Person(FirstName, LastName, PhoneNumbers)
{
    protected override bool PrintMembers(StringBuilder stringBuilder)
    {
        if (base.PrintMembers(stringBuilder))
        {
            stringBuilder.Append(", ");
        };
        stringBuilder.Append($"Grade = {Grade}");
        return true;
    }
};

public static void Main()
{
    Person teacher = new Teacher("Nancy", "Davolio", new string[2] { "555-1234", "555-6789" }, 3);
    Console.WriteLine(teacher);
    // output: Teacher { FirstName = Nancy, LastName = Davolio, PhoneNumber1 = 555-1234, PhoneNumber2 = 555-6789, Grade = 3 }
}

Поведение деконструктора в производных записях

Метод Deconstruct производной записи возвращает значения всех позиционных свойств с типом времени компиляции. Если переменная имеет тип базовой записи, деконструкция выполняется только для свойств базовой записи, если объект не приведен к производному типу. Следующий пример демонстрирует вызов деконструктора для производной записи.

public abstract record Person(string FirstName, string LastName);
public record Teacher(string FirstName, string LastName, int Grade)
    : Person(FirstName, LastName);
public record Student(string FirstName, string LastName, int Grade)
    : Person(FirstName, LastName);

public static void Main()
{
    Person teacher = new Teacher("Nancy", "Davolio", 3);
    var (firstName, lastName) = teacher; // Doesn't deconstruct Grade
    Console.WriteLine($"{firstName}, {lastName}");// output: Nancy, Davolio

    var (fName, lName, grade) = (Teacher)teacher;
    Console.WriteLine($"{fName}, {lName}, {grade}");// output: Nancy, Davolio, 3
}

Общие ограничения

Не существует общих ограничений, по которым тип обязан являться записью. Записи соответствуют ограничению class. Чтобы задать ограничение для конкретной иерархии типов записей, задайте это ограничение для базовой записи, как обычно для базового класса. Дополнительные сведения см. в статье Ограничения параметров типа.

Спецификация языка C#

Дополнительные сведения см. в разделе о классах в спецификации языка C#.

Дополнительные сведения о функциях, появившихся в C# версии 9 и более поздних, см. в следующих заметках о функциях:

См. также раздел