Реалізація compose
compose() - це функція-комбінатор, яка збирає кілька функцій в одну: compose(f, g, h)(x) еквівалентно f(g(h(x))) і виконує їх справа наліво. Уся реалізація зводиться до згортки списку функцій: reduceRight для compose і reduce для дзеркального pipe.
Теорія
TL;DR
compose(f, g, h)(x)дорівнюєf(g(h(x))), порядок виконання справа наліво.- Базова реалізація - це
fns.reduceRight((v, fn) => fn(v), x). - Щоб остання функція в ланцюжку могла прийняти кілька аргументів, акумулятор тримають у масиві і розпаковують через spread.
- Асинхронний варіант загортає перший результат у
Promise.resolveі зшиває решту через.then. - Для TypeScript пишуть перевантаження на кожну кількість функцій, бо загальний тип змінної арності вивести складно.
- У продакшені зручно мати обидва варіанти:
compose(справа наліво) іpipe(зліва направо), плюс їхні асинхронні версії.
Швидкий приклад
const compose = (...fns) => x => fns.reduceRight((v, fn) => fn(v), x);
const trim = s => s.trim();
const toInt = s => parseInt(s, 10);
const inc = n => n + 1;
const parseAndInc = compose(inc, toInt, trim);
console.log(parseAndInc(' 41 ')); // 42Рядок спершу обрізається, потім перетворюється на число, і лише тоді збільшується на одиницю: функції читаються справа наліво.
Мінімальна реалізація (ES6)
Універсальна версія, у якій останній обробник може прийняти багато аргументів:
// Універсальна версія: останній обробник може прийняти багато аргументів
const compose = (...fns) => (...args) =>
fns.reduceRight(
(acc, fn) => [fn(...acc)],
args
)[0];
// Приклад
const trim = s => s.trim();
const toInt = s => parseInt(s, 10);
const inc = n => n + 1;
const parseAndInc = compose(inc, toInt, trim);
parseAndInc(' 41 '); // 42Трюк тут у тому, що акумулятор завжди є масивом аргументів. Початкове значення - це args, а кожен крок повертає [fn(...acc)], тобто знову масив з одного елемента. Наприкінці залишається взяти [0].
Варіант для unary-функцій
Якщо всі функції приймають рівно один аргумент, обгортка в масив зайва, і реалізація стає коротшою та трохи швидшою:
const composeUnary = (...fns) => x =>
fns.reduceRight((v, fn) => fn(v), x);Це найпоширеніша форма: у функціональному коді ланцюжки майже завжди складаються з унарних перетворень.
Promise-aware compose
Асинхронна версія працює і з sync, і з async функціями:
const composeAsync = (...fns) => (...args) =>
fns
.slice(0, -1)
.reduceRight(
(p, fn) => p.then(res => fn(res)),
Promise.resolve(fns[fns.length - 1](...args))
);
// Приклад
const fetchUser = async id => ({ id, name: 'Tim' });
const getName = u => u.name;
const shout = s => s.toUpperCase();
const getUserNameLoud = composeAsync(shout, getName, fetchUser);
getUserNameLoud(7).then(console.log); // 'TIM'Остання функція списку (fetchUser) викликається окремо і її результат одразу нормалізується через Promise.resolve. Решта функцій підчіплюються через .then, тому кожна отримує вже розгорнуте значення, незалежно від того, синхронна вона чи ні.
Типобезпечна версія для TypeScript
Загальний тип для функції змінної арності вивести складно, тому на практиці пишуть перевантаження. Наведені покривають до 4 функцій, далі розширюється за аналогією.
type Unary<A, R> = (a: A) => R;
export function compose<A, R>(f1: Unary<A, R>): Unary<A, R>;
export function compose<A, B, R>(
f1: Unary<B, R>,
f2: Unary<A, B>
): Unary<A, R>;
export function compose<A, B, C, R>(
f1: Unary<C, R>,
f2: Unary<B, C>,
f3: Unary<A, B>
): Unary<A, R>;
export function compose<A, B, C, D, R>(
f1: Unary<D, R>,
f2: Unary<C, D>,
f3: Unary<B, C>,
f4: Unary<A, B>
): Unary<A, R>;
export function compose(...fns: Function[]) {
return (x: unknown) => fns.reduceRight((v, f) => f(v), x);
}Перевантаження зшивають типи сусідніх функцій: вихід f2 має збігатися зі входом f1, інакше компілятор одразу покаже помилку.
pipe і корисні нотатки
Часто потрібен і pipe(), який виконує функції зліва направо:
const pipe = (...fns) => (...args) =>
fns.reduce((acc, fn) => [fn(...acc)], args)[0];
const parseAndInc2 = pipe(trim, toInt, inc);
parseAndInc2(' 41 '); // 42- Якщо компонуєте методи об'єктів, не забудьте про
this: зв'язуйте їх черезfn.bind(obj). - Намагайтеся, щоб функції були чистими та унарними: композиція буде простішою і передбачуванішою.
- Для продакшену зручно мати обидва варіанти:
compose(справа наліво) іpipe(зліва направо), плюсcomposeAsync/pipeAsync.
Типові помилки
- Взяти
reduceзамістьreduceRight. Тоді ви отримаєтеpipe, а неcompose, і ланцюжок виконається у зворотному порядку. - Забути початкове значення у
reduce. Без нього першим акумулятором стане сама функція зі списку, а не вхідні дані. - Очікувати, що звичайний
composeрозгорне Promise. Синхронна версія передасть далі сам Promise; потрібенcomposeAsync. - Втратити
thisу методів.compose(obj.method)викличе метод без контексту; требаobj.method.bind(obj). - Розраховувати на кілька аргументів у середині ланцюжка. Кілька аргументів приймає лише остання (найправіша) функція, усі наступні отримують рівно одне значення.
- Композувати нечисті функції. Побічні ефекти в середині ланцюжка роблять результат залежним від порядку і часу виклику.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.