Как я провел лето с C# 8 +15



В недавнем выпуске подкаста 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
    /#20694079 / +3

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

  2. Romfos
    /#20694171 / +1

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

    • KAW
      /#20695931

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

  3. Kanut
    /#20695103

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

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

  4. igor7
    /#20695155

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

    • KAW
      /#20695467

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

    • Drag13
      /#20695939

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

    • Kanut
      /#20696913

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

      • slonopotamus
        /#20699655

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

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

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

        • Kanut
          /#20699727

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


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

    • AndyKorg
      /#20700479

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

      • a-tk
        /#20701687

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

  5. sebasww
    /#20695157

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

    • KAW
      /#20695459

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

  6. sebasww
    /#20696521 / -1

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

    • KAW
      /#20696629

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

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

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

      • VanKrock
        /#20698389

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

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

  7. a-tk
    /#20697753

    Вместо такого

    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
      /#20697843

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

      • a-tk
        /#20698269

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

        • Dimtry44
          /#20699029

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

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

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

          • a-tk
            /#20701699

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

    • KAW
      /#20698465

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

      • VanKrock
        /#20698579

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

        • a-tk
          /#20698917

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

  8. MaxKot
    /#20704383

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

    Можно делать "закрытые" иерархии при помощи вложенных типов:


    public abstract class Adt
    {
        public sealed class Case1 : Adt
        {
    
        }
    
        public sealed class Case2 : Adt
        {
    
        }
    
        private Adt()
        {
    
        }
    }

    Это один вариантов, во что превращаются алгебраические типы данных в F# в скомпилированных сборках.

    • KAW
      /#20706629

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

      • MaxKot
        /#20710971

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


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


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

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

        • KAW
          /#20711567

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

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