Что такое каррирование? Функциональное программирование
2 года назад·5 мин. на чтение
В этой статье на простых и доступных примерах рассмотрим одну из концепций функционального программирования - Каррирование.
Это серия статей о функциональном программировании:
Представим, что у нас есть функция
Итак, сначала мы создали
Пояснение к функции
Функция
Каждый раз, когда мы вызываем
Каррирование действительно внесло улучшения, нам не нужно каждый раз указывать локаль. Вместо этого каррированная функция
Для лучшего понимания можно развернуть каррирование и вместо этого использовать обычные функции.
- Парадигмы программирования
- Композиция
- Функторы
- Каррирование (рассматривается в этой статье)
- Чистые функции
- Функции первого класса
Что такое каррирование?
Каррированная функция — это функция, которая продолжает возвращать функции до тех пор, пока не будут отправлены все ее параметры.Как работает каррирование?
Предположим, есть функция сложенияadd
.
Простейшая реализация каррирования — заставить функцию возвращать функцию и т. д.const add = (a, b) => a + b
Эту функцию можно использовать так.const add = (a) => (b) => a + b
const addOne = add(1) // addOne = (b) => 1 + b
curry
, которая принимает функцию и каррирует ее.
Как мы видим,const add = curry((a, b) => a + b)
curry
— это функция, которая использует другую функцию для ленивой обработки параметров. Итак, теперь мы можем вызвать ее следующим образом.
const addOne = add(1) // addOne = (b) => 1 + b
addOne
, передав 1
в качестве первого параметра (a
) каррированной функции добавления. Что привело к другой функции, которая ожидает остальные параметры, где логика добавления не будет выполняться, пока не будут предоставлены все параметры.
Теперь, передаваяaddOne(2) // 3
2
(как b
) в addOne
выполняет логику 1 + 2
.
Пояснение к функции curry
Функция curry
принимает функцию и делает ее параметры ленивыми, другими словами, вы предоставляете эти параметры по мере необходимости. Так же, как addOne
.
Вы по-прежнему можете вызывать каррированную версию функции добавления следующим образом.
Таким образом, он либо принимает аргументы по частям, либо все аргументы сразу.const three = add(1, 2)
Для чего нужно каррировать функции?
Каррирование делает код:- Чище
- Уменьшает количество повторяющихся параметров и делает код более лаконичным
- Более компонуемым
- Переиспользуемым
Почему каррирование делает код лучше?
Некоторые функции принимают конфигурацию в качестве входных данных
Если у нас есть функции, которые принимают конфигурацию (начальные настройки), нам лучше их каррировать, потому что эти конфигурации, вероятно, будут повторяться снова и снова. Предположим, что у нас есть функция перевода, которая принимает локаль и текст для перевода.Использование будет выглядеть так.const translate = (locale, text) => { /* логика перевода */ }
translate('ru', 'Hello') translate('ru', 'Goodbye') translate('ru', 'How are you?')
translate
, мы должны указывать язык и текст. Что является избыточным для предоставления локали при каждом вызове.
Но вместо этого давайте каррируем translate
следующим образом.
Теперьconst translate = curry( (locale, text) => { /* логика перевода */ } ) const translateToRu = translate('ru')
translateToRu
имеет ru
в качестве locale
, предоставленного каррированной функции translate
, и ожидает текста. Мы можем использовать это так.
translateToRu('Hello') translateToRu('Goodbye') translateToRu('How are you?')
translateToRu
содержит locale
из-за каррирования.
После каррирования - в этом конкретном примере код стал:
- чище
- менее многословным и менее избыточным.
На практике
На практике у нас есть динамическая локаль (у каждого пользователя свой язык), может бытьfr
, en
, de
или что-то еще. Поэтому вместо этого лучше переименовать translateToRu
в translateTo
, где translateTo
может быть загружен с любой локалью.
Теперь у нас есть translate
, который принимает locale
как конфигурацию и text
как данные. Благодаря тому, что translate
каррирован, мы смогли отделить параметры конфигурации от данных.
Зачем отделять конфигурации от данных?
Многие компоненты и функции нуждаются в использовании некоторой функциональности (в нашем случае,translateTo
), но не должны или не могут знать о части конфигурации (locale
). Эти компоненты или функции имеют только часть данных (text
). Таким образом, эти функции смогут использовать эту функцию без необходимости знать о части конфигурации.
Таким образом, этот компонент или функция будут меньше связаны с системой, что сделает компоненты более компонуемыми и более удобными в сопровождении.
Когда применять каррирование?
Когда мы знаем, что в функции есть конфигурация и есть данные, лучше их каррировать. Каррирование даст нам возможность их разделить. И это признак зрелого дизайна системы. Потому что одним из основных столпов качества кода является разделение задач. Даже если функции нужны все параметры для правильной работы, мы все равно лучше знаем, когда передавать параметры и на каком уровне приложения.Связь между замыканием и каррированием
Замыкание - это функция, возвращаемая «родительской» функцией и имеющая доступ к внутреннему состоянию родительской функции. Каррирование всегда приводит к замыканию. Потому что каждая функция, возвращаемая каррированной функцией, будет снабжена внутренним состоянием родителей.Примеры каррирования
Перед тем как продолжить
Добавим некоторые утилиты, чтобы мы могли перейти к примерам. Прототип массива имеет такие утилиты, какfilter
, map
и другие. Но они не поддерживают каррирование, потому что используют запись через точку (.
).
Итак, давайте конвертируем их в каррируемый формат.
Теперь мы можем использовать их так.const filter = (fn, list) => list.filter(fn) const map = (fn, list) => list.map(fn) const startsWith = (starter, s) => s.startsWith(starter)
Мы исключили запись через точку и передали обработанные данные в качестве последнего параметра. Затем мы их каррируем. Функцияconst lessThan21 = user => user.age < 21 // Вместо такого использования... const filteredUsers = users.filter(lessThan21 ) // ...будем использовать такое const filteredUsers = filter(lessThan21, users)
curry
будет принимать функцию и возвращать каррированную функцию.
const filter = curry((fn, list) => list.filter(fn)) const map = curry((fn, list) => list.map(fn)) const startsWith = curry((starter, s) => s.startsWith(starter))
Пример 1
Дан список чисел, нужно увеличить все числа на 1. Вход:[1, 2, 3, 4, 5]
Выход: [2, 3, 4, 5, 6]
Реализация:
// каррированная функция add была определена ранее const addOne = add(1) const incrementNumbers = map(addOne) const incrementedNumbers = incrementNumbers(numbers)
Пример 2
Дана строка, оставить все слова, начинающиеся с буквыc
.
Вход: "currying javascript function”
Выход: “currying”
Реализация:
const startsWithC = startsWith('c') const filterStartsWithC = filter(startsWithC) const filteredWords = filterStartsWithC(words)
Пример 3
Дан список диапазонов и список чисел. Создайте массив функций, которые могут фильтровать числа на основе предоставленных диапазонов.Выход: массив функций. Каждая функция может принимать числа и возвращать отфильтрованные числа, которые находятся в заданном диапазоне.const ranges = [ { min: 10, max: 100 }, { min: 100, max: 500 }, { min: 500, max: 999 } ] const numbers = [30, 50, 110, 200, 650, 700, 1000] // 30 и 50 в первом диапазоне // 110 и 200 во втором диапазоне // 650 и 700 в третьем диапазоне // 1000 не принадлежит ни одному диапазону
В этом примере есть двойное каррирование,const isInRange = curry( (range, val) => val > range.min && val < range.max ) const filters = ranges.map((range) => filter(isInRange(range)))
filter
и isInRange
.
filters
теперь представляют собой список функций, каждая из которых ожидает numbers
для обработки.
const isInRange = (range, val) => val > range.min && val < range.max const filters = ranges.map( (range) => (numbers) => numbers.filter( number => isInRange(range, number) ) )
Итоги
Каррирование просто делает параметры ленивыми. Когда функция продолжает возвращать функцию до тех пор, пока все ее аргументы не будут выполнены, она вычисляет и возвращает результат. Мы также увидели, как это делает наш код чище, более оаконичным, более компонуемым и даже более пригодным для повторного использования на практических примерах. И это тоже является примером принципа разделения ответственности.Что такое функтор? Функциональное программирование
2 года назад·5 мин. на чтение
В этой статье на простых и доступных примерах рассмотрим одну из концепций функционального программирования - Функтор.
Это серия статей о функциональном программировании:
Функтор
Как мы обсуждали ранее, нам нужна только обертка, которая абстрагируется от обработки данных. Итак, роль функтора Имплементация функтора
Как использовать функтор
С валидными данными:
Функтор Решение задачи с функтором
Комментарии к решению с функтором
Мы смогли улучшить традиционное решение при помощи функтора
- Парадигмы программирования
- Композиция
- Функторы (рассматривается в этой статье)
- Каррирование
- Чистые функции
- Функции первого класса
Что такое функтор?
Функтор (functor) это:- обертка над значением,
- предоставляет интерфейс для преобразование (map),
- подчиняется законам функтора (поговорим о них позже).
Примеры функторов
- Массив (
Array
), - Промис (
Promise
).
Почему массив - функтор?
Вспомним определение функтора:- обертка над списком значений,
- предоставляет интерфейс для преобразования - метод
map
, - подчиняется законам функтора.
[1, 2, 3] // обернутое значение .map( // интерфейс для преобразования значения x => x * 2 )
Почему промис - функтор?
Промис это:- обертка над любым значением из JavaSctipt типов,
- предоставляет интерфейс для преобразования - метод
then
, - подчиняется законам функтора.
const promise = new Promise((resolve, reject) => { resolve( { data: "value" } // обернутое значение, в данном случае объект ) }); promise .then( // интерфейс для преобразования значения response => console.log(response) );
Что объединяет массив (или промис) и функтор?
Функтор - это паттерн проектирования, аArray
и Promise
- типы данных, которые основаны на этом паттерне.
Почему мы говорим, что массив и промис - функторы?
Чтобы понять, что функторы ближе чем кажутся. Массив и промис легко понять, при это они являются мощной концепцией. Мы используем их ежедневно, даже не подозревая об их сущности.Где использовать функторы?
Немного поговорив о функторах и связав их с нашим повседневным использованием, было бы разумно рассмотреть их подробнее. Чтобы лучше понять идею функтора, создадим свои собственные функторы. Для начала рассмотрим такую задачу. Предположим, есть следующий кусочек данных.{ products: [ { name: "All about JavaScript", type: "book", price: 22, discount: 20 } ] }
Постановка задачи
Найти финальную цену первого товара с учетом скидки. Если по какой-либо причине будут переданы неправильные данные, вывести строку "No data".Шаги алгоритма
- Найти первый продукт со скидкой,
- Применить скидку,
- Продолжать проверку данных на валидность. Если данные не валидны - вернуть "No data".
Традиционное решение
const isProductWithDiscount = product => { return !isNaN(product.discount) } const findFirstDiscounted = products => { products.find(isProductWithDiscount) } const calcPriceAfterDiscount = product => { return product.price - product.discount } const findFinalPrice = (data, fallbackValue) => { if(!data || !data.products) return fallbackValue const discountedProduct = findFirstDiscounted(data.products) if(!discountedProduct) return fallbackValue return calcPriceAfterDiscount(discountedProduct) } findFinalPrice(data, "No data")
Комментарии к традиционному решению
Достоинства:- Атомарные логические единицы (
isProductWithDiscount
,findFirstDiscounted
иcalcPriceAfterDiscount
), - Логику защищена от невалидных данных.
- Cлишком много защитных проверок. (Защитное программирование (Defensive programming) является обязательным в любом отказоустойчивом программном обеспечении. Однако, в нашем коде 50% тела функции
findFinalPrice
— проверка на валидность данных. Это слишком много). fallbackValue
почти везде.
Почему нас волнуют эти улучшения?
Потому что данный код заставляет слишком в него вникать. Это негативно влияет на DX (Developer Experience) - уровень удовлетворенности разработчика от работы с кодом. Проанализируем код, чтобы прийти к лучшему решению. Части, которые мы стремимся улучшить, формируют паттерны (защита (defence) и откат (fallback)). Хорошо то, что эти части на самом деле цельные и атомарные. Мы должны иметь возможность абстрагировать этот паттерн в оболочку, которая могла бы обрабатывать эти крайние случаи вместо нас. Обертка позаботится о крайних случаях, а нам останется позаботиться только о бизнес-логике.Функтор Maybe
Как мы обсуждали ранее, нам нужна только обертка, которая абстрагируется от обработки данных. Итак, роль функтора Maybe
состоит в том, чтобы обернуть наши данные (потенциально невалидные данные) и обработать для нас крайние случаи.
Имплементация функтора Maybe
function Maybe(value) { const isNothing = () => { return value === null || value === undefined } const map = (fn) => { return isNothing() ? Maybe() : Maybe(fn(value)) } const getValueOrFallback = { return (fallbackValue) => isNothing() ? fallbackValue : value; } return { map, getValueOrFallback, }; }
Пояснения к имплементации
isNothing
проверяет валидно ли обернутое в функторMaybe
значениеmap
- интерфейс для преобразования обернутого значения, с помощью которого мы применяем функции с бизнес логикой к обернутому значению.map
возвращает новое значение в другом экземпляреMaybe
. Таким образом, мы можем сделать цепочку вызововmap
-.map().map().map...
.getValueOrFallback
возвращает обернутое значение или запасное значениеfallbackValue
.
Как использовать функтор Maybe
?
С валидными данными:
С невалидными данными:Maybe('Hello') .map(x => x.substring(1)) .getValueOrFallback('fallback') // 'ello'
Maybe(null) .map(x => x.substring(1)) // функция не будет запущена .getValueOrFallback('fallback') // 'fallback'
Maybe
обработал крайние случаи вместо нас и не запустил функцию с невалидными данными. Нам нужно лишь позаботиться о бизнес логике. Таким образом, мы внедрили улучшение, о котором говорили в традиционном решении. Внедрим это решение в задачу.
Решение задачи с функтором Maybe
const isProductWithDiscount = product => { return !isNaN(product.discount) } const findFirstDiscounted = products => { return products.find(isProductWithDiscount) } const calcPriceAfterDiscount = product => { return product.price - product.discount } Maybe(data) .map((x) => x.products) .map(findFirstDiscounted) .map(calcPriceAfterDiscount) .getValueOrFallback("No data")
Комментарии к решению с функтором Maybe
Мы смогли улучшить традиционное решение при помощи функтора Maybe
:
- мы не защищаем код сами, вместо нас это делает функтор
Maybe
, - мы указали
fallbackValue
только один раз.
Maybe
соответствует определению функтора? Функтор Maybe
это:
- обертка над любым значением из JavaScript типов,
- предоставляет интерфейс для преобразования - метод
map
, - подчиняется законам функтора.
Законы функторов
Закон идентичности (Identity law)
Если при выполнении операции преобразования, значения в функторе преобразовываются сами на себя, результатом будет немодифицированный функтор.const m1 = Maybe(value) const m2 = Maybe(value).map(v => v) // m1 и m2 эквивалентны
Закон композиции (Composition law)
Если две последовательные операции преобразования выполняются одна за другой с использованием двух функций, результат должен быть таким же, как и при одной операции отображения с одной функцией, что эквивалентно применению первой функции к результату второй.const m1 = Maybe(value).map(v => f(g(v))) const m2 = Maybe(value).map(v => g(v)).map(v => f(v)) // m1 и m2 эквивалентны
Зачем использовать функторы?
- Абстракция над применением функции,
- Усиление композиции функций,
- Уменьшение количества защитного кода (как в функторе
Maybe
), - Более чистая структура кода,
- Переменные более явно указывают на то, что мы ожидаем (что
Maybe
моделирует значение, которое может присутствовать, а может и не присутствовать).
Что означает Абстракция над применением функции?
То, что мы передаем функцию (т.е.x => x.products
) в интерфейс преобразования (т.е. map
) обертки (т.е. Maybe
), и она знает, как позаботиться о себе (посредством своей внутренней реализации).
Нас не интересуют детали реализации оболочки, которые она содержит (детали реализации скрыты), и тем не менее мы знаем, как использовать обертку (Array
или Promise
), используя их интерфейсы преобразования (map
).
И это на самом деле крайне важно в мире программирования. Это снижает планку того, как много мы, как программисты, должны понимать, чтобы иметь возможность что-то сделать. Функторы могут быть реализованы на любом языке, поддерживающем функции высшего порядка (а таких в наши дни большинство).
Почему функторы не используются повсеместно?
Просто потому, что мы к ним не привыкли. До.map
(и .then
) мы мутировали массивы или перебирали их значения вручную. Но как только мы обнаружили .map
, мы начали адаптировать его в качестве нового инструмента преобразования. Я надеюсь, что, поняв ценность функторов, мы начнем чаще внедрять их в наши ежедневные задачи как привычный инструмент.
Функтор Maybe
- лишь пример функтора. Существует множество функторов, которые выполняет различные задачи. В этой статье мы рассмотрели самый простой из них, чтобы понять саму идею функторов.