company_banner

Приключения оператора pipeline в babel@7

https://babeljs.io/blog/2018/07/19/whats-happening-with-the-pipeline-proposal
  • Перевод

В релизе babel@7.0.0-beta52 появился новый обязательный флаг конфига для плагина @babel/plugin-proposal-pipeline-operator, что ломает обратную совместимость для предыдущих версий плагина. Из этой статьи вы узнаете, что такое оператор pipeline и зачем ему нужна конфигурация.


Текущий статус


Gilbert Garza, изначально предложивший оператор pipeline, ставил целью получить простой синтаксис для «упорядоченных цепочек вызовов функций в удобочитаемом функциональном стиле». Оператор pipeline берёт своё начало в таких языках, как F#, Hack, Elm, Elixir и других, а при добавлении его в JavaScript возникают два спорных момента:


  • Где и как использовать плейсхолдеры?
  • Как должны работать async / await в пайплайне?

Плейсхолдеры


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


namespace Hack\UserDocumentation\Operators\Pipe\Examples\MapFilterCountPiped;

function piped_example(array<int> $arr): int {
  return $arr
    |> \array_map($x ==> $x * $x, $$)
    |> \array_filter($$, $x ==> $x % 2 == 0)
    |> \count($$);
}

var_dump(piped_example(range(1, 10)));

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


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


Async / Await


Первый вариант оператора pipeline содержал такой синтаксис для await:


x |> await f

что может быть развёрнуто в:


await f(x)

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


(await f)(x)

Пока буксовала сама идея добавления async в пайплайн, члены комитета высказались против оператора pipeline, который не поддерживает async / await. Да, есть варианты, как без явного синтаксиса работать с функциями, возвращающими Promise, но все эти варианты слишком громоздкие или требуют вспомогательных функций.


Предлагаемые решения


В результате дискуссий сформировались два предложения (вдобавок к минимальному варианту): использовать F#-пайплайны и Smart-пайплайны. Давайте посмотрим на эти предложения.


Минимальный вариант оператора


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


F# пайплайны


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


На текущий момент (по спецификации F#-пайплайнов) стрелочные функции должны быть обёрнуты в скобки:


let person = { score: 25 };

let newScore = person.score
  |> double
  |> (_ => add(7, _))
  |> (_ => boundScore(0, 100, _));

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


Что касается асинхронности, в F#- пайплайнах await работает как унарная функция:


promise |> await

Что разворачивается в:


await promise

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


promise
  |> await
  |> (x => doubleSay(x, ', '))
  |> capitalize
  |> (x => x + '!')
  |> (x => new User.Message(x))
  |> (x => stream.write(x))
  |> await
  |> console.log;

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


Smart-пайплайны


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


promise
  |> await #
  |> doubleSay(#, ', ')
  |> # || throw new TypeError()
  |> capitalize
  |> # + '!'
  |> new User.Message(#)
  |> await stream.write(#)
  |> console.log;

Правила использования плейсхолдеров в Smart-пайплайнах довольно просты. Если одиночный идентификатор передан в шаг пайплайна, то никакой дополнительный токен (плейсхолдера) не требуется, это называется «минималистским стилем» ("bare style"):


x |> a;
x |> f.b;

В отличие от Hack, унарные функции не требуют токена плейсхолдера.


Для других выражений плейсхолдер (называемый "lexical topic token" — «лексема тематического стиля») обязателен, а пайплайн считается работающим в рамках «тематического стиля» — "topic style". Отсутствие токена плейсхолдера в таком случае вызывает раннюю ошибку SyntaxError:


10 |> # + 1;
promise |> await #;

Если имеются какие-либо операторы, скобки (в том числе для вызова метода), кавычки, или вообще что угодно, кроме идентификатора и точки, то токен плейсхолдера обязателен. Это поможет избежать выстрела себе в ногу и устранить неопределённость.


Smart-пайплайны решают проблему поддержки асинхронности в более общем виде, что разрешает использовать в пайплайнах все возможные выражения, не только await, но и typeof, yield и любые другие операторы.


На сцену выходит Babel


Как только все три предложения были конкретизированы, мы пришли к выводу, что такие обсуждения не приведут к разрешению глубоких противоречий между предложениями. Мы решили, что лучший способ — собрать отзывы разработчиков, использующих предложения в реальном коде. С учётом роли Babel в сообществе разработчиков, мы решили добавить все три варианта в плагин оператора pipeline.


Поскольку парсинг для всех трёх предложений незначительно, но отличается, их поддержка должна быть сначала добавлена в @babel/parser (который babylon), причём парсер должен знать, какое предложение нужно сейчас поддерживать. Таким образом плагин оператора pipeline требует опции "proposal", как для конфигурирования babylon для парсинга, так и для последующей трансформации.


Мы работали над этим в оперативном режиме, потому что нам надо сделать все изменения, ломающие обратную совместимость, до того, как babel@7 перестанет быть бетой. Мы бы хотели в итоге сделать один из вариантов пайплайнов дефолтным для плагина, чтобы избавиться от необходимости в конфигурационной опции.


Учитывая эти ограничения, мы решили добавить опцию в конфигурацию плагина и сделать её обязательной, принуждая пользователей решать, какое из предложений они хотят использовать в своём проекте. Как только конкретное предложение будет выбрано как каноническое поведение оператора, мы пометим опцию "proposal" как устаревшую, а канонический вариант станет работать по-умолчанию. Поддержка отменённых предложений будет работать до следующей мажорной версии.


Принять участие


Если хотите участвовать в обсуждении предложения, то все обсуждения публичны и вы можете найти их в репозитории предложения оператора pipeline. К вашим услугам также презентация со встречи TC39. В конце концов, вы можете обратиться к James DiGioia, J. S. Choi или к Daniel Ehrenberg в твиттере.


Но что гораздо важнее, как только работа над pipeline будет завершена, попробуйте его в своих проектах! Мы также работаем над добавлением новых возможностей в repl, так что вы сможете проверить свой код и в интерактивном режиме. Нам нужна обратная связь, и использование в реальном коде очень поможет её собрать. Отправляйте твиты на @babeljs.

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

А какой из вариантов pipeline оператора на первый взгляд вам кажется удобней для использования?

Mail.Ru Group

848,24

Строим Интернет

Поделиться публикацией

Похожие публикации

Комментарии 16
    +1

    А можно какой-нибудь пример использования pipeline operator c async / await из реальной жизни?


    С синхронным вариантом все понятно: его можно использовать для преобразования коллекций.
    Например, написать map, filter, reduce для Object, Map, Set без использования Lodash.


    А что насчет асинхронного?

      0
      А можно какой-нибудь пример использования pipeline operator c async / await из реальной жизни?

      Лично мне кажется что многие привычные асинхронные сценарии в генераторах redux-saga будут выглядеть намного удобнее с пайплайном (пока что запись саг доступна только через Smart-пайплайны, так как в F#-like пайплайнах нет yield). Как и любая запись сценария в функциональном стиле будет удобней, чем в императивном (с моей точки зрения, само собой).

      Вот сейчас у меня перед глазами функция без всяких redux-saga, которая содержит сценарий отправки отзыва, она должна сделать три асинхронные операции: если пользователь аноним, то создаётся пользователь, затем собственно отправляется отзыв, затем перезапрашиваются отзывы к текущей странице. Между этими тремя вызовами есть синхронные операции, и было бы неудобно часть действий писать в пайплайне, но иногда разрывать цепочку, чтобы использовать await. В конце концов надо ещё вернуть промис всей этой катавасии, и функциональная запись пайплайна тут как нельзя кстати: вся функция выглядела бы как несколько шагов пайплайна.

      Если вы хотите больше точек зрения, то вот тут идут баталии по вашему вопросу: github.com/tc39/proposal-pipeline-operator/issues/86
      +2

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

        +2

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


        // Before:
        class Comment extends Sharable(Editable(Model)) {
          // ...
        }
        // After:
        class Comment extends Model |> Editable |> Sharable {
          // ...
        }

        Дальше — больше.
        Да, в случае классов нам вполне можно использовать и декораторы для примешивания, но для функциональных компонентов так просто всё не получится (декораторы не применимы просто к функциям). Поэтому обычно в recompose/recompact используют обычный функциональный compose/pipe (который надо импортить из какой-либо библиотеки). Пайплайн позволяет собирать энхансер (higher-order component) из своих хоков (или рекомпоузовских) используя нативный javascript-синтаксис.


        Этот оператор (как и стагнирующее предложение bind-оператора) вводится как раз ради появления нативного синтаксиса для обработки цепочек/пайплайнов, а они очень часто используются, это много юзкейзов. Пакетами, реализующими пайплайны и цепочки, завален весь npm. Это примерно то же самое, как ранее, до появления ключевого слова class в es6, каждый начинающий javascript-ер был обязан сделать свою реализацию "полноценного ООП" в js (и написать об этом на Хабр).

          +1

          Спасибо за объяснения.


          Честно говоря, меня вполне устраивает Sharable(Editable(Model)) (да это даже меньше символов занимает), но это вкусовщина.


          Что касается recompose и HOC'ов, то и так было понятно, что пайплайны затеваются почти и исключительно под них, поэтому я и ворчу про узкий юзкейс.


          Цепочек я не писал уже очень давно, а когда писал, то мне вполне хватало Array#reduce.


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

        +3
        Интересно мнение сообщества. Какой из предложенных вариантов вам больше нравится?
        // С именованием переменных
        function getRandomColor() {
          const base = Math.random() * 0xFFFFFF;
          const floored = Math.floor(base);
          const hexString = floored.toString(16);
          return `#${hexString}`;
        }
        

        // Просто последовательные вызовы
        function getRandomColor() {
          return `#${Math.floor(Math.random() * 0xFFFFFF).toString(16)}`;
        }
        

        // С пайплайн оператором
        function getRandomColor() {
          return Math.random()
            |> base => base * 0xFFFFFF
            |> Math.floor
            |> floored => floored.toString(16)
            |> hex => `#${hex}`
        }
        
          +1

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


          • код выровнен, а оттого проще воспринимается
          • за '|>' цепляется глаз, проще воспринимать саму последовательность действий
          • нет проблемы с именованием

          Собственно по этой причине те, кто часто имеет дело с чем-то около-функциональным и не только, предпочитают использовать цепочки трансформаций. Даже без '|>' они позволяют многое упростить и избежать дубляжа. Но, без '|>' — шаг влево, шаг в право — цепочка порвалась.

            +2

            На самом деле не очень честное сравнение. Каждый инструмент в языке предназначен для чего-нибудь, и pipeline operator не исключение: это инструмент для функционального подхода,
            и он лучше себя покажет в таком сравнении, например:


            // (1) Просто вызов функций с промежуточными значениями
            const doEverything = val => {
                const junk1 = doSomething(val);
                const junk2 = doSomethingElse(junk1);
                return doSomeMore(junk2);
            };

            // (2) Вложенные вызовы тех же функций
            const doEverything = val => doSomething(doSomethingElse(doSomeMore(val)));

            // (3) Бесточечный подход (с импортом pipe откуда-нибудь)
            const doEverything = pipe(
                doSomething,
                doSomethingElse,
                doSomeMore,
            );

            // (4) Новый модный молодёжный оператор
            const doEverything = val => val
                |> doSomething
                |> doSomethingElse
                |> doSomeMore

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


            Получается, что pipeline operator — это самый удобный для чтения и для отладки инструмент из тех, которые на текущий момент существуют в языке.

              +1
              Опечатка: в примере (2) нарушен порядок вызовов в теле doEverything, должен быть обратный.
            +2

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

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

                Кстати в переводе (как и в оригинале) упоминалось частичное применение, правда без ссылки. Предложение proposal-partial-application находится в stage-1, как и сам пайплайн оператор. Так что предполагается, что в светлом будущем они либо вместе выйдут в свет, либо один за другим.


                А что за особый образ реализации стандартной библиотеки в F#? Адаптированность к функциональному применению, а-ля ramda?

                  0

                  В пайпах предлагают # в качестве плейсхолдера, в частичном применении — ?. Надеюсь, они все-таки договорятся!


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


                  // хорошо
                  let good a b current = ...
                  someValue |> good 1 2
                  
                  // плохо
                  let bad current a b  = ...
                  someValue |> (fun x -> bad x 1 2)

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

                    +2

                    Первым делом хочу сказать, что мой коммент не является защитой авторов черновика пайплайн-оператора и не является попыткой противопоставлять JavaScript и F# (или превозносить JS над F#). И да, я не собирался писать портянку такую, она сама.


                    … итого вы аппелируете к:
                    1) пайплайны
                    2) частичное применение
                    3) "особым образом подготовленная стандартная библиотека"
                    4) статическая типизация


                    В пайпах предлагают # в качестве плейсхолдера, в частичном применении —?.. Надеюсь, они все-таки договорятся!

                    Это касается только Smart-пайплайнов. В предложении, которое называют F#-пайплайны, нет дополнительного символа, т.е. любые функции, которые уже каррированы, будут работать с F#-пайплайнами из коробки.


                    По поводу стандартной библиотеки — исторически так сложилось, что в JS нет стандартной библиотеки, зато есть россыпь всевозможных библиотек, и некоторые из них занимают нишу стандартной библиотеки. Например core-js (спасибо rock) стала частью Babel ещё когда эта штука называлась 6to5. А Ramda.js де-факто стала таким же стандартом при функциональном подходе в JS.


                    … у методов так подбирается сигнатура, чтобы «текущий элемент» был последним аргументом. Так можно указать все нужные аргументы в частичном применении, а последний будет автоматически получен из пайпа

                    Не знаю, в курсе вы или нет, но именно так и работает Ramda. Все функции получают этсамый "текущий элемент" последним, ну и вдобавок все функции уже каррированы. Итого мы имеем ту же стандартную библиотеку, с такими же возможностями. Что позволяет из коробки использовать рамду вместе с F#-пайплайн-оператором:


                    import { map, filter, sum } from 'ramda'
                    
                    const someValue = [1, 2, 3, 4]
                    const doubler = x => x * 2
                    
                    map(doubler, someValue)  // вернёт [2, 4, 6, 8]
                    map(doubler)(someValue)  // вернёт [2, 4, 6, 8]
                    someValue |> map(doubler)  // вернёт [2, 4, 6, 8]
                    
                    // вернёт число 14
                    someValue
                        |> map(doubler)
                        |> filter(x => x > 5)
                        |> sum

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


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

                    На это пока нечего ответить, могу лишь подтвердить, хоть я и не использовал ramda в чистом JS (только c FlowType). С flowtype мы получаем сильную типизацию и статические проверки… но другие грабли — настройка и интеграция самого flowtype (и либдефов). Ну и детские баги flowtype, часто — молчаливая потеря обратной совместимости между версиями.

                      0

                      Спасибо за подробный ответ! Со всем согласен, кроме двух маленьких моментов.


                      Во-первых, RamdaJS и CoreJS все-таки не стандартные, а просто популярные библиотеки. Стандартной библиотекой в моем понимании являются встроенные типы и их методы — String, Array, Number и прочие. В этом смысле несогласованность между частями стандарта остается, хотя лучшего решения я предложить не могу.


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

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

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

              Самое читаемое