Pipeline operator в JavaScript: Hack vs F# — почему TC39 выбрал не тот вариант, считает лид RxJS
Pipeline operator |> в JavaScript решает ключевую проблему: трансформация значения через цепочку функций без вложенных вызовов. TC39 выбрал Hack-вариант со специальным символом-плейсхолдером, отклонив F#-вариант, удобный для библиотек вроде RxJS. Перевод статьи Бена Леша из RxJS Core Team — почему это, по его мнению, ошибка.
Если вы пишете на RxJS, Ramda или просто часто применяете несколько функций подряд к одному значению — эта дискуссия про вас. Перевод статьи Бена Леша (RxJS Core Team) о двух вариантах оператора |> в TC39: компромиссном Hack-стиле и более «функциональном» F#-стиле, который в комитете отклонили — и почему это, по мнению автора, ошибка.
Я хочу поделиться всем, что знаю про предложение оператора пайплайна, который сейчас находится на stage 2 в TC39 (это значит «спецификация утверждена в общих чертах, но детали и финальный синтаксис ещё могут меняться»). Это будет, конечно, в какой-то мере предвзятый рассказ — статья моя — но я постараюсь представить обе стороны, при этом отстаивая свою позицию. Если заметите, что я что-то упустил, — напишите мне в DM в Twitter, я стараюсь отвечать на все.
ВАЖНО. Перед тем как мы погрузимся в это: есть люди, включая меня, у которых очень сильные чувства по этому поводу. Пожалуйста, придерживайте их. Те, кто работает над этими стандартами, — хорошие люди, пытающиеся сделать как лучше для сообщества, даже если мы с ними не согласны.
Ключевые выводы
- Pipeline operator
|>в TC39 решает одну боль: «передать значение через цепочку функций без вложенных вызовов». Сейчас в stage 2 принят Hack-вариант, F#-вариант — отклонён, хотя его поддерживала функциональная часть сообщества. - Hack-вариант:
2 |> ^ ** 2 |> ^ - 1, где^— placeholder для значения слева. Работает с любым выражением, не требует higher-order functions, но плохо дружит с библиотеками типа RxJS. - F#-вариант:
2 |> squared |> subtractOne, без специального символа. Передаёт значение последним аргументом функции справа. Идеально для библиотек с unary-функциями (RxJS, Ramda, fp-ts). - Главная претензия Леша: библиотеки, которые годами популяризировали идею пайплайна функций (RxJS, Ramda), с Hack-оператором практически не получают выигрыша — там надо писать
map(fn)(^)вместо простоmap(fn). - Альтернатива: продвигать F#-pipeline + дополнить proposal-ом partial application (тоже в TC39 на stage 1). Тогда получаем лучшее из обоих миров.
Что такое pipeline operator?
Если коротко, pipeline — это оператор, в данном случае |>, который позволяет разработчику «прокинуть» вычисленное выражение из левой части (LHS) в какую-то функцию или выражение справа (RHS). Этот приём реализован в куче языков мира программирования, и мы сейчас в удивительной ситуации, когда TC39 — комитет, который управляет стандартом ECMAScript, на котором основан JavaScript — рассматривает добавление такого оператора в стандарт. Конкурировало много proposals, два выделились больше всего: Hack pipeline (сейчас на stage 2) и F# pipeline (отклонён, несмотря на популярность).
Как сейчас «пайпят» функции в JS
Первое, что нужно понять, — что вообще значит «пайпить» функции. В сегодняшнем JavaScript есть, по сути, только один способ: через «функциональный пайп». Это распространённая утилита из функционального программирования, которая существует в библиотеках вроде Ramda и RxJS, и многих других.
Идея в том, чтобы применить к значению серию функций в указанном порядке, передавая возвращаемое значение каждой следующей. Сами утилиты делаются довольно просто.
Сильная сторона функциональных пайпов в том, что функции переносимы и композируемы. Можно переписать пример выше, чтобы он стал читаемее и состоял из переиспользуемых частей.
Где это пригождается
Конечно, реальные сценарии для пайпинга функций сильно сложнее простой математики. Самый частый — повторное применение функций к разным наборам данных. Главный пример (и да, я о нём поговорю) — RxJS.
RxJS использует пайп-функции для трансформации observables. Раньше у Observable были только методы класса для трансформаций. Это работало в целом нормально, но при таком количестве возможных операций и того, что методы плохо «трясутся» (tree-shake) современными бандлерами, оказалось, что огромный набор методов не годится для сообщества. Мы пробовали «prototype patching», когда модули добавляют методы к Observable «по меню», но это породило кучу других проблем (об этом — в другой статье). В итоге мы остановились на пайп-функциях. Преимущество: вы платите бандл-сайзом только за то, что используете. Импортируете и применяете нужные операторы, остальное «вытряхивается».
В случае RxJS простая pipe-функция (как в первом примере) используется внутри метода pipe у Observable, где this передаётся первым аргументом. Все операторы — filter, map, concatMap — это higher-order functions: они принимают аргументы для настройки, а возвращают унарные пайпуемые функции вида (source) => result.
Это значит, что общая реализация RxJS-функций неизбежно сложная в плане функциональной композиции — все они в основном (arg) => (source) => result, чтобы внутреннее (source) => result можно было пайпить.
Hack pipeline (текущее предложение TC39)
Текущий вариант оператора, который TC39 продвигает, называется Hack pipeline. Назван по языку Hack (диалект PHP, созданный в Facebook), где есть оператор пайплайна, моделью для которого он и стал. Стоит отметить, что предложение находится на stage 2, и я описываю состояние на момент написания.
В Hack-варианте значение из LHS передаётся в выражение справа через специальный символ-плейсхолдер. В официальной спецификации в качестве presumptive token указан %, но финальный выбор не сделан — обсуждаются варианты %, ^, ^^, @@, #. В примерах ниже автор использует ^.
Базовый пример и тот же через именованные функции:
RxJS — обратите внимание на лишние (^) после каждого оператора:
Существующие non-unary API:
Внутри async-функции:
Throw внутри шага пайпа — приходится оборачивать в функцию (см. минусы ниже):
Плюсы Hack pipeline
- Эксплицитность. Многие сторонники Hack любят его за то, что он более явный, чем F#: видно, что именно выполняется.
- Не требует higher-order functions. Поскольку значение из LHS подаётся как специальный символ в RHS, нет необходимости создавать обёртки-функции, чтобы воспользоваться этим значением.
- Работает с любой существующей функцией. Hack pipeline можно применять к любой JavaScript-функции без дополнительной работы.
- Большинство выражений «просто работают». Хотите запайпить в
^ + ^? Пожалуйста. - Можно await/yield из окружающего контекста. Если
|>внутри async-функции — внутри него можно делать await. Внутри генератора или async-генератора — можно использовать yield в RHS.
Минусы Hack pipeline
- «Магический» символ нельзя переименовать. В текущем предложении нет способа переименовать
^, кроме как обернуть RHS в функцию. - «Магический» символ нужно искать. Где именно используется значение из LHS, решает разработчик и куда поставит
^. В обычном текстовом редакторе вам, возможно, придётся играть в «найди шапочку», чтобы понять, где значение применяется. - Некоторые выражения просто не работают. Запайпить можно в большинство выражений, но очевидно, что некоторые не сработают — например, выражение, в котором есть ещё один
|>. - Плохо работает с существующими функционально-пайпуемыми библиотеками. На мой взгляд — самый важный минус. Библиотеки, которые годами популяризировали пайпинг функций, выиграют от Hack pipeline куда меньше: например, RxJS пришлось бы вызывать
mapкакsource$ |> map(fn)(^). - Нет прямого способа бросить исключение в шаге пайпа без функции. Тут много нюансов, но если вы решили, что не можете сложить значения через
^ + ^, нет чистого механизма выбросить TypeError, кроме как обернуть сложение в функцию или, может быть, в скобки (это не специфицировано). В async-контексте это может стать совсем мутно. - Может убить proposal partial application. Иметь в языке сразу две похожие, но разные фичи — наверняка запутает. Partial application очень похож: тоже использует магический символ для применения значения к выражению, возвращая функцию, принимающую столько аргументов, сколько раз символ встречается. (Очень круто, хотя слегка путает.)
- Едва отличается от использования
letи=. Тяжело объяснить без кода: по сути это слегка более эффективный способ написать примерно то же самое (см. ниже).
F# pipeline
F# pipeline — другой вариант предложения, у которого было много сторонников за пределами TC39, но его пропустили в пользу Hack pipeline (со ссылкой на «плюсы», описанные выше). Назван так по самой известной реализации — в языке F#.
Идея F# pipeline в том, что значение из LHS передаётся как последний аргумент функции справа. Это идеально работает с унарными функциями — точно с такими, что и при функциональном пайпинге выше.
Базовый пример и тот же через именованные функции:
RxJS — никаких дополнительных обвязок, операторы используются как есть:
Существующие non-unary API — через стрелочные обёртки, причём промежуточные значения можно именовать:
В async-функции прямого аналога нет — пишите как обычно:
Throw внутри шага пайпа — без всяких обвязок:
Плюсы F# pipeline
- Имплицитность. Передаёт значение из LHS в функцию справа предсказуемо и неявно. Нет вопросов «куда ушло значение» — оно всегда передаётся последним аргументом функции справа. А в самом частом случае унарной функции — единственным.
- Работает с существующими функционально-пайпуемыми библиотеками. Из коробки. Всё сообщество JavaScript, которое хотело пайпить функции и пайпило их, выиграет от нового оператора. Например, RxJS сможет вызывать
mapпросто какsource$ |> map(fn). - Работает с любой функцией через arrow-обёртки. Если использовать стрелочные функции с F#-pipeline, можно использовать любой существующий API в точности так же, как с Hack — только с бонусом, что значение можно назвать.
- Не требует магического символа. Не нужно вводить в JavaScript специальный символ. Через стрелочные функции вы используете обычные аргументы.
- Разработчики, знакомые с функциональным программированием, получат дополнительную силу. Кто умеет создавать унарные higher-order functions (например,
(...args) => (in) => out) — может строить интересные переиспользуемые паттерны, недоступные в Hack. - Интересные паттерны с await/yield. С F#-pipeline можно асинхронно получить функцию, которая примет значение из LHS:
value |> await getSomeFunc(). В генераторе можно получить ссылку на функцию через корутину с yield. - Код в RHS переиспользуем. В отличие от Hack, всё что находится в правой части F#-оператора, можно положить в переменную и переиспользовать — потому что оно обязано вычисляться в реальную ссылку на функцию.
- Хорошо работает с records/tuples (тоже stage 2). Можно деструктурировать в RHS через обычные стрелочные функции. В Hack планов как работать с деструктуризацией из «магического» символа я не видел.
- Хорошо работает с partial application (stage 1). Partial application добавляет к F#-pipeline все «приятные» возможности Hack — и даёт лучшее из обоих миров. По моему мнению, если бы partial application уже существовал, Hack pipeline даже не было бы на столе.
Минусы F# pipeline
- Требует функцию в RHS. Чаще всего это будет стрелочная функция, но может быть и higher-order, если кто-то хочет переиспользовать.
- Не позволяет такое же использование await/yield. Использовать оба можно, но они должны разрешаться или yield-ить ссылку на функцию. Это не полное ограничение, но другое поведение. Кроме того, неочевидно, насколько полезен await после пайпа в принципе.
- Продвинутое использование разнесёт higher-order functions. Кто-то считает это минусом — упомяну. HOF — новая сложность для части людей. Я лично думаю, что это будет катализатором лучшего понимания HOF (которые на самом деле — просто другой способ замкнуться над состоянием через функцию, не через класс с методом). В любом случае, простые функции «просто работают» через стрелочную обёртку:
|> (x) => plainFunc(x, x).
Резюме автора
Я знаю, что в статье много мнения, но надеюсь, этого хватит, чтобы заставить ещё несколько человек подумать над проблемой — и заставить очень важных и умных людей, работающих над предложением, остановиться и пересмотреть некоторые принятые решения. По-моему, жаль, что предложение в текущем виде уже на stage 2.
Можно получить лучшее из обоих миров одним из способов: (1) разрешить Hack pipeline неявно вести себя как F# pipeline, когда «магический» символ отсутствует; или (2) переключиться на F# pipeline и одновременно протолкнуть предложение partial application. На бумаге второй вариант даёт JavaScript-разработчикам куда более мощный набор инструментов.
Я долго ругаюсь на эту тему, это правда. Делаю что считаю лучшим для JavaScript-сообщества с тем небольшим влиянием, которое у меня есть. Если бы Hack-предложение «просто работало» с пайпуемыми унарными функциями — у меня не было бы к нему вопросов, и эта статья не была бы написана.
Надеюсь, обе стороны спора найдут информацию полезной. Также надеюсь, что мы решим вопрос так, чтобы «обеих сторон» больше не было, и мы все двигались вперёд вместе.
От переводчика
Статья написана в 2021 году, но за прошедшее время мало что изменилось: pipeline operator всё ещё на stage 2, проектное решение не пересматривалось. Hack-вариант остаётся официальным, F# — отклонён. Параллельный proposal partial application — на stage 1.
Если нужен пайплайн уже сейчас — Babel-плагин @babel/plugin-proposal-pipeline-operator поддерживает Hack и F# режимы. Для Hack обязательно указывать topicToken, иначе плагин упадёт:
Для российских команд практическая мысль: пока оператора нет в стандарте, не торопитесь добавлять Babel-плагин ради эстетики — это лишний build-step и потенциальная сложность поддержки. Чистый pipe(value, ...fns) из ramda или собственная утилита в 5 строк дают 90% выигрыша по читаемости без внешних зависимостей.
Оригинал статьи: benlesh.com.
Часто задаваемые вопросы
Зачем вообще нужен pipeline operator?
Чтобы избавиться от вложенных вызовов функций и временных переменных при последовательной трансформации значения. Вместо subtractOne(squared(2)) или let x = 2; x = squared(x); x = subtractOne(x); писать 2 |> squared |> subtractOne. Это улучшает читаемость в data-pipelines, особенно длинных.
Почему TC39 выбрал Hack, а не F#?
Главные аргументы за Hack — эксплицитность (видно, куда подставляется значение), отсутствие необходимости в higher-order functions и универсальная работа с любым выражением справа. F# был отклонён в основном из-за второго пункта: HOF добавляют когнитивную нагрузку для разработчиков без функционального бэкграунда.
Можно ли использовать pipeline operator уже сейчас?
Да — через Babel-плагин @babel/plugin-proposal-pipeline-operator. Он поддерживает оба варианта (Hack и F#), вариант указывается в опциях. В нативном JavaScript оператор пока не работает ни в одном движке (V8, SpiderMonkey, JavaScriptCore), потому что предложение всё ещё на stage 2.
Что выбрать: ramda/fp-ts/RxJS или ждать оператора?
Если уже используете эти библиотеки — продолжайте: они дают рабочий пайпинг без новых синтаксических конструкций. Если только присматриваетесь к функциональному стилю — учиться на библиотеках лучше: они переносимы между проектами, а оператор появится в стандарте неизвестно когда.
Как pipeline отличается от method chaining?
Method chaining (например, arr.filter(...).map(...)) работает только если методы определены на типе и нельзя «отключить» неиспользуемые. Pipeline работает с любой свободно-стоящей функцией, что открывает tree-shaking и переиспользование.