Можем ли мы написать лучший поток кода без традиционных императивов? 🤔

Композиция функций - одно из важнейших понятий в программировании, особенно в функциональном программировании. Это математический эквивалент f (g (h (x))), который естественным образом выполняется от h до f, compose (f, g, h) . Функция Pipe предлагает те же результаты, но ее выполнение более удобно для чтения людьми, pipe (h, g, f). Для получения дополнительной информации ознакомьтесь с предложением оператор трубопровода.

В этой статье я намерен познакомить вас с композицией функций с минимальным количеством зависимостей. Без библиотек вроде Ramda или каррирования (я также покажу альтернативы каррирования), которые могут быть написаны как функции более высокого порядка. Только то, что JavaScript как язык уже предлагает, с простыми функциями, такими как compose или withProp, которые мы можем легко и ясно писать, используя этот язык.

В этой статье мы будем часто использовать функцию compose. Есть много определений, таких как составить у Ramda, но мы будем использовать более простую реализацию, чтобы лучше объяснить это:

Сначала попробуйте представить вышеупомянутый сценарий: f (g (h (x, y, z))). Это должно переводиться как compose (f, g, h) (x, y, z). Compose должен работать таким образом, чтобы результат функции h (x, y, z) передавался в качестве аргумента функции g. И так далее.

Теперь мы можем изучить приведенный выше код. Если у нас есть композиция функций compose (fn3, fn2, fn1) и , поскольку начальное значение не указано, аккумулятор становится первой функцией в … fns массив, который равен fn3, а исходная текущая функция - fn2. Мы начинаем с функции (… args) = ›AccumulatorFn (currentFn (... args)), которая переводится в (… args) =› fn3 (fn2 (… args)) и, что немаловажно, он становится аккумулятором для следующей итерации, и мы можем назвать его результат K. На следующей итерации fn1 - это наша текущая функция, а аккумулятор - это предыдущая функция, которая приводит к K, который после этой итерации становится (… args) = ›K (fn1 ( … Args)), который в конечном итоге становится (… args) = ›fn3 (fn2 (fn1 (… args))), когда мы выполняем замену для К.

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

Почему я должен предпочесть наследование?

Я часто слышу этот вопрос, но не стоит слишком сильно его волновать. Оба являются хорошими способами создания программного обеспечения, и вам нужно решить, является ли это:

  • Для вас важнее в текущем случае определить, что-то ЕСТЬ? (императив, ООП, полиморфизм, наследование, перегрузка)
  • Или вам нужно описать, КАК что-то построено, или из каких частей (описательная, FP, композиция, каррирование)

Что может быть препятствием на пути полиморфизма и перегрузки?

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

Что вообще такое составное? (И ЧТО НЕТ)

Чтобы композиция функций работала, она должна состоять, ну, ну, из функций. Даже сама композиция должна приводить к функции, чтобы ее можно было в дальнейшем компоновать. Если функции в композиции нужен аргумент, это должна быть функция более высокого порядка или каррированная. Например:

// composable
fn = () => 'Hello!'
compose(fn)    // Immediate result is a function: "[Function: fn]", composable
compose(fn)()  // Final result: "Hello!"
// composable
fn = (name) => () => `Hello ${name}!`
compose(fn)            // [Function (anonymous)]
compose(fn)('Jane')    // [Function (anonymous)], composable
compose(fn)('Jane')()  // Final result: "Hello Jane!"
// not composable
year = 1901
compose(year)    // Immediate result is a number: 1901, not a function, not composable
compose(year)()  // Uncaught TypeError: compose(...) is not a function

Еще раз обратите внимание, что для того, чтобы композиция работала, вы всегда должны возвращать функцию. Вот почему функции высшего порядка () => () => {} так приветствуются, когда вам нужен один или несколько аргументов. Каррирование также подойдет. Если аргументов нет, функция не должна быть более высокого порядка, если вам не нужна функция в качестве аргумента для следующей функции. Однако с аргументами вы должны сделать их более высокого порядка и сделать их видимыми в области внутренней функции, например (x, y, z) => () => x + y + z.

Каррирование также подойдет

Есть много разновидностей каррирования, много определений, просто зайдите и погуглите. Если у вас еще нет собственной реализации, самый быстрый способ запустить ее - использовать реализацию Ramda curry, установив Ramda глобально npm i ramda -g.

Затем вы можете потребовать / импортировать его откуда угодно. В MacOS он устанавливается под “/usr/local/lib/node_modules/ramda/dist/ramda”. Введите node в свой интерфейс командной строки (вам необходимо установить Node.js) и протестируйте его, как в этом примере:

// depending on OS
const R = require('/usr/local/lib/node_modules/ramda/dist/ramda')
const fn = R.curry((x, y, z) => x + y + z)
[Function (anonymous)]
fn(1)
[Function (anonymous)]
fn(1)(2)
[Function (anonymous)]
fn(1)(2)(3)
6
fn(1, 2, 3)  // specialty of currying, something that can't be achieved with higher order functions
6

Короче говоря, каррирование позволяет избежать перегрузок. Это похоже на функции высшего порядка, хотя каррирование fn(1, 2) также работает. Если вы наткнетесь на композицию, в которой вы можете иногда получить более одного аргумента или произвольное количество аргументов, curry - ваш друг. Вы можете добиться аналогичного результата, перегрузив функции более высокого порядка, например:

const fn1 = x => y => z => () => x + y + z  // scenario 1
const fn2 = (x, y, z) => () => x + y + z    // scenario 2
const fn3 = (x, y) => z => () => x + y + z  // scenario 3
const fn4 = x => (y, z) => () => x + y + z  // scenario 4

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

Машинопись? Я не вижу смысла создавать функциональные конструкции в TypeScript, поскольку наши потоки могут обрабатывать любое определение функции.

Состав объекта

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

Для композиции объекта нам всегда нужны аргументы («реквизиты») от предыдущего выполнения. Например, чтобы составить человеческое тело, нам нужны голова, 2 руки, 2 ноги и туловище. Итак, вопрос в том, как мы можем это составить?

const head = () => ({ head: 1 })
const arms = () => ({ arms: 2 })
const legs = () => ({ legs: 2 })
const torso = () => ({ torso: 1 })
compose(head, arms, legs, torso)()  // { head: 1 }

Недостаточно просто вернуть результат и волшебным образом кусочки сойдутся. Нам также нужны «реквизиты» (свойства объекта), чтобы передавать и комбинировать их. Лучший пример (с заменой):

const head  = props => ({ ...props, head: 1 })
const arms  = props => ({ ...props, arms: 2 })
const legs  = props => ({ ...props, legs: 2 })
const torso = props => ({ ...props, torso: 1 })
compose(head, arms)()  // { arms: 2, head: 1 }
compose(arms, head)()  // { head: 1, arms: 2 }
compose(head, arms, legs, torso)()
// --> { torso: 1, legs: 2, arms: 2, head: 1 }
compose(head, compose(arms, legs), torso)()
// --> { torso: 1, legs: 2, arms: 2, head: 1 }

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

Совет: сделайте withProp Helper

Вы заметили, что с props => ({ ...props, ...}) много повторений? Вы можете сделать помощник, конечно же, составной:

const withProp = prop => props => ({ ...props, ...prop })

// new method
const head = withProp({ head: 1 })
const arms = withProp({ arms: 2 })
const legs = withProp({ legs: 2 })
const torso = withProp({ torso: 1 })
compose(head, arms, legs, torso)()
// --> { torso: 1, legs: 2, arms: 2, head: 1 }

И если нам нужны конкретные аргументы или аргументы по умолчанию, например, для создания циклопа:

const withEyes = (eyes = 2) => withProp({ eyes })
const cyclops = compose(withEyes(1), head, arms, legs, torso)()
// --> { torso: 1, legs: 2, arms: 2, head: 1, eyes: 1 }

Иногда нам приходится начинать с некоторых начальных значений. Вы можете видеть, что на самом деле нам ничего не нужно делать, поскольку props передаются, начальные будут объединены:

const cyclops = compose(head, arms, legs, torso)({
  eyes: 1,
  weapon: 'rock',
})
// { eyes: 1, weapon: 'rock', torso: 1, legs: 2, arms: 2, head: 1 }

Таким образом, мы можем поэкспериментировать с телом по умолчанию и создавать существ с разными опорами:

const withDefaultBody = compose(head, arms, legs, torso)
const withFullArmour = withProp({ armour: 'sword', shield: true })
const knight = compose(withDefaultBody, withFullArmour)()
// --> { armour: 'sword', shield: true, torso: 1, legs: 2, arms: 2, head: 1 }
const cyclops = compose(withDefaultBody, withEyes(1))
const troglodyte = compose(withDefaultBody, withEyes(0))

Возможности безграничны.

Асинхронная композиция

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

Вместо «нормальной» синхронизации мы используем так называемую «asyncCompose». Идея состоит в том, что даже если у нас есть 100 функций синхронизации и только одна асинхронная, мы должны сделать всю композицию асинхронной. Вся цепочка «полезна». Один из способов сделать это - снова использовать сокращение и объединить их в цепочку, только так мы не создаем композицию функций, а обещаем композицию:

Здесь наша традиционная композиция f(g(h(x))) превращается в:

h(x).then(result => g(result)).then(result => f(result))

Для окончательного результата вам нужно просто вызвать then на композиции, или await ее:

const fn1 = props => ({ ...props, fn1: true })         // sync
const fn2 = async props => ({ ...props, fn2: true })   // async
const fn3 = props => ({ ...props, fn3: true })         // sync
asyncCompose(fn3, fn2, fn1)().then(r => console.log(r))
// --> { fn1: true, fn2: true, fn3: true }

Тот же совет, что и выше, лучше иметь аналогичную вспомогательную функцию - в данном случае withPropAsync , но мы можем комбинировать ее с withProp, который является синхронным, поэтому давайте вернемся к нему:

const withProp = prop => props => ({ ...props, ...prop })
const withPropAsync = prop => async props => ({ ...props, ...prop })

Это только добавляет ключевое слово async к функции, которая получает реквизиты. То же, что и выше, но переписано:

const fn1 = withProp({ fn1: true })
const fn2 = withPropAsync({ fn2: true })
const fn3 = withProp({ fn3: true })
asyncCompose(fn3, fn2, fn1)().then(r => console.log(r))
// --> { fn1: true, fn2: true, fn3: true }

Теперь мы можем смешивать синхронизацию и асинхронную цепочку и получать одинаковые результаты.

Рабочий процесс программы с ветвлением

Я уже писал статью на эту тему, не совсем с таким названием, но на самом деле она описывает паттерн реального мира, который я хотел бы показать здесь более коротким (более понятным) способом.

Ранее я писал о двух типах композиции (ИМХО). Здесь мы рассмотрим композицию применительно к рабочему процессу программы.

Пример приложения:

У нас есть приложение, в котором нам нужна аутентификация пользователя, база данных и множество ветвлений if-else / try-catch. Поток приводит к успеху или неудаче, который возвращается нам. Приложение является поддельным и представляет собой эскизы пользователя, сохраненные где-то в какой-то базе данных. Мы будем использовать тот же asyncCompose, который использовали раньше.

Предположим, у нас есть следующая композиция:

const getAllUserSketches = asyncCompose(findSketches, withDB, getUser)
const withAdditionalFilter = asyncCompose(filterSketchesBy, getAllUserSketches)
const paginatedSketches = asyncCompose(paginateSketches, withAdditionalFilter)

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

Полный пример и пример набросков для передачи sketchesSuccess взгляните на эту суть, также с комментариями, чтобы облегчить понимание.

  1. Первый подход используется, когда нас не волнует результат композиции, если произошло что-то плохое, то есть когда мы «досрочно выходим» из потока с выдачей исключения, то есть когда конкретная функция композиции необходима для остальной части поток.
  2. Второй подход используется, когда мы заботимся о конечном результате, то есть, если мы хотим увидеть, была ли там и где была ошибка, мы можем проверить ее в ее описании результата, то есть когда нам нужен результат, но также нужно знать, что случилось. в составе, если что-то не так.

Итак, давайте сначала рассмотрим случай, когда время запроса к базе данных истекает, например, но также давайте посмотрим, как можно записать withDB:

import { dbService } from 'dbservice'
const withDB = async ({ db = dbService, ...dependencies }) => {
  const dbInstance = db.currentInstance()
  if (!dbInstance) {
    throw new Error('no database :(')
  }
  return { coll: dbInstance, ...dependencies }
}
const findSketches = async ({ coll, ...dependencies }) => {
  const { username, sketch: sketchSearch } = dependencies
  const sketchesByName = await coll.findByName(
    username,
    sketchSearch
  )
  return { sketches: sketchesByName, ...dependencies }
}

И теперь то, что мы можем сделать, когда время ожидания базы данных истекло, - это либо «async-await» с помощью try-catch around, либо «.catch». В сценариях, где ошибка возникает как часть результата, нам даже не нужно ловить (но не делать этого), например, результат успеха будет иметь это в .error.

paginatedSketches({
  auth: () => Promise.resolve({ username: 'marry321' }),
  db: {
    currentInstance: () => ({
      findByName: () => Promise.reject(new Error('timed out...')),
    }),
  },
  filter: 'mock',
})
  .then(console.log)
  .catch(({ message }) => console.error(message))

Здесь у нас только «тайм-аут…» в результате, больше ничего. База данных не удалась, все не работает.

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

import { authService } from 'authservice'
const getUser = async ({ auth = authService, ...dependencies }) => {
  try {
    const user = await auth()
    return { username: user.username, ...dependencies }
  } catch (e) {
    return {
      error: e.message,
      username: 'default',
      ...dependencies,
    }
  }
}

И теперь мы можем получить весь результат (при условии, что с базой данных все в порядке):

paginatedSketches({
  auth: () => Promise.reject('auth service is down'),
  db: {
    currentInstance: () => ({
      findByName: () => Promise.resolve(sketchesSuccess),
    }),
  },
  filter: 'mock',
}).then(console.log)  // result
// we don't need catch only for demonstration purposes

Как видите, у нас нет .catch. Это только для демонстрации того, что будет возвращен успешный результат, и вы можете увидеть, что он содержит .error:
result.error === "auth service is down”.

NB: Если вы когда-нибудь слышали о монадах типа Either or Maybe, это похожий подход. В нашем сценарии «сбой базы данных» мы генерируем исключения и останавливаем выполнение, нас не волнует результат композиции, поэтому результат либо есть, либо нет. Немного другой подход к Maybe, но в нашем случае мы хотим, чтобы наша композиция работала и в других композициях, чтобы у нас всегда был какой-то результат, не опасаясь исключения нулевого указателя.

Я надеюсь, что этот немного лучше объясняет, как писать код «компонуемым способом», поэтому все функции получают аргументы, которые необходимы, без побочных эффектов, а в тестах вы можете просто заменить их посредством внедрения зависимостей. Эти функции, которые всегда возвращают и отображают один и тот же вывод для одного и того же ввода, называются чистыми функциями. Такие функции / службы, как наши вымышленные authservice или dbservice, также считаются побочными эффектами, т.е. если вы используете импортированные модули непосредственно внутри своей функции - вспомогательные функции подходят, например, «util.isEqual». Если вы сделаете это, очень сложно проводить тестирование, и вам нужно будет заняться проксированием модулей ES или чем-то подобным (что-то вроде запаха кода?).

Минус может заключаться в том, что «… зависимости» должны всегда передаваться по цепочке, но сама функция извлекает только необходимые аргументы, поэтому ее можно использовать и тестировать только с ними. Таким образом, эти функции могут использоваться вне цепочки в любом месте как автономные.

Идентификация и функции Tap / Debug

Один из распространенных случаев - использование так называемой функции идентификации и касания. И мы получаем много вопросов, например, зачем нам тривиальные 28_ функции.

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

Он используется там, где у вас может быть любая функция в определенном месте - не обязательно в композиции или как переход через композицию. Но вы не можете просто поместить примитивное значение, такое как строка или число, потому что, как упоминалось ранее, оно не может быть составлено, поэтому программа завершится ошибкой. В следующем псевдокоде мы видим, что operation должен просто выполнить проход, если условие не выполняется, чтобы его можно было компоновать:

const id = v => v
const expensiveOp = () => {}
const operation = expensiveOp || id
compose(doSomething, operation, doSomething)(someProps)

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

const tap = logger => any => {
  logger(any)
  return any
}
const fn1 = withProp({ a: 1 })
const fn2 = withProp({ b: 2 })
const fn3 = withProp({ c: 3 })
const result = compose(fn3, tap(console.log), fn2, fn1)()
// --> console: { a: 1, b: 2 }

Таким образом, вы можете отлаживать свои результаты между композициями.

Конец

Я попытался объяснить тему компоновки (видимо, это не слово) как можно более по-английски, и, надеюсь, мне удалось приблизить вас к ней. Он призван упростить ваши способы работы, когда вы натыкаетесь на сложный для тестирования код, чтобы дважды подумать, можно ли его написать по-другому. Иногда на размышления о шаблонах проблемы уходит меньше времени, чем на непосредственный ввод кода, даже в сжатые сроки.

Сообщите мне свои мысли по этому поводу.

Последний, но тем не менее важный:

Практическое правило: если вашей функции требуется более двух блоков if-else или блоков try-catch, разделите функцию дальше. На данный момент это покажется незначительным, но когда ваше приложение вырастет до 30+ таких функций, вы начнете это ценить.

Ваше здоровье!