Давайте пофантазируем на тему функциональной композиции, а так же проясним смысл оператора композиции/пайплайна.
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, часть реализации с мемоизацией на основе частей композиции, а не каждой функции по отдельности.
Комментарии (17)
bayarsaikhan
06.12.2018 12:49Композиция, или в данном случае скорее pipe (так как слева направо), еще хорошо реализуется простым Array.reduce:
[func1, func2, func3].reduce( (val, func) => func(val), initialParam );shammasov Автор
06.12.2018 18:41Очень странный комментарий.
А как же эквивалентность, переиспользуемость и мемоизация всего это добра?
faiwer
06.12.2018 15:56shammasov вопрос: у вас богатый опыт работы с weakMap? я в одном проекте ради удобства организовал глубокую мемоизацию за счёт вложенных weakMap-ов. Удобно, но это немного мешает спать, т.к. нет к ним у меня пока доверия. И дебажить утечки памяти сильно мешают (постоянно все пути
ведут в Римв очередной weakMap). Вы могли бы их рекомендовать в боевому применению на больших объёмах?mayorovp
06.12.2018 16:10+1На самом деле, вместо WeakMap можно использовать символ, если вы достаточно доверяете самому себе.
faiwer
06.12.2018 16:13Не очень понял вас. Можно на примере?
const map = new WeakMap(); map.set({}, 1); // map { %obj: 1 } gc(); map; // map {}
Как это повторить используя символы?
mayorovp
06.12.2018 16:26Если вам нужно просматривать содержимое WeakMap в целях диагностики — то символы тут, конечно же, не помогут.
Но зачем так делать, если можно сделать Heap Snapshot и для любого объекта увидеть всех его настоящих ретайнеров, а не вспомогательные WeakMap?faiwer
06.12.2018 16:37Честно говоря я окончательно потерял нить беседы. Примером выше я хотел показать не просмотр содержимого, а суть слабых ссылок. Что ты пользуешься ими и не думаешь об утечках. Не пишешь всякие фабрики селекторов и прочую муть. Просто и элегантно. Я не очень понял вас, как можно сделать самодельные WeakMap-ы без слабых ссылок, но с использованием символов. Или вы не об этом говорили?
Касательно retainer-ов — я тут совсем поплыл. Вам случаем не попадалось какого-нибудь крутого мануала по профилированию утечек? Прошлый раз когда я пытался понять куда уплывает память, я в 95%+ случаях попадал в эти чёртовы weakMap-ы на каком-нибудь 15 уровне вложенности, и ничего не мог понять. Зачем они там вообще отображаются? о_О
mayorovp
06.12.2018 16:46+1Я не очень понял вас, как можно сделать самодельные WeakMap-ы без слабых ссылок, но с использованием символов. Или вы не об этом говорили?
Именно об этом я и говорил.
const symbol = Symbol("..."); // map.set({}, 1); ({})[symbol] = 1; gc();
Зачем они там вообще отображаются?
Затем, что недоработка инструментария. В .NET, кстати, такая же ерунда с ConditionalWeakTable.
faiwer
06.12.2018 16:49А ну да, точно. Под вечер мозг совсем плохо работает. Sorry :) Про подобный подход знал.
shammasov Автор
06.12.2018 18:44Давайте оставим кэширование каждому на своё устмотрение. Идеального алгоритма не существует. Не надо устраивать бои по вопросам кэша.
mayorovp
Ай-ай-ай, утечка памяти же будет...
Кстати, зачем вам вообще свойства
tail
иhead
, если все что нужно уже лежит вlist
?shammasov Автор
Этот вопрос задаю в конце статьи.
Устроит ли всех WeakMap или важнее указывать лимит кеша, или нужен лимит кэша на основе последних вызванных функций?
Проблема выглядит иначе, если использовать только чистые функции не ссылающиеся на свои замыкания (что потенциально опасно в любой композиции и мемоизации), а оставить только унарные или каррированные с переиспользованием уже созданных.
Tail и head — это часть другой реализации, где каждой композиции заводится пространство мемоизации. Таким образом композиции могут переиспользвать кэши друг друга. На гитхаб упрощённый вариант, который работает только со списком.
mayorovp
Любой обработчик события должен быть так или иначе привязан к компоненту, а для этого он обязан быть различным в каждом компоненте.
Любое применение вашего
compose
к обработчику событий в пересоздаваемом компоненте даст утечку памяти.shammasov Автор
Почему обязан? Обработчик что-то берёт из замыкания? Он обращается к this? Значит он тесно связан с внутренней логикой компонента. Это не функциональный подход.
Такой обработчик нельзя использовать в композиции, особенно если он обращается не к лексическому this.
Строго говоря на все события одного типа может быть один обработчик. Это хороший повод подумать о дата флоу внутри приложения.
Я привёл пример с обработчиками событий на примере реакта.
mayorovp
Да, согласен, сглупил: this и правда необязателен.
Но у вашего кода все равно утечка памяти. id-то, как правило, может быть произвольным, а значит и
compose(dispatch, makeAction, contsant(id))
будут накапливаться.shammasov Автор
Это вопрос к тому, кому какие кэши нужны и в каких случаях.
Используйте WeakMap.
Вообще, что бы не было подобных вопросов — напишу возможность подставить своё middleware в качестве кэша, предложив несколько базовых стратегий. Пусть этот вопрос будет за скоупом npm пакета.
faiwer
Тут стоит ещё учесть, что weakMap не будет работать с не-объектами. И пример из комментария майора придётся переписать, закешировав где-нибудь
constant(id)
.