Pipeline operator в JavaScript: Hack vs F# — почему TC39 выбрал не тот вариант, считает лид RxJS

Pipeline operator |> в JavaScript решает ключевую проблему: трансформация значения через цепочку функций без вложенных вызовов. TC39 выбрал Hack-вариант со специальным символом-плейсхолдером, отклонив F#-вариант, удобный для библиотек вроде RxJS. Перевод статьи Бена Леша из RxJS Core Team — почему это, по его мнению, ошибка.

Обложка: Pipeline operator в JavaScript: Hack vs F# — почему TC39 выбрал не тот вариант, считает лид RxJS

Если вы пишете на 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, и многих других.

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

			function pipe(initialArg, ...fns) {
  return fns.reduce((prevValue, fn) => fn(prevValue), initialArg);
}

// Базовый пример использования:
const result = pipe(
  2,
  (x) => x ** 2,
  (x) => x - 1,
);
// result: 3
		

Сильная сторона функциональных пайпов в том, что функции переносимы и композируемы. Можно переписать пример выше, чтобы он стал читаемее и состоял из переиспользуемых частей.

			function squared(x) { return x ** 2; }
function subtractOne(x) { return x - 1; }

// Уточнённое использование
const result = pipe(2, squared, subtractOne);
// result: 3
		

Где это пригождается

Конечно, реальные сценарии для пайпинга функций сильно сложнее простой математики. Самый частый — повторное применение функций к разным наборам данных. Главный пример (и да, я о нём поговорю) — RxJS.

RxJS использует пайп-функции для трансформации observables. Раньше у Observable были только методы класса для трансформаций. Это работало в целом нормально, но при таком количестве возможных операций и того, что методы плохо «трясутся» (tree-shake) современными бандлерами, оказалось, что огромный набор методов не годится для сообщества. Мы пробовали «prototype patching», когда модули добавляют методы к Observable «по меню», но это породило кучу других проблем (об этом — в другой статье). В итоге мы остановились на пайп-функциях. Преимущество: вы платите бандл-сайзом только за то, что используете. Импортируете и применяете нужные операторы, остальное «вытряхивается».

			// RxJS 5.5 и ниже (методы, без пайпинга):
source$
  .filter((x) => x % 2 === 0)
  .map((x) => x * x)
  .concatMap((x) => of(x + 1, x + 2, x + 2, x + 4))
  .subscribe(console.log);

// RxJS 5.5 и выше (с пайп-функциями):
source$.pipe(
  filter((x) => x % 2 === 0),
  map((x) => x * x),
  concatMap((x) => of(x + 1, x + 2, x + 2, x + 4)),
).subscribe(console.log);
		

В случае 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 указан %, но финальный выбор не сделан — обсуждаются варианты %, ^, ^^, @@, #. В примерах ниже автор использует ^.

Базовый пример и тот же через именованные функции:

			// Коротко и понятно, хоть глаза и устают:
const result = 2 |> ^ ** 2 |> ^ - 1;

// То же самое только функциями:
const result = 2 |> squared(^) |> subtractOne(^);
		

RxJS — обратите внимание на лишние (^) после каждого оператора:

			source$
  |> filter((x) => x % 2 === 0)(^) // <-- лишняя обвязка
  |> map((x) => x * x)(^)
  |> concatMap((x) => of(x + 1, x + 2, x + 2, x + 4))(^)
  |> ^.subscribe(console.log);
		

Существующие non-unary API:

			const randomNumberBetween20And50 = Math.random() * 100
  |> Math.pow(^, 2)
  |> Math.min(^, 50)
  |> Math.max(20, ^);
		

Внутри async-функции:

			async function demo() {
  return await fetch(url)
    |> await ^.json()
    |> await delayValue(1000, ^);
}
		

Throw внутри шага пайпа — приходится оборачивать в функцию (см. минусы ниже):

			const validNumberBetween20and50 = maybeGetNumber()
  |> ((num) => {
      if (Number.isNaN(+num)) {
        throw new TypeError('Did not get a number');
      }
      return num;
    })(^)
  |> Math.pow(^, 2)
  |> Math.min(^, 50)
  |> Math.max(20, ^);
		

Плюсы 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 и =. Тяжело объяснить без кода: по сути это слегка более эффективный способ написать примерно то же самое (см. ниже).
			// Hack:
const a = 2 |> squared(^) |> subtractOne(^);

// То же через let и присваивания.
// Удивительно, но в TypeScript это работает отлично.
// (если x объявлен как any, тип выводится на каждом шаге)
let x;
x = 2;
x = squared(x);
x = subtractOne(x);

// Альтернативный паттерн с отдельными объявлениями.
// Дополнительный плюс: каждый шаг можно использовать дальше в цепочке.
const a = 2,
      b = squared(a),
      c = subtractOne(b),
      d = a + b + c;
// Так с Hack pipeline сделать нельзя.
		

F# pipeline

F# pipeline — другой вариант предложения, у которого было много сторонников за пределами TC39, но его пропустили в пользу Hack pipeline (со ссылкой на «плюсы», описанные выше). Назван так по самой известной реализации — в языке F#.

Идея F# pipeline в том, что значение из LHS передаётся как последний аргумент функции справа. Это идеально работает с унарными функциями — точно с такими, что и при функциональном пайпинге выше.

Базовый пример и тот же через именованные функции:

			const result = 2 |> (n) => n ** 2 |> (n) => n - 1;

// То же самое только функциями:
const result = 2 |> squared |> subtractOne;
		

RxJS — никаких дополнительных обвязок, операторы используются как есть:

			source$
  |> filter((x) => x % 2 === 0)
  |> map((x) => x * x)
  |> concatMap((x) => of(x + 1, x + 2, x + 2, x + 4))
  |> result$ => result$.subscribe(console.log);
		

Существующие non-unary API — через стрелочные обёртки, причём промежуточные значения можно именовать:

			const randomNumberBetween20And50 = Math.random() * 100
  |> randomNum => Math.pow(randomNum, 2)
  |> squared => Math.min(squared, 50)
  |> atLeast50 => Math.max(20, atLeast50);
		

В async-функции прямого аналога нет — пишите как обычно:

			async function demo() {
  const response = await fetch(url);
  const data = await response.json();
  return await delayValue(1000, data);
}
		

Throw внутри шага пайпа — без всяких обвязок:

			const validNumberBetween20and50 = maybeGetNumber()
  |> (num) => {
      if (Number.isNaN(+num)) {
        throw new TypeError('Did not get a number');
      }
      return num;
    }
  |> validNum => Math.pow(validNum, 2)
  |> squared => Math.min(squared, 50)
  |> atLeast50 => Math.max(20, atLeast50);
		

Плюсы 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.config.json
{
  "plugins": [
    ["@babel/plugin-proposal-pipeline-operator", {
      "proposal": "hack",
      "topicToken": "^^"
    }]
  ]
}
		

Для российских команд практическая мысль: пока оператора нет в стандарте, не торопитесь добавлять Babel-плагин ради эстетики — это лишний build-step и потенциальная сложность поддержки. Чистый pipe(value, ...fns) из ramda или собственная утилита в 5 строк дают 90% выигрыша по читаемости без внешних зависимостей.

Оригинал статьи: benlesh.com.

Часто задаваемые вопросы
1
Зачем вообще нужен pipeline operator?

Чтобы избавиться от вложенных вызовов функций и временных переменных при последовательной трансформации значения. Вместо subtractOne(squared(2)) или let x = 2; x = squared(x); x = subtractOne(x); писать 2 |> squared |> subtractOne. Это улучшает читаемость в data-pipelines, особенно длинных.

2
Почему TC39 выбрал Hack, а не F#?

Главные аргументы за Hack — эксплицитность (видно, куда подставляется значение), отсутствие необходимости в higher-order functions и универсальная работа с любым выражением справа. F# был отклонён в основном из-за второго пункта: HOF добавляют когнитивную нагрузку для разработчиков без функционального бэкграунда.

3
Можно ли использовать pipeline operator уже сейчас?

Да — через Babel-плагин @babel/plugin-proposal-pipeline-operator. Он поддерживает оба варианта (Hack и F#), вариант указывается в опциях. В нативном JavaScript оператор пока не работает ни в одном движке (V8, SpiderMonkey, JavaScriptCore), потому что предложение всё ещё на stage 2.

4
Что выбрать: ramda/fp-ts/RxJS или ждать оператора?

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

5
Как pipeline отличается от method chaining?

Method chaining (например, arr.filter(...).map(...)) работает только если методы определены на типе и нельзя «отключить» неиспользуемые. Pipeline работает с любой свободно-стоящей функцией, что открывает tree-shaking и переиспользование.