Пост содержит перевод статьи «Why Ramda?», которую подготовил один из контрибьютеров Скот Сайет. Статья была опубликована 10 Июня 2014 года на сайте и рассказывает о том почему стоит обратить своё внимание на библиотеку Ramda и функциональное программирование в целом.
Примечание переводчика
В связи с тем, что статья была написана в 2014 году, некоторые примеры устарели и не работали с последней версией библиотеки. Поэтому они были адаптированы под последнюю версию Ramda@0.25.0.
Почему Ramda?
Когда-то давно buzzdecafe представил миру Ramda, в тот же момент сообщество поделилось на два лагеря.
Первый лагерь собрал тех, кто привык к функциональному стилю в JavaScript. Они тепло приняли новую библиотеку, потому что чётко понимали для чего она нужна.
А во втором лагере собрались люди, которые никак не отреагировали.
Тем кто не привык к функциональному программированию, Ramda будет безразлична. Так как большинство её основных возможностей уже покрыты такими библиотеками, как Underscore и Lodash.
Если вы из тех кто хочет сохранить свой код в императивном или объектно-ориентированном стиле, то Ramda вам ничем не поможет.
Тем не менее Ramda предлагает другой стиль написания кода, стиль, который был позаимствован из чисто функциональных языков программирования. Здесь механизм создание сложной функциональной логики реализуется с помощью композиции. Однако, любая библиотека предоставляет возможность реализовать функциональную композицию. Но в отличие от других, тут это «делается с легкостью».
Давайте посмотрим как работать с Ramda.
В качестве подопытного возьмем «TODO list», так как он является распространённым способом сравнения для веб-фреймворков, библиотек и прочего. Начнём с того, что нам необходимо получить список завершённых задач:
С помощью встроенных методов прототипа Array
фильтрация делается вот так:
// Plain JS
const incompleteTasks = tasks.filter(task => !task.complete);
С помощью Lodash, это выглядит немного проще:
// Lo-Dash
const incompleteTasks = _.filter(tasks, {complete: false});
В любом случае получается отфильтрованный список задач.
В Ramda фильтрацию можно сделать так:
const incomplete = R.filter(R.whereEq({complete: false}));
Заметили, что чего-то не хватает? Не хватает списка задач. Код Ramda возвращает функцию, а не данные.
Чтобы получить данные, необходимо вызвать эту функцию со списком задач, после чего она вернёт отфильтрованный список.
Поскольку у нас имеется только функция, мы можем комбинировать её с любым набором данных, либо с другими функциями. Представьте, что у нас есть функция groupByUser
, которая группирует список задач по пользователям. Тогда можно с лёгкостью создать новую функцию, которая берёт не завершённые задачи и группирует их по пользователям.
const activeByUser = R.compose(groupByUser, incomplete);
Выходит так, что мы комбинируем функции без привязки к данным и получаем новую функцию. Если бы нам захотелось написать пример выше самим, то это выглядело бы так:
const activeByUser = tasks => groupByUser(incomplete(tasks));
Благодаря композиции можно строить новые функции на основе других без привязки к данным. Таким образом, можно сделать вывод, что композиция является ключевой техникой в функциональном программировании.
Давайте пойдём дальше и посмотрим, что ещё можно сделать с нашим примером. Вдруг вам пришло в голову отсортировать сгруппированный по пользователям список по срокам? Тогда решение не заставит себя долго ждать:
const sortUserTasks = R.compose(R.map(R.sortBy(R.prop('dueDate'))), activeByUser);
Всё в одной функции?
Стоит отметить, что вышеизложенные примеры можно объединить, поскольку функция compose
позволяет использовать более двух параметров:
const sortUserTasks = R.compose(
R.mapObj(R.sortBy(R.prop('dueDate'))),
groupByUser,
R.filter(R.where({complete: false})
);
Однако в этом нет никакого смысла, так как у нас есть промежуточные функции activeByUser
и incomplete
. И ко всему прочему, отладка станет очень сложной, а код нечитаемым.
Поэтому я предлагаю пойти другим путём. Разбить всю логику на маленькие функции, которые можно было бы повторно использовать.
const sortByDate = R.sortBy(R.prop('dueDate'));
const sortUserTasks = R.compose(R.mapObj(sortByDate), activeByUser);
Теперь sortByDate
можно использовать для сортировки любой коллекции задач по дате. Фактически, это более гибкий вариант, он сортирует любую коллекцию объектов, содержащих сортируемое свойство dueDate
.
Подождите, а вдруг понадобится сортировать даты по убыванию?
const sortByDateDescend = R.compose(R.reverse, sortByDate);
const sortUserTasks = R.compose(R.mapObj(sortByDateDescend), activeByUser);
Если бы мы знали наверняка, что будем сортировать только в порядке убывания даты, то можно было бы объеденить эту сортировку в одно определение sortByDateDescend
. Но лично я предпочитаю держать оба варианта, на случай если решу сортировать данные в порядке возрастания или убывания. Но это зависит от вас.
Где данные?
Пока у нас нет данных, но тогда что здесь происходит? Обработка данных без данных что ли? Потерпите ещё немного и всё станет понятно. Когда вы пишите в функциональным стиле, всё, что получается на выходе, это функции, образующие конвейер. Одна функция передает данные в следующую, которая передает их в следующую и так далее, пока нужный результат не будет достигнут.
На данный момент у нас есть следующий набор функций:
incomplete: [Task] -> [Task]
sortByDate: [Task] -> [Task]
sortByDateDescend: [Task] -> [Task]
activeByUser: [Task] -> {String: [Task]}
sortUserTasks: {String: [Task]} -> {String: [Task]}
Для реализации sortUserTasks
, мы создали вышеперечисленные функции но, несмотря на это, они полезны и по отдельности. Ранее я вас просил представить, что есть функция groupByUser
, однако я так и не показал способа её реализации.
Вот один из способов:
const groupByUser = R.groupBy(R.prop('username'));
Функция groupBy
под капотом использует reduce
от Ramda, которая очень похожа на Array.prototype.reduce
. Итак функция groupBy
использует reduce
для группировки списка по полю username
, то есть получится объект где ключ это username
, а значение — список задач пользователя.
Ну что, удалось ли мне вас впечатлить гибкостью Ramda? Заметьте, я всё ещё не упоминаю данные. Вы меня извините, но дальше я покажу ещё несколько возможностей этой библиотеки.
Подождите, ещё немного
Представьте, что вам захотелось получить первые 5 элементов из списка. Это можно сделать с помощью функции take
. Чтобы получить первые 5 задач каждого пользователя из нашего TODO листа, достаточно будет написать вот так:
const topFiveUserTasks = R.compose(R.map(R.take(5)), sortUserTasks);
Затем стоит уменьшить размер возвращаемых объектов, убрав лишние поля, например, можно оставить только title
и dueDate
. В этой структуре данных информация о пользователях является избыточной и создает только накладные расходы, которые нам не нужны.
Такую выборку можно реализовать с помощью функции Ramda project
, которая является аналогом select
из SQL:
const importantFields = R.project(['title', 'dueDate']);
const topDataAllUsers = R.compose(R.mapObj(importantFields), topFiveUserTasks);
Некоторые функции, которые мы создали ранее, кажутся действительно полезными и могут быть использованы для других целей внутри нашего TODO приложения. Остальные же являются просто заполнителями, поэтому их можно объединить. Если пересмотреть весь наш код, то его можно было бы отрефакторить следующим образом:
const incomplete = R.filter(R.where({complete: false}));
const sortByDate = R.sortBy(R.prop('dueDate'));
const sortByDateDescend = R.compose(R.reverse, sortByDate);
const importantFields = R.project(['title', 'dueDate']);
const groupByUser = R.partition(R.prop('username'));
const activeByUser = R.compose(groupByUser, incomplete);
const topDataAllUsers = R.compose(R.mapObj(R.compose(importantFields,
R.take(5), sortByDateDescend)), activeByUser);
Супер! А теперь я могу увидеть данные?
Да, теперь я вам покажу сами данные.
Самое время передать их в наши функции. Но дело в том, что все эти функции принимают одни и те же данные, это массив элементов TODO. Я специально не описывал структуру этих элементов, но из кода видно что они должны обладать, по крайней мере, следующими свойствами:
complete
: BooleandueDate
: String, formatted YYYY-MM-DDtitle
: StringuserName
: String
Итак, если у нас есть список задач, как нам его использовать? Да очень просто:
const results = topDataAllUsers(tasks);
И это всё? Все выше описанные функцию отработают и получится необходимый результат?
Боюсь, что так. Результатом будет объект:
{
Michael: [
{dueDate: '2014-06-22', title: 'Integrate types with main code'},
{dueDate: '2014-06-15', title: 'Finish algebraic types'},
{dueDate: '2014-06-06', title: 'Types infrastucture'},
{dueDate: '2014-05-24', title: 'Separating generators'},
{dueDate: '2014-05-17', title: 'Add modulo function'}
],
Richard: [
{dueDate: '2014-06-22', title: 'API documentation'},
{dueDate: '2014-06-15', title: 'Overview documentation'}
],
Scott: [
{dueDate: '2014-06-22', title: 'Complete build system'},
{dueDate: '2014-06-15', title: 'Determine versioning scheme'},
{dueDate: '2014-06-09', title: 'Add `mapObj`'},
{dueDate: '2014-06-05', title: 'Fix `and`/`or`/`not`'},
{dueDate: '2014-06-01', title: 'Fold algebra branch back in'}
]
}
Но вот интересная особенность. Вы можете передать тот же самый список задач в функцию incompleteTasks
, в результате чего получится отфильтрованный список:
const incompleteTasks = incomplete(tasks);
[
{
username: 'Scott',
title: 'Add `mapObj`',
dueDate: '2014-06-09',
complete: false,
effort: 'low',
priority: 'medium'
}, {
username: 'Michael',
title: 'Finish algebraic types',
dueDate: '2014-06-15',
complete: true,
effort: 'high',
priority: 'high'
} /*, ... */
]
И, конечно же, вы также можете передать список задач в sortBydate
, sortByDateDescend
, importantFields
, toUser
или activeByUser
. Поскольку все они работают с аналогичным типом список задач TODO. Таким образом можно создать большую коллекцию инструментов при помощи простых комбинаций.
Новые требования
После того, как всё было сделано, вы узнали что нужно реализовать ещё одну функцию. Она должна отфильтровать список задач только у одного конкретного пользователя. Для этого вам надо отобрать подмножество задач только для одного пользователя, а затем произвести такую же сортировку и фильтрацию, которую использовали раннее для всей группы пользователей.
Данная логика в настоящее время встроена в topDataAllUsers
… На самом деле это довольно агрессивное решение. Но реорганизовать это очень легко. Как это часто бывает, самое сложное — придумать хорошее название. "Gloss", вероятно, не лучшее имя для функции, но это всё, что я смог придумать поздно ночью:
const gloss = R.compose(importantFields, R.take(5), sortByDateDescend);
const topData = R.compose(gloss, incomplete);
const topDataAllUsers = R.compose(R.mapObj(gloss), activeByUser);
const byUser = R.useWith(R.filter, [R.propEq('username'), R.identity])
Теперь, когда понадобится воспользоваться этим, достаточно будет вызвать следующую функцию:
const results = topData(byUser('Scott', tasks));
Классно, но я просто хочу получить данные
"Окей", — скажите вы, — "может быть это и круто, но пока я просто хочу получить данные. Мне не нужны функции, которые однажды возвратят мои данные… Могу ли я использовать в таком случае Ramda?"
Конечно можете.
Вернёмся к самой первой функции:
const incomplete = R.filter(R.whereEq({ complete: false }));
Как превратить эту функцию в такую, которая возвращает данные? Очень просто:
const incompleteTasks = R.filter(R.whereEq({ complete: false }), tasks);
То же самое относится и к остальным функциям, просто добавьте параметр tasks
, и вы получите данные обратно.
Что случилось?
Это ещё один важный момент в Ramda. Все её функции автоматически каррированы. Если такой функции передать не все ожидамые аргументы, то она вернёт новую функцию, которая будет ожидать оставшиеся аргументы. Функция R.filter
, используемая в incomplete
, принимает массив значений, а так же функцию предиката для их фильтрации. В исходной версии мы не передавали массив значений, поэтому фильтр просто возвращал новую функцию, которая ожидает этот массив. Во второй версии ожидаемый массив был передан сразу и он использовался вместе с предикатом для вычисления ответа.
Автокаррирование функций Ramda сочетается с принципом "вначале функции, потом данные". Передача данных в последнюю очередь делает Ramda очень простой библиотекой для работы в стиле функциональной композиции.
Более детально о каррировании в Ramda рассказывается в статье: Favoring Curry. В то же время, безусловно, стоит прочитать отличный пост Хью Джексона: Why Curry Helps.
Действительно ли эта штука работает?
Вот код который обсуждается в статье
Этот код наглядно демонстрирует почему стоит использовать Ramda.
Использование Ramda
У Ramda имеется очень хорошая документация.
Описанный код вполне применим и должен помочь вам на первое время.
Исходный код Ramda можно взять из репозитория GitHub или установить через npm.
Для использования в Node.JS достаточно сделать следующее:
npm install ramda
const R = require('ramda')
Для использования в браузере просто добавьте:
<script src="path/to/yourCopyOf/ramda.js"></script>
Или:
<script src="path/to/yourCopyOf/ramda.min.js"></script>
А так же можно воспользоваться CDN:
<script src="//cdnjs.cloudflare.com/ajax/libs/ramda/0.25.0/ramda.min.js"></script>