Привет, друзья!
В этой небольшой заметке я хочу рассказать вам об одном интересном предложении по дальнейшему совершенствованию всеми нами любого 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!
ссылка на оригинал статьи https://habr.com/ru/company/timeweb/blog/713768/
Добавить комментарий