Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Первую часть статьи читайте здесь.
Структуры записей (#UsedOccasionally)
Поддержка записей была добавлена еще в C# 9.0. На тот момент все записи были ссылочными типами (и были потенциально мутабельными). Преимущество добавления типов-записей (объявляемых ключевым словом record) заключается в том, что они предоставляют компактный синтаксис для определения нового типа, основной целью которого является инкапсуляция данных (с меньшим акцентом на обеспечении обслуживания и поведении). Теперь же мы имеем записи двух типов, которые имеют генерируемые компилятором C# реализации равенства на основе значений, обратимого изменения (non-destructive mutation) и встроенного форматирования отображения.
Структуры записей и классы записей
Итак, в C# 10.0, в дополнение к ссылочным типам записей (классы записей), были представлены значимые типы записей (структуры записей). В качестве примера давайте рассмотрим объявление Angle
, приведенное ниже:
record struct Angle(double Degrees, double Minutes, int Seconds)
{
// По умолчанию параметры первичного конструктора генерируются как свойства чтения/записи:
public double Degrees {get; set;}
// Вы можете переопределить реализацию параметров первичного конструктора, в том числе сделав их доступными только для чтения (без сеттера) или только для инициализации.
// public double Degrees {get; set;}
// Параметры первичного конструктора можно переопределить, чтобы они были просто полями.
public int Seconds = Seconds;
}
Для объявления записи в качестве значимого типа нужно просто добавить ключевое слово struct
между контекстным ключевым словом record
и именем типа данных. Как и в классах записей, вы также можете объявить первичный конструктор сразу после имени типа данных. Это объявление указывает компилятору сгенерировать публичный конструктор (т. е. Angle [double Degrees, double Minutes, int Seconds]
), который присваивает полям и свойствам (т. е. членам degrees
, minutes
, и seconds
) соответствующие значения параметров конструктора. Если это не было объявлено явно, компилятор C# сгенерирует свойства в соответствии с параметрами первичного конструктора (т.е. Degrees). Конечно, вы можете добавить дополнительные элементы в структуру записей и переопределить сгенерированные свойства кастомными реализациями, даже если модификатор доступа не является публичным или свойство доступно только для чтения, только для инициализации или и для чтения, и для записи.
Структуры записей обладают теми же преимуществами, что и классы записей, за исключением поддержки иерархий наследования. Например, методы равенства (Equals(), !=, == и GetHashCode()
) автоматически генерируются во время компиляции. В конце концов, вероятно, именно эти методы являются ключевой фичей, подкупающей в этой языковой конструкции. Кроме того, как и классы записей, структуры записей включают реализацию по умолчанию для ToString()
, которая обеспечивает форматированный вывод значений свойств. (ToString()
для инстанса Angle
возвращает Angle { Degrees = 30, Minutes = 18, Seconds = 0 }
). В дополнение к предыдущему, структуры записей включают в себя метод деконструкции, который позволяет преобразовать инстанс этого типа в набор переменных, в соответствии с первичным конструктором: т. е. (int degrees, int minutes, int seconds) = new Angle(30, 18, 42)
. Последняя фича, общая для обоих типов записей, — это оператор with, который позволяет клонировать запись в новый инстанс, при необходимости с изменением выбранных свойств. Вот пример:
public static Angle operator +(Angle first, Angle second)
{
(int degrees, int minutes, int seconds) = (
(first.Degrees + second.Degrees),
(first.Minutes + second.Minutes),
(first.Seconds + second.Seconds));
return first with
{
Seconds = seconds % 60,
Minutes = (minutes + (int)(seconds / 60)) % 60,
Degrees = (int)(degrees + (int)((minutes +
(int)(seconds / 60)) / 60)) % 360
};
}
И, как бонус, в C# 10.0 поддержка with
была добавлена и для структур (не только структур записей).
В отличие от классов записей, структуры записей всегда наследуются от System.ValueType
, поскольку они являются значимыми типами .NET и, следовательно, какое-либо дополнительное наследование для них не поддерживается. Кроме того, структуры записей могут быть объявлены к квалификатором readonly
(readonly record struct Angle {})
, что делает их иммутабельными после полного инстанцирования экземпляра. При наличии модификатора readonly
, компилятор будет проверять, что никакие поля (включая автоматические поля поддержки свойств) не были изменены после завершения инициализации объекта. Как показано на примере параметра Seconds
в первичном конструкторе, еще одно отличие структур записей заключается в том, что вы можете переопределить поведение первичного конструктора для генерации полей, а не свойств.
Кроме того, теперь вы можете объявлять записи ссылочного типа с применением ключевого слова class
(использование простой формы record <TypeName>
по-прежнему допустимо), тем самым обеспечивая симметрию между двумя типами объявления
record class FingerPrint(
string CreatedBy, string? ModifiedBy = null) {}
Примечание: В классификатор readonly
не разрешен для классов записей.
Мутабельные структуры записей
Я хочу обратить ваше внимание на то, что, в отличие от класса записей, параметры первичного конструктора структуры записей доступны для чтения и записи по умолчанию (по крайней мере на момент написания статьи, в Visual Studio Enterprise 2022 Preview [64-bit] Version 17.0.0 Preview 3.1). Такое поведение по умолчанию меня удивляет по двум причинам:
Исторически сложилось, что структуры объявляются иммутабельными, чтобы избежать ошибочных попыток модифицировать структуру путем коварного изменения копии. В первую очередь это связано с тем, что передача значимого типа в качестве параметра по определению создает копию. Непреднамеренное изменение копии может не отражается на вызывающем объекте (если не было очевидно или хорошо известно, что тип является значимым). Возможно, более коварным является член, который мутирует инстанс. Представьте себе метод
Rotate()
нашей структуры записейAngle
, который должен производить поворот своего инстансаAngle
. Вызов указанного метода из коллекции, т. е.Angle[0].Rotate(42,42,42)
, непреднамеренно не изменяет значение, хранящееся вangle[0]
.
По правде говоря, мутабельные значимые типы были гораздо более проблематичными, когда их изменение внутри коллекции было допустимо. Однако что-то вроде
Angle[0].Degrees = 42
теперь предотвращается компилятором, даже еслиDegrees
доступен для записи, что предотвращает неожиданное отсутствие измененияDegrees
.
Мутабельные структуры записей по умолчанию были бы непоследовательным решением при наличии классов записей. Последовательное поведение является сильным мотиватором, когда речь идет об обучении, понимании и запоминании.
Несмотря на эти соображения, все-таки есть пару важных доводов в пользу мутабельных структур записей:
Использование структуры в качестве ключа в словаре не сопряжено с таким же большим риском потеряться в словаре (при условии, что не предоставляется никаких самомодифицирующийся членов).
Существуют вполне разумные сценарии, в которых мутабельные поля не вызывают проблем (например, с
System.ValueTuple
).
Поддержка мутабельности и полей в структурах записей позволяет легко “проапгрейдить” кортежи до структур записей.
Структуры записей и так включают квалификатор
readonly
, который делает их иммутабельными (с помощью всего одного ключевого слова!).
Примечание: На момент написания этой статьи в начале сентября 2021 г. решение по мутабельности по умолчанию еще не принято окончательно.
Разработчикам относительно редко требуются кастомные значимые типы, поэтому я дал этой фиче тег #UsedOccasionaly
. Тем не менее, я очень оценил насколько тщательно была продумана возможность определения значимых типов записей. Что еще более важно, почти все значимые типы требуют реализации равенства. По этой причине я предлагаю вам принять на вооружение новый гайдлайн написания кода: если вам необходимо определить структуру, используйте структуры записей.
Конструкторы по умолчанию (без параметров) для структур
C# никогда не допускал использования конструкторов по умолчанию (конструктора без параметров) в структурах. Без них также не возможна поддержка инициализаторов полей в структурах, потому что у компилятора нет места для внедрения кода. В C# 10.0 этот разрыв между структурами и классами устранен благодаря возможности определять конструктор по умолчанию для структур (включая структуры записей) и разрешать инициализаторы полей (и свойств) в структурах. В следующем фрагменте кода приведен пример данной возможности:
public record struct Thing(string Name)
{
public Thing() : this("<default>")
{
Name = Id.ToString();
}
public Guid Id { get; } = Guid.NewGuid();
}
В этом примере мы определяем свойство ID, которому назначается инициализатор свойства. Кроме того, определен конструктор по умолчанию, который инициализирует свойство Name
значением Id.ToString()
. Важно отметить, что компилятор C# внедряет инициализатор поля/свойства в начало конструктора по умолчанию. Такое расположение гарантирует, что компилятор C# создаст первичный конструктор для структуры, а также гарантирует, что он вызывается до оценки (evaluation) тела конструктора по умолчанию. Концептуально это очень похоже на то, как ведут себя конструкторы классов. Инициализаторы свойств и полей также выполняются внутри сгенерированного первичного конструктора, чтобы гарантировать, что эти значения будут установлены до выполнения тела конструктора по умолчанию. В результате ID будет уже установлен к моменту выполнения определяемой пользователем части конструктора. Имейте в виду, что компилятор энфорсит вызов конструктора this()
, когда первичный конструктор указан в структуре записей.
Поскольку конструкторы по умолчанию ранее были недоступны, гайдлайны требовали, чтобы значения по умолчанию (обнуленные) были валидны даже в неинициализированном состоянии. К сожалению, то же самое верно даже при наличии конструкторов по умолчанию, потому что обнуленные блоки памяти по-прежнему используются для инициализации структур. Например, при создании массива из n
элементов Thing
, т. е. var Things = new Thing[42]
, или когда поля/свойства-члены не устанавливаются в содержащем типе до непосредственного доступа к ним.
Еще несколько улучшений из C# 10.0
C# 10.0 принес еще несколько упрощений. Я отношу их к категории “вы не знали, что это невозможно, пока не попробовали”. Другими словами, если вы сталкивались с такими сценариями раньше, вы, вероятно, сильно разочаровывались, но находили обходной путь и жили дальше. В C# 10.0 некоторые из таких проблем устранены. Ниже приведен список этих фич вместе с примерами кода.
Улучшенный анализ явного присваивания
string text = null;
if (
text?.TryIntParse(
out int number) == true)
{
number.ToString(); // Undefined error
}
Иногда область видимости параметра out
, объявленного встроенным в метод, не распространялась в блок операторов. Это усовершенствование также повышает качество анализа нулевых ссылок для ссылочных типов.
#UsedRarely
Классы записей с sealed ToString()
public record class Thing1(string Name)
{
public sealed override string
ToString() => Name;
}
Вы можете указать в классах записей метод ToString()
как sealed
, чтобы предотвратить переопределение реализации подтипами и потенциальное искажение изначальной цели метода. Это невозможно в структурах записей, поскольку наследование там не поддерживается.
#UsedRarely
Расширенная директива #Line
#line 42 "8. LineDirectiveTests.cs"
throw new Exception();
// ^
// |
// Column 13
#line default
Это идентифицирует начальный символ в директиве #line
на основе первого символа следующей строки. В этом примере первый символ t
в throw
определяет номер столбца.
#UsedRarely
Переопределение AsyncMethodBuilder
[AsyncMethodBuilder(
typeof(AsyncValueTaskMethodBuilder))]
public readonly struct ValueTask :
lEquatable<ValueTask>
{
//…
}
Это позволяет каждому асинхронному методу указывать собственный AsyncMethodBuilder
, а не полагаться только на построитель, указанный в классе.
#UsedRarely
Расширенные шаблоны свойств
// C# 8.0 syntax:
// if(person is
// { Name: {Length: :0} }) {}
if (person is { (Name.Length: 0 })
{
throw new InvalidOperationException(
@$"Invalid {
nameof(Person.Name)}.");
}
Вместо использования фигурных скобок для обхода цепочки свойств C# 10.0 допускает “точечную” нотацию, которую куда легче воспринимать. Забегая вперед, вполне разумным гайдлайном по написанию кода будет: обязательно используйте синтаксис точечной нотации при сопоставлении шаблонов свойств.
#UsedOccasionally
Улучшение интерполяции строк
Разрешение ассоциации с константной интерполированной строкой — это значительное улучшение производительности для интерполированных строк в целом. В прошлом интерполированная строка в конечном итоге приводила к вызову string.Format()
, что является неэффективной реализацией, учитывая чрезмерное упаковывание, вероятную аллокацию массива аргументов, инстанцирование строки и невозможность использовать Span. Многое из этого было исправлено в улучшениях компилятора NET 6.0 и C#. Подробности доступны в отличной статье Стивена Туба (Stephen Toub) “Интерполяция строк в C# 10.0 и NET 6”, которую можно найти по ссылке devblogs.microsoft.com/dotnet/string-interpolation-in-c-10-and-net-.6/.
#UsedFrequently
Что не попало в C# 10.0
Несколько запланированных фич C# 10.0 все-таки не дошли до релиза.
nameof(parameter)
внутри конструктора атрибута не будет поддерживаться.
Не будет оператора проверки параметра на
null
, который декорирует параметр, а затем выдает исключение, если используется значениеnull
.
Общие (generic) атрибуты, которые включают параметр типа при использовании атрибута.*
Статические абстрактные члены в интерфейсах, вынуждающие реализующий тип предоставлять такие член.*
Обязательные свойства, чтобы гарантировать, что значение будет установлено способом, проверяемым во время компиляции, до завершения построения.
Ключевое слово поля, которое практически устраняет необходимость в объявлении отдельного поля. *
Элементы со звездочкой (*) доступны в предварительной версии C# 10.0, но ожидается, что они будут удалены в финальном релизе.
Из этих фичя больше всего с нетерпением ждал появления оператора проверки на null, но в то же время я надеюсь, что появится более универсальное решение, обеспечивающее проверку параметров не только на null
. Поддержка nameof(parameter)
в атрибутах метода также будет полезна как для CallerArgumentExpression
, так и для ASP.NET и Entity Framework.
Заключение
C# 10.0 привносит множество относительно небольших “улучшений”; Я не рассматриваю их как новые фичи. Скорее, это те вещи, которые, как я ранее предполагал, уже возможны, но наткнулся бы на ошибку компилятора после написания кода. Теперь, с улучшениями C# 10.0, я, скорее всего, больше не вспомню то время, когда они не работали.
Помимо улучшений, по общему мнению, в C# 10.0 нет ничего революционного, но он, безусловно, включает некоторые фичи, которые изменят способ написания кода: глобальное использование директив и файловых объявлений пространств имен, и это лишь некоторые из них. Хотя это то, что я редко буду писать сам, я очень хочу, чтобы разработчики библиотек логирования, отладки и модульного тестирования обновили свои API с поддержкой атрибутов выражений аргументов вызывающей стороны. Новые API упростят диагностику. И, хотя я думаю, что определение пользовательских значения требуется довольно редко, добавление структур записей, безусловно, упрощает эту задачу благодаря встроенной реализации равенства. По одно только этой причине я подозреваю, что редко будет определяться пользовательский значимый тип без использования структуры записей.
Подытожив, C# 10.0 — долгожданное дополнение со здоровым набором фич и улучшений — на самом деле достаточно улучшений, чтобы я теперь фрустрировал всякий раз, когда мне придется программировать на более ранней версии C#.
Приглашаем всех желающих на открытое занятие «Развертывание ASP.NET Core приложений в Azure». На вебинаре мы рассмотрим, что из себя представляет облачная платформа Azure, а также проведем демо по развертыванию ASP.NET Core приложения с помощью Azure App Service. Регистрация доступна по ссылке.