DEV Community

Cover image for Зустрічайте C# 9.0
Oleksandr Martyniuk
Oleksandr Martyniuk

Posted on • Updated on • Originally published at martyniuk.dev

Зустрічайте C# 9.0

Це переклад статті "Welcome to C# 9.0" Медса Тоерсена - працівника Microsoft і головного дизайнера мови C#.


C# 9.0 набуває форм і я хочу поділитись нашим баченням найбільш важливих можливостей, які ми додаємо в наступну версію мови.

З кожною новою версією ми прагнемо зробити мову більш ясною і простою для загальних сценаріїв використання і C# 9.0 не є винятком. Цього разу нашим фокусом є забезпечення лаконічності в представленні даних та підтримка механізмів їх незмінності.

Ну що ж, поїхали!

Властивості з ініціалізацією

Ініціалізатори об'єктів надзвичайно корисні: вони дають програмісту дуже гнучку і, водночас, зрозумілу форму створення об'єктів. Вони особливо зручні для створення вкладених об'єктів, коли ціла ієрархія створюється однією командою. Ось приклад:

new Person
{
    FirstName = "Scott",
    LastName = "Hunter"
}
Enter fullscreen mode Exit fullscreen mode

Ініціалізатори об'єктів також звільняють автора класу від написання шаблонного коду конструкторів. Все, що потрібно, - лише створити певні властивості!

public class Person
{
    public string FirstName { get; set; }
    public string LastName { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

Проте сьогодні є одне велике обмеження: щоб ініціалізатори працювали, властивості повинні бути змінюваними (mutable). Ініціалізатори працюють завдяки виклику конструктора (без параметрів в даному випадку) і наступному присвоєнню значень через виклик сетерів властивостей.

Властивості з ініціалізацією виправлять це! Вони визначають init аксесор який дуже схожий на set аксесор, але може викликатись лише під час ініціалізації об'єкту:

public class Person
{
    public string FirstName { get; init; }
    public string LastName { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

З таким підходом код, написаний вище, все ще коректний, але всі наступні присвоєння значень властивостям FirstName і LastName викличуть помилку.

Init аксесори та поля лише для читання

Так як init аксесори можуть викликатись лише під час ініціалізації, їм дозволено змінювати поля для читання того ж класу, точно так, як ви зараз можете зробити це у конструкторі.

public class Person
{
    private readonly string firstName;
    private readonly string lastName;

    public string FirstName 
    { 
        get => firstName; 
        init => firstName = (value ?? throw new ArgumentNullException(nameof(FirstName)));
    }
    public string LastName 
    { 
        get => lastName; 
        init => lastName = (value ?? throw new ArgumentNullException(nameof(LastName)));
    }
}
Enter fullscreen mode Exit fullscreen mode

Класи даних (record)

Властивості з ініціалізацією чудово підходять, якщо ви хочете зробити певну властивість об'єкту незмінною. Якщо ж ви хочете зробити незмінним весь об'єкт, щоб він поводив себе як екземпляр типу-значення, вам необхідно визначити його як клас даних (record):

public data class Person
{
    public string FirstName { get; init; }
    public string LastName { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

Ключове слово data у визначенні позначає його як клас даних. Це додає йому певної поведінки характерної для типу-значення, яку ми розглянемо далі. Загалом, класи даних краще розглядати як "значення" (дані), а не як об'єкти. Вони не створені, щоб мати змінюваний стан. Натомість, ви виражаєте зміни у часі, створюючи нові екземпляри класу даних, що відображують новий стан. Класи даних визначаються не унікальністю посилання, а тотожністю вмісту.

Вираз with

При роботі з незмінними даними, загальний підхід полягає у створенні копії для відображення нового стану об'єкту. Для прикладу, якщо наша особа захоче змінити своє прізвище, ми реалізуємо це через створення нового об'єкту, що буде копією старого окрім зміненого прізвища. Цю техніку часто називають неруйнівною мутацією. Замість того, щоб змінювати особу з часом клас даних відображує стан особи в конкретний момент часу.

Щоб програмувати в такому стилі було легше, класи даних підтримують новий тип виразу - with:

var otherPerson = person with { LastName = "Hanselman" };
Enter fullscreen mode Exit fullscreen mode

With-вирази використовують синтаксис ініціалізаторів, щоб визначити, чим будуть відрізнятись новий і старий об'єкти. Ви можете задати відразу декілька властивостей.

Клас даних неявно включає захищений "конструктор копіювання" - це конструктор, який бере об'єкт класу даних, що існує, і копіює його поля одне за одним в новий об'єкт:

protected Person(Person original) { /* копіює всі поля */ } // автозгенерований
Enter fullscreen mode Exit fullscreen mode

Вираз with викликає конструктор копіювання і потім застосовує ініціалізатор для перевизначених властивостей, але вже до проініціалізованих раніше даних.

Якщо вас не влаштовує згенерований конструктор копіювання, ви можете визначити свій власний і він так само буде використовуватись виразом with.

Порівняння за значенням

Всі об'єкти наслідують віртуальний метод Equals(object) від класу object. Він використовується статичним методом Object.Equals(object, object) коли обидва параметри не дорівнюють null.

Структури перевизначають його, щоб отримати "порівняння за значенням". Це коли поля структури порівнюються рекурсивно через виклик Equals. Класи даних роблять так само.

Це означає те, що згідно з їхньою "значеністю", два об'єкти класу даних можуть бути рівними, будучи різними екземплярами одного типу. Для прикладу, якщо ми повернемо назад прізвище у раніше зміненої особи:

var originalPerson = otherPerson with { LastName = "Hunter" };
Enter fullscreen mode Exit fullscreen mode

Тепер ми мали б ReferenceEquals(person, originalPerson) = false (це різні екземпляри), але Equals(person, originalPerson) = true (вони містять однакові дані).

Якщо вам не підходить порівняння по полях, що визначається початково, ви можете написати своє. Але треба бути обережним і розуміти, як працює порівняння за значеннями в структурах, особливо якщо є наслідування (до якого ми ще повернемося нижче).

Поряд з перевизначенням Equals перевизначається також GetHashCode(), бо вони працюють у парі.

Поля класів даних

Класи даних задумувались незмінюваними і такими, що містять лише публічні властивості з ініціалізаторами. Класи даних можуть змінюватись в не деструктивний спосіб завдяки with-виразам. Для того, щоб спростити визначення класів даних для цього поширеного випадку, синтаксис класу даних змінює семантику, що несе string FirstName. Замість неявного приватного поля, як це було б у визначенні класу чи структури, в синтаксисі класу даних це означає публічну автовластивість з ініціалізатором! Таким чином, визначення:

public data class Person { string FirstName; string LastName; }
Enter fullscreen mode Exit fullscreen mode

Означає в точності те ж, що ми мали раніше:

public data class Person
{
    public string FirstName { get; init; }
    public string LastName { get; init; }
}
Enter fullscreen mode Exit fullscreen mode

Ми вважаємо, це дозволяє зробити визначення класу даних чистим і красивим. Якщо вам дійсно потрібне приватне поле, ви завжди можете додати модифікатор private явно:

private string firstName;
Enter fullscreen mode Exit fullscreen mode

Позиційні класи даних

Інколи зручно використовувати більш позиційний підхід до класів даних, при якому дані передаються через аргументи конструктора і можуть бути отримані назад завдяки позиційному деконструюванню.

Абсолютно допустимим є визначення власного конструктора і деконструктора класу даних:

public data class Person 
{ 
    string FirstName; 
    string LastName; 
    public Person(string firstName, string lastName) 
      => (FirstName, LastName) = (firstName, lastName);
    public void Deconstruct(out string firstName, out string lastName) 
      => (firstName, lastName) = (FirstName, LastName);
}
Enter fullscreen mode Exit fullscreen mode

Але існує набагато коротший синтаксис для вираження того самого (зверніть увагу на регістр імен параметрів):

public data class Person(string FirstName, string LastName);
Enter fullscreen mode Exit fullscreen mode

Цей запис визначає публічні автовластивості і конструктор з деконструктором, тож ви можете написати:

var person = new Person("Scott", "Hunter"); // позиціне конструювання
var (f, l) = person;                        // позиціне деконструювання
Enter fullscreen mode Exit fullscreen mode

Якщо вам не подобається згенерована автовластивіть, ви можете натомість визначити свою власну з тим же іменем, і згенеровані конструктор та деконструктор будуть її використовувати.

Класи даних і змінюваний стан

Семантика значення не дуже добре поєднується зі змінюваним станом. Уявіть, ми помістили об'єкт класу даних в словник. Його подальший пошук залежить від Equals та (інколи) GethashCode. Але, якщо клас даних змінює свій стан, він також змінює свою еквівалентність! Ми можемо не знайти його знову! В реалізації хеш таблиці це може пошкодити структуру даних, бо розміщення об'єкту грунтується на хеш коді, який він має в момент запису у таблицю.

Напевно, є допустимі приклади використання змінюваного стану класів даних, зокрема для кешування. Але щоб перевизначити поведінку так, щоб ігнорувати цей стан, доведеться докласти значних зусиль.

With-вираз та наслідування

Порівняння за значенням та недеструктивна мутація значно ускладнюються, коли поєднуються з наслідуванням. Давайте додамо похідний клас даних Student до раніше розглянутого прикладу:

public data class Person { string FirstName; string LastName; }
public data class Student : Person { int ID; }
Enter fullscreen mode Exit fullscreen mode

Почнемо наш приклад зі створення екземпляру Student, але збережемо його у змінній типу Person:

Person person = new Student { FirstName = "Scott", LastName = "Hunter", ID = GetNewId() };
otherPerson = person with { LastName = "Hanselman" };
Enter fullscreen mode Exit fullscreen mode

В останньому рядку з with-виразом компілятор не знає, що person фактично містить екземпляр Student. Тим не менш, новий екземпляр otherPerson не був би коректною копією, якби він не був екземпляром Student і не містив той самий ID, що і оригінальний об'єкт.

C# робить це за нас. Класи даних містять прихований віртуальний метод, якому доручено клонування цілого об'єкту. Кожен похідний тип класу даних перевизначає цей метод і викликає конструктор копіювання для цього типу. Цей конструктор викликає аналогічний конструктор базового типу. With-вираз просто викликає цей прихований метод клонування і застосовує ініціалізатор об'єкту до результату.

Порівняння за значенням і наслідування

Подібно до реалізації with-виразів, порівняння за значенням також повинно бути "віртуальним" в тому сенсі, що для порівняння двох екземплярів типу Student повинні бути порівняні всі поля типу Student, навіть якщо тип посилання на момент порівняння - це базовий тип, наприклад, Person. Цього легко досягти, перевизначивши метод Equals, що наразі вже є віртуальним.

Проте, є ще одна проблема з еквівалентністю. Що, як ви порівнюєте два різних підтипи базового типу Person? Ми не можемо дозволити вибирати метод Equal якого типу використовувати: еквівалентність повинна бути симетричною. Тож результат не повинен залежати від порядку об'єктів. Іншими словами, вони повинні самі узгодити, чию еквівалентність застосовувати.

Цей приклад ілюструє проблему:

Person person1 = new Person { FirstName = "Scott", LastName = "Hunter" };
Person person2 = new Student { FirstName = "Scott", LastName = "Hunter", ID = GetNewId() };
Enter fullscreen mode Exit fullscreen mode

Чи рівні ці два об'єкти? person1 може думати, що так, адже person2 має всі поля типу Person з тими ж значеннями. Але person2 з цим не погодиться! Ми повинні бути впевненими, що вони обоє розуміють, що вони все ж різні об'єкти.

І знову, C# подбав про це. Спосіб, в який це реалізовано: класи даних мають віртуальну захищену властивість EqualityContract. Кожен похідний клас даних перевизначає її і для того, щоб два класи даних були однаковими, вони повинні мати один і той самий EqualityContract.

Програми найвищого рівня

Написання простої програми на C# вимагає значної кількості шаблонного коду:

using System;
class Program
{
    static void Main()
    {
        Console.WriteLine("Hello World!");
    }
}
Enter fullscreen mode Exit fullscreen mode

Це не тільки важко сприймати тим, хто тільки починає вивчати мову, а ще й захаращує код та додає зайві рівні відступів.

Натомість, в C# 9.0 ви можете просто писати вашу програму на найвищому рівні:

using System;

Console.WriteLine("Hello World!");
Enter fullscreen mode Exit fullscreen mode

Будь-які вирази дозволені. Програма може початися відразу після списку using і перед визначенням будь якого типу чи простору імен. Але ви можете так робити тільки в одному файлі, точно так як зараз ви можете мати лише один метод Main.

Якщо ви хочете повернути код статусу, ви можете це зробити. Якщо ви хочете очікувати задачу з await, ви можете це зробити. І якщо ви хочете отримати доступ до аргументів командного рядку, вони доступні як "магічний" параметр args.

Локальні функції - це також вирази і вони так само дозволені на найвищому рівні. Але, у разі виклику їх поза областю програми найвищого рівня, буде згенеровано помилку.

Вдосконалене співставлення за шаблоном

Декілька нових типів шаблонів порівняння були додані в C# 9.0. Давайте подивимось на них в контексті цього фрагменту коду з навчального посібника:

public static decimal CalculateToll(object vehicle) =>
    vehicle switch
    {
       ...

        DeliveryTruck t when t.GrossWeightClass > 5000 => 10.00m + 5.00m,
        DeliveryTruck t when t.GrossWeightClass < 3000 => 10.00m - 2.00m,
        DeliveryTruck _ => 10.00m,

        _ => throw new ArgumentException("Not a known vehicle type", nameof(vehicle))
    };
Enter fullscreen mode Exit fullscreen mode

Спрощений шаблон типу

Наразі, шаблон типу вимагає визначення змінної, навіть якщо ця змінна ігнорується, як у випадку з _. У прикладі вище це DeliveryTruck _. Але тепер ви можете просто написати тип:

DeliveryTruck => 10.00m,
Enter fullscreen mode Exit fullscreen mode

Шаблони порівняння

C# 9.0 вводить шаблони на основі операторів порівняння <, <= і т.ін. Тож тепер ви можете написати чатину з DeliveryTruck з наведеного вище шаблону як вкладений switch вираз:

DeliveryTruck t when t.GrossWeightClass switch
{
    > 5000 => 10.00m + 5.00m,
    < 3000 => 10.00m - 2.00m,
    _ => 10.00m,
},
Enter fullscreen mode Exit fullscreen mode

Тут > 5000 і < 3000 - це шаблони порівняння.

Логічні шаблони

Нарешті ви можете поєднувати шаблони з логічними операторами and, or і not. Вони записані словами, щоб уникнути плутанини з операторами у виразах. Для прикладу, випадок з вкладеним switch виразом вище можна переписати розташувавши діапазони по зростанню, як наведено нижче:

DeliveryTruck t when t.GrossWeightClass switch
{
    < 3000 => 10.00m - 2.00m,
    >= 3000 and <= 5000 => 10.00m,
    > 5000 => 10.00m + 5.00m,
},
Enter fullscreen mode Exit fullscreen mode

Середній вираз тут використовує and, щоб поєднати два шаблони порівняння в єдиний шаблон, що відображує діапазон.

Шаблон not може використовуватись спільно з констатним шаблоном null, як not null. Для прикладу, ми можемо розділити обробку невідомого випадку в залежності від того, чи дорівнює він null:

not null => throw new ArgumentException($"Not a known vehicle type: {vehicle}", nameof(vehicle)),
null => throw new ArgumentNullException(nameof(vehicle))
Enter fullscreen mode Exit fullscreen mode

Також not може бути корисним з оператором if, умова якого містить вираз is замість потворних подвійних дужок:

if (!(e is Customer)) { ... }
Enter fullscreen mode Exit fullscreen mode

Ви можете просто написати

if (e is not Customer) { ... }
Enter fullscreen mode Exit fullscreen mode

Вдосконалене приведення до цільового типу

"Приведенням до цільового типу" ми називаємо ситуацію, коли вираз отримує свій тип з контексту, в якому він використовується. Для прикладу null чи лямбда-вирази завжди використовують приведення до цільового типу.

В C# 9.0 деякі вирази, які до цього не використовували приведення до цільового типу, отримали можливість визначати тип з контексту.

Приведення у виразі new

Вираз new у C# завжди вимагав вказування типу (за винятком неявно типізованих масивів). Тепер ви можете пропустити тип, якщо змінна, якій присвоюється вираз, вже його має.

Point p = new (3, 5);
Enter fullscreen mode Exit fullscreen mode

Приведення у виразах ?? та ?:

Інколи умовні вирази ?? та ?: не мають явного спільного типу між різними гілками виконання. Такі випадки зараз не компілюються, але C# 9.0 буде дозволяти їх, якщо існує цільовий тип, до якого обидва результати можуть бути приведені.

Person person = student ?? customer; // спільний базовий тип
int? result = b ? 0 : null; // значений nullable тип
Enter fullscreen mode Exit fullscreen mode

Коваріантні результати

Інколи корисно якось виразити те, що перевизначений метод похідного класу повертає більш конкретний тип, ніж визначено у базовому класі. C# 9.0 дозволяє таке:

abstract class Animal
{
    public abstract Food GetFood();
    ...
}
class Tiger : Animal
{
    public override Meat GetFood() => ...;
}
Enter fullscreen mode Exit fullscreen mode

І навіть більше…

Найкраще місце, щоб ознайомитися з повним переліком майбутніх можливостей в C# 9.0 і слідкувати за їх реалізацією - це сторінка Статус реалізації нових можливостей мови в репозиторії Roslyn (C#/VB Compiler) на GitHub.

Приємного кодування!


Оригінал перекладу на моєму сайті.

Top comments (0)