JavaScript: заметка об операторе конвейера

от автора

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

В этой небольшой заметке я хочу рассказать вам об одном интересном предложении по дальнейшему совершенствованию всеми нами любого 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!



ссылка на оригинал статьи https://habr.com/ru/company/timeweb/blog/713768/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *