Принципы функционального программирования поддерживает множество языков. Среди них можно отметить JavaScript, Haskell, Clojure, Erlang. Использование механизмов функционального программирование подразумевает знание, кроме прочих, таких концепций, как чистые функции, каррирование функций, функции высшего порядка.
Материал, перевод которого мы сегодня публикуем, посвящён каррированию. Мы поговорим о том, как работает каррирование, и о том, как знание этого механизма может пригодиться JS-разработчику.
Что такое каррирование?
Каррирование или карринг (currying) в функциональном программирование — это преобразование функции с множеством аргументов в набор вложенных функций с одним аргументом. При вызове каррированной функции с передачей ей одного аргумента, она возвращает новую функцию, которая ожидает поступления следующего аргумента. Новые функции, ожидающие следующего аргумента, возвращаются при каждом вызове каррированной функции — до тех пор, пока функция не получит все необходимые ей аргументы. Ранее полученные аргументы, благодаря механизму замыканий, ждут того момента, когда функция получит всё, что ей нужно для выполнения вычислений. После получения последнего аргумента функция выполняет вычисления и возвращает результат.
Говоря о каррировании, можно сказать, что это процесс превращения функции с несколькими аргументами в функцию с меньшей арностью.
Арность — это количество аргументов функции. Например — вот объявление пары функций:
function fn(a, b) {
//...
}
function _fn(a, b, c) {
//...
}
Функция
fn
принимает два аргумента (это бинарная или 2-арная функция), функция _fn
принимает три аргумента (тернарная, 3-арная функция). Поговорим о ситуации, когда, в ходе каррирования, функция с несколькими аргументами преобразуется в набор функций, каждая из которых принимает один аргумент.
Рассмотрим пример. У нас имеется следующая функция:
function multiply(a, b, c) {
return a * b * c;
}
Она принимает три аргумента и возвращает их произведение:
multiply(1,2,3); // 6
Теперь подумаем о том, как преобразовать её к набору функций, каждая из которых принимает один аргумент. Создадим каррированный вариант этой функции и посмотрим на то, как получить тот же результат в ходе вызова нескольких функций:
function multiply(a) {
return (b) => {
return (c) => {
return a * b * c
}
}
}
log(multiply(1)(2)(3)) // 6
Как видите, здесь мы преобразовали вызов единственной функции с тремя аргументами —
multiply(1,2,3)
к вызову трёх функций — multiply(1)(2)(3)
.Оказывается, что одна функция превратилась в несколько функций. При использовании новой конструкции каждая функция, кроме последней, возвращающей результат вычислений, принимает аргумент и возвращает другую функцию, также способную принять аргумент и возвратить другую функцию. Если конструкция вида
multiply(1)(2)(3)
кажется вам не слишком понятной, давайте, чтобы лучше в этом разобраться, распишем её в таком виде:const mul1 = multiply(1);
const mul2 = mul1(2);
const result = mul2(3);
log(result); // 6
Теперь построчно разберём то, что здесь происходит.
Сначала мы передаём аргумент
1
функции multiply
:const mul1 = multiply(1);
При работе этой функции срабатывает такая конструкция:
return (b) => {
return (c) => {
return a * b * c
}
}
Теперь в
mul1
имеется ссылка на функцию, принимающую аргумент b
. Вызовем функцию mul1
, передав ей 2
:const mul2 = mul1(2);
В результате этого вызова выполнится следующий код:
return (c) => {
return a * b * c
}
Константа
mul2
будет содержать ссылку на функцию, которая могла бы оказаться в ней, например, в результате выполнения следующей операции:mul2 = (c) => {
return a * b * c
}
Если теперь вызвать функцию
mul2
, передав ей 3
, то функция выполнит необходимые вычисления, воспользовавшись аргументами a
и b
:const result = mul2(3);
Результатом этих вычислений будет
6
:log(result); // 6
Функция
mul2
, обладающая самым большим уровнем вложенности, имеет доступ к областям видимости, к замыканиям, формируемым функциями multiply
и mul1
. Именно поэтому в функции mul2
можно производить вычисления с переменными, объявленными в функциях, выполнение которых уже завершено, которые уже возвратили некие значения и обработаны сборщиком мусора.Выше мы рассмотрели абстрактный пример, а вот, в сущности, такая же функция, которая предназначена для вычисления объёма прямоугольного параллелепипеда.
function volume(l,w,h) {
return l * w * h;
}
const vol = volume(100,20,90) // 180000
Вот как выглядит её каррированный вариант:
function volume(l) {
return (w) => {
return (h) => {
return l * w * h
}
}
}
const vol = volume(100)(20)(90) // 180000
Итак, каррирование базируется на следующей идее: на основе некоей функции создают другую функцию, которая возвращает специализированную функцию.
Каррирование и частичное применение функций
Сейчас, возможно, возникает ощущение, что количество вложенных функций, при представлении функции в виде набора вложенных функций, зависит от количества аргументов функции. И, если речь идёт о каррировании, то это так.
Особый вариант функции для вычисления объёма, которую мы уже видели, можно сделать и таким:
function volume(l) {
return (w, h) => {
return l * w * h
}
}
Здесь применены идеи, очень похожие на рассмотренные выше. Пользоваться этой функцией можно так:
const hV = volume(70);
hV(203,142);
hV(220,122);
hV(120,123);
А можно и так:
volume(70)(90,30);
volume(70)(390,320);
volume(70)(940,340);
Фактически, здесь можно видеть как мы, командой
volume(70)
, создали специализированную функцию для вычисления объёма тел, одно из измерений которых (а именно — длина, l
), зафиксировано. Функция volume
ожидает 3 аргумента и содержит 2 вложенных функции, в отличие от предыдущей версии подобной функции, каррированный вариант которой содержал 3 вложенных функции.Та функция, которая получилась после вызова
volume(70)
реализует концепцию частичного применения функции (partial function application). Каррирование и частичное применение функций очень похожи друг на друга, но концепции это разные.При частичном применении функцию преобразуют в другую функцию, обладающую меньшим числом аргументов (меньшей арностью). Некоторые аргументы такой функции оказываются зафиксированными (для них задаются значения по умолчанию).
Например, имеется такая функция:
function acidityRatio(x, y, z) {
return performOp(x,y,z)
}
Её можно преобразовать в такую:
function acidityRatio(x) {
return (y,z) => {
return performOp(x,y,z)
}
}
Реализация функции
performOp()
здесь не приводится, так как она на рассматриваемые концепции не влияет.Функцию, которую можно получить, вызвав новую функцию
acidityRatio()
с аргументом, значение которого нужно зафиксировать, представляет собой исходную функцию, один из аргументов которой зафиксирован, а сама эта функция принимает на один аргумент меньше, чем исходная.Каррированный вариант функции будет выглядеть так:
function acidityRatio(x) {
return (y) = > {
return (z) = > {
return performOp(x,y,z)
}
}
}
Как видите, при каррировании число вложенных функций равно числу аргументов исходной функции. Каждая из этих функций ожидает собственный аргумент. При этом понятно, что если функция аргументов не принимает, или принимает лишь один аргумент, то каррировать её нельзя.
В ситуации, когда функция имеет два аргумента, результаты её каррирования и частичного применения, можно сказать, совпадают. Например, у нас имеется такая функция:
function div(x,y) {
return x/y;
}
Предположим, нам нужно переписать её так, чтобы можно было, зафиксировав первый аргумент, получить функцию, выполняющую вычисления при передаче ей лишь второго аргумента, то есть, нам нужно частично применить эту функцию. Выглядеть это будет так:
function div(x) {
return (y) => {
return x/y;
}
}
Точно так же будет выглядеть и результат её каррирования.
О практическом применении концепций каррирования и частичного применения функций
Каррирование и частичное применение функций может оказаться полезным в различных ситуациях. Например — при разработке небольших модулей, подходящих для повторного использования.
Частичное применение функций позволяет облегчить использование универсальных модулей. Например, у нас есть интернет-магазин, в коде которого имеется функция, которая используется для вычисления суммы к оплате с учётом скидки.
function discount(price, discount) {
return price * discount
}
Есть определённая категория клиентов, назовём их «любимыми клиентами», которой мы даём скидку в 10%. Например, если такой клиент покупает что-то на $500, мы даём ему скидку размером $50:
const price = discount(500,0.10); // $50
// $500 - $50 = $450
Несложно заметить, что нам, при таком подходе, постоянно придётся вызывать эту функцию с двумя аргументами:
const price = discount(1500,0.10); // $150
// $1,500 - $150 = $1,350
const price = discount(2000,0.10); // $200
// $2,000 - $200 = $1,800
const price = discount(50,0.10); // $5
// $50 - $5 = $45
const price = discount(5000,0.10); // $500
// $5,000 - $500 = $4,500
const price = discount(300,0.10); // $30
// $300 - $30 = $270
Исходную функцию можно привести к такому виду, который позволял бы получать новые функции с заранее заданным уровнем скидки, при вызове которых им достаточно передавать сумму покупки. Функция
discount()
в нашем примере имеет два аргумента. Вот как выглядит то, во что мы её преобразуем:function discount(discount) {
return (price) => {
return price * discount;
}
}
const tenPercentDiscount = discount(0.1);
Функция
tenPercentDiscount()
представляет собой результат частичного применения функции discount()
. При вызове tenPercentDiscount()
этой функции достаточно передать цену, а скидка в 10%, то есть — аргумент discount
, уже будет задана:tenPercentDiscount(500); // $50
// $500 - $50 = $450
Если в нашем магазине имеются покупатели, которым решено дать скидку размером в 20%, то получить соответствующую функцию для работы с ними можно так:
const twentyPercentDiscount = discount(0.2);
Теперь функцию
twentyPercentDiscount()
можно вызывать для расчёта стоимости товаров с учётом скидки в 20%:twentyPercentDiscount(500); // 100
// $500 - $100 = $400
twentyPercentDiscount(5000); // 1000
// $5,000 - $1,000 = $4,000
twentyPercentDiscount(1000000); // 200000
// $1,000,000 - $200,000 = $600,000
Универсальная функция для частичного применения других функций
Разработаем функцию, которая принимает любую функцию и возвращает её вариант, представляющий собой функцию, некоторые аргументы которой уже заданы. Вот код, который позволяет это сделать (вы, если зададитесь целью разработать подобную функцию, вполне возможно, получите в результате что-то другое):
function partial(fn, ...args) {
return (..._arg) => {
return fn(...args, ..._arg);
}
}
Функция
partial()
принимает функцию fn
, которую мы хотим преобразовать в частично применённую функцию, и переменное число параметров (...args
). Оператор rest
используется для того, чтобы поместить все параметры, идущие после fn
, в args
.Эта функция возвращает другую функцию, которая так же принимает переменное число параметров (
_arg
). Эта функция, в свою очередь, вызывает исходную функцию fn
, передавай ей параметры ...args
и ..._arg
(с использованием оператора spread
). Функция выполняет вычисления и возвращает результат.Применим эту функцию для создания варианта уже знакомой вам функции
volume
, предназначенной для расчёта объёма прямоугольных параллелепипедов, одна из сторон которых зафиксирована:function volume(l,h,w) {
return l * h * w
}
const hV = partial(volume,100);
hV(200,900); // 18000000
hV(70,60); // 420000
Здесь можно найти пример универсальной функции для каррирования других функций.
Итоги
В этом материале мы поговорили о каррировании и частичном применении функций. Эти методы преобразования функций реализуются в JavaScript благодаря замыканиям и благодаря тому, что функции в JS являются объектами первого класса (их можно передавать в качестве аргументов другим функциям, возвращать из них, присваивать переменным).
Уважаемые читатели! Пользуетесь ли вы техниками каррирования и частичного применения функций в своих проектах?
Комментарии (25)
Vasily_T
23.10.2018 12:56+3Вообще конечно «каррированние» — так себе решение чего либо, из серии потому что так можно. Прятать логику нехорошо!
Riim
23.10.2018 13:10+1Универсальная функция для частичного применения других функций
простой, но не самый эффективный вариант реализации, когда-то писал свой вариант с бенчмарками относительно других реализаций: https://github.com/Riim/curry#benchmark .
mayorovp
23.10.2018 13:47-1Во-первых, ваш вариант все-таки медленнее. Во-вторых, вы решали немного другую задачу…
Riim
23.10.2018 13:58+1Ну я вроде дал ссылку на результаты бенчмарков, в чём-то медленнее, в чём-то быстрее. В чём другая задача? Это вы мне за то, что я поделился своим решением минус влепили? Спасибо, на хабре я уже ничего другого и не жду.
mayorovp
23.10.2018 14:18-1Ну и где у вас в сравнении функция partial? Может, мы разные репозитории смотрим?
Riim
23.10.2018 14:46То есть вас просто название смутило? Функционал библиотеки является надмножеством функционала приведённого примера и полностью его покрывает, тоже относится к библиотекам в приведённом вами списке. Почему все они используют название curry вместо partial я не знаю, может они все ошиблись, а может это вы что-то не понимаете.
mayorovp
23.10.2018 15:00-1Так я про это и пишу: вы решали не ту же самую задачу, а ее надмножество. И именно по этой причине ваш вариант медленней. И еще именно по этой причине функция partial не попала в ваше сравнение.
Riim
23.10.2018 15:20вы решали не ту же самую задачу, а ее надмножество
То есть если моя функция решает ту же задачу, что и функция в статье, плюс может делать что-то ещё, то сравнивать эти функции на одинаковых задачах, которые они обе умеют решать по вашему нельзя? Где логика?))
именно по этой причине функция partial не попала в ваше сравнение
Попала, в приведённом списке light-curry примерно настолько же примитивно сделан. Да, сам по себе он быстрее создаёт каррированную функцию, но созданная функция в три раза медленнее такой же, созданной моей библиотекой. Почему то мне кажется, что скорость создаваемой каррированной функции намного важнее, чем скорость её создания, так как она может быть вызвана множество раз (обычно так и случается). Вы так не думаете?
mayorovp
23.10.2018 15:31Логика в том, что на тех задачах, которые умеют обе функции, приведенная тут функция partial таки быстрее.
Попала, в приведённом списке light-curry примерно настолько же примитивно сделан.
light-curry использует arguments вместо spread operator, а это известный убийца производительности.
Riim
23.10.2018 15:43+1Логика в том, что на тех задачах, которые умеют обе функции, приведенная тут функция partial таки быстрее.
так вы согласны, что функции можно сравнивать?
light-curry использует arguments вместо spread operator, а это известный убийца производительности.
вы думаете если переписать без arguments это что-то сильно поменяет? Хорошо, попробуйте! Предлагайте ваш вариант, который создаёт более быстрые каррированные функции. Будет интересно посмотреть.
S_Gonchar
24.10.2018 11:57+1Хорошая статья! Было интересно почитать, спасибо!
Прежде я уже встречал подобные приемы в чужом коде, но не знал, что это называется каррированием. За собой не припомню случаев, чтобы я делал нечто подобное в своем коде.
Однако, сейчас подумал, что этот прием мог бы мне пригодиться в паре моментов, в моем недавнем проекте. Теперь немного жаль…
maximw
Потом прочитал главу «О практическом применении концепций каррирования и частичного применения функций» (и далее пост). Понял идею частичного применения. Но зачем нужно каррирование?
Sirion
Тащемта, да. Каррирование в чистом виде имеет примерно ту же ценность, что и машина Тьюринга: чисто теоретическую.
capslocky
Синтаксис javascript был основан на java, поэтому каррирование в нем выглядит нелепо. А вот, например, в haskell наоборот: все функции принимают только 1 аргумент, поэтому там каррирование происходит всегда и естественным образом.
mayorovp
Поправка: немного некорректно говорить что что в Haskell каррирование «происходит». Там функции изначально находятся в каррированной форме.
anfield343
При написании приложений на реакте, часто удобно использовать каррирование. В событиях при клике, например.
mayorovp
Только там кроме каррирования нужна еще и мемоизация, иначе будет избыточные рендеры. Проще использовать bind в конструкторе.
maximw
Именно каррирование, а не частичное применение?
В чем их принципиальное отличие (по крайней мере так утверждается в статье)?
anfield343
что-то статья оставила больше вопросов, чем ответов. Я не силен в терминологии, и не могу точно сказать частичное ли это применение или «полное» в реакте, может кто из знающих тут расскажет). Всегда считал, что каррирование означает создание новой фукнции, путём фиксирования аргументов уже существующей, т.е. лично у меня нет разделения на фиксированное кол-во аргументов или нет и соответственное нет разделения на частичное и полное. Поэтому однозначно ответить на ваш вопрос не могу, к сожалению.
mayorovp
Каррирование означает преобразование функции нескольких аргументов в цепочку функций, принимающих один аргумент. Больше оно ничего не означает.
ivan386
Не обязательно один. Эти функции тем самым приобретают внутреннее состояние.
В lua например функция ipairs яркий пример каррирования. Она принимает как аргумент массив и возвращает функцию которая при каждом вызове возвращает следующий индекс и значение.
mayorovp
ivan386
Действительно ошибочка вышла. Я то думаю почему только один когда в статье вон примеры с несколькими аргументами.