О композиции функций в JavaScript

Давайте пофантазируем на тему функциональной композиции, а так же проясним смысл оператора композиции/пайплайна.


TL;DR
Compose functions like a boss:
image
Популярные реализации 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
    0
    const functions = new Map<Function, Hash>()
    const compositionsByHash = new Map<string, Composition<any, any>>()

    Ай-ай-ай, утечка памяти же будет...


    Кстати, зачем вам вообще свойства tail и head, если все что нужно уже лежит в list?

      0
      Этот вопрос задаю в конце статьи.
      Устроит ли всех WeakMap или важнее указывать лимит кеша, или нужен лимит кэша на основе последних вызванных функций?
      Проблема выглядит иначе, если использовать только чистые функции не ссылающиеся на свои замыкания (что потенциально опасно в любой композиции и мемоизации), а оставить только унарные или каррированные с переиспользованием уже созданных.

      Tail и head — это часть другой реализации, где каждой композиции заводится пространство мемоизации. Таким образом композиции могут переиспользвать кэши друг друга. На гитхаб упрощённый вариант, который работает только со списком.
        0

        Любой обработчик события должен быть так или иначе привязан к компоненту, а для этого он обязан быть различным в каждом компоненте.


        Любое применение вашего compose к обработчику событий в пересоздаваемом компоненте даст утечку памяти.

          0
          Почему обязан? Обработчик что-то берёт из замыкания? Он обращается к this? Значит он тесно связан с внутренней логикой компонента. Это не функциональный подход.
          Такой обработчик нельзя использовать в композиции, особенно если он обращается не к лексическому this.
          Строго говоря на все события одного типа может быть один обработчик. Это хороший повод подумать о дата флоу внутри приложения.

          Я привёл пример с обработчиками событий на примере реакта.
            0

            Да, согласен, сглупил: this и правда необязателен.


            Но у вашего кода все равно утечка памяти. id-то, как правило, может быть произвольным, а значит и compose(dispatch, makeAction, contsant(id)) будут накапливаться.

              0
              Это вопрос к тому, кому какие кэши нужны и в каких случаях.
              Используйте WeakMap.

              Вообще, что бы не было подобных очевидных вопросов, надо предложить несколько базовых стратегий и возможность подставить другое middleware для кэша. Пусть этот вопрос будет за скоупом npm пакета.
                0

                Тут стоит ещё учесть, что weakMap не будет работать с не-объектами. И пример из комментария майора придётся переписать, закешировав где-нибудь constant(id).

      0
      Композиция, или в данном случае скорее pipe (так как слева направо), еще хорошо реализуется простым Array.reduce:

      [func1, func2, func3].reduce( (val, func) => func(val), initialParam );
        0
        Очень странный комментарий.
        А как же эквивалентность, переиспользуемость и мемоизация всего это добра?
        0

        shammasov вопрос: у вас богатый опыт работы с weakMap? я в одном проекте ради удобства организовал глубокую мемоизацию за счёт вложенных weakMap-ов. Удобно, но это немного мешает спать, т.к. нет к ним у меня пока доверия. И дебажить утечки памяти сильно мешают (постоянно все пути ведут в Рим в очередной weakMap). Вы могли бы их рекомендовать в боевому применению на больших объёмах?

          +1
          На самом деле, вместо WeakMap можно использовать символ, если вы достаточно доверяете самому себе.
            0

            Не очень понял вас. Можно на примере?


            const map = new WeakMap();
            map.set({}, 1); // map { %obj: 1 }
            gc();
            map; // map {}

            Как это повторить используя символы?

              0
              Если вам нужно просматривать содержимое WeakMap в целях диагностики — то символы тут, конечно же, не помогут.

              Но зачем так делать, если можно сделать Heap Snapshot и для любого объекта увидеть всех его настоящих ретайнеров, а не вспомогательные WeakMap?
                0

                Честно говоря я окончательно потерял нить беседы. Примером выше я хотел показать не просмотр содержимого, а суть слабых ссылок. Что ты пользуешься ими и не думаешь об утечках. Не пишешь всякие фабрики селекторов и прочую муть. Просто и элегантно. Я не очень понял вас, как можно сделать самодельные WeakMap-ы без слабых ссылок, но с использованием символов. Или вы не об этом говорили?


                Касательно retainer-ов — я тут совсем поплыл. Вам случаем не попадалось какого-нибудь крутого мануала по профилированию утечек? Прошлый раз когда я пытался понять куда уплывает память, я в 95%+ случаях попадал в эти чёртовы weakMap-ы на каком-нибудь 15 уровне вложенности, и ничего не мог понять. Зачем они там вообще отображаются? о_О

                  +1
                  Я не очень понял вас, как можно сделать самодельные WeakMap-ы без слабых ссылок, но с использованием символов. Или вы не об этом говорили?

                  Именно об этом я и говорил.


                  const symbol = Symbol("...");
                  // map.set({}, 1);
                  ({})[symbol] = 1;
                  gc();

                  Зачем они там вообще отображаются?

                  Затем, что недоработка инструментария. В .NET, кстати, такая же ерунда с ConditionalWeakTable.

                    0

                    А ну да, точно. Под вечер мозг совсем плохо работает. Sorry :) Про подобный подход знал.

            0
            Давайте оставим кэширование каждому на своё устмотрение. Идеального алгоритма не существует. Не надо устраивать бои по вопросам кэша.

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое