Пайплайн Оператор

8 минут на прочтение
Также переведено на:

На начало 2024 года пайплайн оператор находится в стадии Stage 2 и не является стандартом языка ECMAScript. В данный момент в TC39 идёт работа над обсуждением его реализации.

Для меня пайплайн оператор — это самая ожидаема фича языка, поэтому сегодня хочется посмотреть, как это работает и как будет выглядеть прекрасный JavaScript будущего.

Предыстория

Часто нам приходится применять несколько методов или функций для того, чтобы добиться определённого результата.

Взглянем на такой пример:

JavaScript
const getAdmins = users =>
  users
    .filter(({ role }) => status === 'ADMIN')
    .map(({ firstName, lastName }) => [firstName, lastName].join(' '))
    .toSorted((a, b) => a.localeCompare(b))

const admins = getAdmins(users)

Данная функция получает на вход список всех пользователей системы и возвращает отсортированный по алфавиту список имён администраторов.

Что мы можем сказать об этой функции?

  1. Описание операций выглядит довольно подробным, из-за чего код выглядит не очень компактно.
  2. Фокус в данной функции происходит на механике, а не на операциях. Некоторые участки функции выглядят несколько императивно.
  3. Если бы функция была больше, то читать её стало бы явно сложнее
  4. Если бы нам пришлось пользоваться не только встроенными JavaScript методами, а ещё и сторонними функциями — это выглядело бы ужасно, x => f(g(x.h()).i()).

И здесь нам могла бы прийти на помощь Ramda.

Ramda — это библиотека для функционального программирования, которая включает каррирование и поддержку бесточечного стиля функций. Это делает композицию функций более удобной и лаконичной.

Рассмотрим тот же пример с использованием Ramda:

JavaScript
import {
  identity,
  ascend,
  filter,
  propEq,
  props,
  sort,
  join,
  pipe,
  map,
} from 'ramda'

const getAdmins = pipe(
  filter(propEq('ADMIN', 'role')),
  map(pipe(props(['firstName', 'lastName']), join(' '))),
  sort(ascend(identity)),
)

const admins = getAdmins(users)

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

Дело в том, что в Ramda все функции изначально являются каррированными. Простой пример:

JavaScript
const add = a => b => a + b
const addFive = add(5)
const result = addFive(10) // 15

Мы создали функцию addFive с помощью функции add, которая нам вернула функцию с предустановленным первым аргументов. Функция add в нашем случае — каррированная.

Каррирование — это процесс преобразования функции с несколькими аргументами в последовательность функций, каждая из которых принимает один аргумент.

И поскольку при создании каррированой функции все аргументы функции, кроме последнего нам уже известны, их легко применять для композиции функций. Создавать цепочки обработки данных, где выход одной функции становится входом для следующей.

Давайте ещё раз рассмотрим наш пример с функцией получения списка администраторов, написанный с использованием Ramda.

Что мы тут видим?

  1. В функции не указан список аргументов функции, вместо этого функция pipe вернула нам функцию, которую уже можно использовать и применить к массиву. Эта функция также каррированная.
  2. Наша функция написана с помощью других мелких каррированных функций Ramda, объединённых внутри функции pipe в композицию.
  3. Функция теперь выглядит намного декларативнее и читается почти как обычный текст.

Функция pipe в Ramda служит для композиции функций, т. е. для создания функции, путём объединения существующих.

Рассмотрим в качестве примера ещё одну функцию, написанную в императивном стиле, она будет немного сложнее:

JavaScript
import { getDiscountCoefficient } from './get-discount-coefficient'
import { showAlert } from '../lib/show-alert'

const countDiscount = ({ purchases }) => {
  try {
    let purchasesPrice = 0
    let purchasesDiscount = 0

    for (let { discount, amount, price } of purchases) {
      if (discount) {
        purchasesDiscount += price * amount * discount
      }
      purchasesPrice += price * amount
    }

    const discountCoefficient = getDiscountCoefficient()
    const baseDiscount = purchasesPrice * discountCoefficient

    return baseDiscount + purchasesDiscount
  } catch (error) {
    showAlert(`Error in the discount calculation process: ${error}`)
  }
}

const result = countDiscount(data)

Данная функция рассчитывает сумму скидок на покупку.

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

Перепишем эту функцию с Ramda:

JavaScript
import {
  multiply,
  converge,
  tryCatch,
  always,
  values,
  ifElse,
  reduce,
  apply,
  prop,
  pipe,
  pick,
  add,
  map,
  sum,
  has,
} from 'ramda'

import { getDiscountCoefficient } from './get-discount-coefficient'
import { showAlert } from '../lib/show-alert'

const countDiscount = tryCatch(
  converge(add, [
    pipe(
      prop('purchases'),
      map(pipe(pick(['price', 'amount']), values, apply(multiply))),
      sum,
      multiply(getDiscountCoefficient()),
    ),
    pipe(
      prop('purchases'),
      map(
        ifElse(
          has('discount'),
          pipe(
            pick(['price', 'amount', 'discount']),
            values,
            reduce(multiply, 1),
          ),
          always(0),
        ),
      ),
      sum,
    ),
  ]),
  showAlert,
)

const result = countDiscount(data)

Сейчас неважно, как работает каждая функция Ramda. Нас интересует только функция pipe. Обратите внимание на то, как она словно конвейер пропускает наши данные через множество маленьких функций. Эта функция высшего порядка занимается композицией всей нашей бизнес-логики.

Композиция функций — это одна из базовых концепций функционального программирования. И это та задача, которую в будущем в JavaScript будет решать пайплайн оператор.

Назначение пайплайн оператора то же, что и у функции pipe, но работает он несколько иначе. Об этом будет идти речь в статье.

Предварительная Настройка

Поскольку пайплайн оператор не поддерживается ни Node.js, ни одним из браузеров, придётся воспользоваться старым добрым Babel, чтобы посмотреть, как он работает.

Для начала установим сам Babel:

Bash
npm install --save-dev @babel/cli @babel/core

И установим экспериментальный плагин для поддержки пайплайн оператора:

Bash
npm install --save-dev @babel/plugin-proposal-pipeline-operator

Далее в корне проекта создадим конфигурационный файл для Babel .babelrc:

JSON
{
  "plugins": [
    [
      "@babel/plugin-proposal-pipeline-operator",
      {
        "proposal": "hack",
        "topicToken": "%"
      }
    ]
  ]
}

Ну и попробуем выполнить наш скрипт babel ./index.js -d dist && node ./dist/index.js.

На этом подготовка завершена и можно начать использовать пайплайн оператор в коде.

Как Работает Пайплайн Оператор

Согласно спецификации, пайплайн оператор будет иметь синтаксис схожий с синтаксисом языка программирования Hack.

В качестве плейсхолдера используется %. В качестве самого оператора — |>. Наличие плейсхолдера после оператора является обязательным.

Начнём с простого примера использования:

JavaScript
const addFive = num => num + 5
10 |> addFive(%) // 15

Слева от оператора находится значение аргумента, справа — вызов функции с указанием плейсхолдера, куда будет помещено значение из левой части выражения.

Напишем что-то приближенное к реальности и имеющее практическую пользу.

Например, мы хотим преобразовать список транзакций в отчёт о расходах. Нам нужно конвертировать валюту, отфильтровать только расходные транзакции и затем подсчитать их общую сумму.

Выглядит это примерно так:

JavaScript
const convertCurrency = (transactions, rate) =>
  transactions.map(t => ({ ...t, amount: t.amount * rate }))

const filterExpenses = transactions =>
  transactions.filter(t => t.type === 'expense')

const sumTransactions = transactions =>
  transactions.reduce((sum, t) => sum + t.amount, 0)

const transactions = [
  { type: 'income', amount: 100 },
  { type: 'expense', amount: 50 },
  { type: 'expense', amount: 70 },
]

const exchangeRate = 1.1

const totalExpenses = sumTransactions(
  filterExpenses(convertCurrency(transactions, exchangeRate)),
)

Теперь отрефакторим нашу функцию totalExpenses, используя пайплайн оператор:

JavaScript
const totalExpenses =
  transactions
  |> convertCurrency(%, exchangeRate)
  |> filterExpenses(%)
  |> sumTransactions(%)

С помощью пайплайн оператора получилось улучшить читаемость и упростить понимание последовательности операций.

Вернёмся к нашему изначальному примеры на Ramda, функции с получением списка имён администраторов. Поскольку все наши функции (за исключением функции pipe) принимают только один аргумент, мы также можем воспользоваться новым оператором и улучшить предыдущий код:

JavaScript
import {
  identity,
  ascend,
  filter,
  propEq,
  props,
  sort,
  join,
  map,
} from 'ramda'

const admins =
  users
  |> filter(propEq('ADMIN', 'role'), %)
  |> map(user => user |> props(['firstName', 'lastName'], %) |> join(' ', %), %)
  |> sort(ascend(identity), %)

Рецепты

Какие ещё возможности нам дарит пайплайн оператор:

Работа с Асинхронным Кодом

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

Рассмотрим простой пример, где нам нужно выполнить fetch, чтобы получить список пользователей, декодировать ответ в формате JSON и отфильтровать заблокированных пользователей нашей системы:

JavaScript
const filterBlockedUsers = users =>
 users.filter(({ isBlocked }) => !isBlocked)

const getUsers = async () => {
  const response = await fetch('/api/users')
  const json = await response.json()
  const filteredUsers = filterBlockedUsers(json)

  return filteredUsers
}

Как бы мы могли переписать эту функцию, используя пайплайн оператор:

JavaScript
const getUsers = async () =>
  '/users'
  |> (await fetch(%))
  |> (await %?.json())
  |> filterBlockedUsers(%)

Работа с Шаблонными Строками

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

JavaScript
const greetUser = async id =>
  await getUserFirstName(id)
  |> `Hello, ${%}!`

Синтаксический Сахар для if, catch и for-of

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

Статус квоHack-pipe синтаксис
const c = f(); if (c) g(c);if (f()) |> g(%);
catch (e) f(e);catch |> f(%);
for (const v of f()) g(v);for (f()) |> g(%);

Этот синтаксис в настоящее время не поддерживает даже Babel, а в TC39 идёт обсуждение о целесообразности его внедрения.

Интеграция с Функциональными Библиотеками

Пайплайн оператор идеально сочетается с популярными функциональными библиотеками, делает их применение значительно более предсказуемым и снижает количество возможных ошибок.

Lodash

Без оператора:

JavaScript
import _ from 'lodash'

const usersToShow = _.take(
  _.sortBy(_.map(_.filter(users, 'isActive'), 'name')),
  10,
)

С оператором:

JavaScript
import _ from 'lodash'

const usersToShow =
  users
  |> _.filter(%, 'isActive')
  |> _.map(%, 'name')
  |> _.sortBy(%)
  |> _.take(%, 10)

Заключение

Пайплайн оператор в JavaScript — это мощное улучшение для функционального программирования.

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

Тем не менее, в настоящий момент пайплайн оператор находится в стадии предложения и не является частью стандарта JavaScript.

Следите за новостями.