Зустрічайте C# 9.0
Oleksandr Martyniuk
Posted on June 14, 2020
Це переклад статті "Welcome to C# 9.0" Медса Тоерсена - працівника Microsoft і головного дизайнера мови C#.
C# 9.0 набуває форм і я хочу поділитись нашим баченням найбільш важливих можливостей, які ми додаємо в наступну версію мови.
З кожною новою версією ми прагнемо зробити мову більш ясною і простою для загальних сценаріїв використання і C# 9.0 не є винятком. Цього разу нашим фокусом є забезпечення лаконічності в представленні даних та підтримка механізмів їх незмінності.
Ну що ж, поїхали!
Властивості з ініціалізацією
Ініціалізатори об'єктів надзвичайно корисні: вони дають програмісту дуже гнучку і, водночас, зрозумілу форму створення об'єктів. Вони особливо зручні для створення вкладених об'єктів, коли ціла ієрархія створюється однією командою. Ось приклад:
new Person
{
FirstName = "Scott",
LastName = "Hunter"
}
Ініціалізатори об'єктів також звільняють автора класу від написання шаблонного коду конструкторів. Все, що потрібно, - лише створити певні властивості!
public class Person
{
public string FirstName { get; set; }
public string LastName { get; set; }
}
Проте сьогодні є одне велике обмеження: щоб ініціалізатори працювали, властивості повинні бути змінюваними (mutable). Ініціалізатори працюють завдяки виклику конструктора (без параметрів в даному випадку) і наступному присвоєнню значень через виклик сетерів властивостей.
Властивості з ініціалізацією виправлять це! Вони визначають init
аксесор який дуже схожий на set
аксесор, але може викликатись лише під час ініціалізації об'єкту:
public class Person
{
public string FirstName { get; init; }
public string LastName { get; init; }
}
З таким підходом код, написаний вище, все ще коректний, але всі наступні присвоєння значень властивостям 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)));
}
}
Класи даних (record)
Властивості з ініціалізацією чудово підходять, якщо ви хочете зробити певну властивість об'єкту незмінною. Якщо ж ви хочете зробити незмінним весь об'єкт, щоб він поводив себе як екземпляр типу-значення, вам необхідно визначити його як клас даних (record):
public data class Person
{
public string FirstName { get; init; }
public string LastName { get; init; }
}
Ключове слово data
у визначенні позначає його як клас даних. Це додає йому певної поведінки характерної для типу-значення, яку ми розглянемо далі. Загалом, класи даних краще розглядати як "значення" (дані), а не як об'єкти. Вони не створені, щоб мати змінюваний стан. Натомість, ви виражаєте зміни у часі, створюючи нові екземпляри класу даних, що відображують новий стан. Класи даних визначаються не унікальністю посилання, а тотожністю вмісту.
Вираз with
При роботі з незмінними даними, загальний підхід полягає у створенні копії для відображення нового стану об'єкту. Для прикладу, якщо наша особа захоче змінити своє прізвище, ми реалізуємо це через створення нового об'єкту, що буде копією старого окрім зміненого прізвища. Цю техніку часто називають неруйнівною мутацією. Замість того, щоб змінювати особу з часом клас даних відображує стан особи в конкретний момент часу.
Щоб програмувати в такому стилі було легше, класи даних підтримують новий тип виразу - with
:
var otherPerson = person with { LastName = "Hanselman" };
With-вирази використовують синтаксис ініціалізаторів, щоб визначити, чим будуть відрізнятись новий і старий об'єкти. Ви можете задати відразу декілька властивостей.
Клас даних неявно включає захищений "конструктор копіювання" - це конструктор, який бере об'єкт класу даних, що існує, і копіює його поля одне за одним в новий об'єкт:
protected Person(Person original) { /* копіює всі поля */ } // автозгенерований
Вираз with
викликає конструктор копіювання і потім застосовує ініціалізатор для перевизначених властивостей, але вже до проініціалізованих раніше даних.
Якщо вас не влаштовує згенерований конструктор копіювання, ви можете визначити свій власний і він так само буде використовуватись виразом with
.
Порівняння за значенням
Всі об'єкти наслідують віртуальний метод Equals(object)
від класу object
. Він використовується статичним методом Object.Equals(object, object)
коли обидва параметри не дорівнюють null
.
Структури перевизначають його, щоб отримати "порівняння за значенням". Це коли поля структури порівнюються рекурсивно через виклик Equals
. Класи даних роблять так само.
Це означає те, що згідно з їхньою "значеністю", два об'єкти класу даних можуть бути рівними, будучи різними екземплярами одного типу. Для прикладу, якщо ми повернемо назад прізвище у раніше зміненої особи:
var originalPerson = otherPerson with { LastName = "Hunter" };
Тепер ми мали б ReferenceEquals(person, originalPerson)
= false (це різні екземпляри), але Equals(person, originalPerson)
= true (вони містять однакові дані).
Якщо вам не підходить порівняння по полях, що визначається початково, ви можете написати своє. Але треба бути обережним і розуміти, як працює порівняння за значеннями в структурах, особливо якщо є наслідування (до якого ми ще повернемося нижче).
Поряд з перевизначенням Equals
перевизначається також GetHashCode()
, бо вони працюють у парі.
Поля класів даних
Класи даних задумувались незмінюваними і такими, що містять лише публічні властивості з ініціалізаторами. Класи даних можуть змінюватись в не деструктивний спосіб завдяки with
-виразам. Для того, щоб спростити визначення класів даних для цього поширеного випадку, синтаксис класу даних змінює семантику, що несе string FirstName
. Замість неявного приватного поля, як це було б у визначенні класу чи структури, в синтаксисі класу даних це означає публічну автовластивість з ініціалізатором! Таким чином, визначення:
public data class Person { string FirstName; string LastName; }
Означає в точності те ж, що ми мали раніше:
public data class Person
{
public string FirstName { get; init; }
public string LastName { get; init; }
}
Ми вважаємо, це дозволяє зробити визначення класу даних чистим і красивим. Якщо вам дійсно потрібне приватне поле, ви завжди можете додати модифікатор private
явно:
private string firstName;
Позиційні класи даних
Інколи зручно використовувати більш позиційний підхід до класів даних, при якому дані передаються через аргументи конструктора і можуть бути отримані назад завдяки позиційному деконструюванню.
Абсолютно допустимим є визначення власного конструктора і деконструктора класу даних:
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);
}
Але існує набагато коротший синтаксис для вираження того самого (зверніть увагу на регістр імен параметрів):
public data class Person(string FirstName, string LastName);
Цей запис визначає публічні автовластивості і конструктор з деконструктором, тож ви можете написати:
var person = new Person("Scott", "Hunter"); // позиціне конструювання
var (f, l) = person; // позиціне деконструювання
Якщо вам не подобається згенерована автовластивіть, ви можете натомість визначити свою власну з тим же іменем, і згенеровані конструктор та деконструктор будуть її використовувати.
Класи даних і змінюваний стан
Семантика значення не дуже добре поєднується зі змінюваним станом. Уявіть, ми помістили об'єкт класу даних в словник. Його подальший пошук залежить від Equals
та (інколи) GethashCode
. Але, якщо клас даних змінює свій стан, він також змінює свою еквівалентність! Ми можемо не знайти його знову! В реалізації хеш таблиці це може пошкодити структуру даних, бо розміщення об'єкту грунтується на хеш коді, який він має в момент запису у таблицю.
Напевно, є допустимі приклади використання змінюваного стану класів даних, зокрема для кешування. Але щоб перевизначити поведінку так, щоб ігнорувати цей стан, доведеться докласти значних зусиль.
With-вираз та наслідування
Порівняння за значенням та недеструктивна мутація значно ускладнюються, коли поєднуються з наслідуванням. Давайте додамо похідний клас даних Student
до раніше розглянутого прикладу:
public data class Person { string FirstName; string LastName; }
public data class Student : Person { int ID; }
Почнемо наш приклад зі створення екземпляру Student
, але збережемо його у змінній типу Person
:
Person person = new Student { FirstName = "Scott", LastName = "Hunter", ID = GetNewId() };
otherPerson = person with { LastName = "Hanselman" };
В останньому рядку з 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() };
Чи рівні ці два об'єкти? person1
може думати, що так, адже person2
має всі поля типу Person
з тими ж значеннями. Але person2
з цим не погодиться! Ми повинні бути впевненими, що вони обоє розуміють, що вони все ж різні об'єкти.
І знову, C# подбав про це. Спосіб, в який це реалізовано: класи даних мають віртуальну захищену властивість EqualityContract
. Кожен похідний клас даних перевизначає її і для того, щоб два класи даних були однаковими, вони повинні мати один і той самий EqualityContract
.
Програми найвищого рівня
Написання простої програми на C# вимагає значної кількості шаблонного коду:
using System;
class Program
{
static void Main()
{
Console.WriteLine("Hello World!");
}
}
Це не тільки важко сприймати тим, хто тільки починає вивчати мову, а ще й захаращує код та додає зайві рівні відступів.
Натомість, в C# 9.0 ви можете просто писати вашу програму на найвищому рівні:
using System;
Console.WriteLine("Hello World!");
Будь-які вирази дозволені. Програма може початися відразу після списку 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))
};
Спрощений шаблон типу
Наразі, шаблон типу вимагає визначення змінної, навіть якщо ця змінна ігнорується, як у випадку з _
. У прикладі вище це DeliveryTruck _
. Але тепер ви можете просто написати тип:
DeliveryTruck => 10.00m,
Шаблони порівняння
C# 9.0 вводить шаблони на основі операторів порівняння <
, <=
і т.ін. Тож тепер ви можете написати чатину з DeliveryTruck
з наведеного вище шаблону як вкладений switch вираз:
DeliveryTruck t when t.GrossWeightClass switch
{
> 5000 => 10.00m + 5.00m,
< 3000 => 10.00m - 2.00m,
_ => 10.00m,
},
Тут > 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,
},
Середній вираз тут використовує 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))
Також not
може бути корисним з оператором if, умова якого містить вираз is
замість потворних подвійних дужок:
if (!(e is Customer)) { ... }
Ви можете просто написати
if (e is not Customer) { ... }
Вдосконалене приведення до цільового типу
"Приведенням до цільового типу" ми називаємо ситуацію, коли вираз отримує свій тип з контексту, в якому він використовується. Для прикладу null
чи лямбда-вирази завжди використовують приведення до цільового типу.
В C# 9.0 деякі вирази, які до цього не використовували приведення до цільового типу, отримали можливість визначати тип з контексту.
Приведення у виразі new
Вираз new
у C# завжди вимагав вказування типу (за винятком неявно типізованих масивів). Тепер ви можете пропустити тип, якщо змінна, якій присвоюється вираз, вже його має.
Point p = new (3, 5);
Приведення у виразах ??
та ?:
Інколи умовні вирази ??
та ?:
не мають явного спільного типу між різними гілками виконання. Такі випадки зараз не компілюються, але C# 9.0 буде дозволяти їх, якщо існує цільовий тип, до якого обидва результати можуть бути приведені.
Person person = student ?? customer; // спільний базовий тип
int? result = b ? 0 : null; // значений nullable тип
Коваріантні результати
Інколи корисно якось виразити те, що перевизначений метод похідного класу повертає більш конкретний тип, ніж визначено у базовому класі. C# 9.0 дозволяє таке:
abstract class Animal
{
public abstract Food GetFood();
...
}
class Tiger : Animal
{
public override Meat GetFood() => ...;
}
І навіть більше…
Найкраще місце, щоб ознайомитися з повним переліком майбутніх можливостей в C# 9.0 і слідкувати за їх реалізацією - це сторінка Статус реалізації нових можливостей мови в репозиторії Roslyn (C#/VB Compiler) на GitHub.
Приємного кодування!
Оригінал перекладу на моєму сайті.
Posted on June 14, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.