CProgramming

В преддверии релиза .NET 5, объединяющего среды выполнения .NET, Microsoft недавно анонсировали возможности, которые будут включены в C# 9. О выпуске финальной предварительной версии C# было объявлено 25 августа, а полностью завершенный релиз намечен на ноябрь. 

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

1. Записи (Records)

Записи сокращают разрыв, на данный момент существующий между типами class и struct. Применение классов для передачи более эффективно, но при этом их равенство определяется базовой ссылкой, а не значениями их членов. Структуры (struct) же получают семантику значений при определении равенства, но для передачи требуют копирования. 

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

Записи же сглаживают это неудобство благодаря наличию в них семантик значений и при этом продолжают передаваться через ссылку.

Классы, действующие как записи, в определении типа помечаются ключевым словом record:

public record Order
{
    public int Id { get; init set; }
    public string Status { get; init set; }
    public bool IsPaid { get; init set; }
}

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

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

Обновление записей

Как я уже отметил, записи намеренно создаются неизменяемыми. Вместо изменения их данных, вы создаете новый экземпляр, но уже с другими значениями. В этот момент в дело вступает выражение with

К примеру, у нас есть запись Order, чей статус изменяется на “Delivered”. Вместо обновления свойства Status объекта заказа (order) мы создаем новый экземпляр типа Order, копируя значения из исходного заказа, но при этом обновляя статус:

var updatedOrder = order with { Status = "Delivered" };

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

Подобная немутабельность позволяет методам, работающим с записью, иметь гарантию, что представленные им данные не изменятся между выполнениями. 

Равенство

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

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

[Records] определяются не по их идентичности, а по своему содержанию.

Зачем нужны записи?

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

Передача данных и события

Независимо от того, взаимодействуем ли мы с внешним сервисом, отправляя/получая данные или просто передаем данные между внутренними слоями приложения, Data Transfer Objects (объекты передачи данных) являются прекрасным примером эффективности применения записей.

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

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

2. Свойства init-only 

Мы только что рассмотрели записи, представляющие неизменяемые данные, которые вместо обновления копируются. Наличие встроенных неизменяемых типов данных  —  это очень полезная черта языка, но как же такие объекты инстанцировать? Придется ли нам передавать все их данные в виде аргументов конструктора? Не совсем. Вот здесь и вступают в дело свойства init-only, т.е. только для инициализации. 

Как и предполагает их название, эти свойства могут устанавливаться только при инициализации объекта. Их лаконичный синтаксис  —  init добавляется к сеттерам свойств в качестве модификатора:

public record Order
{
    public int Id { get; init set; }
    public string Status { get; init set; }
    public bool IsPaid { get; init set; }
}

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

Свойство, сеттер которого отмечено как init, может быть присвоено только при создании экземпляра объекта:

var order = new Order
{
    Id = 100,
    Status = "Created",
    IsPaid = false
};

Любая попытка впоследствии изменить значение одного из таких свойств, например order.Status = "Shipped", приведет к ошибке компиляции.

Зачем это нужно?

Как я уже упоминал в предыдущем разделе, инстанцирование неизменяемых объектов в сжатой форме на данный момент в C# недоступно. Раньше синтаксис инициализации объекта, позволяющий устанавливать свойства при его инстанцировании, например new Order { <property assignments> };, требовал, чтобы сеттеры свойства были доступны вызывающему и объявлял их как public или internal.

Естественно, если мы хотим создать неизменяемые типы и оградить класс от изменений извне, нам не стоит делать сеттеры открытыми. 

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

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

Начиная с C# 7 Microsoft постепенно улучшали поддержку сопоставления с шаблоном. Поскольку данные, обрабатываемые приложениями, все больше и больше определяются формой этих данных, а не условными деталями реализации вроде типа их CLR (общеязыковой среды выполнения), улучшение возможности объявлять логические шаблоны для определения поведения стало очень уместным дополнением языка.

В предыдущей версии C# был введен новый синтаксис switch, который позволил выполнять сопоставление с шаблоном для свойств типа:

static string Display(object o) => o switch
{
    Point { X: 0, Y: 0 }         => "origin",
    Point { X: var x, Y: var y } => $"({x}, {y})",
    _                            => "unknown"
};

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

static int CalculateTicketPrice(Person person)
{
    return person switch
    {
        var p when p.Age <= 12 => 5,
        var p when p.Age > 12 && p.Age <= 60 => 15,
        var p when p.Age > 60 => 10
    };
}

Обратите внимание, что в этом коде многовато “шума”, хотя наш switch, по сути, касается только свойства p.Age класса.

В C# 9 выражения switch теперь также могут быть относительными. Это означает, что они могут работать с такими относительными операторами, как < > >= и т.д., т.е. мы можем выражать ветви switch в отношении p.Age.

static int CalculateTicketPrice(Person person)
{
    return person when person.Age switch
    {
        <= 12 => 5,
        > 12 and <= 60 => 15,
        > 60 => 10
    };
}

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

Помимо этого, теперь вы также можете использовать сопоставление с шаблоном в инструкциях if, чтобы определять, соответствует ли объект конкретной “форме”. Например, у нас есть список объектов Vector3, и мы хотим вывести все вектора, чьи координаты x равняются 1. Применив сопоставление с шаблоном, это можно выразить так:

foreach (var v in vectors)
    {
        if (v is { X: 1 })
            Console.WriteLine(v);
    }

Обратите внимание на ключевое слово is, при помощи которого здесь также неявно выполняется null-проверка. Это не особо полезно при работе с типами значений, наподобие Vector3, но смысл вы уловили.

Зачем это нужно?

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

Все это чисто синтаксический сахар, и, по сути, выражение switch компилируется в ряд инструкций if:

private static int CalculateTicketPrice(Person person)
{
    int result;
    if (person.Age > 12)
    {
        if (person.Age <= 12 || person.Age > 60)
        {
            if (person.Age <= 60)
            {
                throw new SwitchExpressionException(person);
            }
            result = 10;
        }
        else
        {
            result = 15;
        }
    }
    else
    {
        result = 5;
    }
    return result;

Однако в данном случае сахар определенно делает код слаще. Если бы вам пришлось выбирать, то стали бы вы писать все вышеприведенные запутанные конструкции if else или же предпочли бы выражение switch?

Мы можем ожидать, что в последующих версиях C# появится поддержка дополнительных шаблонов. Больше подробной информации и примеров можно найти в разделе Pattern Matching официальной документации C# (eng). Знакомство с ними с лихвой себя оправдает, поскольку вы сможете прописывать деревья логики существенно лаконичнее.

4. Улучшения целевой типизации

Целевая типизация  —  это принцип, позволяющий компилятору делать вывод типа, к примеру, во время присвоения null:

string s = null;

Строго говоря, это присвоение (string) null, но так как левая сторона уже объявляет тип переменной как string, то вам не нужно делать приведение к нему вручную.

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

В C# 9 целевая типизация расширена, что может в некоторой степени упростить работу программиста: 

Применение целевой типизации к выражениям new

Запрещая неявные инициализаторы массивов, данный синтаксис для создания нового объекта всегда задействовал new T() там, где инициализировался тип T

В C# 9 вы можете опустить тип в выражении new, если левая сторона присвоения этот тип определяет. Следовательно следующее присвоение будет правильным:

Vector3 vec = (1, 2, 3);

Целевая типизация и общие типы

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

public IEnumerable<int> GetNumbers(bool even)
{
    return even ? new List<int> {2, 4, 6} : new []{1, 3, 5};
}

Даже несмотря на то, что и List<int>, и int[] имеют тип IEnumerable<int>, компилятор все равно выдает предупреждение. Вы можете обойти данную проблему приведением к IEnumerable<int> либо массива, либо List<int>:

public IEnumerable<int> GetNumbers(bool even)
{
    return even ? (IEnumerable<int>) new List<int> {2, 4, 6} : new []{1, 3, 5};
}

Однако в C# 9 вам больше не придется этого делать. До тех пор, пока два операнда имеют общий тип (в данном случае iEnumerable<int>), ваш код будет компилироваться успешно.

Зачем это нужно?

Данные возможности языка по большому счету упрощают нам жизнь. Наличие возможности опускать тип при создании объекта  —  это очень приятно, хоть и не изменит вещи в корне. 

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

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

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

Раньше при создании простейшего приложения, элементарно выводящего в консоль “Hello World!”, вам приходилось столкнуться с написанием кучи кода:

using System;

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

Причем это уже обрезанный пример, где удалены необязательные директивы using, которые вы получали в нагрузку. Опытным программистам известны все эти конструкции: директивы using, объявления namespace и в завершении class с методом, который содержит код, выводящий пресловутый текст “Hello World!”.

Тем не менее для начинающих разработчиков этого очень много. Для них даже будет логичным спросить: “Зачем нужен весь этот код для написания простой программы Hello World?”. И это хороший вопрос, потому что на самом деле для написания этой программы весь этот код вам и не нужен. Это часть церемонии, характерной для C# и его номинальной системы типов. 

Данная церемония невероятно полезна при проектировании крупных приложений и систем, но при написании чего-то простейшего вроде Hello World это просто излишнее раздувание кода. 

Начиная с C# 9, вы можете просто пропустить ее, и выразить программу Hello World так:

using System;

Console.WriteLine("Hello World!");

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

Если вы взглянете на декомпилированный исходный код для приведенного выше фрагмента, то увидите, что компилятор выполнил всю церемонию за вас:

using System;
using System.Runtime.CompilerServices;

// Токен: 0x02000002 RID: 2
[CompilerGenerated]
internal static class <Program>$
{
	// Токен: 0x06000001 RID: 1 RVA: 0x00002050 Смещение файла: 0x00000250
	private static void <Main>$(string[] args)
	{
		Console.WriteLine("Hello World!!");
	}
}

Здесь стоит запомнить, что несмотря на неявность этого, ваш код по-прежнему выполняется в статическом контексте, благодаря методу static void Main().

Зачем это нужно?

Это относительно небольшое нововведение, которое в теории делает C# более доступным для новичков. Visual Studio продолжает по умолчанию генерировать все, как и раньше, включая определение пространств имен и классов, но в теории вы можете написать пару строк кода с подходящими директивами using и выполнить его.

Я подозреваю, что эта возможность косвенно предназначена для таких проектов, как Try.NET, т.е. для случаев, когда вам нужно просто выполнить фрагмент C#, где наличие программ верхнего уровня оказывается очень кстати.

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

И напротив, для сценариев, в которых предпочтительно выполнение произвольных фрагментов C#, наличие программ верхнего уровня очень уместно. Просто берите код пользователя вместе со всеми его using и пусть компилятор расставляет их все по своим местам. 

Стоит упомянуть

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

Упрощенные null-проверки аргументов

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

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

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

public MemoryCache(IOptions<MemoryCacheOptions> optionsAccessor, ILoggerFactory loggerFactory)
{
    if (optionsAccessor == null)
    {
        throw new ArgumentNullException(nameof(optionsAccessor));
    }

    if (loggerFactory == null)
    {
        throw new ArgumentNullException(nameof(loggerFactory));
    }

    // Здесь идет код конструктора.
}

Стандартные библиотеки .NET перегружены такими проверками и ваш код, скорее всего, тоже.

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

public MemoryCache(IOptions<MemoryCacheOptions> optionsAccessor!, ILoggerFactory loggerFactory!)
{
    // Здесь идет код конструктора, имеющий гарантии, что ни optionsAccessor, ни loggerFactory не являются нулевыми.
    _options = optionsAccessor.Value;
    _logger = loggerFactory.CreateLogger<MemoryCache>();
}

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

Статическая проверка вместо runtime-проверки

В предыдущей версии C# была добавлена встроенная поддержка подписки (opt-in) ссылочных типов, допускающих нулевое значение. Если эта функция активна, компилятор выдает предупреждение при каждой передаче или присвоении null ссылочному типу, который не был явно отмечен как допускающий null.

Конечно же, иногда null ожидаем, например, когда FirstOrDefault не находит значение, соответствующее его предикату. Вы можете обойти предупреждение, выразив возвращаемый тип как, например, string!. Это выражение называется оператором, допускающим null.

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

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

Все эти новые проверки относятся только ко времени выполнения и являются прямым эквивалентом:

if (o == null) 
    throw new ArgumentNullException(nameof(o));

Зачем это нужно?

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

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

Поэтому проверка на null остается хоть и повторяющимся, но необходимым рутинным процессом. Если взглянуть на случайные файлы кода в библиотеках .NET, то можно заметить, что многие из раскрытых public методов содержат очень похожие вступления: проверки на null. Данная функция просто позволяет нам писать все эти проверки с помощью всего одного символа, но ведь это уже хорошо!

Заключение

За последние несколько лет это был самый обширный набор нововведений в C#. Добавив возможность работать с данными на основе их формы, а не условных деталей реализации вроде типа CLR, Microsoft укрепляет C# как современный многоцелевой язык. 

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

Если вы задумываетесь о том, чтобы попробовать этот язык, то сейчас самое подходящее время!

Репозиторий C#

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

Например, вот список предложений возможностей, описанных в данной статье: 

  1. Типы Record 
  2. Сеттеры свойств init-only
  3. Сопоставление с шаблоном
  4. Целевая типизация
  5. Программы и инструкции верхнего уровня

Читайте также:

Читайте нас в Telegram, VK и Яндекс.Дзен


Перевод статьи Martin Cerruti: An Introduction to the New Features in C# 9