Новые возможности C# 9.0

В C# 9.0 добавлены следующие функции и улучшения языка C#.

C# 9.0 поддерживается в .NET 5. Дополнительные сведения см. в статье Управление версиями языка C#.

Вы можете скачать последний пакет SDK для .NET на странице скачиваемых файлов .NET.

Типы записей

В C# 9.0 появились типы записей. Вы можете использовать ключевое слово 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 создается для каждого позиционного параметра, предоставленного в объявлении записи.

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

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

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

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

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

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

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

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

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
}

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

Дополнительные сведения см. в разделе Равенство значений статьи, посвященной записям, в справочнике по языку C#.

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

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

Дополнительные сведения см. в разделе Обратимое изменение статьи, посвященной записям, в справочнике по языку C#.

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

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

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

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

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

Дополнительные сведения см. в разделе Встроенное форматирование статьи, посвященной записям, в справочнике по языку C#.

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

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

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

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.

В выходные данные 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 }
}

Дополнительные сведения см. в разделе Наследование статьи, посвященной записям, в справочнике по языку C#.

Методы задания только инициализации

Методы задания только для инициализации обеспечивают единообразный синтаксис для инициализации членов объекта. Инициализаторы свойств позволяют ясно понять, какое значение задает то или иное свойство. Недостаток заключается в том, что эти свойства должны быть устанавливаемыми. Начиная с C# 9.0, для свойств и индексаторов можно создавать методы доступа init, а не методы доступа set. Вызывающие объекты могут использовать синтаксис инициализатора свойств для установки этих значений в выражениях создания, но после завершения конструирования эти свойства будут доступны только для чтения. Методы задания только для инициализации предоставляют окно для изменения состояния. Это окно закрывается, когда завершается этап конструирования. Этап конструирования фактически завершается после всех инициализаций, включая инициализаторы свойств и выражения with.

Можно объявить методы задания только для инициализации (init) в любом написанном вами типе. Ниже приведен пример определения структуры наблюдения за погодой.

public struct WeatherObservation
{
    public DateTime RecordedAt { get; init; }
    public decimal TemperatureInCelsius { get; init; }
    public decimal PressureInMillibars { get; init; }

    public override string ToString() =>
        $"At {RecordedAt:h:mm tt} on {RecordedAt:M/d/yyyy}: " +
        $"Temp = {TemperatureInCelsius}, with {PressureInMillibars} pressure";
}

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

var now = new WeatherObservation 
{ 
    RecordedAt = DateTime.Now, 
    TemperatureInCelsius = 20, 
    PressureInMillibars = 998.0m 
};

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

// Error! CS8852.
now.TemperatureInCelsius = 18;

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

Дополнительные сведения см. в разделе, посвященном ключевому слову init (справочник по C#).

Инструкции верхнего уровня

Инструкции верхнего уровня избавляют от ненужных формальностей во многих приложениях. Рассмотрим каноническую программу Hello World!. .

using System;

namespace HelloWorld
{
    class Program
    {
        static void Main(string[] args)
        {
            Console.WriteLine("Hello World!");
        }
    }
}

Здесь только одна строка кода, которая что-то делает. С помощью инструкций верхнего уровня можно заменить весь этот шаблон директивой using и одной строкой, которая делает всю работу:

using System;

Console.WriteLine("Hello World!");

Если требуется однострочная программа, можно удалить директиву using и использовать полное имя типа.

System.Console.WriteLine("Hello World!");

Только один файл в приложении может использовать инструкции верхнего уровня. Если компилятор обнаруживает операторы верхнего уровня в нескольких исходных файлах, это приводит к ошибке. Ошибка также возникает, если объединить операторы верхнего уровня с объявленным методом точки входа программы (как правило, это метод Main). В определенном смысле можно сказать, что один файл содержит инструкции, которые обычно находятся в методе Main класса Program.

Одним из наиболее распространенных применений этой функции является создание обучающих материалов. Начинающие разработчики на языке C# могут написать каноническую программу Hello World! в одной-двух строках кода. Никакие дополнительные формальности не требуются. Но и опытные разработчики также найдут много применений для этой функции. Инструкции верхнего уровня позволяют экспериментировать в стиле написания сценариев, аналогично записным книжкам Jupyter. Инструкции верхнего уровня отлично подходят для небольших консольных и служебных программ. Функции Azure являются идеальным примером использования операторов верхнего уровня.

Что важнее всего, инструкции верхнего уровня не ограничивают область применения или сложность приложения. Эти инструкции могут обращаться к любому классу .NET и использовать его. Они также не ограничивают использование аргументов командной строки и возвращаемых значений. Инструкции верхнего уровня могут обращаться к массиву строк с именем args. Если инструкции верхнего уровня возвращают целочисленное значение, это значение преобразуется в целочисленный код возврата из синтезированного метода Main. Инструкции верхнего уровня могут содержать асинхронные выражения. В этом случае синтезированная точка входа возвращает Task или Task<int>.

Дополнительные сведения см. в статье Инструкции верхнего уровня руководства по программированию на C#.

Улучшения сопоставления шаблонов

C# 9 включает новые улучшения сопоставления шаблонов.

  • Шаблоны типов проверяют соответствие переменной определенному типу.
  • Шаблоны в круглых скобках усиливают или подчеркивают приоритет сочетаний шаблонов.
  • В шаблонах конъюнкций and требуется соответствие обоих шаблонов.
  • В шаблонах дизъюнкций or требуется соответствие хотя бы одного из шаблонов.
  • В шаблонах not с отрицанием требуется несоответствие данного шаблона.
  • В шаблонах сравнения требуется, чтобы входные данные были меньше, больше, меньше или равны, больше или равны данной константе.

Эти шаблоны обогащают синтаксис шаблонов. Рассмотрим следующие примеры.

public static bool IsLetter(this char c) =>
    c is >= 'a' and <= 'z' or >= 'A' and <= 'Z';

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

public static bool IsLetterOrSeparator(this char c) =>
    c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or '.' or ',';

Одним из наиболее распространенных применений нового синтаксиса является проверка значения на null.

if (e is not null)
{
    // ...
}

Любой из этих шаблонов можно использовать в любом контексте, где разрешены шаблоны: выражения с шаблоном is, выражения switch, вложенные шаблоны и шаблоны метки case оператора switch.

Дополнительные сведения см. в разделе Шаблоны (справочник по C#).

Дополнительные сведения см. в разделах Реляционные шаблоны и Логические шаблоны статьи Шаблоны.

Производительность и взаимодействие

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

Целые числа собственного размера nint и nuint являются целочисленными типами. Они выражаются базовыми типами System.IntPtr и System.UIntPtr. Компилятор предоставляет дополнительные преобразования и операции для этих типов в качестве собственных целых чисел. Целые числа собственного размера определяют свойства для MaxValue или MinValue. Такие значения не могут быть выражены как константы времени компиляции, так как они зависят от собственного размера целого числа на целевом компьютере. Во время выполнения эти значения доступны только для чтения. Для nint можно использовать значения констант в диапазоне [int.MinValue .. int.MaxValue]. Для nuint можно использовать значения констант в диапазоне [uint.MinValue .. uint.MaxValue]. Компилятор выполняет сворачивание константы для всех унарных и бинарных операторов, используя типы System.Int32 и System.UInt32. Если результат не помещается в 32 бит, операция выполняется во время выполнения и не считается константой. Целые числа собственного размера могут повысить производительность в сценариях с большим количеством целочисленных вычислений, в которых необходимо обеспечить максимально высокую производительность. Дополнительные сведения см. в статье о типах nint и nuint.

Указатели функций предоставляют простой синтаксис для доступа к кодам операций IL ldftn и calli. Указатели функций можно объявлять с помощью нового синтаксиса delegate*. Тип delegate* — это тип указателя. При вызове типа delegate* используется calli, в отличие от делегата, который использует callvirt в методе Invoke(). Синтаксически вызовы являются идентичными. При вызове указателя функции используется соглашение о вызовах managed. Если требуется объявить о соглашении о вызовах unmanaged, добавьте ключевое слово unmanaged после синтаксиса delegate*. Другие соглашения о вызовах можно указать с помощью атрибутов в объявлении delegate*. Дополнительные сведения см. в разделе Небезопасный код и типы указателей.

Наконец, можно добавить атрибут System.Runtime.CompilerServices.SkipLocalsInitAttribute, чтобы компилятор не создавал флаг localsinit. Этот флаг указывает среде CLR на нулевую инициализацию всех локальных переменных. Флаг localsinit используется в C# по умолчанию, начиная с версии 1.0. Однако при использовании дополнительной нулевой инициализации в некоторых сценариях может снизиться производительность. В частности, при использовании stackalloc. В таких случаях можно добавить атрибут SkipLocalsInitAttribute. Его можно добавить в один метод или свойство, в class, struct, interface или даже в модуль. Этот атрибут не влияет на методы abstract. Он влияет на код, созданный для реализации. Дополнительные сведения см. в разделе об атрибуте SkipLocalsInit.

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

Функции подбора и завершения

Многие другие функции позволяют более эффективно писать код. В C# 9.0 можно опустить тип в выражении new, если тип созданного объекта уже известен. Наиболее часто это используется в объявлениях полей.

private List<WeatherObservation> _observations = new();

new с целевым типом можно также использовать, если необходимо создать объект для передачи его в качестве аргумента в метод. Рассмотрим метод ForecastFor() со следующей сигнатурой.

public WeatherForecast ForecastFor(DateTime forecastDate, WeatherForecastOptions options)

Его можно вызвать следующим образом.

var forecast = station.ForecastFor(DateTime.Now.AddDays(2), new());

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

WeatherStation station = new() { Location = "Seattle, WA" };

Экземпляр, созданный конструктором по умолчанию, можно вернуть с помощью инструкции return new();.

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

Начиная с C# 9.0, можно добавлять модификатор static в лямбда-выражения или анонимные методы. Статические лямбда-выражения аналогичны локальным функциям с модификатором static: статические лямбда-выражения и анонимные методы не могут захватывать локальные переменные и состояние экземпляра. Модификатор static предотвращает случайное захватывание других переменных.

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

Кроме того, цикл foreach будет распознавать и использовать метод расширения GetEnumerator, который в противном случае удовлетворяет шаблону foreach. Это изменение означает, что foreach согласуется с другими конструкциями на основе шаблонов, такими как асинхронная модель и деконструирование на основе шаблона. На практике это изменение означает, что можно добавить поддержку foreach в любой тип. При перечислении объектов имеет смысл ограничить его использование.

Кроме того, можно использовать пустые переменные в качестве параметров для лямбда-выражений. Это удобное изменение позволяет избежать присвоения имени аргументу, а компилятор может избежать его использования. Для любого аргумента используется символ _. Дополнительные сведения см. в разделе Входные параметры лямбда-выражения статьи о лямбда-выражениях.

Наконец, теперь можно применять атрибуты к локальным функциям. Например, к локальным функциям можно применить заметки атрибутов, допускающих значение null.

Поддержка генераторов кода

Две заключительные функции обеспечивают поддержку генераторов кода в C#. Генераторы кода C# — это компонент, который вы можете написать подобно анализатору или исправлению кода Roslyn. Разница заключается в том, что генераторы кода анализируют код и пишут новые файлы исходного кода в рамках процесса компиляции. Типичный генератор кода ищет в коде атрибуты или другие соглашения.

Генератор кода считывает атрибуты или другие элементы кода с помощью анализирующих интерфейсов API Roslyn. На основе этой информации он добавляет новый код в компиляцию. Генераторы исходного кода могут только добавлять код, они не могут изменять существующий код в компиляции.

Этими двумя функциями, добавленными для поддержки генераторов кода, являются расширения для синтаксиса разделяемого метода _ и _инициализаторов модулей**. Сначала рассмотрим изменения в разделяемые методы. До C# 9.0 разделяемые методы были private, но не могли иметь модификаторов доступа, иметь возвращаемое значение void и параметры out. Эти ограничения подразумевают, что, если реализация метода не предоставлена, компилятор удаляет все вызовы к разделяемому методу. В C# 9.0 эти ограничения снимаются, но требуется, чтобы объявления разделяемых методов имели реализацию. Генераторы кода могут предоставить такую реализацию. Чтобы избежать критических изменений, компилятор рассматривает любой разделяемый метод без модификатора доступа как метод, следующий старым правилам. Если разделяемый метод включает модификатор доступа private, этот разделяемый метод обрабатывается в соответствии с новыми правилами. Дополнительные сведения см. в разделе о разделяемом методе (Справочник по C#).

Второй новой функцией для генераторов кода являются инициализаторы модулей. Инициализаторы модулей — это методы, к которым прикреплен атрибут ModuleInitializerAttribute. Эти методы будут вызываться средой выполнения до доступа к полю или вызова метода в целом модуле. Метод инициализатора модуля:

  • должен быть статическим;
  • должен быть без параметров;
  • должен возвращать значение void;
  • не должен быть универсальным методом;
  • не должен содержаться в универсальном классе;
  • должен быть доступен из содержащего модуля.

Последний пункт фактически означает, что метод и содержащий его класс должны быть внутренними или открытыми. Метод не может быть локальной функцией. Дополнительные сведения см. в разделе об атрибуте ModuleInitializer.