Это пятая часть серии Функциональный JS. Перейти к предыдущей части здесь или к началу серии здесь.

Вступление

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

Мы упоминали, что это станет полезным, как только мы узнаем о частичном применении и каррировании. Пришло время вникнуть в это.

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

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

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

О чем мы говорим?

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

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

Если это сбивает с толку или звучит как «правильно, это здорово, но действительно ли это полезно» - держитесь! Мы углубимся в это, но сначала давайте взглянем на примеры, чтобы понять, о чем мы говорим.

Частичное применение

Взгляните на функцию getApiURL и то, как мы ее используем в нескольких местах:

const getApiURL = (apiHostname, resourceName, resourceId) => {
return `https://${apiHostname}/api/${resourceName}/${resourceId}`
}
const getUserURL = userId => {
return getApiURL('localhost:3000', 'users', userId)
}
const getOrderURL = orderId => {
return getApiURL('localhost:3000', 'orders', orderId)
}
const getProductURL = productId => {
return getApiURL('localhost:3000', 'products', productId)
}

Как видите, здесь происходит довольно очевидное дублирование кода. getUserURL, getOrderURL и getProductURL все выглядят примерно одинаково, но не совсем очевидно, как это исправить. Давай попробуем:

const getApiURL = (apiHostname, resourceName, resourceId) => {
return `https://${apiHostname}/api/${resourceName}/${resourceId}`
}
const getResourceURL = (resourceName, resourceId) => {
return getApiURL('localhost:3000', resourceName, resourceId)
}
const getUserURL = userId => {
return getResourceURL('users', userId)
}
const getOrderURL = orderId => {
return getResourceURL('orders', orderId)
}
const getProductURL = productId => {
return getResourceURL('products', productId)
}

Так-то лучше. Здесь мы извлекли общую часть getUserURL, getOrderURL и getProductURL в функцию getResourceURL. Если подумать, общая часть - это вызов getApiURL и передача ему https://localhost:3000 в качестве первого аргумента.

Таким образом, мы использовали более общую getApiURL функцию для создания более специализированной getResourceURL, которая является оболочкой. Его задача - предоставить getApiURL первый аргумент. При последующих вызовах getResourceURL не нужно больше беспокоиться о https://localhost:3000 части - о ней позаботятся.

Здесь мы частично применили функцию getApiURL и создали вокруг нее функцию-оболочку getResourceURL.

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

const partial = (fn, ...argsToApply) => {
return (...restArgsToApply) => {
return fn(...argsToApply, ...restArgsToApply)
}
}
const getApiURL = (apiHostname, resourceName, resourceId) => {
return `https://${apiHostname}/api/${resourceName}/${resourceId}`
}
const getResourceURL = partial(getApiURL, 'localhost:3000')
const getUserURL = userId => {
return getResourceURL('users', userId)
}
const getOrderURL = orderId => {
return getResourceURL('orders', orderId)
}
const getProductURL = productId => {
return getResourceURL('products', productId)
}

Найдите минутку, чтобы понять, как partial работает.

Посмотрите, как мы используем закрытия и оставим argsToApply доступным к моменту вызова fn(...argsToApply, ...restArgs)?

Сделав еще один шаг, мы могли бы частично применить getResourceURL для создания getUserURL и других вариантов:

const partial = (fn, ...argsToApply) => {
return (...restArgsToApply) => {
return fn(...argsToApply, ...restArgsToApply)
}
}
const getApiURL = (apiHostname, resourceName, resourceId) => {
return `https://${apiHostname}/api/${resourceName}/${resourceId}`
}
const getResourceURL = partial(getApiURL, 'localhost:3000')
const getUserURL = partial(getResourceURL, 'users')
const getOrderURL = partial(getResourceURL, 'orders')
const getProductURL = partial(getResourceURL, 'products')

Начинает хорошо выглядеть!

Интересно, что есть метод, который можно использовать точно так же, как partial, и он встроен в сам язык! Возможно, вы даже использовали его. Это называется bind. Он связывает функцию не только с ее this контекстом (более популярный вариант использования), но и с ее первыми аргументами. Давайте взглянем:

const getApiURL = (apiHostname, resourceName, resourceId) => {
return `https://${apiHostname}/api/${resourceName}/${resourceId}`
}
const getResourceURL = getApiURL.bind(null, 'localhost:3000')
const getUserURL = getResourceURL.bind(null, 'users')
const getOrderURL = getResourceURL.bind(null, 'orders')
const getProductURL = getResourceURL.bind(null, 'products')

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

const getApiURL = (apiHostname, resourceName, resourceId) => {
return `https://${apiHostname}/api/${resourceName}/${resourceId}`
}
const partial = (fn, ...argsToApply) => {
return (...restArgsToApply) => {
return fn(...argsToApply, ...restArgsToApply)
}
}
const getUserURL = partial(getApiURL, 'localhost:3000', 'users')
const getOrderURL = partial(getApiURL, 'localhost:3000', 'orders')
const getProductURL = partial(getApiURL, 'localhost:3000', 'products')
// Alternatively...
const getUserURL = getApiURL.bind(null, 'localhost:3000', 'users')
const getOrderURL = getApiURL.bind(null, 'localhost:3000', 'orders')
const getProductURL = getApiURL.bind(null, 'localhost:3000', 'products')

Каррирование

Каррирование - это техника, в чем-то похожая на частичное применение. Это также позволяет нам «исправить» некоторые параметры функции и вернуть функцию, которая «принимает» остальные.

Разница в том, что при каррировании мы передаем аргументы функции по одному.

Давайте посмотрим на пример:

const getApiURL = (apiHostname, resourceName, resourceId) => {
return `https://${apiHostname}/api/${resourceName}/${resourceId}`
}
// Curried version
const getApiURLCurried = apiHostname => {
return resourceName => {
return resourceId => {
return `https://${apiHostname}/api/${resourceName}/${resourceId}`
}
}
}
const getUserURL = getApiURLCurried('localhost:3000')('users')
const getOrderURL = getApiURLCurried('localhost:3000')('orders')
const getProductURL = getApiURLCurried('localhost:3000')('products')
const firstUserURL = getUserURL(1)

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

Чтобы использовать каррированную версию getApiURL, нам нужно предоставить аргументы по одному: сначала apiHostname, затем resourceName функции, которую она возвращает, а затем, наконец, resourceId еще одной внутренней функции.

Мы можем писать каррированные функции более кратко:

const getApiURL = (apiHostname, resourceName, resourceId) => {
return `https://${apiHostname}/api/${resourceName}/${resourceId}`
}
// Curried succint version
const getApiURLCurried = apiHostname => resourceName => resourceId => {
return `https://${apiHostname}/api/${resourceName}/${resourceId}`
}

Что интересно, эта форма не намного более подробна, чем исходная версия.

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

Вот как можно использовать утилиту curry (здесь из ramda библиотеки):

import R from 'ramda'
const getApiURL = (apiHostname, resourceName, resourceId) => {
return `https://${apiHostname}/api/${resourceName}/${resourceId}`
}
const getApiURLCurried = R.curry(getApiURL)
const getUserURL = getApiURLCurried('localhost:3000')('users')

Опять же, какая разница?

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

Разница в том, что:

  1. Частичное приложение - это более или менее образец вызова функции. Вы можете частично применить любую функцию.
    Каррирование - это больше о форме функции. Чтобы иметь возможность использовать каррирование, вы должны явно создать новую функцию, которая является каррированной версией исходной.
  2. Допустим, функция foo принимает N = 5 аргументов. Частичное приложение позволяет нам вызывать его с K аргументами и возвращать функцию, которая принимает N - K аргументов. Например, если мы вызовем foo с K = 2 аргументами, мы получим функцию, которая принимает 3 аргумента. Каррирование, с другой стороны, преобразует foo во вложенную цепочку функций, каждая из которых принимает 1 аргумент. Вызов каррированной версии foo с первым аргументом вернет функцию, которая принимает второй аргумент, и возвращает функцию, которая принимает третий аргумент, и так далее ...
  3. Как следствие вышесказанного, каррирование отличается от частичного применения тем, что происходит, если вы действительно хотите применить аргументы один за другим. С каррированными функциями вы получаете эту опцию прямо из коробки - нет необходимости использовать curry утилиту для данной функции более одного раза. Если вы хотите применить еще один аргумент (но не все из них) к частично примененной функции, вам нужно снова частично применить его (используя partial).

Но почему?

Использование частичного приложения и каррирования в коде приложения дает несколько преимуществ.

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

Но что это значит? Давайте рассмотрим несколько конкретных примеров, иллюстрирующих преимущества.

Разделение проблем

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

// Path: src/network_utils.js
// A curried version of the getApiURL
export const getApiURL = apiHostname => resourceName => resourceId => {
return `https://${apiHostname}/api/${resourceName}/${resourceId}`
}
// Path: src/api.js
import { getApiURL } from './network_utils'
export const getResourceAPI = getApiURL('localhost:3000')
// Path: src/api/users.js
import { getResourceAPI } from '../api'
export const getUserURL = getResourceAPI('users')
// Path: src/repositories/users.js
import axios from 'axios'
import { getUserURL } from '../api/users'
export const getUserEmail = id => axios.get(getUserURL(id)).then(({data: {user}) => user.email)

Этот пример может быть немного надуманным. Однако я пытаюсь подчеркнуть, что иногда лучше, чтобы разные «слои» вашего кода отвечали за внесение определенных параметров (которые лучше всего соответствуют их уровню абстракции) в более сложную часть бизнес-логики.

Читаемость

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

// Version 1: A regular `add` function
const add = (x, y) => x + y
const result = [1, 2, 3].map(number => add(number, 5))
// Version 2: Partially applied `add`
const add = (x, y) => x + y
const add5 = partial(add, 5)
const result = [1, 2, 3].map(add5)
// Version 3: Curried version of `add5`
const add = x => y => x + y
const result = [1, 2, 3].map(add(5))

Для меня использование каррированной версии add, безусловно, наиболее элегантно. Что вы думаете?

Сочетаемость

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

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

Составление функций заключается в передаче результата функции непосредственно в качестве аргумента другой функции, например:

const increment = x => x + 1
const double = x => x * 2
const composedOperation = x => increment(double(x))

Этому примеру довольно легко следовать, но в основном потому, что и increment, и double являются унарными функциями (т.е. они принимают только 1 аргумент). Мы могли бы использовать такую ​​же композицию с любой каррированной функцией - потому что все они тоже унарные.

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

Не волнуйтесь, если вы еще не понимаете, как все это сочетается друг с другом. Мы вернемся к этому в следующих частях.

Попался!

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

Что ж, мы могли бы… :) В Haskell, например, автоматическое каррирование встроено в сам язык (ссылка на Haskell здесь не удивительна, не так ли?).

Очевидно, есть некоторые компромиссы, которые следует учитывать, прежде чем приступать к каррингу, - кроме того, что ваши коллеги ненавидят вас за то, что вы пишете код, который они не понимают.

Они выглядят как-то странно

Если вы не привыкли к этому стилю программирования, эти методы сделают определение или место вызова функции - или и то, и другое, немного неудобным («зачем вам здесь 5 пар круглых скобок ?!»).

Давайте сравним одну и ту же функциональность, написанную с использованием разных стилей:

// Definition
// - Traditional, but also usable with partial application
const getApiURLT = (apiHostname, resourceName, resourceId) => {
return `https://${apiHostname}/api/${resourceName}/${resourceId}`
}
// - Curried version
const getApiURLC = apiHostname => resourceName => resourceId => {
return `https://${apiHostname}/api/${resourceName}/${resourceId}`
}
// Call site – passing all arguments at once
// - Traditional
getApiURLT('localhost:3000', 'users', 1)
// - Curried
getApiURLC('localhost:3000')('users')(1)
// Call site - passing a portion of the arguments
// - Traditional
const getUserByIdT = id => getApiURLT('localhost:3000', 'users', id)
getUserByIdT(1)
// - Partial application
const getUserByIdP = partial(getApiURLT, 'localhost:3000', 'users')
getUserByIdP(1)
// - Currying
const getUserByIdC = getApiURLC('localhost:3000')('users')
getUserByIdC(1)

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

Порядок аргументов

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

const getApiURL = (apiHostname, resourceName, resourceId) => {
return `https://${apiHostname}/api/${resourceName}/${resourceId}`
}
const firstAPIUser = apiHostname => {
return `https://${apiHostname}/api/users/1`
}
firstAPIUser('localhost:3000')

Если бы apiHostname был последним параметром исходной функции getApiURL, мы могли бы легко частично применить как resourceName, так и resourceId и получить функцию, которая принимала бы apiHostname как свой («последний») параметр. Поскольку наше намерение не совсем соответствует сигнатуре функции, проще создать firstAPIUser функцию-оболочку.

Необязательные аргументы

Еще одна вещь, которая не очень интуитивно понятна для моделирования с частичным применением и каррированием, - это необязательные параметры. Давайте рассмотрим следующий пример каррирования с использованием синтаксиса параметров по умолчанию ES6:

// Traditional declaration
const getApiURL = (apiHostname, resourceName = 'users', resourceId = 1) => {
return `https://${apiHostname}/api/${resourceName}/${resourceId}`
}
getApiURL('localhost:3000', 'orders', 2)
getApiURL('localhost:3000', 'orders')
getApiURL('localhost:3000')
// Curried declaration
const getApiURLCurried = apiHostname => (resourceName = 'users') => (resourceId = 1) => {
return `https://${apiHostname}/api/${resourceName}/${resourceId}`
}
getApiURLCurried('localhost:3000')('orders')(2)
getApiURLCurried('localhost:3000')('orders') // This doesn't work as intended, we need to:
getApiURLCurried('localhost:3000')('orders')()
getApiURLCurried('localhost:3000') // This doesn't work as intended, we need to:
getApiURLCurried('localhost:3000')()()

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

Вводящие в заблуждение сообщения об ошибках

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

// Traditional declaration
const getApiURL = (apiHostname, resourceName, resourceId) => {
return `https://${apiHostname}/api/${resourceName}/${resourceId}`
}
getApiURL('localhost:3000', 'orders') // We forgot one argument, so this returns "https://localhost:3000/api/orders/undefined"
getApiURL('localhost:3000', 'orders').startsWith('https') // This still works – returns true
// Curried declaration
const getApiURLCurried = apiHostname => (resourceName = 'users') => (resourceId = 1) => {
return `https://${apiHostname}/api/${resourceName}/${resourceId}`
}
getApiURLCurried('localhost:3000')('orders') // We forget one argument, so this returns a function
getApiURLCurried('localhost:3000')('orders').startsWith('https') // We get an error: "Uncaught TypeError: getApiURLCurried(...)(...).startsWith is not a function"

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

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

Резюме

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

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

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

До встречи в следующей части!