Пошук уроків, статей та іншого контенту
Познайомтеся з каруванням — перетворенням функції з кількома параметрами на послідовність функцій, кожна з яких приймає один аргумент. Розберіть практичне застосування цього підходу у функціональному програмуванні.
Карування (англ. currying) — це перетворення функції, яка приймає кілька аргументів одночасно, на послідовність функцій, кожна з яких приймає один аргумент.
Наприклад, функцію:
const add = (a, b, c) => a + b + c;можна представити у карованій формі:
const curriedAdd = (a) => (b) => (c) => a + b + c;Тепер аргументи передаються по одному:
curriedAdd(1)(2)(3); // 6Карування не змінює логіку обчислення. Воно змінює спосіб передавання аргументів і дає змогу створювати спеціалізовані функції на основі вже отриманих значень.
Ці поняття пов’язані, але не є тотожними.
Карування перетворює функцію з кількома аргументами на ланцюжок унарних функцій:
const multiply = (a, b, c) => a * b * c;
const curriedMultiply = (a) => (b) => (c) => a * b * c;
curriedMultiply(2)(3)(4); // 24Часткове застосування фіксує частину аргументів і повертає функцію, яка очікує решту:
const multiply = (a, b, c) => a * b * c;
const multiplyByTwo = (b, c) => multiply(2, b, c);
multiplyByTwo(3, 4); // 24Карована функція приймає аргументи послідовно. Частково застосована функція може приймати решту аргументів одночасно.
На практиці бібліотеки часто поєднують ці підходи: реалізують карування, яке дозволяє передавати аргументи як по одному, так і групами.
Карування можна написати вручну для конкретної функції:
const formatPrice = (currency) => (locale) => (amount) => {
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
}).format(amount);
};
const formatInUah = formatPrice("UAH");
const formatInUahForUkraine = formatInUah("uk-UA");
console.log(formatInUahForUkraine(1250.5));
// 1 250,50 грнПісля фіксації валюти та локалі ми отримали функцію, яка відповідає лише за форматування суми:
console.log(formatInUahForUkraine(99.99));
console.log(formatInUahForUkraine(2500));Це корисно, коли певні параметри повторюються в багатьох місцях програми.
curryЩоб не створювати вкладені функції вручну для кожної операції, можна написати універсальну реалізацію.
function curry(fn) {
return function curried(...args) {
if (args.length >= fn.length) {
return fn(...args);
}
return (...nextArgs) => curried(...args, ...nextArgs);
};
}
function calculateTotal(taxRate, discount, price) {
const priceWithTax = price * (1 + taxRate);
return priceWithTax * (1 - discount);
}
const curriedCalculateTotal = curry(calculateTotal);
console.log(curriedCalculateTotal(0.2)(0.1)(100)); // 108
console.log(curriedCalculateTotal(0.2, 0.1)(100)); // 108
console.log(curriedCalculateTotal(0.2)(0.1, 100)); // 108У цьому прикладі:
fn.length визначає кількість параметрів, яку очікує функція.
Якщо аргументів уже достатньо, початкова функція викликається.
Якщо аргументів недостатньо, повертається нова функція.
Нова функція накопичує вже отримані та наступні аргументи.
Така реалізація підтримує як поетапне передавання аргументів, так і групове.
Властивість length функції не завжди надійно описує кількість необхідних аргументів:
const fn = (a, b = 10, c) => a + b + c;
console.log(fn.length); // 1Параметри зі значеннями за замовчуванням не враховуються після першого такого параметра. Функція з rest-параметром також має неочікуване значення length:
const sum = (...numbers) => {
return numbers.reduce((total, number) => total + number, 0);
};
console.log(sum.length); // 0Тому універсальний curry, який покладається лише на fn.length, підходить переважно для функцій із фіксованою кількістю звичайних параметрів.
Для функцій зі змінною кількістю аргументів кількість параметрів краще передавати явно:
function curryN(arity, fn) {
return function curried(...args) {
if (args.length >= arity) {
return fn(...args);
}
return (...nextArgs) => curried(...args, ...nextArgs);
};
}
const sumThree = curryN(
3,
(a, b, c) => a + b + c,
);
console.log(sumThree(1)(2)(3)); // 6Карування особливо зручне разом із композицією функцій. Розглянемо кілька невеликих функцій для обробки даних:
const map = curry((callback, items) => items.map(callback));
const filter = curry((predicate, items) => items.filter(predicate));
const greaterThan = curry((minimum, value) => value > minimum);
const multiplyBy = curry((factor, value) => factor * value);
const numbers = [1, 2, 3, 4, 5, 6];
const greaterThanThree = filter(greaterThan(3));
const doubled = map(multiplyBy(2));
const result = doubled(greaterThanThree(numbers));
console.log(result); // [8, 10, 12]Зверніть увагу на порядок параметрів:
const filter = curry((predicate, items) => items.filter(predicate));Спочатку передається predicate, а потім колекція. Це дає змогу заздалегідь створити спеціалізовану функцію:
const greaterThanThree = filter(greaterThan(3));Якби колекція була першим параметром, таке застосування було б менш зручним.
Композиція дозволяє об’єднати кілька функцій у конвеєр. Функції виконуються справа наліво у compose або зліва направо у pipe.
function pipe(...functions) {
return (initialValue) => {
return functions.reduce(
(value, fn) => fn(value),
initialValue,
);
};
}
const map = curry((callback, items) => items.map(callback));
const filter = curry((predicate, items) => items.filter(predicate));
const greaterThan = curry((minimum, value) => value > minimum);
const multiplyBy = curry((factor, value) => factor * value);
const processNumbers = pipe(
filter(greaterThan(2)),
map(multiplyBy(10)),
);
console.log(processNumbers([1, 2, 3, 4])); // [30, 40]filter(greaterThan(2)) і map(multiplyBy(10)) — це вже готові функції, які очікують лише масив.
Такий стиль поділяє логіку на невеликі незалежні операції:
greaterThan(2) створює предикат;
filter(...) відбирає елементи;
multiplyBy(10) створює перетворення;
map(...) застосовує перетворення до масиву;
pipe(...) об’єднує кроки.
thisКарування саме по собі не зберігає контекст this. Це важливо під час карування методів об’єктів.
const calculator = {
base: 10,
add(value, multiplier) {
return (this.base + value) * multiplier;
},
};
const curriedAdd = curry(calculator.add);
// Значення this втрачається під час передавання методу окремо.
console.log(curriedAdd(5)(2)); // NaNМетод було передано без об’єкта-власника, тому this більше не посилається на calculator.
Можна зафіксувати контекст за допомогою bind:
const boundAdd = curry(calculator.add.bind(calculator));
console.log(boundAdd(5)(2)); // 30Або написати функцію так, щоб вона не залежала від this:
const addToBase = curry((base, value, multiplier) => {
return (base + value) * multiplier;
});
console.log(addToBase(10)(5)(2)); // 30Для функціонального стилю другий підхід зазвичай простіший: усі залежності передаються явно через аргументи.
Іноді потрібно зафіксувати не перший аргумент, а, наприклад, другий. Звичайний карринг не підтримує пропуски:
const createUrl = (protocol, host, path) => {
return `${protocol}://${host}/${path}`;
};
const curriedCreateUrl = curry(createUrl);
// Тут "example.com" стане protocol, а не host.Для такого сценарію можна додати спеціальний маркер-пропуск.
const __ = Symbol("placeholder");
function curryWithPlaceholder(fn, arity = fn.length) {
function curried(...receivedArgs) {
const hasEnoughArguments =
receivedArgs.length >= arity &&
receivedArgs.slice(0, arity).every((argument) => argument !== __);
if (hasEnoughArguments) {
return fn(...receivedArgs);
}
return (...nextArgs) => {
const mergedArgs = [...receivedArgs];
let nextIndex = 0;
for (let index = 0; index < mergedArgs.length; index += 1) {
if (mergedArgs[index] === __ && nextIndex < nextArgs.length) {
mergedArgs[index] = nextArgs[nextIndex];
nextIndex += 1;
}
}
while (nextIndex < nextArgs.length) {
mergedArgs.push(nextArgs[nextIndex]);
nextIndex += 1;
}
return curried(...mergedArgs);
};
}
return curried;
}
const createUrl = (protocol, host, path) => {
return `${protocol}://${host}/${path}`;
};
const curriedCreateUrl = curryWithPlaceholder(createUrl);
const createExampleUrl = curriedCreateUrl("https", __, "docs");
console.log(createExampleUrl("example.com"));
// https://example.com/docsРеалізація з пропусками складніша:
потрібно зберігати позиції незаповнених аргументів;
потрібно вирішувати, як обробляти кілька пропусків;
потрібно визначити поведінку, коли нові аргументи також містять пропуски;
потрібно контролювати кількість аргументів після заповнення.
Тому в прикладному коді не варто додавати таку функціональність без реальної потреби.
Карування найкраще працює з чистими функціями — функціями, які:
для однакових аргументів повертають однаковий результат;
не змінюють зовнішній стан;
не виконують неочікуваних побічних ефектів.
Наприклад:
const createLogger = curry((prefix, message) => {
console.log(`[${prefix}] ${message}`);
});
const logError = createLogger("ERROR");
logError("Не вдалося завантажити дані");
logError("Недійсний формат відповіді");Сам виклик createLogger("ERROR") ще не виводить повідомлення. Він лише створює функцію з наперед визначеним префіксом.
Водночас карування не робить функцію чистою автоматично:
let counter = 0;
const increment = curry((step, value) => {
counter += 1;
return value + step;
});Тут функція змінює зовнішню змінну counter, тому результат її роботи має побічний ефект.
Карувати можна й функції, які повертають Promise. Карування відбувається до або незалежно від очікування результату:
const curry = (fn) => {
return function curried(...args) {
if (args.length >= fn.length) {
return fn(...args);
}
return (...nextArgs) => curried(...args, ...nextArgs);
};
};
const fetchJson = curry(async (baseUrl, endpoint, options) => {
const response = await fetch(`${baseUrl}${endpoint}`, options);
if (!response.ok) {
throw new Error(`HTTP-помилка: ${response.status}`);
}
return response.json();
});
const fetchFromApi = fetchJson("https://api.example.com");
async function loadUser() {
try {
const user = await fetchFromApi("/users/42", {
method: "GET",
});
console.log(user);
} catch (error) {
console.error(error.message);
}
}
loadUser();У реальному застосунку базову URL-адресу можна налаштувати один раз, а потім повторно використовувати створену функцію.
Важливо: у прикладі виконується реальний HTTP-запит, тому він залежить від доступності вказаного сервера. Сам механізм карування від цього не змінюється.
Конструктори класів не варто карувати так само, як звичайні функції. Виклик конструктора потребує new, а універсальний curry зазвичай викликає функцію через fn(...args).
Замість цього можна карувати фабричну функцію:
class User {
constructor(role, name) {
this.role = role;
this.name = name;
}
}
const createUser = curry((role, name) => {
return new User(role, name);
});
const createAdmin = createUser("admin");
console.log(createAdmin("Olena"));
// User { role: 'admin', name: 'Olena' }Фабричні функції добре поєднуються з каруванням і не створюють проблем із контекстом конструктора.
fn.lengthУніверсальна функція curry може мати неоднозначну поведінку в таких випадках:
const greet = (name, greeting = "Привіт") => {
return `${greeting}, ${name}!`;
};
console.log(greet.length); // 1Для curry(greet) функція вважатиметься такою, що має один параметр, хоча логічно вона підтримує два.
const join = (...parts) => parts.join("-");
console.log(join.length); // 0Карування через fn.length одразу вважатиме таку функцію готовою до виклику.
Якщо параметр справді необов’язковий, незрозуміло, коли карована функція має завершити накопичення аргументів.
Для таких функцій краще:
використовувати явну арність через curryN;
створювати спеціалізовані обгортки вручну;
не карувати функцію, якщо це ускладнює її використання.
const add = (a) => (b) => a + b;
const addFive = add(5); // функція
const result = addFive(3); // числоadd(5) ще не повертає результат додавання. Воно повертає нову функцію.
Порядок параметрів має значення:
const hasRole = curry((role, user) => user.role === role);
const isAdmin = hasRole("admin");Такий порядок зручний, бо стабільний параметр role передається першим.
thisПередавання методу окремо від об’єкта може змінити його контекст. Для методів використовуйте bind або перепишіть функцію з явними параметрами.
Карування не потрібно застосовувати до кожної функції. Надмірна кількість вкладених викликів може ускладнити читання:
const result = fn(a)(b)(c)(d)(e);Якщо така форма не дає практичної користі, звичайний виклик може бути зрозумілішим:
const result = fn(a, b, c, d, e);Функції з rest-параметрами або параметрами за замовчуванням потребують явного визначення моменту завершення накопичення аргументів.
Карування добре підходить, коли:
частина параметрів стабільна в межах певного модуля;
потрібно створити набір спеціалізованих функцій;
функції об’єднуються в конвеєри через pipe або compose;
обробку даних можна розділити на маленькі чисті операції;
потрібно передавати налаштування окремо від основних даних.
Приклад створення спеціалізованих валідаторів:
const curry = (fn) => {
return function curried(...args) {
if (args.length >= fn.length) {
return fn(...args);
}
return (...nextArgs) => curried(...args, ...nextArgs);
};
};
const minLength = curry((length, value) => {
return value.length >= length;
});
const hasAtLeastEightCharacters = minLength(8);
console.log(hasAtLeastEightCharacters("JavaScript")); // true
console.log(hasAtLeastEightCharacters("JS")); // falseОдна загальна функція minLength породжує конкретний валідатор hasAtLeastEightCharacters.
Карування перетворює функцію з кількома параметрами на послідовність функцій з одним параметром.
Карована функція може зберігати отримані аргументи та повертати нову функцію.
Часткове застосування фіксує частину аргументів, але не обов’язково створює ланцюжок унарних функцій.
Карування зручне для створення спеціалізованих функцій і побудови конвеєрів обробки даних.
Універсальна реалізація часто використовує fn.length, але це має обмеження для параметрів за замовчуванням і rest-параметрів.
Під час карування методів потрібно враховувати контекст this.
Карування не робить функцію чистою автоматично.
Використовуйте карування там, де воно покращує повторне використання та композицію, а не просто заради функціонального стилю.