Pull to refresh

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

Reading time4 min
Views15K

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


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, часть реализации с мемоизацией на основе частей композиции, а не каждой функции по отдельности.

Tags:
Hubs:
+16
Comments17

Articles

Change theme settings