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

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

В следующих двух примерах показаны record ссылочные типы (или record class):

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

В следующих двух примерах показаны record struct типы значений:

public readonly record struct Point(double X, double Y, double Z);
public record struct Point
{
    public double X {  get; init; }
    public double Y {  get; init; }
    public double Z {  get; init; }
}

Вы также можете создавать записи с изменяемыми свойствами и полями:

public record Person
{
    public string FirstName { get; set; } = default!;
    public string LastName { get; set; } = default!;
};

Структуры записей также могут быть изменяемыми, как позиционированные структуры записей, так и структуры записей без параметров позиционирования:

public record struct DataMeasurement(DateTime TakenAt, double Measurement);
public record struct Point
{
    public double X { get; set; }
    public double Y { get; set; }
    public double Z { get; set; }
}

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

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

  • Объект record или record class объявляет ссылочный тип. Ключевое слово class является необязательным, но может добавить ясность для читателей. record struct объявляет тип значения.
  • Свойства позиционирования являются неизменяемыми в record class и readonly record struct. Они являются изменяемыми в record struct.

В оставшейся части этой статьи обсуждаются типы record class и record struct. Различия подробно описаны в каждом разделе. Выберите между record class и record struct, как вы выбираете между class и struct. Термин запись используется для описания поведения, которое применяется ко всем типам записей. record struct или record class используется для описания поведения, которое применяется только к типам структур или классов соответственно. Этот record тип был введен в C# 9; record struct типы появились в C# 10.

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

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

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 }
}

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

  • Общедоступное автоматически реализуемое свойство создается для каждого позиционного параметра, предоставленного в объявлении записи.
  • Основной конструктор, параметры которого соответствуют позиционным параметрам в объявлении записи.
  • Для типов структуры записей конструктор без параметров присваивает каждому полю значение по умолчанию.
  • Метод 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>, чтобы добавить документацию для параметров первичного конструктора.

Если вам не подходит созданное определение автоматически реализуемого свойства, вы можете определить собственное свойство с тем же именем. Например, возможно, потребуется изменить доступность либо изменяемость или предоставить реализацию для метода доступа get либо set. Если вы объявляете свойство в источнике, его необходимо инициализировать из позиционного параметра записи. Если свойство является автоматически реализуемым свойством, необходимо инициализировать свойство. При добавлении резервного поля в источник необходимо инициализировать резервное поле. Созданный деконструктор будет использовать определение свойства. Например, в следующем примере объявляются свойства FirstName и LastName для позиционной записи public, но параметр позиционирования Id ограничен до internal. Этот синтаксис можно использовать для типов записей и структур записей.

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

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

}

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

public record Person(string FirstName, string LastName)
{
    public string[] PhoneNumbers { get; init; } = Array.Empty<string>();
};

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

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

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

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

Свойства только для инициализации, созданные на основе позиционных параметров (record class и readonly record struct) или путем указания методов доступа 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
}

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

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

Если вы не переопределяете или не заменяете методы равенства, объявленный тип определяет, как определяется равенство:

  • Для типов class два объекта равны, если они ссылаются на один и тот же объект в памяти.
  • Для типов struct два объекта равны, если хранят одинаковые значения и имеют один и тот же тип.
  • Для типов record, включая record struct и readonly record struct, два объекта равны, если хранят одинаковые значения и имеют один и тот же тип.

Определение равенства для record struct совпадает с struct. Разница заключается в том, что для struct реализация находится в ValueType.Equals(Object) и зависит от отражения. Для записей реализация синтезируется компилятором и использует объявленные члены данных.

Для некоторых моделей данных требуется ссылочное равенство. Например, 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 значение.

  • sealedИлиvirtual, Equals(R? other) где R находится тип записи. Этот метод реализует IEquatable<T>. Этот метод можно объявить явным образом.

  • Если тип записи является производным от базового типа Baseзаписи , . Equals(Base? other) Это ошибка, если переопределение объявлено явным образом. Если вы предоставляете собственную реализацию Equals(R? other), предоставьте также реализацию GetHashCode .

  • Переопределение Object.GetHashCode(). Этот метод можно объявить явным образом.

  • Переопределения операторов == и !=. Это ошибка, если операторы объявлены явным образом.

  • Если тип записи является производным от базового типа записи, protected override Type EqualityContract { get; };. Это свойство можно объявить явным образом. Дополнительные сведения см. в разделе "Равенство в иерархиях наследования".

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

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

Если нужно копировать экземпляр с изменениями, вы можете с помощью выражения 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 является неполная копия, то есть для ссылочных свойств в нее копируется только ссылка на экземпляр. В итоге исходная запись и ее копия указывают на один и тот же экземпляр.

Для реализации такой возможности для типов record class компилятор синтезирует метод клонирования и конструктор копии. Метод виртуального клонирования возвращает новую запись, инициализированную конструктором копий. При использовании выражения with компилятор создает код, который вызывает метод клона, а затем устанавливает указанные в выражении with свойства.

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

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

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

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

<имя> типа записи { <имя> свойства = <значение>, <имя> свойства = <значение>, ...}

Строка, которая выводится для <value>, — это строка, возвращаемая ToString() для типа свойства. В следующем примере ChildNames используется , System.Arrayгде ToString возвращается System.String[]:

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

Для реализации этой функциональной возможности в типах record class компилятор синтезирует виртуальный метод PrintMembers и переопределение ToString. В типах record struct этот член является private. Переопределение 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 в производных записях далее в этой статье. В C# 10 и более поздних версиях ваша реализация ToString может включать модификатор sealed, который не позволяет компилятору создавать реализацию ToString для любых производных записей. Вы можете реализовать такое поведение, чтобы создать согласованное строковое представление по всей иерархии типов record (производные записи по-прежнему будут использовать метод PrintMembers, создаваемый для всех производных свойств).

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

Этот раздел относится только к типам record class.

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

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

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

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

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 }
}

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

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

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
}

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

Чтобы реализовать такое поведение, компилятор синтезирует свойство EqualityContract, которое возвращает объект Type, соответствующий типу записи. EqualityContract позволяет методам равенства учитывать тип времени выполнения при проверке объектов на равенство. Если запись имеет базовый тип object, это свойство получает атрибут virtual. Если базовый тип имеет другой тип записи, это свойство становится переопределением. Если тип записи — sealed, это свойство фактически имеет значение 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, производной от другой записи (обратите внимание, что включающий тип — sealed, поэтому метод фактически имеет значение sealed): protected 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 }
}

Примечание

В C# 10 и более поздних версий компилятор будет синтезировать PrintMembers в производных записях, даже если базовая запись запечатала метод ToString. Вы также можете создать собственную реализацию PrintMembers.

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

Метод 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 или struct. Чтобы задать ограничение для конкретной иерархии типов записей, задайте это ограничение для базовой записи, как обычно для базового класса. Дополнительные сведения см. в статье Ограничения параметров типа.

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

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

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

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