Использование линз на реальных примерах


Всем привет! Меня зовут Андрей, и я Javascript-разработчик команды «Восход». В своей работе мне часто приходится иметь дело с иммутабельными данными, имеющими сложную иерархическую структуру, и использование стандартных средств для этого мне показалось довольно неудобным. В итоге в поисках лучшего решения я пришел к такой интересной концепции как линзы.

Про иммутабельные данные

Иммутабельные данные последнее время переживают всплеск популярности во фронтенде; отправной точкой можно считать эту статью. Основной причиной всплеска является проблема «определения изменений» (change detection), которую так или иначе пытается решать любой Javascript-фреймворк или инфраструктура. Тотальное использование иммутабельных данных — это один из способов её решения; в частности оно является основным в ReactJS сообществе.

Однако, при попытке использовать иммутабельные данные появляется проблема с «глубокими» изменениями во вложенных структурах. Предлагаю разобраться на реальных примерах, в чем же проблема и можем ли мы как-то её решить.

Пример попроще

Иммутабельная vs мутабельная структура

Вот пример очень простой функции для иммутабельного изменения несложной структуры:

const setProps = (state, action) => ({
    ...state,
    rangeSlider: {
        ...state.rangeSlider,
        ...pick(['from', 'to', 'left', 'right'], action.payload)
    }
})

Довольно многословно, не правда ли? К тому же, в определении два раза повторяется state и rangeSlider, что противоречит хорошей практике.

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

Сравните запись выше и обычное мутабельное присваивание:

Object.assign(
    state.rangeSlider,
    pick(['from', 'to', 'left', 'right'], action.payload)
)

То есть, переходя от мутабельных структур к иммутабельным, мы теряем в лаконичности и читабельности кода. Естественно, функциональное сообщество не могло этого допустить, и в нем родилась такая концепция, как линзы.

Что такое линза

Чтобы иммутабельно установить значение, необходимо знать, как его прочитать (как до него добратся). Следовательно, разумным решением будет хранить функции для чтения и установки значения вместе, в одной сущности, которую и назвали линзой.

Таким образом линза состоит из:

  • getter — функции для получения значения;
  • setter — функции для установки значения, которая обязательно должна быть чистой и выполнять установку иммутабельно.

Создание линзы

Для создания линз мы будем использовать функцию lens из библиотеки ramda. Первый её аргумент — getter, второй — setter. Простой пример — определение линзы lensProp для аттрибута обьекта:

const lensProp = prop => lens(
    // getter - получаем свойство
    obj => obj[prop],
    // setter - ставим свойство иммутабельно
    (newVal, obj) => ({...obj, [prop]: newVal})
)

Использование линзы

Есть две главные и одна интересная операции:

  • view — получить данные по линзе, где первый аргумент линза, а второй — структура, из которой надо получить данные:
const a = {key: 2},
    val = view(lensProp('key'), a) // val - 2
  • set — установить данные по линзе, где первый аргумент линза, второй — значение, которое надо установить, а третий — данные, куда установить значение:
const a = {key: 2},
    newA = set(lensProp('key'), 4, a) // newA - {key: 4}
  • over — операция, которая вытаскивает через view по линзе значение, применяет к нему некотoрую функцию и устанавливает его обратно:
const over = (someLens, func, data) => {
    const val = view(someLens, data)
    const newVal = func(val)
    return set(someLens, newVal)
}

Тренировка

const a = {key: 2},
    changedA = over(lensProp('key'), val => val + 1, a) // changedA - {key: 3}

Вроде всё есть, теперь вернемся к примеру. Напишем наше преобразование, используя lensProp:

const setProps = (state, action) => over(
    lensProp('rangeSlider'),
    slider => ({...slider, ...pick(['from', 'to', 'left', 'right'], action.payload)}),
    state
)

Получается чуть-чуть лаконичнее и, что очень важно, без каких-либо повторений: нет дублирования имени аттрибута обьекта(rangeSlider). Это просто следствие главного свойства линз — композируемости. Линзы — это пример отлично композируемой абстракции.

Давайте теперь сделаем линзу для работы с аттрибутом from у rangeSlider. Вынесем линзу для работы с rangeSlider в переменную:

const lensRangeSlider = lensProp('rangeSlider')

Можно определить линзу для работы с аттрибутом from у любого обьекта, используя все тот же lensProp:

const lensFrom = lensProp('from')

А теперь чудеса композиции! Чтобы получить линзу для from у rangeSlider, нужно просто скомпозировать две уже опеределенныe нами линзы:

const lensRangeSliderFrom = compose(
    lensRangeSlider,
    lensFrom
)

Пробуем:

const state = {
    rangeSlider: {
        from: 3,
        to: 4
    }
}

view(lensRangeSliderFrom, state) // 3
set(lensRangeSliderFrom, 5, state)
/* {
    rangeSlider: {
        from: 5,
        to:4
    }
} */

over(lensRangeSliderFrom, val => val * 100, state)
/* {
    rangeSlider: {
        from: 300,
        to:4
    }
} */

Последний пример в Ramda REPL.

Получилось довольно немногословно, да? На самом деле, такую линзу можно было бы определить, используя встроенную в ramda функцию lensPath. Получилось бы просто:

const lensRangeSliderFrom = lensPath(['rangeSlider', 'from'])

Результат тот же самый.

Пример посложнее

Представим, что есть вот такая структура:

const struct = {
    id: 2,
    description: 'Some cool thing',
    users: [
        {
            id: 1,
            fio: {
                name: 'Ivan'
            },
            familyMembers: [
                {
                    id: 5,
                    role: 'sister',
                    fio: {
                        name: 'Olga'
                    }
                }
            ]
        }
    ]
}

Существует некотoрый обьект, в котором есть юзеры с данными, а у юзеров — члены семьи, у которых есть свои данные. Задача — написать функцию, которая будет обновлять имя юзера по id. Давайте решим эту задачу с помощью линз.

Начало решения

Сначала нам нужно научиться работать с ключем users в объекте. Воспользуемся функцией lensProp, с которой мы познакомились ранее:

const lensUsers = lensProp('users')

Дальше нам надо как-то научиться работать с конкретным юзером, зная его id. Необходимо написать функцию, которая будет получать id юзера и возвращать линзу для этого юзера.

Определение функций getter и setter

Как говорилось ранее, линза состоит из двух функций — getter и setter. Давайте определим обе:

  • getter должен получать на вход массив и возврашать юзера с определенным id. Напишем функцию, которая создает такую функцию для определенного id:
const makeGetterById = id => array => array.find(item => item.id === id)
  • setter должен получать на вход нового юзера и массив и устанавливать нового юзера на место старого с определенным id:
const makeSetterById = id =>
                (newItem, array) =>
                    array.map(item => item.id === id ? newItem : item)

Теперь определим саму функцию, создающую линзу:

const lensById = id => lens(
    makeGetterById(id),
    makeSetterById(id)
)

Проверим работоспособность функции в Ramda REPL:

const users = [{id:1, name: 'Ivan'}, {id:2, name: 'Oleg'}]

view(lensById(1), users)
// {"id": 1, "name": "Ivan"}
set(lensById(2), {id:2, name: 'Olga'}, users)
// [{"id": 1, "name": "Ivan"}, {"id": 2, "name": "Olga"}]
over(lensById(2), user => assoc('name', 'Fillip', user), users)
// [{"id": 1, "name": "Ivan"}, {"id": 2, "name": "Fillip"}]

Продолжение решения

Работает! Продолжаем. :) Осталось определить линзы для работы с ключами fio и name. Снова воспользуемся lensProp:

const lensFio = lensProp('fio')
const lensName = lensProp('name')

Сведём всё это вместе. Определим саму функцию, которая по id юзера будет создавать линзу для работы с его именем:

const lensUserNameById = id => compose(
    lensUsers,
    lensById(id),
    lensFio,
    lensName
)

Выглядит довольно декларативно. Давайте попробуем функцию в деле:

view(lensUserNameById(1), struct)
// -> "Ivan"
set(lensUserNameById(1), 'Petr', struct)
/* ->
{
    "description": "Some cool thing",
    "id": 2,
    "users": [
        {
            "familyMembers": [
                {
                    "fio": {
                        "name": "Olga"
                    },
                    "id": 5,
                    "role": "sister"
                }
            ],
            "fio": {
                "name": "Petr"
            },
            "id": 1
        }
    ]
}
*/

over(lensUserNameById(1), name => name + '!!!', struct)
/* ->
{
    "description": "Some cool thing",
    "id": 2,
    "users": [
        {
            "familyMembers": [
                {
                    "fio": {
                        "name": "Olga"
                    },
                    "id": 5,
                    "role": "sister"
                }
            ],
            "fio": {
                "name": "Ivan!!!"
            },
            "id": 1
        }
    ]
}
*/

Пример в Ramda REPL.

Конец решения и новая задача

Ура! Мы справились, мы молодцы! Казалось бы, можно пойти отдыхать с чистой совестью и смотреть свежую серию любимого сериала. Но вот незадача: нам внезапно понадобилось уметь работать с именами членов семьи юзера.

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

Для начала нам потребуется линза для работы с ключом users, но она уже у нас определена — lensUsers; потом — уже определенная линза для работы с юзером по idlensById. Поэтому давайте создадим линзу для работы с familyMembers:

const lensFamilyMembers = lensProp('familyMembers')

Далее нам необходима линза для работы с членом семьи по id. Звучит знакомо, не правда ли? А не подойдет ли для этого ранее определенная lensById? Конечно же, подойдёт. Суть работы с членами семьи та же, что и с юзерами: ищем по id и заменяем по id.

Далее нам нужны линзы для работы с fio и name. Они уже были определены нами — lensFio и lensName соответственно.

Итак, у нас есть все необходимые составляющие. Давайте определим функцию, создающую нужную нам линзу:

const lensUserMemberFioNameById = (userId, memberId) => compose(
    lensUsers,
    lensById(userId),
    lensFamilyMembers,
    lensById(memberId),
    lensFio,
    lensName
)

Протестируем:

view(lensUserMemberFioNameById(1, 5), struct)
// -> "Olga"
set(lensUserMemberFioNameById(1, 5), 'Tanya', struct)
/* ->
{
    "description": "Some cool thing",
    "id": 2,
    "users": [
        {
            "familyMembers": [
                {
                    "fio": {
                        "name": "Tanya"
                    },
                    "id": 5,
                    "role": "sister"
                }
            ],
            "fio": {
                "name": "Ivan"
            },
            "id": 1
        }
    ]
}
*/
over(lensUserMemberFioNameById(1, 5), name => name + '!!!', struct)
/* ->
{
    "description": "Some cool thing",
    "id": 2,
    "users": [
        {
            "familyMembers": [
                {
                    "fio": {
                        "name": "Olga!!!"
                    },
                    "id": 5,
                    "role": "sister"
                }
            ],
            "fio": {
                "name": "Ivan"
            },
            "id": 1
        }
    ]
}
*/

Все работает, как и ожидалось, см. пример в Ramda REPL.

Усложнение и новая задача

И все хорошо, но внезапно нам захотелось работать с членами семьи не по id, а по роли role. Допустим, роль уникальна. Мы можем легко написать линзу для работы со значением по role.

Определяем функцию для создания геттера:

const makeGetterByRole = role => array => array.find(item => item.role === role)

Определяем функцию для создания сеттера:

const makeSetterByRole = role =>
                     (newItem, array) =>
                         array.map(item => item.role === role ? newItem : item)

Теперь определим саму функцию, создающую линзу:

const lensByRole = role => lens(
    makeGetterByRole(role),
    makeSetterByRole(role)
)

Избавление от копипасты

Если перечитать статью заново, то можно заметить, что эта запись является почти полной копипастой определения lensById. Мы можем легко избавиться от копипасты. Давайте просто вынесем название аттрибута, по которому определяется, какой итем надо взять, в аргументы функции:

Определяем функцию для создания геттера:

const makeGetterBy = (attr, val) =>
                                array =>
                                    array.find(item => item[attr] === val)

Определяем функцию для создания сеттера:

const makeSetterBy = (attr, val) =>
                        (newItem, array) =>
                            array.map(item => item[attr] === val ? newItem : item)

Теперь определим саму функцию создающую линзу:

const lensBy = attr => val => lens(
    makeGetterBy(attr, val),
    makeSetterBy(attr, val)
)

При помощи нее переопределим lensById и lensByRole:

const lensById = lensBy('id')
const lensByRole = lensBy('role')

И теперь по аналогии с lensUserMemberFioNameById определим lensUserMemberFioNameByRole:

const lensUserMemberFioNameByRole = (userId, memberRole) => compose(
    lensUsers,
    lensById(userId),
    lensFamilyMembers,
    lensByRole(memberRole),
    lensFio,
    lensName
)

Протестируем:

view(lensUserMemberFioNameByRole(1, 'sister'), struct)
set(lensUserMemberFioNameByRole(1, 'sister'), 'Tanya', struct)
over(lensUserMemberFioNameByRole(1, 'sister'), name => name + '!!!', struct)

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

Полный код примера в Ramda REPL.

Перепроверка

Вспомним, что у нас получилось в первом примере:

const setProps = (state, action) =>  over(
    lensProp('rangeSlider'),
    slider => ({...slider, ...pick(['from', 'to', 'left', 'right'], action.payload)}),
    state
)

Мы немного схитрили и использовали over, чтобы смержить значение из state.rangeSlider со значениями из action.payload. Эту операцию также можно вынести в отдельную линзу, так как любую операцию с данными можно вынести в линзу.

Домашнее задание или DIY

Попробуйте сделать это самостоятельно. Необходимо написать определение функции lensByPick, которая будет работать так:

const data = {
    key: 'value',
    key1: 'value1',
    key2: 'value2',
}
view(lensByPick(['key1', 'key2']), data)
// -> {key1: 'value1', key2: 'value2'}
set(
    lensByPick(['key1', 'key2']),
    {key1: 'newValue1', key2: 'newValue2', key3: 'newValue3'},
    data
)
/* ->
{
    key: 'value',
    key1: 'newValue1',
    key2: 'newValue2',
}
*/
over(
    lensByPick(['key1', 'key2']),
    obj => mapObjIndexed(val => val + '!!!', obj),
    data
)
/* ->
{
    key: 'value',
    key1: 'value1!!!',
    key2: 'value2!!!',
}
*/

Начать можно отсюда — заготовка для Ramda REPL.

При помощи созданой вами линзы можно будет переписать наш пример вот так:

const lensRangeSliderByPick = keys => compose(
    lensProp('rangeSlider'),
    lensByPick(keys)
)

const setProps = (state, action) => set(
    lensRangeSliderByPick(['from', 'to', 'left', 'right']),
    action.payload,
    state
)

Полезные ссылки про работу с данными

  • Ramda — функциональный набор утилит для Javascript (аналог Lodash);
  • partial.lenses — альтернативная реализация линз для Javascript с более удобным и согласованным API;
  • Immutable-js и Mori — хорошие реализации “чисто функциональных” структур данных, использующие “структурное переиспользование” для сокращения оверхеда по памяти при написании кода в иммутабельном стиле;
  • Datascript — структура данных напоминающая in-memory базу данных(с индексами, связями, и богатым языком запросов). Может подойти вам если ваши даные очень-очень сложные(со множеством связей) и стандартные структуры данных неудобны для вас даже при использовании линз;
  • Immutability is not enough — хорошая статья о том, что многие ошибки управления данными, которые присутсвуют в мутабельном коде, перешли и в иммутабельный.

Спасибо за внимание!

Возникшие вопросы можно задавать в комментариях или мне в твиттере - @bracketsarrows

comments powered by Disqus