Skip to main content

Заметка об операторе конвейера

· 9 min read
JavaScript Developer

Привет, друзья!

В этой небольшой заметке я хочу рассказать вам об одном интересном предложении по дальнейшему совершенствованию всеми нами любого JavaScript, а именно: об операторе конвейера (pipe operator) |>.

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

Сегодня в JavaScript существует 2 основных способа выполнения последовательных операций (например, вызовов функций) над значением:

  • передача значения операции в качестве аргумента (вложенные операции - three(two(one(value))));
  • вызов функции как метода значения (цепочка методов - value.one().two().three()).

Пример первого способа:

const toUpperCase = (str) => str.toUpperCase()
const removeSpaces = (str) => str.replace(/\s/g, '')
const addExclamation = (str) => str + '!'

const formatStr = (str) => toUpperCase(removeSpaces(addExclamation(str)))
console.log(formatStr('hello world')) // HELLOWORLD!

Пример второго способа:

const formatStr = (str) =>
str
.padEnd(str.length + 1, '!')
.replace(/\s/g, '')
.toUpperCase()

console.log(formatStr('hello world')) // HELLOWORLD!

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

const pipe = (...fns) =>
fns.reduce(
(prevFn, nextFn) =>
(...args) =>
nextFn(prevFn(...args))
)

const formatStr = pipe(toUpperCase, removeSpaces, addExclamation)
console.log(formatStr('hello world')) // HELLOWORLD!

Или, в случае с асинхронными функциями:

const pipeAsync =
(...fns) =>
(...args) =>
fns.reduce(
(prevFn, nextFn) => prevFn.then(nextFn),
Promise.resolve(...args)
)

const sleep = (ms) => new Promise((r) => setTimeout(r, ms))

const sayHiAndSleep = async (name) => {
console.log(`Hi, ${name}!`)
await sleep(1000)
return name.toUpperCase()
}
const askQuestionAndSleep = async (name) => {
console.log(`How are you, ${name}?`)
await sleep(1000)
return new TextEncoder()
.encode(name) // Uint8Array
.toString()
.replaceAll(',', '-')
}
const sayBi = async (name) => {
console.log(`Bye, ${name}!`)
}

const speak = pipeAsync(sayHiAndSleep, askQuestionAndSleep, sayBi)

speak('John')
/*
Hi, John! - сразу
How are you, JOHN? - через 1 сек
Bye, 74-79-72-78! - через 1 сек
*/

Но вернемся к встроенным способам выполнения последовательных операций.

Проблемы

Оба названных выше способа имеют свои недостатки.

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

Рассмотрим пример реального кода из экосистемы React:

console.log(
chalk.dim(
`$ ${Object.keys(envars)
.map(envar =>
`${envar}=${envars[envar]}`)
.join(' ')
}`,
'node',
args.join(' ')));

Вот как мы читаем этот пример:

  1. Находим начальные данные (envars).
  2. Затем двигаемся назад и вперед изнутри наружу для каждого этапа преобразования данных, рискуя пропустить какой-нибудь префиксный оператор слева или суффиксный оператор справа:
    • Object.keys() (слева);
    • .map() (справа);
    • .join() (справа);
    • шаблонные литералы (с обеих сторон);
    • chalk.dim() (слева);
    • console.log() (слева).

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


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

Цепочка методов используется многими популярными библиотеками. Например, jQuery - это один супер-объект с десятками методов, каждый из которых возвращает тот же объект для обеспечения возможности вызова следующего метода. Такой стиль программирования называется текучим интерфейсом (fluent interface).

К сожалению, этот подход ограничен синтаксисом, не позволяющим вызывать функции, выполнять арифметические операции, использовать литералы массивов и объектов, ключевые слова await, yield и т.п.


Оператор конвейера (pipe operator) объединяет согласованность и легкость использования цепочки методов с широкой применимостью вложенных операций.

Общая структура этого оператора - value |> e1 |> e2 |> e3, где e1, e2 и e3 - выражения, последовательно принимающие значение в качестве параметра. Другими словами, каждое последующее выражение в качестве параметра принимает результат предыдущей операции. В качестве заменителя (placeholder) такого результата используется токен %.

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

Object.keys(envars)
.map(envar => `${envar}=${envars[envar]}`)
.join(' ')
|> `$ ${%}`
|> chalk.dim(%, 'node', args.join(' '))
|> console.log(%);

Теперь мы легко находим начальные данные (envars) и читаем каждое преобразование данных линейно, слева направо.


Разумеется, мы можем использовать временные переменные на каждом шаге трансформации данных:

const envarString = Object.keys(envars)
.map(envar => `${envar}=${envars[envar]}`)
.join(' ');
const consoleText = `$ ${envarString}`;
const coloredConsoleText = chalk.dim(consoleText, 'node', args.join(' '));
console.log(coloredConsoleText);

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


Мы также можем использовать одну мутабельную (изменяемую) переменную с коротким названием:

let _ = Object.keys(envars)
.map(envar => `${envar}=${envars[envar]}`)
.join(' ');
_ = `$ ${_}`;
_ = chalk.dim(_, 'node', args.join(' '));
console.log(_);

Такой подход в "дикой природе" встречается крайне редко. Главная причина этого кроется в том, что значение мутабельной переменной может меняться непредсказуемо, что, в свою очередь, может приводить к тихим багам (silent bugs), которые сложно обнаружить. Например, переменная может быть случайно использована в замыкании или ошибочно переопределена в выражении.

Рассмотрим пример:

function one() { return 1; }
function double(x) { return x * 2; }

let _ = one(); // _ равняется 1
_ = double(_); // _ равняется 2
_ = Promise.resolve().then(() =>
// что будет выведено в терминал?
// мы ожидаем увидеть 2, но видим 1,
// поскольку `_` переопределяется ниже
console.log(_));

// _ получает значение 1 перед выполнением коллбэка промиса
_ = one();

В случае с |> предыдущее значение не может переопределяться, поскольку оно имеет ограниченную лексическую область видимости (lexical scope):

let _ = one()
|> double(%)
|> Promise.resolve().then(() =>
// в терминал выводится 2, как и ожидается
console.log(%));

_ = one();

Еще одним преимуществом |> перед последовательностью инструкций присваивания (assignment statements) является то, что |> - это выражение (expression).

Это означает, что они могут возвращаться, присваиваться переменным или использоваться в таких контекстах, как выражения JSX.

// |>
const envVarFormat = vars =>
Object.keys(vars)
.map(var => `${var}=${vars[var]}`)
.join(' ')
|> chalk.dim(%, 'node', args.join(' '));

// мутабельная переменная
const envVarFormat = (vars) => {
let _ = Object.keys(vars);
_ = _.map(var => `${var}=${vars[var]}`);
_ = _.join(' ');
return chalk.dim(_, 'node', args.join(' '));
}
// |>
return (
<ul>
{values
|> Object.keys(%)
|> [...Array.from(new Set(%))]
|> %.map(envar => (
<li onClick={
() => doStuff(values)
}>{envar}</li>
))}
</ul>
);

// мутабельная переменная
let _ = values;
_= Object.keys(_);
_= [...Array.from(new Set(_))];
_= _.map(envar => (
<li onClick={
() => doStuff(values)
}>{envar}</li>
));
return (
<ul>{_}</ul>
);

Синтаксис предлагаемого оператора конвейера позаимствован из языка программирования Hack. В синтаксисе конвейера этого языка правое выражение содержит специальный заменитель, который заменяется результатом вычисления левого выражения. Поэтому мы пишем value |> one(%) |> two(%) |> three(%) для того, чтобы "пропустить" value через 3 функции.

С правой стороны от |> может находиться любое выражение, а заменитель является обычной переменной, поэтому в конвейере может использоваться любой код без каких-либо ограничений:

  • value |> foo(%) для вызова унарной функции (с одним аргументом);
  • value |> foo(1, %) для вызова н-арной функции (с несколькими аргументами);
  • value |> %.foo() для вызова метода;
  • value |> % + 1 для выполнения арифметической операции;
  • value |> [%, 0] для литерала массива;
  • value |> {foo: %} для литерала объекта;
  • value |> ${%} для шаблонных литералов;
  • value |> new Foo(%) для создания объектов;
  • value |> await % для ожидания разрешения промиса;
  • value |> (yield %) для получения значения генератора; и т.д.

Формальное описание

Ссылка на предыдущее значение (topic reference) % - это нулевой оператор (nullary operator). Он является заменителем для предыдущего значения (topic value), имеет лексическую область видимости и иммутабелен (неизменяем).

Оператор конвейера (pipe operator) - это инфиксный оператор (infix operator), формирующий выражение конвейера (pipe expression, pipeline). Он оценивает левое выражение (голову или входное значение конвейера - pipe head/pipe input), привязывает итоговое значение (topic value) к ссылке (topic reference), затем оценивает правое выражение (тело конвейера - pipe body) с этой привязкой (binding). Итоговое значение правого выражения становится итоговым значением всего конвейера (выходным значением конвейера - pipe output).

Приоритет |> аналогичен приоритету:

  • стрелочной функции =>;
  • операторов присваивания =, += и др.;
  • операторов генератора yield и yield *.

Ниже приоритет только у оператора запятой ,. У всех остальных операторов приоритет выше. Например, v => v |> % == null |> foo(%, 0) будет сгруппировано в v => (v |> (% == null) |> foo(%, 0)), что эквивалентно v => foo(v == null, 0).


Предыдущее значение должно использоваться в теле конвейера хотя бы один раз. Например, value |> foo + 1 - невалидный синтаксис, поскольку в теле конвейера предыдущее значение не используется. Это связано с тем, что отсутствие предыдущего значения в теле конвейера почти наверняка является ошибкой.

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

Запрещается использовать операторы с аналогичным |> приоритетом в качестве головы или тела конвейера. При использовании таких операторов совместно с |> для явной группировки следует использовать скобки. Например, a |> b ? % : c |> %.d - невалидный синтаксис. Валидные версии: a |> (b ? % : c) |> %.d или a |> (b ? % : c |> %.d).

Последнее: привязки предыдущего значения внутри динамически компилируемого кода (например, eval или new Function) не должны использоваться за пределами этого кода. Например, v |> eval('% + 1') выбросит синтаксическую ошибку при вычислении выражения eval в процессе выполнения кода (runtime).

При необходимости выполнения побочного эффекта в середине конвейера без модификации данных, пропускаемых через конвейер, можно использовать выражение запятой (comma expression) - value |> (sideEffect(), %). Это может быть полезным для быстрой отладки - value |> (console.log(%), %).

Здесь можно посмотреть еще несколько примеров реального кода, переписанного с помощью |>.

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

Благодарю за внимание и happy coding!