Создание типов записей

Записи — это типы, использующие равенство на основе значений. В C# 10 добавляются структуры записей, чтобы можно было определять записи в качестве типов значений. Две переменные типа записи равны, если определения типов записей идентичны и если для каждого поля значения в обеих записях равны. Две переменные типа класса равны, если объекты, на которые они ссылаются, относятся к одному и тому же типу класса, а переменные ссылаются на один и тот же объект. Равенство на основе значений подразумевает другие полезные возможности. Компилятор создает многие из этих элементов при объявлении record вместо class. Компилятор создает те же методы для типов record struct.

Из этого руководства вы узнаете, как выполнять следующие задачи:

  • Определите, добавляете record ли модификатор в class тип.
  • Объявите типы записей и типы позиционных записей.
  • Замените методы на созданные компилятором методы в записях.

Необходимые компоненты

Вам нужно настроить компьютер для выполнения .NET 6 или более поздней версии, включая компилятор C# 10 или более поздней версии. Компилятор C# 10 доступен начиная с Visual Studio 2022 или пакета SDK для .NET Core 6.

Характеристики записей

Запись определяется путем объявления типа с record ключевое слово, изменением или struct объявлениемclass. При необходимости можно опустить class ключевое слово для созданияrecord class. Запись следует семантике равенства на основе значений. Чтобы обеспечить такую семантику, компилятор создает несколько методов для типа записи (для типов record class и record struct):

  • Переопределение Object.Equals(Object).
  • Виртуальный метод Equals, параметр которого является типом записи.
  • Переопределение Object.GetHashCode().
  • Методы для operator == и operator !=.
  • Типы записей реализуют System.IEquatable<T>.

Записи также предоставляют переопределение Object.ToString(). Компилятор синтезирует методы для отображения записей с помощью Object.ToString(). Эти элементы будут рассмотрены при написании кода для этого учебника. Записи поддерживают выражения with, позволяющие выполнять обратимое изменение записей.

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

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

Создание данных о температуре

Данные и статистика представляют собой сценарии, в которых необходимо использовать записи. В этом учебнике вы создадите приложение, которое вычисляет градусо-дни для разных целей. Градусо-дни представляют собой меру тепла (или недостатка тепла) в течение нескольких дней, недель или месяцев. Градусо-дни позволяют отслеживать и прогнозировать использование энергии. Чем больше жарких дней, тем больше будет использоваться кондиционер, а чем больше холодных дней, тем больше будут использоваться обогреватели. Градусо-дни помогают управлять популяциями растений и учитывать рост растений по мере смены времен года. Градусо-дни помогают отслеживать миграцию животных в зависимости от климата.

Формула основана на средней температуре в определенный день и базовой температуре. Чтобы вычислить градусо-дни за период времени, вам нужно знать высокую и низкую температуру каждого дня этого периода. Давайте начнем с создания нового приложения. Создание нового консольного приложения. Создать новый тип записи в новом файле с именем DailyTemperature.cs:

public readonly record struct DailyTemperature(double HighTemp, double LowTemp);

В предыдущем коде определяется позиционная запись. Запись DailyTemperature является readonly record struct, так как вы не собираетесь наследовать от нее и она должна быть неизменной. Свойства HighTemp и LowTemp являются свойствами только для инициализации, то есть их можно задать в конструкторе или с помощью инициализатора свойств. Если нужно, чтобы позиционированные параметры были доступны для чтения и записи, объявите record struct вместо readonly record struct. Тип DailyTemperature также имеет основной конструктор с двумя параметрами, соответствующими двум свойствам. Вы воспользуетесь основным конструктором для инициализации записи DailyTemperature. Приведенный ниже код создает и инициализирует несколько записей DailyTemperature. Первая использует именованные параметры для прояснения HighTemp и LowTemp. Остальные инициализаторы используют позиционные параметры для инициализации HighTemp и LowTemp:

private static DailyTemperature[] data = [
    new DailyTemperature(HighTemp: 57, LowTemp: 30), 
    new DailyTemperature(60, 35),
    new DailyTemperature(63, 33),
    new DailyTemperature(68, 29),
    new DailyTemperature(72, 47),
    new DailyTemperature(75, 55),
    new DailyTemperature(77, 55),
    new DailyTemperature(72, 58),
    new DailyTemperature(70, 47),
    new DailyTemperature(77, 59),
    new DailyTemperature(85, 65),
    new DailyTemperature(87, 65),
    new DailyTemperature(85, 72),
    new DailyTemperature(83, 68),
    new DailyTemperature(77, 65),
    new DailyTemperature(72, 58),
    new DailyTemperature(77, 55),
    new DailyTemperature(76, 53),
    new DailyTemperature(80, 60),
    new DailyTemperature(85, 66) 
];

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

public readonly record struct DailyTemperature(double HighTemp, double LowTemp)
{
    public double Mean => (HighTemp + LowTemp) / 2.0;
}

Давайте убедимся, что вы можете использовать эти данные. Добавьте приведенный ниже код в метод Main:

foreach (var item in data)
    Console.WriteLine(item);

Запустите приложение, и вы увидите результат, похожий на следующий (несколько строк удалено для экономии места):

DailyTemperature { HighTemp = 57, LowTemp = 30, Mean = 43.5 }
DailyTemperature { HighTemp = 60, LowTemp = 35, Mean = 47.5 }


DailyTemperature { HighTemp = 80, LowTemp = 60, Mean = 70 }
DailyTemperature { HighTemp = 85, LowTemp = 66, Mean = 75.5 }

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

Вычисление градусо-дней

Чтобы вычислить градусо-дни, нужно взять разность между базовой температурой и средней температурой за определенный день. Чтобы измерить теплые дни за период времени, не учитывайте дни, когда средняя температура была ниже базовой. Чтобы измерить холодные дни за период времени, не учитывайте дни, когда средняя температура была выше базовой. Например, в США за основу берется 65 градусов по Фаренгейту. В эту температуру не требуется включать нагрев или охлаждение. Если средняя температура дня составляет 70 градусов по Фаренгейту, это 5 градусо-дней в плане охлаждения и 0 градусо-дней в плане обогрева. Если средняя температура дня составляет 55 градусов по Фаренгейту, это 0 градусо-дней в плане охлаждения и 10 градусо-дней в плане обогрева.

Эти формулы можно выразить как небольшую иерархию типов записей: абстрактный тип градусо-дней и два конкретных типа для градусо-дней обогрева и градусо-дней охлаждения. Эти типы также могут быть позиционными записями. Они принимают базовую температуру и последовательность ежедневных записей температуры в качестве аргументов для основного конструктора:

public abstract record DegreeDays(double BaseTemperature, IEnumerable<DailyTemperature> TempRecords);

public sealed record HeatingDegreeDays(double BaseTemperature, IEnumerable<DailyTemperature> TempRecords)
    : DegreeDays(BaseTemperature, TempRecords)
{
    public double DegreeDays => TempRecords.Where(s => s.Mean < BaseTemperature).Sum(s => BaseTemperature - s.Mean);
}

public sealed record CoolingDegreeDays(double BaseTemperature, IEnumerable<DailyTemperature> TempRecords)
    : DegreeDays(BaseTemperature, TempRecords)
{
    public double DegreeDays => TempRecords.Where(s => s.Mean > BaseTemperature).Sum(s => s.Mean - BaseTemperature);
}

Абстрактная запись DegreeDays является общим базовым классом для записей HeatingDegreeDays и CoolingDegreeDays. Объявления основного конструктора в производных записях показывают, как управлять инициализацией базовой записи. Ваша производная запись объявляет параметры для всех параметров в основном конструкторе базовой записи. Базовая запись объявляет и инициализирует эти свойства. Производная запись не скрывает их, но создает и инициализирует только свойства для параметров, которые не объявлены в базовой записи. В этом примере производные записи не добавляют новые параметры основного конструктора. Протестируйте код, добавив следующий код в метод Main:

var heatingDegreeDays = new HeatingDegreeDays(65, data);
Console.WriteLine(heatingDegreeDays);

var coolingDegreeDays = new CoolingDegreeDays(65, data);
Console.WriteLine(coolingDegreeDays);

Вы получите выходные данные, подобные следующим:

HeatingDegreeDays { BaseTemperature = 65, TempRecords = record_types.DailyTemperature[], DegreeDays = 85 }
CoolingDegreeDays { BaseTemperature = 65, TempRecords = record_types.DailyTemperature[], DegreeDays = 71.5 }

Определение методов, синтезируемых компилятором

В коде вычисляется правильное число градусо-дней обогрева и охлаждения за этот период времени. Но в этом примере показано, почему может потребоваться заменить некоторые синтезированные методы на записи. Вы можете объявить собственную версию любых синтезированных компилятором методов в типе записи, за исключением метода клонирования. Метод клонирования имеет имя, созданное компилятором, и вы не можете предоставить другую реализацию. Эти синтезированные методы включают конструктор копий, элементы интерфейса System.IEquatable<T>, проверки равенства и неравенства, а также GetHashCode(). Для этой цели вы создадите PrintMembers. Можно также объявить собственный ToString, но PrintMembers предоставляет лучший вариант для сценариев наследования. Чтобы предоставить собственную версию синтезированного метода, сигнатура должна соответствовать синтезированному методу.

Элемент TempRecords в выходных данных консоли не имеет смысла. Он отображает тип, но больше ничего. Это поведение можно изменить, предоставив собственную реализацию синтезированного метода PrintMembers. Сигнатура зависит от модификаторов, применяемых к объявлению record:

  • Если тип записи — sealed или record struct, то сигнатура — private bool PrintMembers(StringBuilder builder);
  • Если тип записи не sealed и является производным от object (то есть базовая запись не объявляется), то сигнатура — protected virtual bool PrintMembers(StringBuilder builder);
  • Если тип записи не sealed и является производным от другой записи, то сигнатура — protected override bool PrintMembers(StringBuilder builder);

Эти правила проще всего осмыслить, поняв цель PrintMembers. PrintMembers добавляет сведения о каждом свойстве в типе записи в строку. По контракту базовые записи должны добавлять свои элементы в отображение. Предполагается, что производные элементы будут добавлять свои элементы. Каждый тип записи синтезирует переопределение ToString, которое выглядит следующим образом для HeatingDegreeDays:

public override string ToString()
{
    StringBuilder stringBuilder = new StringBuilder();
    stringBuilder.Append("HeatingDegreeDays");
    stringBuilder.Append(" { ");
    if (PrintMembers(stringBuilder))
    {
        stringBuilder.Append(" ");
    }
    stringBuilder.Append("}");
    return stringBuilder.ToString();
}

Вы объявляете метод PrintMembers в записи DegreeDays, которая не выводит тип коллекции:

protected virtual bool PrintMembers(StringBuilder stringBuilder)
{
    stringBuilder.Append($"BaseTemperature = {BaseTemperature}");
    return true;
}

Сигнатура объявляет метод virtual protected, чтобы обеспечить соответствие версии компилятора. Не беспокойтесь, если получаете неправильные методы доступа; язык применит правильную сигнатуру. Если вы забыли правильные модификаторы для синтезированного метода, компилятор выдает предупреждения или ошибки, которые помогут получить правильную сигнатуру.

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

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

Синтезированные элементы в классе позиционной записи не изменяют состояние записи. Главная цель — упростить создание неизменяемых записей. Помните, что вы объявили readonly record struct, чтобы создать неизменяемую структуру записи. Просмотрите предыдущие объявления для HeatingDegreeDays и CoolingDegreeDays. Добавленные элементы выполняют вычисления со значениями для записи, но не изменяют состояние. Позиционные записи упрощают создание неизменяемых ссылочных типов.

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

Давайте добавим в программу несколько функций, демонстрирующих выражения with. Во-первых, создадим новую запись для вычислений рост градусо-дней, используя те же данные. Рост градусо-дней обычно использует 41 градус по Фаренгейту в качестве базового показателя и измеряет температуру выше базовой. Чтобы использовать те же данные, можно создать новую запись, похожую на coolingDegreeDays, но с другой базовой температурой:

// Growing degree days measure warming to determine plant growing rates
var growingDegreeDays = coolingDegreeDays with { BaseTemperature = 41 };
Console.WriteLine(growingDegreeDays);

Вы можете сравнить число рассчитанных градусов с числом, вычисленным при более высокой базовой температуре. Помните, что записи являются ссылочными типами, и эти копии являются поверхностными копиями. Массив данных не копируется, но обе записи ссылаются на одни и те же данные. Этот факт является преимуществом в еще одном сценарии. Для растущих градусо-дней полезно отслеживать общее число за предыдущие пять дней. С помощью выражений with можно создавать новые записи с разными исходными данными. Следующий код создает коллекцию этих сумм, а затем отображает значения:

// showing moving accumulation of 5 days using range syntax
List<CoolingDegreeDays> movingAccumulation = new();
int rangeSize = (data.Length > 5) ? 5 : data.Length;
for (int start = 0; start < data.Length - rangeSize; start++)
{
    var fiveDayTotal = growingDegreeDays with { TempRecords = data[start..(start + rangeSize)] };
    movingAccumulation.Add(fiveDayTotal);
}
Console.WriteLine();
Console.WriteLine("Total degree days in the last five days");
foreach(var item in movingAccumulation)
{
    Console.WriteLine(item);
}

Можно также использовать выражения with для создания копий записей. Не указывайте свойства между фигурными скобками в выражении with. Это означает, что нужно создать копию и не менять свойства:

var growingDegreeDaysCopy = growingDegreeDays with { };

Запустите готовое приложение, чтобы увидеть результаты.

Итоги

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

Записи добавляют еще один способ определения типов. Определения class используются для создания объектно-ориентированных иерархий, в которых основное внимание уделяется обязанностям и поведению объектов. Вы создаете типы struct для структур данных, которые хранят данные и достаточно малы для эффективного копирования. Типы record создаются, когда требуется определить равенство и сравнение на основе значений, но не нужно копировать значения, а вы хотите использовать ссылочные переменные. Вы создаете типы record struct, если требуется, чтобы функции записей для типа были достаточно малы для эффективного копирования.

Полное описание записей можно узнать, прочитав справочную статью по языку C# для типа записи, предлагаемую спецификацию типа записи и спецификации структуры записи.