Композиция функций. Функциональное программирование

2 года назад·5 мин. на чтение

Композиция — это способ построения больших модулей из более мелких. В этой статье подробно рассмотрим композицию на примере JavaScript.

Это серия статей о функциональном программировании:
  1. Парадигмы программирования
  2. Композиция (рассматривается в этой статье)
  3. Функторы
  4. Каррирование
  5. Чистые функции
  6. Функции первого класса

Что такое композиция функций?

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

Как работает композиция?

Математическая запись определения композиции выглядит следующим образом:
(f ∘ g)(x) = f(g(x))
В JavaScript композиция, где f и g - это функции, будет выглядеть так:
const compose = (f, g) => (x) => f(g(x))
Функция compose является композицией функций f и g. Выходные данные функции g будут переданы на вход функции f. По другому эта запись выглядела бы следующим образом:
const compose = (f, g) => {
  return (x) => {
    const gResult = g(x)
    const fResult = f(gResult)
    return fResult
  }
}

Пример работы функции compose

Рассмотрим пример с более конкретными функциями:
const getAge = (user) => user.age
const isAgeAllowed = (age) => age >= 30
Теперь составим композицию из этих маленьких функций - getAge и isAgeAllowed:
const user = { name: 'John', age: 35 }
const isAllowedUser = compose(
 isAgeAllowed,
 getAge
)
isAllowedUser(user) // true
compose выполняет функции справа налево. Мы отправляем объект user в функцию isAgeAllowed. Далее user попадает сначала в getAge, потом результат этой функции попадает в isAgeAllowed.

compose и pipe

Функция pipe очень похожа на функцию compose. Они выполняют одну и ту же роль. Обе объединяют функции в цепочки. Однако их реализация и порядок выполнения функций отличается.

Реализация compose и pipe

compose реализован следующим образом:
const compose = (...fns) => {
  return (x) => fns.reduceRight((acc, fn) => fn(acc), x)
}
pipe реализован следующим образом:
const pipe = (...fns) => {
   return (x) => fns.reduce((acc, fn) => fn(acc), x)
}
Они отличаются лишь в применении функции reduce и reduceRight. Это влияет лишь на порядок выполнения функций.

Порядок выполнения функций

compose выполняет функции справа налево. pipe выполняет функции слева направо.

Пример

Предположим имеется 3 функции: f, g и h. При использовании compose:
compose(f, g, h)
        ←  ←  ←
Порядок выполнения функций будет таким h, g и далее f (справа налево). И выходные данные будут передаваться в следующую функцию. При использовании pipe:
pipe(f, g, h)
     →  →  →
Порядок выполнения функций будет таким f, g и далее h (слева направо). И выходные данные будут передаваться в следующую функцию.

Какую функцию использовать?

compose и pipe не сильно отличаются друг от друга. Они решают одну и ту же задачу. compose ближе к математической нотации (f ∘ g)(x) = f(g(x)). С pipe визуально легче воспринимать порядок выполнения функций.

Реальный пример

В следующих примерах будем использовать pipe. Предположим, нужно сделать простой калькулятор цен, в котором нужно применить:
  • налог (30%, по умолчанию),
  • сервисный сбор (10у.е., по умолчанию),
  • скидку,
  • купон,
  • цену доставки на основе веса.
API калькулятора цен на основе этих требований будет выглядеть следующим образом:
const priceCalculator = (
  taxPercentage = 0.3,
  serviceFees = 10,
  price,
  discount,
  percentCoupon,
  weight,
  shippingPricePerKg
) => {
  // реализация
}

Реализация без композиции

const priceCalculator = (
  taxPercentage = 0.3,
  serviceFees = 10,
  price,
  discount,
  percentCoupon,
  weight,
  shippingPricePerKg
) => {
  return (
    price
     - (price * percentCoupon)
     - discount
     + (weight * shippingPricePerKg)
     + serviceFees
  ) * (1 + taxPercentage)
}
Эта функция выполняет свою задачу, но ее сложно читать, тестировать и отлаживать.

Реализация с композицией

const priceCalculator = (
  taxPercentage = 0.3,
  serviceFees = 10,
  price,
  discount,
  percentCoupon,
  weight,
  shippingPricePerKg
) => {
 const applyTax = (val) => val * (1 + taxPercentage)
 const applyServiceFees = (val) => val + serviceFees
 const applyPercentCoupon = (val) => val - val * percentCoupon
 const applyDiscount = (val) => val - discount
 const applyShippingCost = (val) => val + weight * shippingPricePerKg

 return pipe(
   applyPercentCoupon,
   applyDiscount,
   applyShippingCost,
   applyServiceFees,
   applyTax
 )(price)
}
Эта реализация выглядит более чистой, ее легко тестировать и отлаживать. Все это благодаря модульности.

Запуск примера

Запустим, передав только price. Очевидно, получим неверный результат, т.к. передали не все обязательные параметры.
priceCalculator(10) // NaN
Сначала продебажим. Для дебага pipe и compose можно добавить удобную функцию inspect. Она просто логирует в консоль входные данные и возвращает эти данные без изменений.
const inspect = (label) => (x) => {
  console.log(`${label}: ${x}`)
  return x
}
Добавим inspect в цепочку выполнения.
const priceCalculator = (
  taxPercentage = 0.3,
  serviceFees = 10,
  price,
  discount,
  percentCoupon,
  weight,
  shippingPricePerKg
) => {
  const applyTax = (val) => val * (1 + taxPercentage)
  const applyServiceFees = (val) => val + serviceFees
  const applyPercentCoupon = (val) => val - val * percentCoupon
  const applyDiscount = (val) => val - discount
  const applyShippingCost = (val) => val + weight * shippingPricePerKg

  return pipe(
    inspect('price'),
    applyPercentCoupon,
    inspect('after applyPercentCoupon'),
    applyDiscount,
    inspect('after applyDiscount'),
    applyShippingCost,
    inspect('after applyShippingCost'),
    applyServiceFees,
    inspect('after applyServiceFees'),
    applyTax
  )(price)
}
Результат будет примерно таким:
priceCalculator(10)
// price: undefined
// after applyPercentCoupon: NaN
// ...
Видим, price - undefined. Это произошло потому что price - третий аргумент, а мы передаем его первым.

Быстрый фикс

Сделаем так, чтобы функция принимала один объект:
const priceCalculator = ({
  taxPercentage = 0.3,
  serviceFees = 10,
  price,
  discount,
  percentCoupon,
  weight,
  shippingPricePerKg
}) => {
  // ...
}
Далее, используем:
priceCalculator({ price: 10 })
 
// price: 10
// after applyPercentCoupon: NaN
// ...
Все еще получаем NaN, в этот раз потому что не был передан percentCoupon и он имеет значение undefined.
Добавим для всех параметров, кроме price, значения по умолчанию.
const priceCalculator = ({
  taxPercentage = 0.3,
  serviceFees = 10,
  price,
  discount = 0,
  percentCoupon = 0,
  weight = 0,
  shippingPricePerKg = 0
}) => {
  // ...
}
Если запустить заново, получим результат:
priceCalculator({ price: 10 }) // 26
Это пример того как композиция позволяет нам тестировать и исправлять код проще и быстрее, просто проверяя области, которые вызывают подозрения. Проблема, которую мы отлаживали, была очень простой. Когда мы переходим к более масштабным модулям, все становится еще более трудным для проверки. Большие атомарные функции трудно поддерживать. Разделение функций на более мелкие упрощает отладку, тестирование, поддержку и разработку функций.

Для чего нужна композиция?

Композиция это объединение меньших модулей в более крупные. Если мы думаем оперируем модулями (что обеспечивается композицией), мы улучшаем:
  • модульное мышление,
  • тестируемость,
  • возможность отладки,
  • поддерживаемость.

Итоги

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

Чистые функции. Функциональное программирование

2 года назад·3 мин. на чтение

В этой статье на простых и доступных примерах рассмотрим одну из концепций функционального программирования - Чистые функции.

Это серия статей о функциональном программировании:
  1. Парадигмы программирования
  2. Композиция
  3. Функторы
  4. Каррирование
  5. Чистые функции (рассматривается в этой статье)
  6. Функции первого класса

Что такое чистая функция?

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

Характеристики чистой функции

️1. Чистые функции должны быть детерминированными

Детерминированная функция — это функция, которая при одном и том же входе x всегда должна иметь один и тот же результат y.

Примеры недетерминированных функций

  1. Math.random
const getRandom = () => Math.random()
  1. ФункцииDate
const getDate = () => Date.now()
  1. getUsers
const getUsers = await fetch('/users')
Функция getUsers недетерминирована, потому что пользователи могли обновиться, нет подключения к интернету, сервер может быть недоступен или что-то еще.

Комментарии к примерам

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

️2. Чистые функции не должна иметь побочных эффектов

Побочным эффектом может быть:
  • Внешняя зависимость (доступ к внешним переменным, потокам ввода/вывода, чтение/запись файлов или выполнение HTTP-вызовов).
  • Мутация (мутации локальных/внешних переменных или переданных аргументов по ссылке).
Чистые функции должны быть детерминированными и не должны давать никаких побочных эффектов. При этом невозможно иметь приложения с состоянием без побочных эффектов (запрос к базе данных, выполнение http-вызова, чтение пользовательского ввода или даже отображение результатов в пользовательском интерфейсе). Но в функциональном программировании есть еще несколько концепций/исправлений для этого.

Примеры побочных эффектов

  1. Функция isLessThanMin
Функция с побочным эффектом
const min = 60
const isLessThanMin = value => value < min
Чистая функция
const isLessThanMin = (min, value) => value > min
Побочный эффект заключается во внешней зависимости. Для исправления используется внедрение зависимости (dependency injection).
  1. Функция для вычисления квадратов чисел
Функция с побочным эффектом
const squares = (nums) => {
  for(let i = 0; i < nums.length; i++) {
    nums[i] **= 2;
  }
}
Чистая функция
const squares = (nums) => nums.map(num => num * num)
Побочный эффект заключается в наличии императивного кода, который выполняет мутации в исходном массиве по ссылке. Для исправления используется функциональный .map, который создает новый массив.
  1. Функция updateUserAge
Функция с побочным эффектом
const updateUserAge = (user, age) => {
  user.age = age
}
Чистая функция
const updateUserAge = (user, age) => ({ ...user, age })
Побочный эффект заключается в мутации объекта user по ссылке. Нужно избегать изменения объектов по ссылке, вместо этого следует вернуть новый объект с новыми/обновленными свойствами.
  1. Функция getFirst2Elements
Функция с побочным эффектом
const getFirst2Elements = (arr) => arr.splice(0, 2)
Чистая функция
const getFirst2Elements = (arr) => arr.slice(0, 2)
Побочный эффект заключается в мутировании arr, переданного по ссылке методом .splice. Для исправления используется функциональный метод .slice, который не изменяет сам массив.

Почему функции с побочными эффектами - плохо?

У функций с побочными эффектами есть несколько очевидных недостатков:
  • Это делает функции тесно связанными с окружающей средой
  • Увеличивает когнитивную нагрузку на разработчика
  • Вызывает неочевидные изменения состояния
  • Увеличивает кривую обучения кодовой базы разработчика
  • Невозможность параллелизации
  • Высокая непредсказуемость
  • + потеря преимуществ чистых функций

Почему чистые функции - хорошо?

Можно вывести две основные категории улучшений. Улучшение опыта разработки (developer experience) и улучшение производительности приложений.

Улучшение опыта разработки

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

Улучшение производительности

  • Способность к кэшированию (мемоизация): детерминизм функций даст нам возможность предсказывать, каким будет вывод для определенного ввода, затем мы можем кэшировать функции на основе вводов.
  • Возможность распараллеливания: поскольку функции теперь свободны от побочных эффектов и независимы, их можно легко распараллелить.