>
>
>
Обзор нововведений в C# 9

Константин Волоховский
Статей: 14

Обзор нововведений в C# 9

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

Свойства только для инициализации

В C# появилось новое ключевое слово – init. Свойство с init нельзя будет изменять после того, как объект инициализирован. Можно ли было запрограммировать что-то подобное раньше? Через конструктор – да, но вот через инициализатор бы уже не вышло:

public class PersonClass
{
    public string Name { get;}
    public string Surname { get; set; }
}

public static void Main()
{
    var person = new PersonClass() { Name = "Silver", Surname = "Chariot" };
    //Error CS0200
    //Property or indexer 'PersonClass.Name' cannot be assigned
    //to --it is read only
 }

Изменим код на использование init:

public class PersonClass
{
    public string Name { get; init; }
    public string Surname { get; init; }
}

public static void Main()
{
    var person = new PersonClass() { Name = "Silver", Surname = "Chariot" };
    //Ошибки нет
    person.Name = "Hermit";
    //Error CS8852
    //Init-only property or indexer 'PersonClass.Name' can only be assigned
    //in an object initializer, or on 'this' or 'base'
    //in an instance constructor or an 'init' accessor.
}

Записи

Что это такое?

Одним из главных нововведений в C# 9 стало добавление нового типа record. Запись — новый ссылочный тип, который можно создать вместо классов или структур. Для прояснения разницы между ними рассмотрим свойства, которыми обладает новый тип.

Позиционный синтаксис

Так, уже с этапа объявления нас встречает несколько новшеств. Конечно, можно по старинке объявить тело записи, как это было с классами и структурами, но есть и сокращённая запись:

public record PersonRecord(string Name, string Surname);

Разворачивается конструкция примерно так:

public record PersonRecord
{
    public string Name { get; init; }
    public string Surname { get; init; }

    public PersonRecord(string name, string surname)
    {
        Name = name;
        Surname = surname;
    }
    public void Deconstruct(out string name, out string surname)
    {
        name = Name;
        surname = Surname;
    }
}

Окей, появился деконструктор, а ещё? Правильно, здесь используется уже упомянутое ключевое слово init вместо set. Следовательно, по умолчанию записи неизменяемы – для таких случаев они в основном и предназначены.

Деконструктор позволяет получить значения всех параметров объявленной таким образом записи при инициализации переменных:

var person = new PersonRecord("Silver", "Chariot");
var (name, surname) = person;

Изменить такую запись не выйдет:

person.Name = "Hermit";
//Error CS8852
//Init - only property or indexer 'PersonRecord.Name' can only be assigned
//in an object initializer, or on 'this' or 'base'
//in an instance constructor or an 'init'

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

public record PersonRecord(string Name, string Surname)
{
    public string Name { get; set; } = Name;
    public string Surname { get; set; } = Surname;
    public string GetFullName()
        => Name + " " + Surname;
}
public static void Main()
{
    var person = new PersonRecord("Hermit", "Purple");
    person.Name = "Silver";
    Console.WriteLine(person.GetFullName());
    //Silver Purple
}

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

Как мы знаем, у структур нет переопределённых операторов сравнения, и, проверяя на равенство экземпляры классов, мы сравниваем не данные внутри объектов, а ссылки на них. А теперь посмотрим на то, как это происходит у записей:

public record Person(string Name, string Surname);

public static void Main()
{
    var first = new Person("Hermit", "Purple");
    var second = new Person("Hermit", "Purple");
    Сonsole.WriteLine(first == second);
    //true
}

Всё верно, сравнение ведётся по значениям полей записи. Операторы "==" и "!=", а также метод Object.Equals(Object) переопределены заранее, и утруждать себя нам не стоит.

Метод ToString

Кстати, о переопределённых методах. ToString тоже переопределён. Если для структур и классов просто возвращаются их названия, то запись вернёт и содержимое:

var personRecord = new PersonRecord("Moody", "Blues");
var personStruct = new PersonStruct("Moody", "Blues");
var personClass = new PersonClass("Moody", "Blues");

Console.WriteLine(personRecord.ToString());
Console.WriteLine(personStruct.ToString());
Console.WriteLine(personClass.ToString());

//PersonRecord { Name = Moody, Surname = Blues }
//PersonStruct
//PersonClass

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

Ранее как-то не представилось возможности упомянуть, что в IL коде записи являются классами. Это так, но отождествлять их не стоит. Хотя записи и поддерживают наследование, от классов наследоваться они не могут (что, впрочем, не касается реализации интерфейсов).

У наследования можно выделить несколько интересных моментов. Рассмотрим следующий пример:

public record Person(string Name, string Surname);
public record PersonEnglish(string Name, string MiddleName, string Surname)
    : Person(Name, Surname);

public static void Main()
{
    var person = new Person("Tom", "Twain");
    var englishPerson = new PersonEnglish("Tom", "Finn", "Twain");

    Console.WriteLine(englishPerson);
    //PersonEnglish { Name = Tom, Surname = Twain, MiddleName = Finn }

    var (one, two, three) = englishPerson;
    Console.WriteLine(one + " " + two + " " + three);
    //Tom Finn Twain
}

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

Ещё кое-что интересное можно обнаружить при сравнении записей между собой. Посмотрим на следующий код:

public record Person(string Name, string Surname);
public record Teacher(string Name, string Surname, int Grade)
    : Person(Name, Surname);
public record Student(string Name, string Surname, int Grade)
    : Person(Name, Surname);
public static void Main()
{
    Person teacher = new Teacher("Tom", "Twain", 3);
    Person student = new Student("Tom", "Twain", 3);
    Console.WriteLine(teacher == student);
    //false
    Student student2 = new Student("Tom", "Twain", 3);
    Console.WriteLine(student2 == student);
    ///true
}

В данном примере все экземпляры имеют одинаковый набор свойств и их значений. И, тем не менее, переменные, объявленные как Person, дают при сравнении false, а сравнение Person и Student даёт true. Такое поведение достигается за счёт того, что метод равенства учитывает тип времени выполнения при сравнении.

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

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

var person = new Person("Tom", "Twain");
var another = person with { Name = "Finn" };

Console.WriteLine(another);
//Person { Name = Finn, Surname = Twain } 

var another2 = another with { };
Console.WriteLine(another == another2);
//true

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

Где использовать?

Для кого и чего в язык был добавлен record? Как я уже говорил, предполагается, что записи неизменяемы. Следовательно, использовать их следует в тех ситуациях, когда нам нужен неизменяемый набор данных (без методов). Например, это функциональное программирование, которое представляет программу как последовательность чистых функций, поэтому неизменяемость для него очень важна.

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

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

Хорошие новости: на C# стало писать ещё приятнее! Ну, по крайней мере новичкам, а также тем, кто хочет что-то быстро проверить. Например, чтобы написать статью о новом C#.

Благодаря инструкциям верхнего уровня нам больше не нужно тащить за собой громоздкие конструкции пространств имён и классов. То есть "Hello World" может выглядеть не так:

using System;

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

А так:

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

Конечно, написать полноценное приложение так не выйдет – инструкции верхнего уровня может иметь лишь один файл. Впрочем, такой цели и не стояло. Интересно другое – по факту наш метод Main никуда не делся, более того – в нём выполнение кода и происходит. Из-за этого, во-первых, мы не можем его переопределить, а во-вторых, имеем возможность получить доступ к args:

using System;
Console.WriteLine(args[0]);

static void Main(string[] args)
    //Warning CS7022: The entry point of the program is global code;
    //ignoring 'Main(string[])'{
    Console.WriteLine(args[1]);
}

Запустим программу через терминал:

TestApp.exe Hello World!
Hello

Target typing

new()

Термином target typing называется получение типа переменной из контекста, и именно это в C# 9 решили улучшить.

Первым нас встречает новый... new. По сути, новый синтаксис new – это var наоборот. Если тип переменной уже известен (например, из объявления), то в выражении new его можно опустить:

ObservableCollection<string> collection = new();
Person person = new("Hermit", "Purple");

К сожалению, C# пока не умеет читать мысли, так что такую запись он не поймёт:

var collection = new();
//Error CS8754 There is no target type for 'new()'

Остаётся один достаточно правильный вопрос – где его использовать? Уже существует общепринятый var, а теперь у нас есть две тождественные формы сокращённой записи:

var collection = new ObservableCollection<string>();
ObservableCollection<string> collection = new();

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

public Dictionary<int,List<string>> field = new Dictionary<int,List<string>>();

В классе бы это нововведение выглядело так:

public class School
{
    ObservableCollection<Student> studentList = new();
    ObservableCollection<Teacher> teacherList = new();
}

Операторы ?? и ?:

Тернарный оператор теперь может лучше понимать аргументы разных типов, из-за чего мы можем обойтись без явного приведения:

Person person = expr ? student : teacher;

В ранних статьях по превью C# 9 сообщалось, что оператор нулевого слияния также сможет обрабатывать разные типы с общим базовым классом, но, видимо, до релиза это не добралось:

Person person = student ?? teacher;
//Error CS0019
//Operator '??' cannot be applied to operands of type 'Student' and 'Teacher'

Ковариантный возвращаемый тип

В дочерних классах теперь можно переопределять возвращаемый тип. Разумеется, никакой динамической типизации нет – переопределять можно только типы, связанные наследованием. Данное изменение призвано облегчить жизнь при работе с паттерном "Фабричный метод". Приведу простой пример: в видеоигре есть разные типы торговцев, продающих товары разных типов (обладающих разными свойствами):

public abstract class Item
{
    ....
}
public class MagicItem : Item
{
    ....
}
public class WeaponItem : Item
{
    ....
}
public abstract class Merchant
{
    ....
    public abstract Item BuyItem();
}

public class MagicianMerchant : Merchant
{
    ....
    public override MagicItem BuyItem() { return new MagicItem(); }
}

public class WeaponMerchant : Merchant
{
    ....
    public override WeaponItem BuyItem() { return new WeaponItem(); }
}

public static void Main()
{
    var magician = new MagicianMerchant();
    var blacksmith = new WeaponMerchant();

    MagicItem boughtItem1 = magician.BuyItem();
    WeaponItem boughtItem2 = blacksmith.BuyItem();

}

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

MagicItem boughtItem1 = (MagicItem)magician.BuyItem();
WeaponItem boughtItem2 = (WeaponItem)blacksmith.BuyItem();

Кстати, если Item будет интерфейсом, то такая фишка ещё сработает, а если Merchant – нет.

Статические лямбда-выражения и анонимные функции

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

double RequiredScore = 4.5;
var students = new List<Student>() 
{ 
    new Student("Hermit", "Purple", average: 4.8),
    new Student("Hierophant", "Green", average: 4.1),
    new Student("Silver", "Chariot", average: 4.6)
};

var highScoreStudents =
    students.Where(static x => x.AverageScore > RequiredScore);
//Error CS8820
//A static anonymous function cannot contain a reference to 'RequiredScore'

При этом ссылки на константы передать можно:

const double RequiredScore = 4.5;
var students = new List<Student>() 
{ 
    new Student("Hermit", "Purple", average: 4.8),
    new Student("Hierophant", "Green", average: 4.1),
    new Student("Silver", "Chariot", average: 4.6)
};

var highScoreStudents =
    students.Where(static x => x.AverageScore > RequiredScore);
//Ошибки нет

Discard для параметров анонимных и лямбда функций

Здесь же упомяну ещё одно маленькое улучшение – если нам не нужны параметры в выражении, то можно оставить на их месте знак нижнего подчёркивания. Например, если нам не нужны sender и EventArgs, можно избежать предупреждения компилятора:

button1.Click += (_, _) => ShowNextWindow();

При желании можно явно указать тип:

button1.Click += (object _, EventArgs _) => ShowNextWindow();

Поддержка расширения для GetEnumerator

Теперь foreach может распознать GetEnumerator как метод расширения, благодаря чему можно перебрать то, что раньше было нельзя. Мотивацией для введения этой особенности были случаи вроде перебора кортежа:

public static class TupleExtensions
{
    public static IEnumerator<T>
        GetEnumerator<T>(this ValueTuple<T, T, T, T> tuple)
    {
        yield return tuple.Item1;
        yield return tuple.Item2;
        yield return tuple.Item3;
        yield return tuple.Item4;
    }
}
foreach(var item in (1, 2, 3, 4))
{
   //1 2 3 4
}

Теперь можно перебрать даже Range:

public static IEnumerator<Index> GetEnumerator(this Range number)
{
    for (Index i = number.Start; i.Value < number.End.Value; i = i.Value + 1)
    {
        yield return i;
    }
}

public static void Main()
{
    foreach (var i in 1..5)
    {
        Console.WriteLine(i);
        //1 2 3 4
    }
}

Вместо диапазона можно было бы сделать то же самое для других типов, например int. Что с этим не так? В документации Microsoft прямо сказано, что те же диапазоны для этого не предназначены. Наиболее часто встречающаяся рекомендация звучит так, что использовать метод расширения GetEnumerator следует только в том случае, если это оправдано дизайном приложения. Это имеет смысл – многие могут удивиться, увидев в проекте перечисляемый Object.

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

В C# 9 у нас появилось ещё несколько ключевых слов: and, not, or. Используются они в синтаксисе шаблонов, и вместе с ними в нём стало можно использовать операторы сравнения (<, <=, >, >=) и скобки. Исчерпывающий пример использования такого синтаксиса в логическом выражении приведён далее:

public static bool IsPasses(Student student)
{
    return student is ({ AverageScore: >= 4.4, } or { Privilege: true }) 
                   and not {Department: "Central" };
}

static void Main()
{
    Student nullCandidate = null;
    var candidate = new Student(name: "Tom", surname: "Twain",
        department: "South", privilege: false, score: 4.6);

    Console.WriteLine(IsPasses(nullCandidate));
    //false

    Console.WriteLine(IsPasses(candidate));
    //true
}

Разумеется, всё это – синтаксический сахар. Тот же метод можно было бы реализовать, используя более классический синтаксис:

public static bool IsPasses2(Student student)
{
    return    student != null
           && (student.AverageScore >= 4.4 || student.Privilege == true) 
           &&  student.Department != "Central";
}

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

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

public static bool IsPassesCommon(Student student)
    => student switch
    {
        { Privilege: true} => true,
        { AverageScore: >= 3.5 } and {AverageScore: <= 4.5 } => true,
        _ => false
    };

Кстати, раньше в switch выражениях требовалось обязательно указать имя переменной (или нижнее подчёркивание вместо него) после имени класса, даже если это имя нигде не использовалось. Теперь можно его опустить:

public static bool IsStudies(Person person)
    => person switch
    {
        Student => true,
        Teacher => false,
        _ => false
    };

Атрибуты локальных функций

Здесь всё ясно из названия, теперь атрибуты можно применять и к локальным функциям. Например, атрибут Conditional:

static void Main()
{
    [Conditional("DEBUG")]
    static void PrintDebug()
    {
        Console.WriteLine("This is debug mode");
    }

    PrintDebug();
    Console.WriteLine("Hello World!");
    //Debug:
    //This is debug mode
    //Hello World!

    //Release:
    //Hello World!
    }
}

Новые типы данных и производительность

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

Новые типы данных: nint, nuint и half. Первые два, как нетрудно догадаться, — целочисленные, размер которых зависит от разрядности системы. На 32-битных — 4 байта, а на 64-битных — 8 байт. half же является 16-битным вещественным числом, чьё главное предназначение — это хранение информации, когда не требуется слишком большой точности. Да, я имел в виду только хранение, арифметические операции в счёт не включены.

Ещё два улучшения, работающие только в unsafe режиме, — это атрибут SkipLocalsInit для подавления флагов localsinit, а также указатели на функции. Из документации:

public static T UnsafeCombine<T>(delegate*<T, T, T> comb, T left, T right) => 
    comb(left, right);
....
static int localMultiply(int x, int y) => x * y;
int product = UnsafeCombine(&localMultiply, 3, 4);

Генераторы кода

Что это такое?

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

Частичные методы

Частичные методы получили обновления, продиктованные нуждами генераторов кода. Частичные методы существовали и раньше, но теперь они могут иметь возвращаемое значение, out-параметры и модификаторы доступа. В общем, ничем не отличаются от обычных:

public partial class Person
{
    public string Name { get; set; }
    public string Surname { get; set; }
    public Person(string name, string surname)
    {
        Name = name;
        Surname = surname;
    }
    public partial bool Speak(string line, out string text)
}
public partial class Person
{
    public partial bool Speak(string line, out string text)
    {
        if (string.IsNullOrEmpty(line))
            return false;

        text = Name + ": " + line; 
        Console.WriteLine(text);
        return true;
    }
}

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

Отмечу, что если частичному методу всё-таки дали модификатор доступа, то без реализации проект не скомпилируется.

ModuleInitializerAttribute

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

  • метод должен быть статическим;
  • метод должен быть обычным методом-членом (т.е. не методом доступа, конструктором, локальной функцией и пр.);
  • метод не должен иметь параметров;
  • метод должен возвращать void;
  • метод не должен быть обобщённым (универсальным) или содержаться в обобщённом классе;
  • метод должен быть доступен из содержащего модуля (иметь модификатор доступа internal или public).

Простейший пример выглядит так:

public class Program
{
    static string StartMessage;

    [ModuleInitializer]
    public static void Init()
    {
        StartMessage = "Hello World!";
    }

    static void Main()
    {
        Console.WriteLine(StartMessage);
        //Hello World!
    }
}

Также я упомянул, что инициализационных методов может быть несколько:

public class Program
{
    static string StartMessage;

    [ModuleInitializer]
    internal static void Init1()
    {
        StartMessage = "Hello World!";
    }

    [ModuleInitializer]
    internal static void Init2()
    {
        StartMessage = "foo bar";
    }

    static void Main()
    {
        Console.WriteLine(StartMessage);
        //foo bar
    }
}

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

[ModuleInitializer]
public static void Main()
{
    Console.WriteLine("Hello World!");
    //Hello World!
    //Hello World!
}

Да, он бесстыдно вызвался два раза. Думаю, понятно, почему делать Main инициализационным не стоит.

Заключение

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

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