Программировать — сложно.
Хороший код адекватно отражает систему, которую описывает, он устойчив к изменениям в этой системе. Плохой код запутанный, хрупкий и непонятный — он замедляет разработку.
Код становится плохим, когда он перестаёт соответствовать реальности — бизнес-логике, правилам поведения частей системы, их отношениям друг с другом. Бизнес-логика — это территория. Код — карта этой территории. Чем точнее карта, тем проще справляться с изменениями в требованиях и даже предвидеть их.
Функциональное программирование — одна из парадигм, которые помогают спроектировать программу так, чтобы она верно отражала эти правила и была устойчивой к изменениям.
Функция как элемент композиции
СкопированоЛюбая система состоит из частей. Программы — тоже системы со своими целями и средствами достижения этих целей. Сопоставление разных частей системы вместе называется композицией, а эти части — элементами композиции (composition units).
Добиться хорошей композиции трудно, потому что для этого нужно правильно провести границы между элементами. Правильные границы — очень размытое понятие, но в целом можно выделить несколько свойств и требований:
- Границы должны быть проведены так, чтобы элемент решал только одну проблему, а не несколько (принцип разделения ответственности).
- Элементы ничего не должны знать об устройстве других элементов, а общаться они должны через данные (закон Деметры).
- Данные и настройки должны быть отделены от кода программы (The Twelve-Factor App).
В функциональном программировании элемент композиции — это функция. Передача данных через несколько вызовов функций — их композиция. Например, если мы хотим к числу 10 прибавить 5, а потом умножить результат на 42, то последовательно вызовем функции add
и multiply
:
function add(a, b) { return a + b}function multiply(a, b) { return a * b}const result = multiply(add(10, 5), 42)
function add(a, b) { return a + b } function multiply(a, b) { return a * b } const result = multiply(add(10, 5), 42)
Если разбить процесс на несколько шагов, то сперва мы получим результат сложения, а затем передадим его как аргумент умножения:
const additionResult = add(10, 5)const finalResult = multiply(additionResult, 42)
const additionResult = add(10, 5) const finalResult = multiply(additionResult, 42)
Такая последовательная передача данных на вход следующей функции и есть простейшая функциональная композиция.
У подобной передачи данных даже есть математическая основа и нотация, и в целом функциональное программирование очень близко к математике. Мы ещё поговорим об этом в конце статьи.
Чистые функции и побочные эффекты
СкопированоЧтобы композиция функций была проще и не вызывала проблем, эти функции должны быть чистыми (pure). Чистая функция — это функция, которая не вызывает побочных эффектов (side effects), то есть никак не влияет на состояние внешнего мира.
Чистую функцию можно сравнить с понятием функции из математики: это нечто, что преобразует входные данные по заданным правилам.
Например, pure
при вводе 10 и 20 всегда будет возвращать 15, значит она чистая:
function pureFn(a, b) { return ((a + b) * a) / b}
function pureFn(a, b) { return ((a + b) * a) / b }
А impure
нечистая — она будет возвращать разные значения, потому что использует случайное число:
function impureFn(a, b) { return ((a + b) * a) / Math.random()}
function impureFn(a, b) { return ((a + b) * a) / Math.random() }
И also
тоже нечистая:
function alsoImpureFn() { return Date.now()}
function alsoImpureFn() { return Date.now() }
В последних двух случаях функции производят побочные эффекты, потому что обращаются к глобальным объектам Math
и Date
. Да, любое взаимодействие с чем-либо «снаружи» функции считается побочным эффектом, даже получение значений.
Дело в том, что мы не знаем, как именно устроены методы random
и now
в объектах снаружи. Они могут не только возвращать результат, но и менять состояние окружающего мира, например, меняя какую-то переменную.
В примере ниже мы обращаемся к методу now
, который всегда возвращает одно и то же значение, но попутно меняет значение переменной counter
. Если мы не знаем, как устроен метод now
, мы не можем гарантировать, что impure
не имеет побочных эффектов, поэтому считаем её тоже нечистой:
let counter = 0const FakeDate = { now() { counter++ return 42 },}function impureFn() { return FakeDate.now()}impureFn()// 42, counter === 1impureFn()// 42, counter === 2
let counter = 0 const FakeDate = { now() { counter++ return 42 }, } function impureFn() { return FakeDate.now() } impureFn() // 42, counter === 1 impureFn() // 42, counter === 2
Рекурсия
СкопированоТак как в функциональном программировании нельзя менять состояние, то для итеративных процессов мы не можем применять циклы. Вместо этого нам нужно использовать отображение (map
) и свёртку (reduce
) или рекурсию.
Оба способа берут начало в математике. Рекурсия помогает даже выразить некоторые задачи в том виде, в котором они формулируются в математике. Вот, например, рекурсивное вычисление факториала:
function factorial(n) { if (n <= 1) { return 1 } return n * factorial(n - 1)}
function factorial(n) { if (n <= 1) { return 1 } return n * factorial(n - 1) }
Функции высших порядков
СкопированоИногда нам попадаются почти одинаковые задачи, которые отличаются только деталями. Например, нам может быть нужно достать из массива только отрицательные числа или только чётные числа. Мы могли бы написать нечто вроде:
const list = [-1, 2, 5, -5, 6, 3]const negative = []for (const element of list) { if (element < 0) { negative.push(element) }}const even = []for (const element of list) { if (element % 2 === 0) { even.push(element) }}// negative: [-1, -5]// even: [2, 6]
const list = [-1, 2, 5, -5, 6, 3] const negative = [] for (const element of list) { if (element < 0) { negative.push(element) } } const even = [] for (const element of list) { if (element % 2 === 0) { even.push(element) } } // negative: [-1, -5] // even: [2, 6]
Если приглядеться, станет видно, что схема выполнения в обоих случаях одинаковая: «перебрать каждое значение и проверить его по условию». Меняется же лишь условие, по которому мы фильтруем массив:
const filteredList = []for (const element of someList) { if (someCondition) { filteredList.push(element) }}
const filteredList = [] for (const element of someList) { if (someCondition) { filteredList.push(element) } }
Мы бы могли перебор вариантов превратить в другую функцию, в которую бы передавали массив и условие проверки:
const isNegative = (n) => n < 0const isEven = (n) => n % 2 === 0const negative = filter(list, isNegative)const even = filter(list, isEven)
const isNegative = (n) => n < 0 const isEven = (n) => n % 2 === 0 const negative = filter(list, isNegative) const even = filter(list, isEven)
Здесь новая функция filter
, которая непосредственно перебирает значения. Она принимает на вход массив и функцию-предикат, которая проверяет каждое значение массива по своему условию.
Реализуем filter
самостоятельно, чтобы понять, как всё работает. Объявим функцию filter
, в которую передадим два аргумента: массив и функцию, проверяющую условие — предикат.
function filter(list, predicate) {}
function filter(list, predicate) {}
Внутри создадим пустой массив, который будем наполнять подходящими под условие элементами, а в конце — вернём как результат:
function filter(list, predicate) { const result = [] return result}
function filter(list, predicate) { const result = [] return result }
Каждый элемент переданного массива мы передадим в функцию-предикат, и если она вернёт true
, добавим этот элемент в массив-результат:
function filter(list, predicate) { const result = [] list.forEach((value) => { if (predicate(value)) { result.push(value) } }) return result}
function filter(list, predicate) { const result = [] list.forEach((value) => { if (predicate(value)) { result.push(value) } }) return result }
Таким образом мы абстрагируемся от деталей проверки каждого элемента. Вместо того, чтобы писать несколько почти одинаковых функций для фильтрации массивов мы написали один фильтр и несколько условий. Эти условия мы теперь можем передавать в filter
как аргументы.
Небольшой рефакторинг 😃
Вообще, в JavaScript filter
уже есть, поэтому мы можем переписать код вот так:
const isNegative = (n) => n < 0const isEven = (n) => n % 2 === 0const negative = list.filter(isNegative)const even = list.filter(isEven)
const isNegative = (n) => n < 0 const isEven = (n) => n % 2 === 0 const negative = list.filter(isNegative) const even = list.filter(isEven)
Функции высшего порядка часто используются как основа для паттернов проектирования, например, для декорирования.
Частичное применение
СкопированоХорошо, мы научились абстрагировать похожие задачи с разными аргументами. А что делать, если надо «запомнить» часть аргументов перед выполнением?
Например, есть функция умножения multiply
, но мы хотим дополнительно создать ещё и удвоитель double
. Например, потому что он используется в программе чаще другого умножения.
Решением в лоб было бы просто написать ещё одну функцию:
function multiply(a, b) { return a * b}function double(x) { return x * 2}
function multiply(a, b) { return a * b } function double(x) { return x * 2 }
Но мы видим, что схема выполнения обеих функций одинаковая. Просто в одном случае мы принимаем 2 аргумента, а в другом — 1, потому что второй аргумент «уже есть».
Функции высшего порядка могут помочь и в этой ситуации тоже. Мы можем превратить функцию multiply
в функцию, которая будет принимать лишь один аргумент и возвращать другую функцию:
function multiply(a) { return function performWith(b) { return a * b }}
function multiply(a) { return function performWith(b) { return a * b } }
Тогда создать удвоитель мы сможем, написав:
const double = multiply(2)
const double = multiply(2)
Эта запись превратит double
в функцию perform
, у которой аргумент a
будет «заполнен заранее». То есть это:
const double = multiply(2)
const double = multiply(2)
По сути равно этому:
const double = function performWith(b) { return 2 * b}
const double = function performWith(b) { return 2 * b }
Таким же образом мы можем создать и утроитель и множитель на 10:
const triple = multiply(3)const tenTimes = multiply(10)
const triple = multiply(3) const tenTimes = multiply(10)
Однако пользоваться самой функцией multiply
становится непривычно, приходится вызывать функцию сразу после вызова функции:
const fifty = multiply(5)(10)
const fifty = multiply(5)(10)
Поэтому чаще оригинальную функцию под частичное применение переделывают не руками, а каррируют.
Каррирование
СкопированоКаррирование – это трансформация функций таким образом, чтобы они принимали аргументы не как f
, а как f
. То есть это буквально то же, что мы сделали с функцией multiply
, только автоматизировано.
Попробуем сделать это в лоб:
function curry(fn) { return function rememberFirstArg(a) { return function rememberSecondArg(b) { return fn(a, b) } }}const curriedMultiply = curry(multiply)// multiply(2, 10)// curriedMultiply(2)(10)
function curry(fn) { return function rememberFirstArg(a) { return function rememberSecondArg(b) { return fn(a, b) } } } const curriedMultiply = curry(multiply) // multiply(2, 10) // curriedMultiply(2)(10)
Вроде просто, но если аргументов будет больше 2, то придётся добавлять ещё одну обёртку. Поэтому лучше посчитать количество аргументов и автоматизировать создание обёрток:
function curry(func) { return function curried(...args) { if (args.length >= func.length) { return func.apply(this, args) } return function continueCurrying(...args2) { return curried.apply(this, args.concat(args2)) } }}
function curry(func) { return function curried(...args) { if (args.length >= func.length) { return func.apply(this, args) } return function continueCurrying(...args2) { return curried.apply(this, args.concat(args2)) } } }
В примере выше мы проверяем, закончились ли аргументы. Если закончились, то передаём их все в оригинальную функцию и вызываем её. Если аргументы ещё есть, то используем рекурсию, чтобы каррировать ещё раз.
Теперь мы можем как применить функцию частично, так и выполнить сразу, если потребуется:
const curriedMultiply = curry(multiply)const double = curriedMultiply(2)// [Function: continueCurrying]const result = curriedMultiply(2, 10)// 20
const curriedMultiply = curry(multiply) const double = curriedMultiply(2) // [Function: continueCurrying] const result = curriedMultiply(2, 10) // 20
Также для частичного применения можно использовать `bind()`, хотя это и не очень «функционально».
Например:
function multiply(a, b) { return a * b}const double = multiply.bind(null, 2)double(3)// 6
function multiply(a, b) { return a * b } const double = multiply.bind(null, 2) double(3) // 6
Особенность такого способа в том, что контекст выполнения таких функций будет зафиксирован на null
, а это не всегда удобно или даже применимо.
Работа с побочными эффектами
СкопированоВ функциональном программировании не принято менять состояние. В самом пуристском смысле даже менять значения переменных считается неправильным. Вместо изменения переменной мы должны создать новое значение, как-то его преобразовав. Это не труъ:
let a = 1const update = (value) => { a = 2 * value}update(2)
let a = 1 const update = (value) => { a = 2 * value } update(2)
А вот это уже труъ:
const a = 1const update = (original, value) => original * valueconst changedA = update(a, 2)
const a = 1 const update = (original, value) => original * value const changedA = update(a, 2)
Такое неизменяемое состояние называется иммутабельным (immutable). В функциональном программировании любое значение считается неизменяемым и чтобы его поменять, нужно создать «копию с изменениями».
С одной стороны, это удобно, потому что всегда можно сделать слепок состояния и исследовать его. Можно даже путешествовать во времени, перебирая слепки состояния по очереди.
С другой стороны, это делает взаимодействие с реальностью несколько затруднительным, потому что реальность вся состоит из побочных эффектов. Например, это всё побочные эффекты:
- Запись данных в базу;
- Получение данные от API;
- Запрос к сети за картинкой...
Эту проблему решают по-разному в зависимости от того, насколько строго хотят придерживаться функциональной парадигмы.
Функциональное ядро в императивной оболочке
СкопированоСамый простой и нестрогий способ — использовать чистые функции внутри нечистого контекста. Нечистый контекст (он же императивная оболочка) занимается общением со внешним нечистым миром, а функциональное ядро — только преобразованием данных.
На примере обновления данных в базе это может выглядеть так:
- сперва мы запрашиваем и получаем данные из базы, то есть производим побочный эффект;
- затем преобразовываем данные с помощью чистой функции;
- после записываем данные в базу, то есть снова производим побочный эффект.
Получается такой сэндвич: побочный-эффект, чистое преобразование, побочный-эффект:
Этот способ подходит для проекта, построенном по нестрогой функциональной парадигме. Там мы можем использовать нечистые функции сами и общаться с помощью них с внешним миром. Самое главное — соблюдать ограничение, что только нечистые функции могут вызывать чистые, и никогда не наоборот.
В строгой парадигме всё несколько сложнее
Если мы работаем в строгой парадигме, нам придётся использовать функтор State.
Мы не будем вдаваться в подробности этого подхода, потому что с наскока это будет сделать трудно. Основная его идея в том, что состояние — это не «что-то снаружи», а аргумент. Функция, которая принимает состояние и возвращает возможно изменённое состояние и будет функтором State.
Контейнеры результатов
СкопированоОбычно в JavaScript ошибки обрабатывают императивно с помощью try
:
try { performDangerousOperation()} catch (e) { console.log('Что-то пошло не так!')}
try { performDangerousOperation() } catch (e) { console.log('Что-то пошло не так!') }
В функциональном программировании для их обработки используют контейнеры.
Контейнер в общем смысле можно представить как «коробку», в которой может лежать значение. Основной смысл таких контейнеров в том, чтобы облегчить нам доступ и передачу значения внутри контейнера, а также упростить композицию трансформаций.
Сравним два способа обрезать строку, привести её к числу и прибавить единицу:
const withoutContainer = (str) => Number(str.trim()) + 1const withContainer = (str) => [str] .map((s) => s.trim()) .map((s) => Number(s)) .map((n) => n + 1) [0]
const withoutContainer = (str) => Number(str.trim()) + 1 const withContainer = (str) => [str] .map((s) => s.trim()) .map((s) => Number(s)) .map((n) => n + 1) [0]
Обе функции делают одно и то же, но во второй функции операция разбита на чёткие шаги. Сперва мы помещаем значение в массив — «контейнер». Затем мы используем map
, чтобы преобразовать каждое значение из этого массива по некоторым правилам. В конце достаём из массива единственное значение, которое там было, но уже преобразованное.
Такой поток выполнения линейный, в нём значение переходит от одного преобразования к следующему. Заметьте, что композиция этих преобразований у нас строится на поочерёдном вызове map
на контейнере. Сейчас «контейнер» — это массив, но это совсем не обязательно.
Мы можем реализовать собственный контейнер, операции с которым тоже можно будет компоновать с помощью map
.
const Box = (x) => ({ map: (f) => Box(f(x)),})
const Box = (x) => ({ map: (f) => Box(f(x)), })
Мы создали функцию Box
, которая возвращает объект. Метод map
принимает функцию-преобразование и возвращает новый контейнер, чтобы уже к нему можно было применить следующее преобразование.
Теперь мы можем соединять преобразования с помощью map
— то есть использовать композицию:
const withContainer = (str) => Box(str) .map((s) => s.trim()) .map((s) => Number(s)) .map((n) => n + 1)const result = withContainer('45')// Box(46)
const withContainer = (str) => Box(str) .map((s) => s.trim()) .map((s) => Number(s)) .map((n) => n + 1) const result = withContainer('45') // Box(46)
Чтобы достать значение из такого контейнера, код контейнера нужно слегка дополнить.
Нам понадобится ещё один метод — fold
, который сможет достать из замыкания функции Box
нужное значение и вернуть его:
const Box = (x) => ({ map: (f) => Box(f(x)), fold: (f) => f(x),})
const Box = (x) => ({ map: (f) => Box(f(x)), fold: (f) => f(x), })
Тогда достать значение с его помощью мы сможем так:
const withContainer = (str) => Box(str) .map((s) => s.trim()) .map((s) => Number(s)) .fold((n) => n + 1)const result = withContainer('45')// 46
const withContainer = (str) => Box(str) .map((s) => s.trim()) .map((s) => Number(s)) .fold((n) => n + 1) const result = withContainer('45') // 46
Контейнер же результата можно представить как коробку, в которой после успешного выполнения операции находится результат, а в случае ошибки — ошибка.
Результат будет с типом Ok
:
const Ok = (x) => ({ map: (f) => Ok(f(x)),})
const Ok = (x) => ({ map: (f) => Ok(f(x)), })
Ошибка будет с типом Error
:
const Error = (x) => ({ map: (f) => Error(x),})
const Error = (x) => ({ map: (f) => Error(x), })
Обратите внимание, что Error
при вызове map
не выполняет переданную функцию. Это позволяет разветвлять код и обрабатывать разные случаи и ошибки, не заботясь о каждом этапе обработки ошибок отдельно.
Теперь с помощью этих двух «коробок» мы можем решить, что именно хотим вернуть при работе с опасной операцией. Объявим функцию find
, которая может вернуть undefined
.
function findName(alias) { return { nagibator3000: 'Mike', superUfaStar: 'Alice', }[alias]}
function findName(alias) { return { nagibator3000: 'Mike', superUfaStar: 'Alice', }[alias] }
Проблема этой функции в том, что мы не знаем, как обрабатывать её результат: это может быть или строка, или undefined
. То есть следующий код приведёт к ошибке:
findName('missing-alias').toUpperCase()
findName('missing-alias').toUpperCase()
С контейнером же мы можем не беспокоиться о случае с undefined
:
function fromNullable(x) { return x ? Ok(x) : Error(x)}fromNullable(findName('missing-alias')) .map((value) => value.toUpperCase())
function fromNullable(x) { return x ? Ok(x) : Error(x) } fromNullable(findName('missing-alias')) .map((value) => value.toUpperCase())
Самое классное, что мы можем применять сколько угодно преобразований, и они не вызовут ошибок. Если хотя бы на одном из этапов появится Error
, то ни одно последующее преобразование не будет выполнено:
fromNullable(x) .map((value) => value.toUpperCase()) .map((value) => value.trim()) .map((value) => '@' + value)
fromNullable(x) .map((value) => value.toUpperCase()) .map((value) => value.trim()) .map((value) => '@' + value)
В примере выше если x
— строка, к нему применится 3 преобразования из map
и в конце на экране появится alert
. В случае если x
, то преобразования будут проигнорированы.
Паттерн-матчинг
СкопированоЕщё одна мощная концепция из функционального программирования — это паттерн-матчинг. В нём проверяемое значение сопоставляется с какими-либо заранее подготовленными. В зависимости от того, с каким значением совпадает проверяемое, выполняются определённые действия.
Концептуально он похож на switch:
function isRGBComponent(color) { switch (color) { case 'red': case 'green': case 'blue': return true default: false }}isRGBComponent('blue')// trueisRGBComponent('gray')// false
function isRGBComponent(color) { switch (color) { case 'red': case 'green': case 'blue': return true default: false } } isRGBComponent('blue') // true isRGBComponent('gray') // false
Во многих функциональных языках проверяемое значение можно сопоставлять не только с другими значениями, но и использовать предикаты, сравнивать типы данных и т. д.
В JavaScript тоже можно (хоть и с костылями) использовать предикаты для паттерн-матчинга. Мы можем проверять результат выражений прямо в case
:
function stringifyAmount(amount) { switch (true) { case amount === 0: return 'Empty!' case 0 < amount && amount < 10: return 'A few' default: return 'Many' }}stringifyAmount(0)// Empty!stringifyAmount(5)// A fewstringifyAmount(100)// Many
function stringifyAmount(amount) { switch (true) { case amount === 0: return 'Empty!' case 0 < amount && amount < 10: return 'A few' default: return 'Many' } } stringifyAmount(0) // Empty! stringifyAmount(5) // A few stringifyAmount(100) // Many
Но обычно, чтобы использовать паттерн-матчинг в JavaScript, подключают дополнительные библиотеки.
Математические основы
СкопированоФункциональное программирование по сути — это просто интерпретация функций как математического понятия. То есть функция здесь — это отображение входных данных на выходные.
Отсюда как раз следует, что у функции не должно быть побочных эффектов — у математических функций их просто нет! У каждого входного значения есть одно и только одно выходное, исключений не бывает.
Основы функционального программирования — это лямбда-исчисление и теория категорий. Лямбда-исчисление отвечает за описание и вычисление функций, а теория категорий — за отношения между объектами.
Плюсы функционального программирования
СкопированоСейчас функциональное программирование популярно, потому что решает несколько важных проблем.
Надёжность и удобство тестирования
СкопированоЧистые функции, которые лежат в основе ФП, надёжны, потому что всегда выдают одинаковый результат при одинаковых входных данных.
Это значит, что в какой бы момент времени мы ни запускали такую функцию, мы всегда можем рассчитывать на предсказуемый результат. Более того, сам вызов чистой функции можно заменить на её значение-результат, и программа не сломается. Это свойство называется ссылочной прозрачностью.
Также чистые функции удобно тестировать, потому что они не требуют большой тестовой инфраструктуры. А если такая функция написана на языке со строгой статической типизацией, то часть тестов оказывается вовсе не нужна.
Оптимизация при компиляции
СкопированоПри компиляции кода, который обладает ссылочной прозрачностью, некоторые его куски можно «выполнить» заранее и получить готовое значение. Это позволяет не тратить вычислительные ресурсы на выполнение функции в рантайме, а сделать это заранее, что ускорит работу программы.
Параллелизм и потокобезопасность
СкопированоФункциональное программирование запрещает менять состояние, а значит не случится ситуации, когда две функции пытаются записать разные значения в одну переменную. Это значит, что выполнение кода можно безопасно разбивать на несколько параллельных потоков или процессов.
Минусы функционального программирования
СкопированоЛюбая парадигма, в том числе и функциональное программирование, имеет и ряд минусов.
Повышенное потребление памяти
СкопированоТак как состояние программы неизменяемо, при его «изменении» приходится создавать его полную копию. Это требует грамотной и своевременной работы с памятью — выделения, мониторинга и очищения неиспользуемых участков.
Сложность при работе с нечистыми сервисами
СкопированоЧистое ФП сложно подружить с реальностью, которая полностью состоит из побочных эффектов. Способы решения этой проблемы мы описывали чуть ранее в этой статье.
На собеседовании
Скопировано отвечает
СкопированоОбъект первого класса (first class object или first class citizen) это объект, который может быть передан как аргумент функции, возвращён из функции или присвоен переменной.
Функции в JavaScript полностью соответствуют этому определению.
Функцию можно присвоить переменной:
const multipleTwo = (n) => n * 2;
const multipleTwo = (n) => n * 2;
Функция может быть передаваемым аргументом другой функции:
async function loadData(func) { loading = true; // другой код относящийся к инициализации статусов загрузки await func(); loading = false; // другой код относящийся к обработке статуса загрузки}function getData() { // код получения данных с сервера}loadData(getData);
async function loadData(func) { loading = true; // другой код относящийся к инициализации статусов загрузки await func(); loading = false; // другой код относящийся к обработке статуса загрузки } function getData() { // код получения данных с сервера } loadData(getData);
Функции могут быть возвращаемым значением другой функции:
function makeAdder(x) { return function(y) { return x + y; };};
function makeAdder(x) { return function(y) { return x + y; }; };
отвечает
СкопированоКомпозиция – основа функционального подхода. Операция композиции в теории категорий определяется для разных сущностей. Но сейчас мы обратим внимание именно на композицию функций.
Нам нужно создать функцию, которая принимает массив других функций и возвращает новую функцию.
Используем правило «Не думай, просто пиши» 🙂
const compose = (...fns) => x => // функция которую нам надо реализовать
const compose = (...fns) => x => // функция которую нам надо реализовать
В условии нам подсказали как это сделать — compose
.
Если сходу решение в голову не приходит, давайте попробуем посмотреть на примерах.
Композиция для одной функции — это сама функция:
compose(f) = f
compose(f) = f
Композиция для двух функций:
compose(f,g) = x => { const prevResult = g(x) // выполнили g return f(prevResult) // выполнили f}
compose(f,g) = x => { const prevResult = g(x) // выполнили g return f(prevResult) // выполнили f }
Тогда общее решение выглядит так:
const compose = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x)
const compose = (...fns) => x => fns.reduceRight((acc, fn) => fn(acc), x)
Для каждой предыдущей функции из массива вызовите её на результате выполнения следующей. Тут важно что функции выполняются справа налево.
отвечает
СкопированоФункция высшего порядка (Higher Order Function) — это функция, которая принимает в качестве аргумента другие функции и/или возвращает в результате своей работы функцию.
Функции высшего порядка нужны, чтобы создавать более гибкий код, строить абстракции и применять паттерны функционального программирования.
Рассмотрим несколько примеров.
set
— глобальная функция высшего порядка, так как принимает в качестве аргумента функцию, которая выполняется с указанной задержкой:
setTimeout( () => {console.log('А вот и я!')}, 5000)
setTimeout( () => {console.log('А вот и я!')}, 5000 )
Многие методы массива являются функциями высшего порядка, так как принимают в качестве аргумента колбэк-функции, которые перебирают элементы массива. Например, метод .map
принимает в качестве аргумента функцию, которая преобразовывает каждый элемент в массиве:
const years = [1970, 1990, 1995]const objects = years.map(item => { return { year: item }})console.log(objects)// [ { year: 1970 }, { year: 1990 }, { year: 1995 } ]
const years = [1970, 1990, 1995] const objects = years.map(item => { return { year: item } }) console.log(objects) // [ { year: 1970 }, { year: 1990 }, { year: 1995 } ]
Функции высшего порядка используют для реализации подхода «частичное применение функции». В нём функция при первом вызове принимает только часть нужных аргументов и возвращает новую функцию, пока ждёт остальные аргументы. Такой подход строится на механизме замыкания.
Рассмотрим как работает реализация подхода на примере. Создадим простую функцию логирования. Она принимает имя источника сообщения и текст сообщения в качестве аргументов:
const log = (sourceName, message) => { console.log(sourceName,':', message)}
const log = (sourceName, message) => { console.log(sourceName,':', message) }
Такую функцию не удобно многократно использовать для одного и того же источника сообщений, так как придётся повторно указывать первый аргумент:
log('Модуль A', 'Запуск')// Модуль A: Запускlog('Модуль A', 'Проведено тестирование грунта')// Модуль A: Проведено тестирование грунта
log('Модуль A', 'Запуск') // Модуль A: Запуск log('Модуль A', 'Проведено тестирование грунта') // Модуль A: Проведено тестирование грунта
Изменим функцию так, чтобы она умела «запоминать» первый аргумент source
:
// HOF-функция логированияconst log = sourceName => message => { console.log(sourceName,':', message)}
// HOF-функция логирования const log = sourceName => message => { console.log(sourceName,':', message) }
Теперь log
— это функция высшего порядка, так как она возвращает другую функцию.
Полученная в результате вызова log
функция хранит источник сообщений и готова для вызова только с аргументом message
:
const logAppolo = log('Appolo 13')logAppolo('Хьюстон…')// Appolo 13: Хьюстон…logAppolo('Хьюстон, у нас проблема')// Appolo 13: Хьюстон, у нас проблема
const logAppolo = log('Appolo 13') logAppolo('Хьюстон…') // Appolo 13: Хьюстон… logAppolo('Хьюстон, у нас проблема') // Appolo 13: Хьюстон, у нас проблема