Мышление в стиле Ramda: Неизменяемость и объекты

http://randycoulman.com/blog/2016/06/28/thinking-in-ramda-immutability-and-objects/
  • Перевод
  • Tutorial

1. Первые шаги
2. Сочетаем функции
3. Частичное применение (каррирование)
4. Декларативное программирование
5. Бесточечная нотация
6. Неизменяемость и объекты
7. Неизменяемость и массивы
8. Линзы
9. Заключение


Данный пост — это шестая часть серии статей о функциональном программировании под названием "Мышление в стиле Ramda".


В пятой части мы говорили о написании функций в стиле бесточечной нотации, где главный аргумент с данными для нашей функции не указывается явным образом.


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


Чтение свойств объекта


Давайте снова взглянем на пример с определением людей, имеющих право голоса, который мы рассматривали в пятой части:


const wasBornInCountry = person => person.birthCountry === OUR_COUNTRY
const wasNaturalized = person => Boolean(person.naturalizationDate)
const isOver18 = person => person.age >= 18

const isCitizen = either(wasBornInCountry, wasNaturalized)
const isEligibleToVote = both(isOver18, isCitizen)

Как вы можете видеть, мы сделали isCitizen и isEligibleToVote бесточечными, но мы не можем сделать это с первыми тремя функциями.


Как мы узнали в четвёртой части, мы можем сделать наши функции более декларативными через использование equals и gte. Начнём с этого:


const wasBornInCountry = person => equals(person.birthCountry, OUR_COUNTRY)

const wasNaturalized = person => Boolean(person.naturalizationDate)

const isOver18 = person => gte(person.age, 18)

Чтобы сделать эти функции бесточечными, нам нужен способ построить функцию таким образом, чтобы применять переменную person в конце выражения. Проблема в том, что нам нужно получить доступ к свойствам person, сейчас мы знаем единственный способ как это можно сделать — и он императивный.


prop


К счастью, Ramda в очередной раз приходит к нам на помощь. Она предоставляет функцию prop для получения доступа к свойствам объектов.


Используя prop, мы можем переписать person.birthCountry в prop('birthCountry', person). Давайте сделаем это:


const wasBornInCountry = person => equals(prop('birthCountry', person), OUR_COUNTRY)

const wasNaturalized = person => Boolean(prop('naturalizationDate', person))

const isOver18 = person => gte(prop('age', person), 18)

Воу, сейчас оно выглядит как-то намного хуже. Но давайте продолжим наш рефакторинг. Давайте изменим порядок аргументов, которые мы передаём в equals, чтобы prop шёл последним. equals работает точно также в обратном порядке, так что мы ничего не сломаем:


const wasBornInCountry = person => equals(OUR_COUNTRY, prop('birthCountry', person))

const wasNaturalized = person => Boolean(prop('naturalizationDate', person))

const isOver18 = person => gte(prop('age', person), 18)

Далее, давайте используем каррирование, природное свойство equals и gte, для того чтобы создать новые функции, к которым будет применяться результат вызова prop:


const wasBornInCountry = person => equals(OUR_COUNTRY)(prop('birthCountry', person))

const wasNaturalized = person => Boolean(prop('naturalizationDate', person))

const isOver18 = person => gte(__, 18)(prop('age', person))

Это всё ещё выглядит более худшим вариантом, но всё же давайте продолжим. Давайте применим преимущество каррирования снова для всех вызовов prop:


const wasBornInCountry = person => equals(OUR_COUNTRY)(prop('birthCountry')(person))

const wasNaturalized = person => Boolean(prop('naturalizationDate')(person))

const isOver18 = person => gte(__, 18)(prop('age')(person))

Снова как-то не очень. Но теперь мы видим знакомый паттерн. Все наши функции имеют тот самый образ f(g(person)), и как мы знаем из второй части, это эквивалентно compose(f, g)(person).


Давайте применим это преимущество к нашему коду:


const wasBornInCountry = person => compose(equals(OUR_COUNTRY), prop('birthCountry'))(person)

const wasNaturalized = person => compose(Boolean, prop('naturalizationDate'))(person)

const isOver18 = person => compose(gte(__, 18), prop('age'))(person)

Теперь мы кое-что получили. Все наши функции выглядят как person => f(person). И мы уже знаем из пятой части, что мы можем сделать эти функции бесточечными.


const wasBornInCountry = compose(equals(OUR_COUNTRY), prop('birthCountry'))

const wasNaturalized = compose(Boolean, prop('naturalizationDate'))

const isOver18 = compose(gte(__, 18), prop('age'))

Когда мы начинали, не было очевидно, что наши методы делали две вещи. Они обращались к свойству объекта и подготавливали некоторые операции с его значением. Этот рефакторинг в бесточечный стиль сделал это очень явным.


Давайте взглянем на некоторые другие инструменты, которые Ramda предоставляет для работы с объектами.


pick


Там, где prop читает одно свойство объекта и возвращает его значение, pick читает множество свойств из объекта и возвращает новый объект только с ними.


К примеру, если нам нужны только имена и годы персон, мы можем использовать pick(['name','age'], person).


has


Если мы просто хотим знать, что наш объект имеет свойство, без чтения его значения, мы можем использовать функцию has для проверки его свойств, а также hasIn для проверки цепочки прототипов: has('name', person).


path


Там, где prop читал свойство объекта, path углубляется во вложенные объекты. К примеру, мы хотим вытащить почтовый индекс из более глубокой структуры: path(['address','zipCode'], person).


Обратите внимание на то, что path более прощающий, чем prop. path вернёт undefined, если что-либо на пути (включая оригинальный аргумент) окажется в значении null или undefined, в то время как prop в таких ситуациях вызовет ошибку.


propOr / pathOr


propOr и pathOr подобны prop и path, совмещённым с defaultTo. Они предоставляют вам возможность указать значение по умолчанию для свойства или пути, которые не будут найдены в изучаемом объекте.


К примеру, мы можем предоставить заполнитель, когда мы не знаем имени персоны: propOr('<Unnamed>, 'name', person). Обратите внимание, что в отличии от prop, propOr не будет вызывать ошибку, если person окажется равен null или undefined; вместо этого он вернёт значение по умолчанию.


keys / values


keys возвращает массив, содержащий все названия всех известных свойств объекта. values вернёт значения этих свойств. Эти функции могут быть полезны при совмещении с функциями итерации по коллекциям, о которых мы узнали в первой части.


Добавление, обновление и удаление свойств


Теперь у нас есть множество инструментов для чтения из объектов в декларативном стиле, но что насчёт внесения изменений?


Так как для нас важна неизменяемость, мы не хотим изменять объекты напрямую. Вместо этого, мы хотим возвращать новые объекты, которые изменились таким образом, каким мы желаем.


И снова Ramda предоставляет нам множество полезностей.


assoc / assocPath


Когда мы программируем в императивном стиле, мы можем установить или изменить имя персоны через оператор присваивания: person.name = 'New name'.


В нашем функциональном, неизменяемом мире, вместо этого мы можем использовать assoc: const updatedPerson = assoc('name', 'newName', person).


assoc возвращает новый объект с добавленным или обновлённым значением свойства, оставляя оригинальный объект неизменённым.


У нас также имеется в распоряжении assocPath для обновления вложенного свойства: const updatedPerson = assocPath(['address', 'zipCode'], '97504', person).


dissoc / dissocPath / omit


Что насчёт удаления свойств? Императивно, мы можем захотеть сказать delete person.age. В Ramda, мы будем использовать dissoc: `const updatedPerson = dissoc('age', person)


dissocPath примерно о том же, но работает на более глубоких структурах объектов: dissocPath(['address', 'zipCode'], person).


А также у нас имеется omit, который может удалить несколько свойств за раз: const updatedPerson = omit(['age', 'birthCountry'], person).


Обратите внимание, что pick и omit немного похожи и очень красиво дополняют друг друга. Они очень удобны для белых списков (сохранять только определённый набор свойств, используя pick) и чёрных списков (избавляться от определённых свойств через использование omit).


Трансформация объектов


Теперь мы знаем достаточно для того чтобы работать с объектами в декларативном и иммутабельном стиле. Давайте напишем функцию celebrateBirthday, которая обновляет возраст персоны на её день рождения.


const nextAge = compose(inc, prop('age'))

const celebrateBirthday = person => assoc('age', nextAge(person), person)

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


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


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


const celebrateBirthday = evolve({ age: inc })

Данный код говорит, что мы преобразуем указанный объект (который не отображается в силу бесточечного стиля) через создание нового объекта с теми же свойствами и значениями, но свойство age будет получено через применение inc к оригинальному значению свойства age.


evolve может преобразовать множество свойств за раз и даже на множественных уровнях вложенности. Преобразование объекта может иметь тот же образ, какой будет иметь изменяемый объект, и evolve будет рекурсивно проходить между структурами, применяя функции трансформации в указанном виде.


Обратите внимание, что evolve не добавляет новые свойства; если вы укажете трансформацию для свойства, которое не встречается в обрабатываемом объекте, evolve просто проигнорирует его.


Я обнаружил, что evolve быстро становится рабочей лошадкой в моих приложениях.


Слияние объектов


Иногда вам нужно объединить два объекта вместе. Типичный случай — когда у вас есть функция, которая берёт именованные опции, и вам хочется объединить их с опциями по умолчанию. Ramda предоставляет функцию merge для этой цели.


function f(a, b, options = {}) {
  const defaultOptions = { value: 42, local: true }
  const finalOptions = merge(defaultOptions, options)
}

merge возвращает новый объект, содержащий все свойства и значения из обоих объектов. Если оба объекта имеют одно и то же свойство, то будет получено значение второго аргумента.


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


Попытка просто использовать merge(newValues) в конвеере не даст того, что мы хотели бы получить.


Для этой ситуации, я обычно создаю свою утилиту под названием reverseMerge. Она может быть написана как const reverseMerge = flip(merge). Вызов flip меняет местами первые два аргумента функции, которая к нему применяется.


merge выполняет поверхностное слияние. Если объекты при объединении имеют свойство, значение которого является подобъектом — то эти подобъекты не сливаются. Ramda в данный момент не имеет способности "глубокого слияния" (оригинальная статья, перевод которой я делаю, уже имеет устаревшую информацию по данной теме. На сегодняшний день в Ramda имеются такие функции как mergeDeepLeft, mergeDeepRight для рекурсивного глубокого слияния объектов, а также другие методы для слияний).


Обратите внимание, что merge принимает только два аргумента. Если у вас есть желание объединить множество объектов в один, вы можете использовать mergeAll, который принимает массив объектов для объединения.


Заключение


Сегодня мы получили чудесный набор инструментов для работы с объектами в декларативном и неизменяемом стиле. Теперь мы можем читать, добавлять, обновлять, удалять и трансформировать свойства в объектах без изменения оригинальных объектов. И мы можем делать все эти штуки в таком стиле, который позволит легко комбинировать функции между собой.


Далее


Теперь мы можем работать с объектами в иммутабельном стиле, но что насчёт массивов? "Иммутабельность и массивы" расскажет нам, что делать с ними.

  • +10
  • 2,3k
  • 4
Поделиться публикацией
Похожие публикации
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 4
    0

    Извините, если задам глупый вопрос,


    const wasBornInCountry = compose(equals(OUR_COUNTRY), prop('birthCountry'))


    Каким образом программа понимает, что нужно именно свойства объекта person брать? Прочитал предыдущую статью, но так и не понял, может упустил что

      0

      wasBornInCountry — это просто функция, которая "работает" с неким типом данных. Когда её вызовут с аргументом person (вообще, с любым объектом, у которого есть поле 'birthCountry'), вот тогда "программа поймёт".

        0

        Спасибо, понятно.


        Примерно так и думал, но не нашёл примера вызова

        0
        prop('birthCountry') — это функция от объекта, которая берет у него свойство с именем birthCountry
        Упрощенно можно было бы написать эту функцию так:
        const prop = propName => obj => obj[propName]

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

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