В недавнем выпуске подкаста 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)


  1. Maxmyd
    30.09.2019 23:25
    +3

    Спасибо за очень краткий пересказ блогов Microsoft.
    И, как же слух режет… :) Интересно, сколько ещё людей слышат букву «Т» в слове feature?


  1. Romfos
    30.09.2019 23:50
    +1

    По поводу DTO — посмотрите в сторону AllowNull, DisallowNull, MaybeNull, NotNull и прочих атрибутов. Если я правильно понимаю проблему — это может помочь?


    1. KAW Автор
      01.10.2019 12:07

      Попробовал, к сожалению, не помогло:( Но спасибо за идею


  1. Kanut
    01.10.2019 09:44

    Copy-paste в switch по идее можно как минимум уменьшить вынеся логику в отдельный метод. Во всяком случае я пока так сделал. Правда это немного портит читаемость кода.

    Ну и будем надеяться что это поправят.


  1. igor7
    01.10.2019 09:58

    .NET очевидно это тупикновая технология, пока были популярны десктопные приложения под Windows она еще как то жила. А сейчас во времена web и смартфонов, учитывая что Windows Phone уже мертвая а главная фишка CLR- кроссплатформенность оказалась не по зубам Микрософту, только неопытный студент захочет использовать тот же .Net Core в продакшене...


    1. KAW Автор
      01.10.2019 10:54

      Простите коллеги, я не мог не пропустить этот коммент, он прекрасен:)


    1. Drag13
      01.10.2019 12:09

      А серверный веб святым духом жив?
      Давайте тогда уже и Java выбросим на помойку. А что, ведь всем очевидно что Swift скоро победит андроид.


    1. Kanut
      01.10.2019 14:40

      Я бы не был столь категоричен учитывая сколько денег и усилий Microsoft сейчас вбухивает в .Net и конкретно в попытки сделать его кроссплатформеным. У меня вообще такое ощущение что они решили "задавить массой".


      1. slonopotamus
        01.10.2019 23:00

        — Итак, господа, пришло время выбрать технологический стэк для нового проекта

        — Конечно же .Net, в него больше денег вбухано!

        Вы так себе это представляете?


        1. Kanut
          01.10.2019 23:13

          Ну если вы выбираете стэк на ближайшие 5-10-15 лет, то при прочих равных на мой взгляд всё таки логичнее выбирать стэк, который будет больше развиваться и поддерживаться. И в который инвестируют.


          И кроме того есть как минимум вероятность, что фреймворки на C# позволят в будущем целиком покрыть достаточно большие "экосистемы". То есть вам нужен будет всего один язык програмирования для бэкенда/фронтенда в вебе, десктопа под разные ОС и мобильных приложений.
          Хотя это всё тоже вилами по воде написано, особенно если вспомнить тот же Silverlight.


    1. AndyKorg
      02.10.2019 09:07

      Полностью согласен! Ведь, что такое шарп? Это виртуальная машина. И для каждого устройства надо делать свою. Ну и где тут кроссплатформенность? Пока Микрософт не соизволит портировать среду вы будете ждать. Поэтому все на Delphi! Компиляция сразу в «родной» код, хоть на андроиде, хоть на iOs, хоть в Linux


      1. a-tk
        02.10.2019 12:38

        Вы ещё Java вспомните…


  1. sebasww
    01.10.2019 09:58

    А задекларить в switch true?)


    1. KAW Автор
      01.10.2019 10:53

      Не очень понял, можно пример?


  1. sebasww
    01.10.2019 13:41
    -1

    switch (true)
    {
    case signal==Signal.Red ll signal==Signal.Yellow:


    1. KAW Автор
      01.10.2019 13:59

      К сожалению, подобный код не поддерживает switch expression:

      signal switch
      {
          Signal.Red || Signal.Yellow => "stop",
          ...
      

      В данном случае будет ошибка компиляции


      1. VanKrock
        01.10.2019 18:06

        А вот так ошибка рантайма, если Enum не флаг.

        signal switch
        {
            Signal.Red | Signal.Yellow => "stop",
            ...


  1. 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;
      }
    }
    


    1. Dimtry44
      01.10.2019 16:51

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


      1. a-tk
        01.10.2019 17:49

        Ага. Сквозной функционал лучше сделать с помощью АОП


        1. Dimtry44
          01.10.2019 20:19

          Честно говоря не вижу большого использования АОП в C#

          Наверное потому что PostSharp за деньги, а остальные недотягивают.

          Плюс, наверное было бы на уровне языка поддержать, что то подобное. И перед компиляцией делать прогон через Roslyn и внедрение желаемой дополнительной функциональности.


          1. a-tk
            02.10.2019 12:39

            Чем не устроили, например, Castle Project и Fody?


    1. KAW Автор
      01.10.2019 18:19

      Браво! Без сарказма, очень хитроумное решение.
      В C# такие хаки не очень приняты, то в том же JS половина паттернов основаны на злоупотреблении особенностями языка


      1. VanKrock
        01.10.2019 18:33

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


        1. a-tk
          01.10.2019 19:46

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


  1. 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# в скомпилированных сборках.


    1. KAW Автор
      03.10.2019 12:28

      Это хороший способ, но вот нет возможности в design time проверить, что в паттерн матчинге обрабатываются все кейсы: Case1 и Case2. Как результат, при добавлении Case3 придется пройтись по всему коду. Что может привести к ошибкам, которые даже модульными тестами не отлавливаются (только интеграционными)


      1. MaxKot
        04.10.2019 10:32

        Видимо, я немного не понял изначальный посыл. Мне показалось, что речь была не столько про exhaustive match, сколько про неожиданное появление новых вариантов в АТД (например, другими людьми).


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


        public abstract T Match<T>(T case1, T case2);

        Но это уже скорее "исчерпывающее сопоставление enum`а".


        1. KAW Автор
          04.10.2019 11:51

          Ну да, «классически» вариант паттерн матчинга через полиморфизм:

          public abstract T Match<T>(Func<Child1, T> case1, Func<Child2, T> case2);