Давайте пофантазируем на тему функциональной композиции, а так же проясним смысл оператора композиции/пайплайна.
TL;DR
Compose functions like a boss:

Популярные реализации compose — при вызове создают новые и новые функции на основе рекурсии, какие здесь минусы и как это обойти.
Можно рассматривать функцию compose как чистую функцию, которая зависит только от аргументов. Таким образом композируя одни и те же функции в одинаковом порядке мы должны получить идентичную функцию, но в JavaScript мире это не так. Любой вызов compose — возвращает новую функцию, это приводит к созданию всё новых и новых функций в памяти, а так же к вопросам их мемоизации, сравнения и отладки.
Надо что-то делать.
Мотивация
- Получить ассоциативную идентичность:
Очень желательно не создавать новых объектов и переиспользовать предыдущие результаты работы compose функции. Одна из проблем React разработчика – реализация shallowCompare, работающая с результатом композиции функций. Например, композиция отправки события с коллбеком — будет всегда создавать новую функцию, что приведёт к обновлению значения свойства.
Популярные реализации композиции не обладают идентичностью возвращаемого значения.
Частично вопрос идентичности композиций можно решить мемоизацией аргументов. Однако остаётся вопрос ассоциативной идентичности:
import {memoize} from 'ramda' const memoCompose = memoize(compose) memoCompose(a, b) === memoCompose(a, b) // да, аргументы одинаковые memoCompose(memoCompose(a, b), c) === memoCompose(a, memoCompose(b, c)) // нет, мемоизация не помогает так как аргументы разные
- Упростить отладку композиции:
Конечно же, использование tap функций помогает при отладке функций имеющих единственное выражение в теле. Однако, желательно иметь как можно более "плоский" стек вызовов для отладки.
- Избавиться от оверхеда связанного с рекурсией:
Рекурсивная реализация функциональной композиции имеет оверхед, создавая новые элементы в стеке вызовов. При вызове композиции 5-ти и более функции это хорошо заметно. А используя функциональные подходы в разработке необходимо выстраивать композиции из десятков очень простых функций.
Решение
Сделать моноид ( или полугруппоид с поддержкой спецификации категории) в терминах fantasy-land:
import compose, {identity} from 'lazy-compose' import {add} from 'ramda' const a = add(1) const b = add(2) const c = add(3) test('Laws', () => { compose(a, compose(b, c)) === compose(compose(a, b), c) // ассоциативность compose(a, identity) === a //right identity compose(identity, a) === a //left identity }
Варианты использования
- Полезно в мемоизации составных композиций при работе с редаксом. Например для redux/mapStateToProps и
reselect. - Композиция линз.
Можно создавать и переиспользовать строго эквивалентные линзы сфокусированные в одно и то же место.
import {lensProp, memoize} from 'ramda' import compose from 'lazy-compose' const constantLens = memoize(lensProp) const lensA = constantLens('a') const lensB = constantLens('b') const lensC = constantLens('c') const lensAB = compose(lensB, lensA) console.log( compose(lensC, lensAB) === compose(lensC, lensB, lensA) )
- Мемоизированные коллбэки, с возможностью композиции вплоть до конечной функции отправки события.
В этом примере в элементы списка будет передаваться один и тот же коллбэк.
```jsx import {compose, constant} from './src/lazyCompose' // constant - returns the same memoized function for each argrum // just like React.useCallback import {compose, constant} from 'lazy-compose' const List = ({dispatch, data}) => data.map( id => <Button key={id} onClick={compose(dispatch, makeAction, contsant(id))} /> ) const Button = React.memo( props => <button {...props} /> ) const makeAction = payload => ({ type: 'onClick', payload, }) ```
Ленивая композиция React компонентов без создания компонентов высшего порядка. В данном случае ленивая композиция будет сворачивать массив функций, без создания дополнительных замыканий. Данный вопрос волнует многих разработчиков использующих библиотеку recompose
import {memoize, mergeRight} from 'ramda' import {constant, compose} from './src/lazyCompose' const defaultProps = memoize(mergeRight) const withState = memoize( defaultState => props => { const [state, setState] = React.useState(defaultState) return {...props, state, setState} } ) const Component = ({value, label, ...props)) => <label {...props}>{label} : {value}</label> const withCounter = compose( ({setState, state, ...props}) => ({ ...props value: state, onClick: compose(setState, constant(state + 1)) }), withState(0), ) const Counter = compose( Component, withCounter, defaultProps({label: 'Clicks'}), )
Монады и аппликативы (в терминах fantasy-land) со строгой эквивалентностью через кэшироваие результата композиции. Если внутри конструктора типа обращаться к словарю ранее созданных объектов, получится следующее:
type Info = { age?: number } type User = { info?: Info } const mayBeAge = LazyMaybe<Info>.of(identity) .map(getAge) .contramap(getInfo) const age = mayBeAge.ap(data) const maybeAge2 = LazyMaybe<User>.of(compose(getAge, getInfo)) console.log(maybeAge === maybeAge2) // создав эквивалентные объекты, мы можем мемоизировать их вместе // переиспользовать как один объект и бонусом получить короткий стек вызовов
Давно использую такой подход, оформил репозиторий здесь.
NPM пакет: npm i lazy-compose .
Интересно получить фидбэк, по поводу ограничения кэша создаваемых в рантайме функций зависящих от замыканий.
UPD
Предвижу очевидные вопросы:
Да, можно заменить Map на WeakMap.
Да, надо сделать возможность подключения стороннего кэша как middleware.
Не стоит устраивать полемику на тему кэшей, идеальной стратегии кэширования не существует.
Зачем tail и head, если всё есть в list — tail и head, часть реализации с мемоизацией на основе частей композиции, а не каждой функции по отдельности.
