В преддверии старта нового потока курса «C#-разработчик» представляем вашему вниманию обзор нововведений. Среди них — новый метод доступа к свойству — init, не позволяющий изменять свойства после инициализации, with-выражения для изменения свойств объекта прямо здесь и сейчас, записи и новые возможности сопоставления шаблонов. Подробности, конечно же, под катом.





Это официально: вышел C# 9.0! Еще в мае я написал в блоге о планах C# 9.0, а ниже — обновлённая версия этого поста, соответствующая тому, что мы сделали по факту. С каждой новой версией C# мы стремимся к большей ясности и простоте в обычных ситуациях кодирования, и C# 9.0 — не исключение. На этот раз особое внимание уделяется поддержке краткого и иммутабельного представления форм данных.

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


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

var person = new Person { FirstName = "Mads", LastName = "Torgersen" };

Инициализаторы объектов также освобождают автора типа от написания большого количества шаблонных конструкций – всё, что им нужно сделать — написать какие-то свойства!

public class Person
{
    public string? FirstName { get; set; }
    public string? LastName { get; set; }
}

Единственное большое ограничение на сегодня: для работы инициализаторов объектов свойства должны быть мутабельными: они функционируют, сначала вызывая конструктор объекта (по умолчанию, без параметров в данном случае), а затем присваивая его сеттерам свойств. Свойства только для инициализации исправляют ситуацию! Они вводят метод доступа init, то есть вариант метода доступа set, который может быть вызван только во время инициализации объекта:

public class Person
{
    public string? FirstName { get; init; }
    public string? LastName { get; init; }
}

При таком объявлении приведённый выше клиентский код все еще корректен, но любое последующее присвоение свойств FirstName и LastName — это ошибка:

var person = new Person { FirstName = "Mads", LastName = "Nielsen" }; // OK
person.LastName = "Torgersen"; // ERROR!

Так, свойства только для инициализации защищают состояние объекта от мутаций после завершения инициализации.

Методы доступа init и поля только для чтения


Поскольку методы доступа init могут вызваться только во время инициализации, им разрешено изменять поля заключающего их класса «только для чтения», точно так же, как это можно сделать в конструкторе.

public class Person
{
    private readonly string firstName = "<unknown>";
    private readonly string lastName = "<unknown>";

    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)));
    }
}

Записи


В основе классического ООП лежит идея о том, что объект обладает сильной идентичностью и инкапсулирует изменчивое состояние, которое развивается с течением времени. C# всегда отлично работал в этом смысле, но иногда вы хотите почти прямо противоположного, и здесь значения по умолчанию C#, как правило, мешают, делая работу крайне трудоёмкой. Если вы хотите, чтобы весь объект был неизменяемым и вел себя как значение, вам следует рассмотреть возможность его объявления как record:

public record Person
{
    public string? FirstName { get; init; }
    public string? LastName { get; init; }
}

Запись — это всё еще класс, но ключевое слово record наделяет класс дополнительным поведением, которое подобно поведению значения. Вообще говоря, записи определяются своим содержанием, а не идентичностью. В этом отношении записи гораздо ближе к структурам, но все же являются ссылочными типами. Хотя записи могут быть изменяемыми, они в первую очередь созданы для лучшей поддержки неизменяемых моделей данных.

With-выражения


При работе с неизменяемыми данными общий шаблон — создание новых значений из существующих для представления нового состояния. Например, если бы наш человек изменил фамилию, мы представили бы его как новый объект — копию старого, за исключением фамилии. Этот метод часто называют «недеструктивной мутацией». Вместо того чтобы представлять человека через какое-то время, запись представляет состояние персоны в данный момент. Чтобы поддержать этот стиль программирования, записи допускают новый вид выражений: with-выражения:

var person = new Person { FirstName = "Mads", LastName = "Nielsen" };
var otherPerson = person with { LastName = "Torgersen" };

With-выражения используют синтаксис инициализатора объекта, чтобы указать, что именно отличается в новом объекте от старого объекта. Можно указать несколько свойств. Это выражение работает, фактически копируя полное состояние старого объекта в новое, а затем мутируя его в соответствии с инициализатором объекта. Это означает, что свойства должны иметь метод доступа init или set, затем изменяемый в with-выражении.

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


Все объекты наследуют виртуальный метод Equals(object) класса object. Это поведение используется как основа для статического метода Object.Equals(object, object), когда оба параметра ненулевые. Структуры переопределяют этот метод, чтобы иметь «равенство на основе значений», сравнивая каждое поле структуры и рекурсивно вызывая для полей «Equals». Записи делают то же самое. Это означает, что в соответствии с их «значением» две записи могут быть равны друг другу, не будучи одним и тем же объектом. Например, если мы снова изменим фамилию в Person:

var originalPerson = otherPerson with { LastName = "Nielsen" };

Теперь у нас будет ReferenceEquals(person, originalPerson) — ложь (это не один и тот же объект), но Equals(person, originalPerson) — истина (они имеют одинаковое значение). Наряду с основанным на значении Equals, есть также основанное на значении переопределение GetHashCode(), которое будет работать вместе с ним. Кроме того, записи реализуют интерфейс IEquatable <T> и перегружают операторы == и !=, Так что поведение, основанное на значениях, последовательно проявляется во всех этих различных механизмах равенства.

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

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


Записи могут наследовать от других записей:

public record Student : Person
{
    public int ID;
}

With-выражения и равенство значений хорошо работают вместе с наследованием записей, поскольку они учитывают весь объект среды выполнения, а не только тип, под которым объект известен статически. Скажем, я создаю Student, но сохраняю его в переменной Person:

Person student = new Student { FirstName = "Mads", LastName = "Nielsen", ID = 129 };

Выражение with по-прежнему будет копировать весь объект с сохранением типа среды выполнения:

var otherStudent = student with { LastName = "Torgersen" };
WriteLine(otherStudent is Student); // true

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

Person similarStudent = new Student { FirstName = "Mads", LastName = "Nielsen", ID = 130 };
WriteLine(student != similarStudent); //true, since ID's are different

Позиционные записи


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

public record Person 
{ 
    public string FirstName { get; init; } 
    public string LastName { get; init; }
    public Person(string firstName, string lastName) 
      => (FirstName, LastName) = (firstName, lastName);
    public void Deconstruct(out string firstName, out string lastName) 
      => (firstName, lastName) = (FirstName, LastName);
}

Но существует гораздо более короткий синтаксис для выражения точно того же:

public record Person(string FirstName, string LastName);

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

var person = new Person("Mads", "Torgersen"); // positional construction
var (f, l) = person;                        // positional deconstruction

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

public record Person(string FirstName, string LastName)
{
    protected string FirstName { get; init; } = FirstName; 
}

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

public record Student(string FirstName, string LastName, int ID) : Person(FirstName, LastName);

Программы верхнего уровня


Написание простой программы на C# требует значительного количества шаблонного кода:

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

Это не только ошеломляет новичков в языке, но и загромождает код, добавляя уровни отступов. В C# 9.0 вы можете написать программу на самом верхнем уровне, вот так:

using System;

Console.WriteLine("Hello World!");

Допускается любое выражение. Программа должна выполняться после «using» и перед любыми объявлениями типа или пространства имен в файле, и это возможно только в одном файле, точно так же, как на данный момент у вас может быть только один метод Main. Допустимы return и await. А если вы хотите получить доступ к аргументам командной строки, то args доступен как «магический» параметр.

using static System.Console;
using System.Threading.Tasks;

WriteLine(args[0]);
await Task.Delay(1000);
return 0;

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

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


В 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 _ выше. Но в C# 9.0 можно просто написать тип:

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 удобен в содержащих is-выражения условиях if вместо громоздких двойных скобок:

if (!(e is Customer)) { ... }

Можно написать только это:

if (e is not Customer) { ... }

Фактически в таком выражении is not мы позволяем вам как-то назвать Customer для дальнейшего использования:

if (e is not Customer c) { throw ... } // if this branch throws or returns...
var n = c.FirstName; // ... c is definitely assigned here

Выражения new и целевой тип


«Целевой тип» — это термин, который мы употребляем, когда выражение получает свой тип из контекста того места, где оно используется. Например, null и лямбда-выражения всегда целевые. Выражения new в C# всегда требовали явного указания типа (кроме неявно типизированных выражений массива). В C # 9.0 можно не указывать тип, если есть чёткий тип, которому присваивается выражение.

Point p = new (3, 5);

Это особенно удобно, когда у вас много повторений, например, в массиве или инициализаторе объекта:

Point[] ps = { new (1, 2), new (5, 2), new (5, -3), new (1, -3) }; 

Ковариантные return


Иногда полезно указать, что переопределение метода в производном классе имеет более конкретный возвращаемый тип, чем объявление в базовом типе. C# 9.0 позволяет это сделать:

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

И многое другое


Узнать о полном наборе функций C# 9.0 можно в документации «Что нового в C# 9.0».
А с промокодом HABR можно получить дополнительные 10 % к скидке, указанной на баннере.

image



Рекомендуемые статьи