Привет, друзья!
В этой небольшой заметке я хочу рассказать вам об одном интересном предложении по дальнейшему совершенствованию всеми нами любого 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(' ')));
Вот как мы читаем этот пример:
- Находим начальные данные (
envars
). - Затем двигаемся назад и вперед изнутри наружу для каждого этапа преобразования данных, рискуя пропустить какой-нибудь префиксный оператор слева или суффиксный оператор справа:
-
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!
fransua
Кажется скоро будет 10 лет как это предложение в Stage 2. Я думал его уже давно забросили, ан-нет, коммиты каждый месяц появляются