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


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




Комментарии (1)


  1. fransua
    01.02.2023 22:38
    +1

    Кажется скоро будет 10 лет как это предложение в Stage 2. Я думал его уже давно забросили, ан-нет, коммиты каждый месяц появляются