Пошук уроків, статей та іншого контенту
Дізнайтеся, як поєднувати невеликі функції так, щоб результат однієї ставав вхідними даними для іншої. Розберіть принцип композиції як спосіб створення складної логіки з простих незалежних функцій.
Функціональна композиція — це побудова нової функції з кількох менших функцій. Результат однієї функції передається як аргумент наступній:
[ f(g(x)) ]
Тут спочатку виконується g, а потім її результат передається у f.
Композиція допомагає:
розділяти складну логіку на незалежні частини;
повторно використовувати функції;
зменшувати кількість вкладених викликів;
будувати зрозумілі конвеєри обробки даних;
легше тестувати окремі кроки.
Наприклад, замість однієї великої функції можна описати окремі операції:
const trim = value => value.trim();
const toLowerCase = value => value.toLowerCase();
const addExclamationMark = value => `${value}!`;
const normalizeMessage = value =>
addExclamationMark(toLowerCase(trim(value)));
console.log(normalizeMessage(" HELLO "));
// hello!Функція normalizeMessage працює правильно, але вкладені виклики стають менш зручними для читання, якщо кроків стає більше.
У класичному математичному записі композиція читається справа наліво:
f(g(h(value)))Порядок виконання:
h(value);
g(...);
f(...).
Можна створити універсальну функцію compose, яка приймає кілька функцій і повертає нову:
const compose = (...functions) => value =>
functions.reduceRight((result, currentFunction) => {
return currentFunction(result);
}, value);
const trim = value => value.trim();
const toLowerCase = value => value.toLowerCase();
const addExclamationMark = value => `${value}!`;
const normalizeMessage = compose(
addExclamationMark,
toLowerCase,
trim
);
console.log(normalizeMessage(" HELLO "));
// hello!Виклик:
compose(addExclamationMark, toLowerCase, trim)(" HELLO ");еквівалентний такому виразу:
addExclamationMark(
toLowerCase(
trim(" HELLO ")
)
);reduceRightМетод reduceRight перебирає масив справа наліво:
const functions = [addExclamationMark, toLowerCase, trim];
const result = functions.reduceRight(
(value, currentFunction) => currentFunction(value),
" HELLO "
);Порядок викликів буде таким:
trim
toLowerCase
addExclamationMarkЦе відповідає математичному порядку композиції.
pipeУ прикладному коді часто зручніше читати операції в тому самому порядку, у якому вони виконуються. Для цього використовують підхід pipe:
const pipe = (...functions) => value =>
functions.reduce((result, currentFunction) => {
return currentFunction(result);
}, value);
const trim = value => value.trim();
const toLowerCase = value => value.toLowerCase();
const addExclamationMark = value => `${value}!`;
const normalizeMessage = pipe(
trim,
toLowerCase,
addExclamationMark
);
console.log(normalizeMessage(" HELLO "));
// hello!Тут порядок очевидний:
обрізати пробіли;
перевести рядок у нижній регістр;
додати знак оклику.
compose і pipe виконують схожу роль, але мають різний напрямок:
const composed = compose(f, g, h);
// h -> g -> f
const piped = pipe(h, g, f);
// h -> g -> fВибір між ними залежить від стилю проєкту. Для послідовних перетворень даних часто зручніший pipe.
У найпростішому випадку кожна функція приймає одне значення і повертає значення сумісного типу:
const double = number => number * 2;
const increment = number => number + 1;
const toText = number => String(number);
const transform = pipe(
double,
increment,
toText
);
console.log(transform(10));
// "21"Типи можна описати так:
double: number -> number
increment: number -> number
toText: number -> stringВажливо, щоб вихід попередньої функції відповідав входу наступної:
const getLength = value => value.length;
const double = value => value * 2;
const calculate = pipe(
getLength,
double
);
console.log(calculate("hello"));
// 10У цьому прикладі:
getLength: string -> number
double: number -> numberКомпозиція коректна, оскільки результат getLength можна передати в double.
Зазвичай композиція передає результат одного кроку як єдиний аргумент наступного. Тому функції з кількома параметрами часто потрібно адаптувати.
Наприклад, функція Math.max може приймати кілька чисел:
Math.max(4, 8, 2);Але для композиції зручно створити функцію, яка приймає масив:
const maxFromArray = numbers => Math.max(...numbers);
const double = number => number * 2;
const calculate = pipe(
maxFromArray,
double
);
console.log(calculate([4, 8, 2]));
// 16Інший варіант — використати замикання, щоб заздалегідь зафіксувати частину аргументів:
const multiply = multiplier => value => value * multiplier;
const multiplyByThree = multiply(3);
console.log(multiplyByThree(5));
// 15Така техніка називається частковим застосуванням: із загальної функції створюється спеціалізована функція.
Композиція особливо корисна під час обробки масивів об’єктів.
const users = [
{ name: "Anna", age: 29, active: true },
{ name: "Oleh", age: 17, active: true },
{ name: "Iryna", age: 34, active: false },
{ name: "Mark", age: 41, active: true }
];
const filterActive = users =>
users.filter(user => user.active);
const filterAdults = users =>
users.filter(user => user.age >= 18);
const getNames = users =>
users.map(user => user.name);
const sortAlphabetically = names =>
[...names].sort((first, second) =>
first.localeCompare(second)
);
const getActiveAdultNames = pipe(
filterActive,
filterAdults,
getNames,
sortAlphabetically
);
console.log(getActiveAdultNames(users));
// [ "Anna", "Mark" ]Кожна функція має одну відповідальність:
filterActive залишає активних користувачів;
filterAdults залишає повнолітніх;
getNames дістає імена;
sortAlphabetically сортує результат.
Завдяки цьому окремі кроки можна тестувати незалежно.
Функціональний стиль зазвичай передбачає, що функції не змінюють вхідні значення. Замість мутації вони повертають нові значення.
Небажаний варіант:
const sortNames = users => {
users.sort((first, second) =>
first.name.localeCompare(second.name)
);
return users;
};Метод sort змінює початковий масив. Це може створити приховані побічні ефекти.
Безпечніший варіант:
const sortNames = users =>
[...users].sort((first, second) =>
first.name.localeCompare(second.name)
);Оператор розгортання створює копію масиву, тому початкове значення не змінюється.
Повністю уникати мутацій особливо важливо, коли:
один і той самий об’єкт використовується в кількох частинах програми;
значення зберігаються у стані інтерфейсу;
функції викликаються повторно;
результат кешується;
код виконується паралельно або асинхронно.
Чиста функція:
для однакових аргументів повертає однаковий результат;
не змінює зовнішній стан;
не виконує прихованих операцій.
Наприклад:
const add = (first, second) => first + second;Натомість ця функція має побічний ефект:
const logValue = value => {
console.log(value);
return value;
};Її можна використовувати для діагностики конвеєра:
const tap = callback => value => {
callback(value);
return value;
};
const processName = pipe(
value => value.trim(),
tap(value => console.log("Після trim:", value)),
value => value.toLowerCase(),
tap(value => console.log("Після toLowerCase:", value)),
value => value[0].toUpperCase() + value.slice(1)
);
console.log(processName(" aNNA "));tap виконує побічну дію, але повертає те саме значення. Через це вона не змінює структуру конвеєра.
Такий підхід корисний для журналювання, але ним не варто зловживати. Основну бізнес-логіку краще залишати чистою, а побічні ефекти — розміщувати на межах системи.
Звичайний pipe не підходить для функцій, які повертають проміси, якщо він не очікує результат попередньої операції.
Розглянемо асинхронний конвеєр:
const pipeAsync = (...functions) => initialValue =>
functions.reduce(
(promise, currentFunction) =>
promise.then(currentFunction),
Promise.resolve(initialValue)
);
const getUser = async userId => ({
id: userId,
name: "Anna",
role: "admin"
});
const getUserName = user => user.name;
const createGreeting = name =>
`Вітаємо, ${name}!`;
const loadGreeting = pipeAsync(
getUser,
getUserName,
createGreeting
);
loadGreeting(42)
.then(console.log);
// Вітаємо, Anna!Кожна наступна функція очікує завершення попередньої:
Promise<user>
-> Promise<name>
-> Promise<string>Але на практиці асинхронний конвеєр часто простіше читати через async/await:
const loadGreeting = async userId => {
const user = await getUser(userId);
const name = getUserName(user);
return createGreeting(name);
};
loadGreeting(42).then(console.log);pipeAsync корисний, коли конвеєр формується динамічно або використовується багато разів. async/await часто краще підходить для невеликої кількості кроків і складнішої обробки помилок.
Помилки промісів передаються по ланцюжку до найближчого catch:
const pipeAsync = (...functions) => initialValue =>
functions.reduce(
(promise, currentFunction) =>
promise.then(currentFunction),
Promise.resolve(initialValue)
);
const parsePositiveNumber = value => {
const number = Number(value);
if (!Number.isFinite(number) || number <= 0) {
throw new Error("Очікується додатне число");
}
return number;
};
const fetchValue = async number => number * 10;
const process = pipeAsync(
parsePositiveNumber,
fetchValue
);
process("5")
.then(result => console.log(result))
.catch(error => {
console.error("Помилка:", error.message);
});Синхронне виключення, створене в parsePositiveNumber, також перетворюється на відхилений проміс усередині ланцюжка.
Карування перетворює функцію з кількома аргументами на послідовність функцій з одним аргументом.
Було:
const add = (first, second) => first + second;Стало:
const curriedAdd = first => second => first + second;
console.log(curriedAdd(2)(3));
// 5Карування зручно поєднувати з композицією:
const multiply = first => second => first * second;
const add = first => second => first + second;
const addFive = add(5);
const multiplyByTwo = multiply(2);
const calculate = pipe(
addFive,
multiplyByTwo
);
console.log(calculate(10));
// 30Значення обробляється так:
10 + 5 = 15
15 * 2 = 30Карування також дозволяє створювати спеціалізовані функції:
const hasRole = requiredRole => user =>
user.role === requiredRole;
const isAdmin = hasRole("admin");
const users = [
{ name: "Anna", role: "admin" },
{ name: "Oleh", role: "editor" }
];
console.log(users.filter(isAdmin));
// [ { name: "Anna", role: "admin" } ]Предикат — це функція, яка повертає логічне значення. Предикати можна комбінувати в нові умови.
const and = (...predicates) => value =>
predicates.every(predicate => predicate(value));
const or = (...predicates) => value =>
predicates.some(predicate => predicate(value));
const isAdult = user => user.age >= 18;
const isActive = user => user.active;
const isAdmin = user => user.role === "admin";
const canAccessDashboard = and(
isAdult,
isActive,
or(isAdmin, user => user.role === "manager")
);
const users = [
{ name: "Anna", age: 29, active: true, role: "admin" },
{ name: "Oleh", age: 31, active: true, role: "editor" },
{ name: "Iryna", age: 25, active: false, role: "manager" }
];
console.log(users.filter(canAccessDashboard));
// [ { name: "Anna", age: 29, active: true, role: "admin" } ]Функції and та or не змінюють предикати, а створюють нові функції з комбінованою логікою.
compose і pipeБазові реалізації працюють із функціями, які приймають один аргумент:
const compose = (...functions) => value =>
functions.reduceRight(
(result, currentFunction) => currentFunction(result),
value
);
const pipe = (...functions) => value =>
functions.reduce(
(result, currentFunction) => currentFunction(result),
value
);Варто також визначити поведінку для порожнього списку функцій. У такому випадку корисно повернути тотожну функцію — функцію, яка повертає отримане значення без змін:
const identity = value => value;
const pipe = (...functions) => value =>
functions.reduce(
(result, currentFunction) => currentFunction(result),
value
);
console.log(pipe()(42));
// 42Для compose важливо враховувати порядок аргументів. Наприклад:
const compose = (...functions) => value =>
functions.reduceRight(
(result, currentFunction) => currentFunction(result),
value
);
const double = value => value * 2;
const increment = value => value + 1;
console.log(compose(double, increment)(3));
// 8Спочатку виконується increment(3), потім double(4).
Композиція має кілька важливих властивостей.
Композицію можна групувати по-різному, не змінюючи результат:
compose(f, compose(g, h));і
compose(compose(f, g), h);математично еквівалентні, якщо функції чисті та не залежать від прихованого стану.
Тотожна функція повертає значення без змін:
const identity = value => value;
console.log(identity("data"));
// dataВона є нейтральним елементом композиції:
compose(identity, double)(5);
// 10
compose(double, identity)(5);
// 10Результатом композиції функцій з одного набору сумісних функцій також є функція цього ж загального виду:
const transform = compose(
value => value + 1,
value => value * 2
);
console.log(typeof transform);
// functionЦе дозволяє будувати композиції будь-якої складності з однакових базових елементів.
Розглянемо повний приклад обробки замовлення:
const orders = [
{
id: 1,
status: "paid",
total: 1200,
customer: { name: "Anna" }
},
{
id: 2,
status: "pending",
total: 800,
customer: { name: "Oleh" }
},
{
id: 3,
status: "paid",
total: 2500,
customer: { name: "Iryna" }
}
];
const filterPaid = orders =>
orders.filter(order => order.status === "paid");
const filterExpensive = orders =>
orders.filter(order => order.total >= 1000);
const mapToSummary = orders =>
orders.map(order => ({
id: order.id,
customer: order.customer.name,
total: order.total
}));
const sortByTotalDescending = orders =>
[...orders].sort((first, second) =>
second.total - first.total
);
const processOrders = pipe(
filterPaid,
filterExpensive,
mapToSummary,
sortByTotalDescending
);
console.log(processOrders(orders));Очікуваний результат:
[
{ id: 3, customer: "Iryna", total: 2500 },
{ id: 1, customer: "Anna", total: 1200 }
]Кожен крок можна змінити або замінити без переписування всієї функції:
const filterPaid = orders =>
orders.filter(order => order.status === "paid");Наприклад, для тестового середовища можна передати інший фільтр, який не відкидає жодного замовлення.
Композиція не є метою сама по собі. Вона може погіршити код, якщо:
функції мають неочевидні назви;
конвеєр містить надто багато дрібних кроків;
типи проміжних значень важко визначити;
між кроками є складна умовна логіка;
потрібно обробляти кілька незалежних значень;
побічні ефекти змішані з чистими перетвореннями.
Порівняйте:
const result = pipe(
normalizeInput,
validateInput,
enrichInput,
calculatePrice,
applyDiscount,
buildResponse
)(input);Такий код може бути зручним, якщо кожна функція добре названа й має простий контракт.
Але якщо функції приховують складні побічні ефекти, обробку стану та винятків, звичайна функція з локальними змінними може бути зрозумілішою:
const createResponse = input => {
const normalized = normalizeInput(input);
const validated = validateInput(normalized);
const enriched = enrichInput(validated);
const price = calculatePrice(enriched);
const discountedPrice = applyDiscount(price);
return buildResponse(discountedPrice);
};Функціональний стиль не вимагає перетворювати кожен фрагмент коду на ланцюжок функцій. Головне — зберігати зрозумілість.
const compose = (...functions) => value =>
functions.reduce(
(result, currentFunction) => currentFunction(result),
value
);Ця реалізація виконує функції зліва направо, хоча назва compose зазвичай передбачає рух справа наліво.
Якщо потрібен порядок зліва направо, краще назвати функцію pipe:
const pipe = (...functions) => value =>
functions.reduce(
(result, currentFunction) => currentFunction(result),
value
);const getLength = value => value.length;
const toUpperCase = value => value.toUpperCase();
const process = pipe(
getLength,
toUpperCase
);getLength повертає число, а toUpperCase очікує рядок. Такий конвеєр завершиться помилкою.
Потрібно або змінити порядок, або додати перетворення:
const process = pipe(
value => value.trim(),
value => value.toUpperCase(),
value => value.length
);
console.log(process(" hello "));
// 5Правильно:
const process = pipe(
value => value.trim(),
value => value.toLowerCase()
);Неправильно:
const process = pipe(
value => value.trim,
value => value.toLowerCase()
);У першому кроці повертається сама функція trim, а не оброблений рядок.
thisМетоди об’єктів можуть залежати від this:
const calculator = {
factor: 3,
multiply(value) {
return value * this.factor;
}
};
const process = value => calculator.multiply(value);
console.log(process(4));
// 12Якщо передати метод без контексту:
const process = calculator.multiply;значення this може бути втрачене. У композиції краще використовувати стрілкові адаптери або прив’язувати контекст через bind:
const process = calculator.multiply.bind(calculator);
console.log(process(4));
// 12Якщо один крок змінює вхідний об’єкт, наступні кроки можуть отримати неочікуваний стан. Для композиції краще повертати нові масиви та об’єкти.
const addFlag = user => ({
...user,
verified: true
});Конвеєр із десятків анонімних функцій важко налагоджувати:
const result = pipe(
value => value.trim(),
value => value.toLowerCase(),
value => value.replace(/\s+/g, " "),
value => value.split(" "),
value => value.filter(Boolean),
value => value.map(...)
)(input);Краще дати важливим крокам імена:
const normalizeWhitespace = value =>
value.replace(/\s+/g, " ").trim();
const splitWords = value =>
value.split(" ");
const normalizeText = pipe(
normalizeWhitespace,
splitWords
);Під час проєктування композицій:
Створюйте функції з однією відповідальністю.
Робіть контракти функцій очевидними за назвами.
Передавайте між кроками прості та передбачувані значення.
Не мутуйте вхідні дані без чіткої потреби.
Виносьте побічні ефекти на межі конвеєра.
Обирайте pipe, якщо код має читатися в порядку виконання.
Обирайте compose, якщо важливий математичний стиль справа наліво.
Для асинхронної логіки явно обробляйте проміси та помилки.
Тестуйте кожну функцію окремо й увесь конвеєр цілком.
Не використовуйте композицію там, де звичайний імперативний код зрозуміліший.
Функціональна композиція створює складну функцію з простих функцій.
compose зазвичай виконує функції справа наліво.
pipe виконує функції зліва направо.
Результат кожного кроку має бути сумісним із входом наступного.
Функції з кількома аргументами можна адаптувати за допомогою часткового застосування, карування або функцій-обгорток.
Чисті функції та незмінність даних роблять композиції передбачуваними.
Для асинхронних функцій потрібен окремий конвеєр на основі промісів.
Композиція корисна для обробки даних, валідації, бізнес-логіки та побудови повторно використовуваних конвеєрів.
Зрозумілість коду важливіша за використання композиції заради композиції.