Skip to main content

Реалізація 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 (зліва направо), плюс їхні асинхронні версії.

Швидкий приклад

javascript
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)

Універсальна версія, у якій останній обробник може прийняти багато аргументів:

javascript
// Універсальна версія: останній обробник може прийняти багато аргументів 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-функцій

Якщо всі функції приймають рівно один аргумент, обгортка в масив зайва, і реалізація стає коротшою та трохи швидшою:

javascript
const composeUnary = (...fns) => x => fns.reduceRight((v, fn) => fn(v), x);

Це найпоширеніша форма: у функціональному коді ланцюжки майже завжди складаються з унарних перетворень.

Promise-aware compose

Асинхронна версія працює і з sync, і з async функціями:

javascript
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 функцій, далі розширюється за аналогією.

typescript
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(), який виконує функції зліва направо:

javascript
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).
  • Розраховувати на кілька аргументів у середині ланцюжка. Кілька аргументів приймає лише остання (найправіша) функція, усі наступні отримують рівно одне значення.
  • Композувати нечисті функції. Побічні ефекти в середині ланцюжка роблять результат залежним від порядку і часу виклику.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.