Новые возможности C# 8

Прошло примерно пол года с момента выхода C# 8, думаем самое время кратко рассмотреть основные нововведения. И начнем мы, пожалуй, по идее, с самого значимого из них, а именно с Nullable reference types.

Nullable reference types

Если бы Microsoft и компания писали бы сочинение по русскому языку в 5-ом классе, то обязательно бы получили минус полбалла за тавтологию, причем  заслуженно. Дело в том, что reference types (ссылочные типы) по определению nullable, т.е. зануляемые, таким образом получается масло масляное. Кроме того, название этого функционала пересекается с уже имеющимся функционалом, а именно с Nullable value types. А так как эти возможности имеют мало общего , это приводит к небольшой неразберихе. И даже больше, не только названия пересекаются, но и синтаксис полностью идентичен, что с одной стороны, конечно, хорошо, но вот с другой… Основная цель этого нововведения — решить (а по факту получается «решить») крайне неприятную и старую, как мир, проблему The Billion Dollar Mistake, т.е. всем известную проблему с Null Pointer Exception. В современных и мейнстрим языках, на наш взгляд, подобной проблемы существовать не должно вовсе, но, по тем или иным причинам, большиство популярных языков программирования продолжают гордо нести дух старой школы, а вместе с тем и весь тот груз, с которым каждому разработчику приходится бороться ежедневно, как будто бы других проблем у разработчиков больше нет. Ошибки дизайнеров, допущенные при укладке фундамента в самом начале, и необходимость в обратной совместимости теперь и не дают шанса на исправление подобных ошибок на корню. Из-за чего мы и получаем то, что имеем, как бы грустно это ни было. Напомним, что первая версия C# вышла в далеком 2002 году. С учетом того, что текущая версия вышла в прошлом году, не трудно подсчитать, что на то, чтобы начать (подчеркнем, не побороть, а именно начать) бороться с проблемой, которой быть в принципе не должно, потребовалось невообразимых 17 лет.

Чтобы начать пользоваться Nullable Reference Types, необходимо одним из возможных способов включить эту функцию вручную. И если для старых проектов это оправдано, то почему не включить эту функцию для новых проектов? Остается только гадать. Активировать эту возможность можно как для проекта целиком, в свойствах проекта, так и для отдельно взятых файлов с помощью директивы  #nullable enable . Давайте рассмотрим несколько примеров.

В данном фрагменте кода нужно обратить внимание на следующие моменты:

  • если скомпилировать этот код, то мы получим два предупреждения (жаль, что по умолчанию компилятор не считает подобный код ошибочным, но это легко исправить):  [CS8618] Non-nullable field 'name' is uninitialized. Consider declaring the field as nullable. и  [CS8625] Cannot convert null literal to non-nullable reference type. и парочку таких: [CS8602] Dereference of a possibly null reference. .  Другими словами, теперь все ссылочные типы по умолчанию не могут принимать значение  null . Если необходимо выставить значение в  null , нужно использовать знак вопроса после ссылочного типа. Компилятор так же будет ругаться на не инициализированные поля (естественно, если инициализировать поле, то предупреждение исчезнет) и попытки разыменования;
  • в отличие от Nullable Value Types, знак вопроса никак не изменяет тип рядом с ним, т.е. в данном случае поле  nickname будет иметь тип  System.String , а не какой-нибудь  Nullable<System.String> . Таким образом, в данном случае единственное, что делает знак вопроса, это добавляет подсказку компилятору, что данное поле можно занулять. По факту, дополнительно добавляется соответствующий атрибут System.Runtime.CompilerServices.NullableAttribute , что-то типа  JetBrains.Annotations.CanBeNull со всеми вытекающими, и теперь работа по определению корректности кода в плане NPE перекладывается со стороннего (сторонних) решения (решений) на компилятор.

Исправленный код (не генерирующий предупреждения компилятором) может выглядеть как-то так:

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

сгенерирует предупреждения. И, кажется, вот оно — счастье, наконец-то, после стольких долгих лет боли и страдания! Но. как говорится, слишком рано радоваться. Давайте рассмотрим следующий простейший кусочек кода:

В строчке вывода мы однозначно не можем получить  null из-за того, что строчкой выше. В таком случае будет сгенерировано исключение: компилятор (к сожалению, он не настолько умен, как хотелось бы) сгенерирует предупреждение:  [CS8602] Dereference of a possibly null reference.  Это, конечно же, печально. Но как же быть? Неужто теперь придется в каждом подобном (и куче других) случае добавлять явную (да еще и избыточную) проверку на  null ?  Да, это один из вариантов решения (возможно даже, не самый худший). Другим вариантом решения является новый Null-Forgiving оператор:  ! , который как бы говорит компилятору: «Не парься! Я знаю, что делаю» (ага, как же)). Теперь мы можем переписать наш код следующим образом:

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

и он тоже скомпилируется и упадет с NPE. Интересно, что так же можно использовать несколько операторов подряд:

как бы «крича» прямо из кода). Также можно объединить оба оператора:

а вот обратный вариант:

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

Стоит еще раз отметить, что никаких измений в типах или дополнительных проверок в run-time не происходит. Мы имеем примерно то же самое, что имели раньше, используя JetBrains.Annotations & Rider/ReSharper, только из коробки. Глобальное Nullable reference types, к большому сожалению, проблему не решает.

Default Interface Members

Default Interface Members. пожалуй, самое неоднозначное (хотя чего уж там…) нововведение. Теперь интерфейсы могут иметь методы с реализацией. Default Interface Members еще больше размывает границы между интерфейсами и абстрактными классами. Таким образом, объяснить новые, и довольно сложные, понятия новичкам, тадам! будет еще сложнее. Нам довольно трудно представить, где эта возможность будет, прямо явно полезной. Официальная документация говорит, что Default Interface Members позволяет безопасно добавлять методы в уже выпущенные и используемые интерфейсы. Звучит так себе — довольно неубедительно. Давайте рассмотрим несколько примеров:

В данном случае стоит обратить внимание на то, что интерфейс  IUserProvider содержит метод  GetUser с реализацией и несмотря на то, что наш класс  UserProvider реализует интерфейс  IUserProvider , явно реализовывать метод  GetUser нет необходимости, так как интерфейс содержит реализацию по умолчанию. Можно подумать, что приведенный код скомпилируется и будет работать. Но это не так. Приведенный фрагмент кода генерирует следующую ошибку:  [CS1061] 'UserProvider' does not contain a definition for 'GetUser' and no accessible extension method 'GetUser' accepting a first argument of type 'UserProvider' could be found (are you missing a using directive or an assembly reference?) . Да как же так? Может (а, наверное, даже должен) воскликнуть читатель. Дело в том, что, как уже выше говорилось, основной сценарий использования этого нововведения — добавление методов в уже существующие интерфейсы. В таком случае получается, что если бы в классе  UserProvider уже был метод  GetUser , то произошел бы конфликт. Поэтому, чтобы обратиться к реализованному в интерфейсе методу, необходимо явно откастить к интерфейсу (это примерно аналогично вызову explicit interface implementation). Например, вот так:

А что же будет, если у нас появится еще один интерфейс, который будет наследоваться от  IUserProvider и также иметь метод  GetUser с реализацией по умолчанию? Такой код вообще скомпилируется?

Да, такой код скомпилируется. А что же будет выведено в консоль при вызове метода  GetUser() ? Если один интерфейс наследуется от другого, то можно предположить, что произойдет переопределение метода, в результате чего в консоль попадет две одинаковых строки: IRemoteUserProvider.GetUser() . Но это не так. В консоли мы увидим следующие строки:

На самом деле здесь ничего (для C# разработчиков) неожиданного быть не должно. Поведение точно такое же, как и в подобных случаях при определении перекрывающих методов в обычных классах:

По запуску этого кода мы также увидим аналогичную картину в консоли:

Но это поведение, в данном случае, довольно легко исправить. Достаточно объявить метод  UserProvider.GetUser() виртуальным и с помощью ключевого слова  override переопределить этот метод в  RemoteUserProvider . А доступна ли такая же возможность (полиморфизм) для методов интерфейсов по умолчанию? И если доступна, то как ей воспользоваться? Возможно, стоит поступить аналогично примеру с классами? Давайте попробуем:

К сожалению, такой код не скомпилируется и выведет ошибку, наподобие этой:  [CS0106] The modifier 'override' is not valid for this item . На самом деле, необходимые нам модификации делаются следующим образом:

Запустив этот вариант кода, мы получим заветные строчки в консоли:

Таким образом, полиформизм и наследование работает и на методах в интерфейсах с реализацией по умолчанию.

Думаем, что все прекрасно помнят, что в отличие от классов, наследоваться от нескольких интерфейсов можно. А что же тогда будет, например, в случае, если мы реализуем сразу пару интерфейсов, каждый из которых переопределяет общий для этих интерфейсов метод с реализацией по умолчанию базового интерфейса. Т.е. фактически мы столкнемся с одной из проблем множественного наследования (Diamond inheritance). Давайте так же рассмотрим такой случай на примере:

Думаем, здесь так же все очевидно. Такой код не скомпилируется, т.к. компилятор не сможет найти более специфичный (т.е. ниже в иерархии наследования) интрефейс для использования, из-за чего не сможет понять. какой метод вызывать, если кто-то попробует вызвать метод  GetUser() .

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

Pattern Matching

Сопоставление с образцом — одна из десятка, если не больше, возможностей,  которую разработчики C# плагиатят из F#. Элементы сопоставления с образцом в C# появляются, начиная с версии 7.0. Это, без сомнений, довольно удобная функция, особенно в функциональных языках, таких как F#, т.к. обычно, она идет вкупе с другими возможностями, такими как алгебраические структуры данных, функциональные списки и т.п., которых, на данный момент в C# нет, но возможно они появяться в будущих версиях языка. В 8-ой версии языка добавили возможность match-иться по полям и свойствам структур и классов. Аналогом ключевого слова  match в F# стало ключевое слово  switch . Т.е. фактически мы теперь имеем аналог обычного  switch , но на стероидах. Стоит обратить внимание на то, что это switch expression, а не switch statement, как в случае обычного  switch , т.е. этот оператор должен возвращать некоторое значение. Пример:

В консоль будет выведана следующая строка:  Ivan social group: Worker . Можно задаться вопросом: а что же произойдет, если никакой из описанных вариантов не сработает? На ум приходит, как минимум, два варианта: 1. возможно, т.к. это выражение, то вернется  default значение; 2. выброс исключения. Дизайнеры языка остановились на последнем варианте. И, в таком случае, будет выкинуто исключение:  Unmatched value was Program+Person. Также стоит отметить, что, если скомпилировать вышеприведенный код, то будет сгенерировано следующее предупреждение:  CS8509: The switch expression does not handle all possible values of its input type (it is not exhaustive). , которое нам намекает, что мы обработали не все случаи. И этот момент разительно (в положительную сторону) отличается от поведения стандартного switch statement. Само собой, в данном случае мы не можем перечислить все возможные варианты, т.к. их неимоверно много. Здесь нам пригодилось бы ключевое слово  default из стандартного свитча. Но, вместо него, как (мы думаем) вы уже догадались, по аналогии с другими вариантами сопоставления с образцом используется нижнее подчеркивание  _ . Новое выражение можно использовать не только для обработки полей и свойств, но и в качестве более удобной и лаконичной замены стандартному switch statement:

Что будет аналогом следующего кода:

аналогично мы можем делать сопоставление с образцом кортежей:

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

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

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

Также может быть интересен следующий момент:

Т.е. можно использовать пустые фигурные скобки в качестве варианта для сопоставления. Такой вариант отловит любой аргумент, который не равен  null . Что в таком случае мы можем сказать о коде выше? Корректен ли этот код? Все ли случаи обработаны? Однозначно ответить на этот вопрос не получится, т.к. мы не знаем, что из себя представляет  phoneNumber . Если это структура (а структуры не могут принимать значение  null ), то да, а если это класс? Вопрос с подвохом! С одной стороны, мы не покрыли вариант с  null , а, с другой, ведь есть же nullable reference types! Таким образом, покрытие всех вариантов зависит не только от типа, но и от текущего nullability контекста.

Рекурсивные образцы (Recursive Patterns) позволяют проверять не только поля объектов, но и подполя и поля подполей и т.д.:

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

Сопоставление с образцом можно использовать не только в switch выражениях, но и в других местах, как, собственно, это было в предыдущих версиях C#. Ну и не стоит забывать про деконструкцию, ее также можно использовать в качестве варианта в проверках. Однозначно, новая возможность сопоставления с образцом — довольно полезная вещь. Жаль, что она появилась так поздно.

Indices and ranges

Были добавлены два новых типа:  Index и  Range . Для индексов и диапазонов соответственно. Основная цель — лаконичный доступ к элементу или диапазону последовательностей (sequences). Сюда относятся не только последовательности  IEnumerable , но и массивы, строки, списки и любые, в том числе и кастомные, коллекции. Также были добавлены новые операторы:  ^  и   .. . Думаем, что нетрудно догадаться, что делает второй оператор — создает диапазон. Это — привычный и понятный для большинства синтаксис. Чего, конечно, не скажешь о первом операторе. Попробуйте догадаться, для чего он нужен)). Было бы гораздо лучше, если бы этот оператор использовался для возведения в степень, как это делается в некоторых языках. Но в данном случае он используется для создания индекса с конца коллекции. Рассмотрим несколько примеров:

Вывод в консоль будет следующим:

Стоит обратить внимание, что нумерация с конца начинается с 1Т.к. запись  list[^0] аналогична записи  list[list.Length] . Другими словами, в стандартных коллекциях при обращении по индексу  ^0 мы получим исключение. В своих же коллекциях мы можем обработать и этот случай, и вернуть, возможно, полезные данные.

Диапазон  Range представляет из себя линейный, строго возрастающий диапазон индексов с шагом 1. При этом старт диапазона включается, а конец исключается. Рассмотрим на примерах:

Вывод будет следующим:

Вроде бы, все очевидно. Но есть и печаль, как уже выше было сказано: диапазон должен быть строго возрастающим, т.е. если попробовать получить диапазон  array[3..0] , то мы получим исключение:  System.ArgumentOutOfRangeException: Specified argument was out of the range of valid values. . По аналогии с предущим типом, здесь также можно учесть такие диапазоны в своих коллекциях. Т.е. всегда необходимо проверять, возрастающий ли диапазон или нет, при обращении к стандартным коллекциям  (еще одно потенциальное слабое место). Другой неприятный момент — это шаг. Нельзя указать шаг, отличный от единицы, что также печалит, т.к. в том же F# мы можем без проблем указать шаг.

 

Readonly members

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

При этом, если в таком методе попробовать изменить данные, то мы получим ошибку компиляции:

[CS1604] Cannot assign to 'name' because it is read-only . При этом, если мы добавим обращение к геттеру без модификатора  readonly , то мы получим уже предупреждение:

[CS8656] Call to non-readonly member 'Person.fullName.get' from a 'readonly' member results in an implicit copy of 'this'. И это — несмотря на то, что геттер, в данном случае, никак не изменяет данные. Т.е. в таком случае будет создана копия структуры и вызов геттера будет с использованием копии. Это — потенциально узкое место по производительности. И, видимо, поэтому (т.к. в итоге оригинальная структура не изменяется) генерируется предупреждение, а не ошибка, как в предыдущем случае. Чтобы избавиться от этого предупреждения, необходимо добавить модификатор  readonly . Таким образом, модификатор только на чтение необходимо указывать и в свойствах только на чтение (геттерах). В случаях авто-реализуемых свойств (auto-implemented properities), указывать модифкатор не нужно, т.к. компилятор будет считать соответствующий геттер не изменяющим данные.

Довольно полезное нововведение, в некоторых случаях, которое позволяет явно указывать намерение (наглядно) для методов структур  и осуществлять корректность этих намерений компилятором. Жаль, что такой же возможности нет для методов классов.

Using declarations

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

Коды выше выведет в консоль, как нетрудно догадаться:

Эта запись полностью аналогична следующей:

Единственная польза от этого нововведения — некоторое уменьшение вложенности.

Static local functions

Теперь с помощью модификатора  static можно запретить захватывать внешний контекст в локальных функциях:

В результате компиляции этого кода будет ошибка:

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

Disposable ref structs

Как известно, структуры, объявленные с модификатором  ref , не могут реализовывать интерфейсы и, как сделствие, нельзя реализовать  IDisposable . Но теперь ref структуры могут быть освобождаемыми.  Для этого такие структуры должны иметь метод  void Dispose() . Эта возможность так же работает и с  readonly ref структурами.

 

Asynchronous streams

Наконец-то асинхронные стримы добрались и до C#.

В консоли мы увидим что-то наподобие такого:

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

Null-coalescing assignment

Был добавлен новый null-coalescing оператор к уже имеющемуся  ??  —  ??=  , позволяющий кроме проверки на  null , еще и присвоить значение:

Т.е. фактически это — краткая запись следующего кода:

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

Enhancement of interpolated verbatim strings

Теперь порядок токенов  $ и  @ может быть любым. Оба варианта корректны:

Мы не охватили еще пару новых возможностей, а именно Unmanaged constructed types и Stackalloc in nested expressionsо которых можно почитать по соответствующим ссылкам.

Заключение

Новый релиз C# принес как полезные вещи, так и довольно бесполезные в повседневной жизни. Многие изменения можно отнести к косметическим и доделкам разного рода косяков, почему-то не реализованных сразу. Часть изменений довольно сильно усложняет и так далеко не самый простой язык. Есть, конечно, и пара полезных нововведений, которые могут быть использованы на ежедневной основе. Какие нововведения, на ваш взгляд, можно к ним отнести? Обязательно пишите о них в комментариях. Есть еще одна печаль, которая напрямую связана с нововведениями, требующими изменений в виртуальной машине (например, default interface methods), из-за чего невозможно перейти на новую версию C# в Unity, пока все эти нововведения не будут обработаны на стороне Unity. Таким образом, использовать C# 8 в Unity можно будет хорошо, если  в этом году. Естественно, вины разработчиков C# в этом нет, чего не скажешь о разработчиках из Unity.

Спасибо за внимание.

Добавить комментарий