Использование линз на реальных примерах
Всем привет! Меня зовут Андрей, и я Javascript-разработчик команды «Восход». В своей работе мне часто приходится иметь дело с иммутабельными данными, имеющими сложную иерархическую структуру, и использование стандартных средств для этого мне показалось довольно неудобным. В итоге в поисках лучшего решения я пришел к такой интересной концепции как линзы.
Про иммутабельные данныеИммутабельные данные последнее время переживают всплеск популярности во фронтенде; отправной точкой можно считать эту статью. Основной причиной всплеска является проблема «определения изменений» (change detection), которую так или иначе пытается решать любой Javascript-фреймворк или инфраструктура. Тотальное использование иммутабельных данных — это один из способов её решения; в частности оно является основным в ReactJS сообществе.
Однако, при попытке использовать иммутабельные данные появляется проблема с «глубокими» изменениями во вложенных структурах. Предлагаю разобраться на реальных примерах, в чем же проблема и можем ли мы как-то её решить.
Пример попроще
Иммутабельная vs мутабельная структураВот пример очень простой функции для иммутабельного изменения несложной структуры:
Довольно многословно, не правда ли? К тому же, в определении два раза повторяется state и rangeSlider , что противоречит хорошей практике.
Даже на этом простом примере видно, в чем проблема иммутабельных изменений. Чтобы установить значение вглубь структуры, нам надо сначала полностью её разобрать, а потом собрать по новой с новыми значениями. Поэтому и получается очень-очень многословно.
Сравните запись выше и обычное мутабельное присваивание:
То есть, переходя от мутабельных структур к иммутабельным, мы теряем в лаконичности и читабельности кода. Естественно, функциональное сообщество не могло этого допустить, и в нем родилась такая концепция, как линзы.
Что такое линзаЧтобы иммутабельно установить значение, необходимо знать, как его прочитать (как до него добраться). Следовательно, разумным решением будет хранить функции для чтения и установки значения вместе, в одной сущности, которую и назвали линзой.
Таким образом линза состоит из:
- getter — функции для получения значения;
- setter — функции для установки значения, которая обязательно должна быть чистой и выполнять установку иммутабельно.
Для создания линз мы будем использовать функцию lens из библиотеки ramda. Первый её аргумент — getter , второй — setter . Простой пример — определение линзы lensProp для атрибута объекта:
Использование линзыЕсть две главные и одна интересная операции:
- view — получить данные по линзе, где первый аргумент линза, а второй — структура, из которой надо получить данные:
- set — установить данные по линзе, где первый аргумент линза, второй — значение, которое надо установить, а третий — данные, куда установить значение:
- over — операция, которая вытаскивает через view по линзе значение, применяет к нему некотoрую функцию и устанавливает его обратно:
Вроде всё есть, теперь вернемся к примеру. Напишем наше преобразование, используя lensProp :
Получается чуть-чуть лаконичнее и, что очень важно, без каких-либо повторений: нет дублирования имени атрибута объекта( rangeSlider ). Это просто следствие главного свойства линз — композируемости. Линзы — это пример отлично композируемой абстракции.
Давайте теперь сделаем линзу для работы с атрибутом from у rangeSlider . Вынесем линзу для работы с rangeSlider в переменную:
Можно определить линзу для работы с атрибутом from у любого объекта, используя все тот же lensProp :
А теперь чудеса композиции! Чтобы получить линзу для from у rangeSlider , нужно просто скомпозировать две уже определенныe нами линзы:
Получилось довольно немногословно, да? На самом деле, такую линзу можно было бы определить, используя встроенную в ramda функцию lensPath. Получилось бы просто:
Пример посложнее
Представим, что есть вот такая структура:
Существует некотoрый объект, в котором есть юзеры с данными, а у юзеров — члены семьи, у которых есть свои данные. Задача — написать функцию, которая будет обновлять имя юзера по id . Давайте решим эту задачу с помощью линз.
Начало решенияСначала нам нужно научиться работать с ключем users в объекте. Воспользуемся функцией lensProp , с которой мы познакомились ранее:
Дальше нам надо как-то научиться работать с конкретным юзером, зная его id . Необходимо написать функцию, которая будет получать id юзера и возвращать линзу для этого юзера.
Определение функций getter и setterКак говорилось ранее, линза состоит из двух функций — getter и setter . Давайте определим обе:
- getter должен получать на вход массив и возвращать юзера с определенным id . Напишем функцию, которая создает такую функцию для определенного id :
- setter должен получать на вход нового юзера и массив и устанавливать нового юзера на место старого с определенным id :
Теперь определим саму функцию, создающую линзу:
Проверим работоспособность функции в Ramda REPL:
Продолжение решенияРаботает! Продолжаем. :) Осталось определить линзы для работы с ключами fio и name . Снова воспользуемся lensProp :
Сведём всё это вместе. Определим саму функцию, которая по id юзера будет создавать линзу для работы с его именем:
Выглядит довольно декларативно. Давайте попробуем функцию в деле:
Конец решения и новая задачаУра! Мы справились, мы молодцы! Казалось бы, можно пойти отдыхать с чистой совестью и смотреть свежую серию любимого сериала. Но вот незадача: нам внезапно понадобилось уметь работать с именами членов семьи юзера.Давайте попробуем решить новую задачу быстрее, максимально переиспользуя уже написанное. Какие линзы нам нужны для этого?
Для начала нам потребуется линза для работы с ключом users , но она уже у нас определена — lensUsers ; потом — уже определенная линза для работы с юзером по id — lensById . Поэтому давайте создадим линзу для работы с familyMembers :
Далее нам необходима линза для работы с членом семьи по id . Звучит знакомо, не правда ли? А не подойдет ли для этого ранее определенная lensById ? Конечно же, подойдёт. Суть работы с членами семьи та же, что и с юзерами: ищем по id и заменяем по id .
Далее нам нужны линзы для работы с fio и name . Они уже были определены нами — lensFio и lensName соответственно.
Итак, у нас есть все необходимые составляющие. Давайте определим функцию, создающую нужную нам линзу:
Все работает, как и ожидалось, см. пример в Ramda REPL.
Усложнение и новая задачаИ все хорошо, но внезапно нам захотелось работать с членами семьи не по id , а по роли role . Допустим, роль уникальна. Мы можем легко написать линзу для работы со значением по role .
Определяем функцию для создания геттера:
Определяем функцию для создания сеттера:
Теперь определим саму функцию, создающую линзу:
Избавление от копипастыЕсли перечитать статью заново, то можно заметить, что эта запись является почти полной копипастой определения lensById . Мы можем легко избавиться от копипасты. Давайте просто вынесем название атрибута, по которому определяется, какой итем надо взять, в аргументы функции:
Определяем функцию для создания геттера:
Определяем функцию для создания сеттера:
Теперь определим саму функцию создающую линзу:
При помощи нее переопределим lensById и lensByRole :
И теперь по аналогии с lensUserMemberFioNameById определим lensUserMemberFioNameByRole :
Результат будет точно такой же, как в предыдущем примере, можете убедиться сами. Видно, что линзы позволяют писать легко переиспользуемый и композируемый код для работы с данными с возможностью простого рефакторинга.
Полный код примера в Ramda REPL.
ПерепроверкаВспомним, что у нас получилось в первом примере:
Мы немного схитрили и использовали over , чтобы смержить значение из state.rangeSlider со значениями из action.payload . Эту операцию также можно вынести в отдельную линзу, так как любую операцию с данными можно вынести в линзу.
Домашнее задание или DIY
Попробуйте сделать это самостоятельно. Необходимо написать определение функции lensByPick , которая будет работать так:
При помощи созданной вами линзы можно будет переписать наш пример вот так:
Полезные ссылки про работу с данными
-
— функциональный набор утилит для Javascript (аналог Lodash); — альтернативная реализация линз для Javascript с более удобным и согласованным API; и Mori — хорошие реализации «чисто функциональных» структур данных, использующие «структурное переиспользование» для сокращения оверхеда по памяти при написании кода в иммутабельном стиле; — структура данных напоминающая in-memory базу данных (с индексами, связями, и богатым языком запросов). Может подойти вам если ваши данные очень-очень сложные (со множеством связей) и стандартные структуры данных неудобны для вас даже при использовании линз; — хорошая статья о том, что многие ошибки управления данными, которые присутсвуют в мутабельном коде, перешли и в иммутабельный.
Спасибо за внимание!
Возникшие вопросы можно задавать в комментариях или мне в твиттере — @bracketsarrows