В недавнем выпуске подкаста DotNet & More Blazor, NetCore 3.0 Preview, C#8 и не только мы лишь вскользь упомянули такую животрепещущую тему, как C#8. Рассказ об опыте работы с C# 8 был недостаточно большим, что-бы посвящать ему отдельный выпуск, так что было решено поделиться им средствами эпистолярного жанра.
В данной статье я бы хотел рассказать о своем опыте использования C#8 на продакшене в течение 4 месяцев. Ниже Вы сможете найти ответы на следующие вопросы:
- Как "пишется" на новом C#
- Какие возможности оказались действительно полезными
- Что разочаровало
Полный список возможностей C#8 можно найти в официальной документации от Microsoft. В данной статье я опущу те возможности, которые не смог опробовать по тем или иным причинам, а именно:
- Readonly members
- Default interface members
- Disposable ref structs
- Asynchronous streams
- Indices and ranges
Начать я предлагаю с одной из самых, как мне раньше казалось, вкусных возможностей
Switch expressions
В наших мечтах мы представляем эту функцию достаточно радужно:
int Exec(Operation operation, int x, int y) =>
operation switch
{
Operation.Summ => x + y,
Operation.Diff => x - y,
Operation.Mult => x * y,
Operation.Div => x / y,
_ => throw new NotSupportedException()
};
Но, к сожалению, реальность вносит свои коррективы.
Во-первых, отсутствует возможность объединения условий:
string TrafficLights(Signal signal)
{
switch (signal)
{
case Signal.Red:
case Signal.Yellow:
return "stop";
case Signal.Green:
return "go";
default:
throw new NotSupportedException();
}
}
На практике это означает что в половине случаев switch expression придется превращать в обычный switch, дабы избежать copy-paste.
Во-вторых, новый синтаксис не поддерживает statements, т.е. код, не возвращающий значения. Казалось бы, ну и не надо, но я был сам удивлен, когда понял, на сколько часто используется switch (в связке с pattern matching) для такой вещи как assertion в тестах.
В третьих, switch expression, что вытекает из прошлого пункта, не поддерживает многострочные обработчики. Насколько это страшно мы понимаем в момент добавления логов:
int ExecFull(Operation operation, int x, int y)
{
switch (operation)
{
case Operation.Summ:
logger.LogTrace("{x} + {y}", x, y);
return x + y;
case Operation.Diff:
logger.LogTrace("{x} - {y}", x, y);
return x - y;
case Operation.Mult:
logger.LogTrace("{x} * {y}", x, y);
return x * y;
case Operation.Div:
logger.LogTrace("{x} / {y}", x, y);
return x / y;
default:
throw new NotSupportedException();
}
}
Я не хочу сказать, что новый switch плох. Нет, он хорош, просто недостаточно хорош.
Property & Positional patterns
Год назад они мне казались главными кандидатами на звание "возможность, изменившая разработку". И, как и ожидалось, что-бы использовать всю мощь positional и property patterns, необходимо поменять свой подход к разработке. А именно, приходится имитировать алгебраические типы данных.
Казалось бы, в чем проблема: берешь маркер-интерфейс и вперед. К сожалению, в большом проекте у этого способа есть серьезный недостаток: никто не гарантирует отслеживание в design time расширения Ваших алгебраических типов. А значит, велика вероятность того, что со временем внесение изменений в код будет приводить к массе "проваливаний в default" в самых неожиданных местах.
Tuple patterns
А вот "младший брат" новых возможностей сопоставления с образцом показал себя настоящим молодцом. Все дело в том, что tuple pattern не требует каких либо изменений в привычной архитектуре нашего кода, он просто упрощает некоторые кейсы:
Player? Play(Gesture left, Gesture right)
{
switch (left, right)
{
case (Gesture.Rock, Gesture.Rock):
case (Gesture.Paper, Gesture.Paper):
case (Gesture.Scissors, Gesture.Scissors):
return null;
case (Gesture.Rock, Gesture.Scissors):
case (Gesture.Scissors, Gesture.Paper):
case (Gesture.Paper, Gesture.Rock):
return Player.Left;
case (Gesture.Paper, Gesture.Scissors):
case (Gesture.Rock, Gesture.Paper):
case (Gesture.Scissors, Gesture.Rock):
return Player.Right;
default:
throw new NotSupportedException();
}
}
Но самое прекрасное, данная возможность, что достаточно предсказуемо, замечательно работает с методом Deconstruct. Достаточно просто передать в switch класс с реализованным Deconstruct и использовать возможности tuple pattern.
Using declarations
Казалось бы минорная возможность, но так много радости приносит. Во всех промо Microsoft рассказывает о таком аспекте как уменьшение вложенности. Но давайте быть честными, не на столько это значимо. А вот что действительно серьезно, так это сайд эффекты от исключения одного блока кода:
- Нередко, при добавлении using нам приходится вытаскивать код "внутрь" блока, методом copy-paste. Теперь мы об этом попросту не думаем
- Переменные, объявленные внутри using и используемые после Dispose объекта using, самая настоящая головная боль. Еще на одну проблему меньше
- В классах, требующих частого вызова Dispose, каждый метод был бы на 2 строчки длиннее. Казалось бы, мелочь, но в условии множества небольших методов эта мелочь не позволяет отобразить достаточное количество этих самых методов на одном экране
В итоге такая простая вещь как using declarations настолько сильно меняет ощущение от кодирования, что попросту не хочется возвращаться на c#7.3.
Static local functions
Если честно, если бы не помощь code analysis, я бы даже не заметил эту возможность. Тем не менее она плотно обосновалась в моем коде: ведь статические локальные функции отлично подходят на роль небольших чистых функций, так как не могут поддержать замыкание переменных метода. Как результат, на сердце легче, так как понимаешь, что на одну потенциальную ошибку в твоем коде меньше.
Nullable reference types
И на десерт хотелось бы упомянуть самую главную возможность C#8. По правде говоря, разбор nullable reference types заслуживает отдельной статьи. Мне же хочется просто описать ощущения.
- Во-первых, это прекрасно. Я и раньше мог описать явное свое намерение объявить поле или свойство nullable, но теперь эта функция встроена в язык.
- Во-вторых, это совершенно не спасает от NullReferenceException. И я не говорю про пресловутое "забивание" на warnings. Просто в runtime Вам никто не генерирует никаких проверок аргументов на null, так что не спешите выкидывать код вида throw new ArgumentNullException()
- В третьих, возникает серьёзная проблема с DTO. Например, вы аннотируете свойство атрибутом Required. Соответственно, в Ваш WebAPI контроллер попадет объект с 100% not null свойством. Однако, невозможно связать данный атрибут и все похожие атрибуты с проверками nullable reference types. Все дело в том, что если вы объявите стандартное MyProperty {get; set;} свойство с NotNull типом, то Вы получите warning: "[CS8618] Non-nullable property 'MyProperty' is uninitialized. Consider declaring the property as nullable". Что достаточно справедливо, так как вы не можете в процессе инициализации объекта гарантировать not null семантику. Только результатом данной особенности является невозможность использовать not null свойства в любых DTO. Но есть хорошая новость, существует простой workaround — достаточно проинициализировать ваше поле значением по-умолчанию:
public string MyProperty { get; set; } = "";
- В четвертых, атрибуты, позволяющие обработать сложные случаи, типа TryGetValue, сами по себе достаточно непросты. Как результат, высока вероятность, что не особо сознательные разработчики будут злоупотреблять операторами (!), тем самым нивелируя возможности nullable reference types. Одна надежда на анализаторы.
- В пятых, и самое главное, лично меня эта возможность уже много раз спасла от NullReferenceException ошибок. Получается банальная экономия времени — масса ошибок ловится на этапе компиляции, а не тестов или отладки. Особенно это актуально не только в процессе разработки сложной бизнес логики, но и в случае банальной работы с внешними библиотеками, DTO, и прочими зависимостями, возможно, содержащими null.
Резюме
Конечно, представленные возможности не дотягивают до полноценной революции, но все меньше и меньше остается зазор между C# и F#/Scala. Хорошо ли это или плохо, время покажет.
В момент релиза данной статьи C#8, возможно, уже поселился в Вашем проекте, потому мне было бы интересно, какие Ваши ощущения от новой версии нашего любимого языка?
Комментарии (29)
Kanut
01.10.2019 09:44Copy-paste в switch по идее можно как минимум уменьшить вынеся логику в отдельный метод. Во всяком случае я пока так сделал. Правда это немного портит читаемость кода.
Ну и будем надеяться что это поправят.
igor7
01.10.2019 09:58.NET очевидно это тупикновая технология, пока были популярны десктопные приложения под Windows она еще как то жила. А сейчас во времена web и смартфонов, учитывая что Windows Phone уже мертвая а главная фишка CLR- кроссплатформенность оказалась не по зубам Микрософту, только неопытный студент захочет использовать тот же .Net Core в продакшене...
Drag13
01.10.2019 12:09А серверный веб святым духом жив?
Давайте тогда уже и Java выбросим на помойку. А что, ведь всем очевидно что Swift скоро победит андроид.
Kanut
01.10.2019 14:40Я бы не был столь категоричен учитывая сколько денег и усилий Microsoft сейчас вбухивает в .Net и конкретно в попытки сделать его кроссплатформеным. У меня вообще такое ощущение что они решили "задавить массой".
slonopotamus
01.10.2019 23:00— Итак, господа, пришло время выбрать технологический стэк для нового проекта
— Конечно же .Net, в него больше денег вбухано!Вы так себе это представляете?
Kanut
01.10.2019 23:13Ну если вы выбираете стэк на ближайшие 5-10-15 лет, то при прочих равных на мой взгляд всё таки логичнее выбирать стэк, который будет больше развиваться и поддерживаться. И в который инвестируют.
И кроме того есть как минимум вероятность, что фреймворки на C# позволят в будущем целиком покрыть достаточно большие "экосистемы". То есть вам нужен будет всего один язык програмирования для бэкенда/фронтенда в вебе, десктопа под разные ОС и мобильных приложений.
Хотя это всё тоже вилами по воде написано, особенно если вспомнить тот же Silverlight.
AndyKorg
02.10.2019 09:07Полностью согласен! Ведь, что такое шарп? Это виртуальная машина. И для каждого устройства надо делать свою. Ну и где тут кроссплатформенность? Пока Микрософт не соизволит портировать среду вы будете ждать. Поэтому все на Delphi! Компиляция сразу в «родной» код, хоть на андроиде, хоть на iOs, хоть в Linux
sebasww
01.10.2019 13:41-1switch (true)
{
case signal==Signal.Red ll signal==Signal.Yellow:KAW Автор
01.10.2019 13:59К сожалению, подобный код не поддерживает switch expression:
signal switch { Signal.Red || Signal.Yellow => "stop", ...
В данном случае будет ошибка компиляцииVanKrock
01.10.2019 18:06А вот так ошибка рантайма, если Enum не флаг.
signal switch { Signal.Red | Signal.Yellow => "stop", ...
a-tk
01.10.2019 16:41Вместо такого
int ExecFull(Operation operation, int x, int y) { switch (operation) { case Operation.Summ: logger.LogTrace("{x} + {y}", x, y); return x + y; case Operation.Diff: logger.LogTrace("{x} - {y}", x, y); return x - y; case Operation.Mult: logger.LogTrace("{x} * {y}", x, y); return x * y; case Operation.Div: logger.LogTrace("{x} / {y}", x, y); return x / y; default: throw new NotSupportedException(); } }
Можно написать:
int ExecFull(Operation operation, int x, int y) { return operation switch { Operation.Summ => Log("{x} + {y}", x, y) ?? x + y; Operation.Diff => Log("{x} - {y}", x, y) ?? x - y; Operation.Mult => Log("{x} * {y}", x, y) ?? x * y; Operation.Div => Log("{x} / {y}", x, y) ?? x / y; _ => throw new NotSupportedException(); }; static int? Log(...) { // log someting return null; } }
Dimtry44
01.10.2019 16:51Это для любителей художественной стрельбы по собственным ногам.
a-tk
01.10.2019 17:49Ага. Сквозной функционал лучше сделать с помощью АОП
Dimtry44
01.10.2019 20:19Честно говоря не вижу большого использования АОП в C#
Наверное потому что PostSharp за деньги, а остальные недотягивают.
Плюс, наверное было бы на уровне языка поддержать, что то подобное. И перед компиляцией делать прогон через Roslyn и внедрение желаемой дополнительной функциональности.
KAW Автор
01.10.2019 18:19Браво! Без сарказма, очень хитроумное решение.
В C# такие хаки не очень приняты, то в том же JS половина паттернов основаны на злоупотреблении особенностями языка
MaxKot
02.10.2019 21:38К сожалению, в большом проекте у этого способа есть серьезный недостаток: никто не гарантирует отслеживание в design time расширения Ваших алгебраических типов.
Можно делать "закрытые" иерархии при помощи вложенных типов:
public abstract class Adt { public sealed class Case1 : Adt { } public sealed class Case2 : Adt { } private Adt() { } }
Это один вариантов, во что превращаются алгебраические типы данных в F# в скомпилированных сборках.
KAW Автор
03.10.2019 12:28Это хороший способ, но вот нет возможности в design time проверить, что в паттерн матчинге обрабатываются все кейсы: Case1 и Case2. Как результат, при добавлении Case3 придется пройтись по всему коду. Что может привести к ошибкам, которые даже модульными тестами не отлавливаются (только интеграционными)
MaxKot
04.10.2019 10:32Видимо, я немного не понял изначальный посыл. Мне показалось, что речь была не столько про exhaustive match, сколько про неожиданное появление новых вариантов в АТД (например, другими людьми).
А так да, для исчерпывающего сравнения только делать вложенные классы private и городить визитор. Для простых случаев, правда, можно обойтись чем-то вроде
public abstract T Match<T>(T case1, T case2);
Но это уже скорее "исчерпывающее сопоставление enum`а".
KAW Автор
04.10.2019 11:51Ну да, «классически» вариант паттерн матчинга через полиморфизм:
public abstract T Match<T>(Func<Child1, T> case1, Func<Child2, T> case2);
Maxmyd
Спасибо за очень краткий пересказ блогов Microsoft.
И, как же слух режет… :) Интересно, сколько ещё людей слышат букву «Т» в слове feature?