Нам предстоит поменять образ мышления. С этого момента мы прекращаем указывать компьютеру, как он должен выполнять свою работу, вместо этого мы будем составлять описание желаемого результата. Я уверен, что вы найдёте этот подход куда менее напряжным, чем попытки вручную управлять каждой мелочью.
Декларативный подход, в отличие от императивного, подразумевает, что мы будем писать выражения, а не пошаговые инструкции.
Вспомните SQL — там нет «Сначала сделай то, затем сделай это». Есть единое выражение, которое определяет, что именно мы хотели бы получить из базы данных. Не мы решаем, как выполнить работу, а сама база. Когда её версия обновится и, к примеру, детали реализации SQL изменятся, нам не придётся редактировать код запросов. Взаимодействие с базой реализовано так потому, что существует множество способов интерпретировать нашу спецификацию и получить такой же результат.
Некоторым людям, включая меня, будет непросто осмыслить концепцию декларативного кодирования вот так сразу, поэтому давайте рассмотрим несколько примеров, чтобы получить более полное представление.
// императивно
const makes = [];
for (let i = 0; i < cars.length; i += 1) {
makes.push(cars[i].make);
}
// декларативно
const makes = cars.map(car => car.make);
В императивном варианте перед циклом нужно сначала инициализировать переменную (пустым массивом). Интерпретатор должен выполнить эту инструкцию перед тем, как двигаться дальше. Затем интерпретатор обходит непосредственно список автомобилей, вручную увеличивая счетчик, вульгарно демонстрируя нам каждую часть итерации.
Вариант с map
— это одно выражение, ему не требуется особенного порядка исполнения (как это нужно для инструкций). В том, как именно функция map
будет проходить по массиву, и как она будет компоновать полученный результат, для неё остаётся много свободы. Такой вариант определяет, что сделать, а не как, поэтому может гордо называться декларативным.
Помимо ясности и краткости, функция map
допускает возможность оптимизации без внесения изменений в наш драгоценный прикладной код.
Тем из вас, кто подумает: «Да, но ведь императивный цикл работает намного быстрее», я предлагаю ознакомиться с тем, как JIT оптимизирует ваш код. Вот потрясающее видео, которое прольёт свет на происходящее
Рассмотрим ещё один пример.
// императивно
const authenticate = (form) => {
const user = toUser(form);
return logIn(user);
};
// декларативно
const authenticate = compose(logIn, toUser);
Хотя в императивной версии и нет ничего ошибочного, она по-прежнему остается пошаговой передачей значения из функции в функцию. А выражение с compose
явно утверждает факт: authenticate
— это композиция функций toUser
и logIn
. Опять же, это оставляет простор для изменения подробностей вспомогательного кода и приводит к тому, что код нашего приложения является высокоуровневой спецификацией.
В приведённом выше примере порядок вычисления задан явно (toUser
должна вызываться до logIn
), но существует много сценариев, в которых порядок не важен, и это очень легко выразить в декларативном коде. И, в сочетании с чистыми функциями, декларативность делает ФП прекрасным способом ведения дел в нашем параллельном будущем — для того, чтобы сделать систему параллельной/конкурентной, ничего специального изобретать не придётся.
Сейчас мы построим приложение декларативным, компонуемым способом. Пока что мы схитрим и будем использовать сайд-эффекты, но мы будем использовать их по-минимуму и отделять от чистого кода. Мы собираемся написать виджет для браузера, который парсит изображения из flickr и отображает их. Начнем с заготовки приложения. Вот HTML:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Flickr App</title>
</head>
<body>
<main id="js-main" class="main"></main>
<script src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.2.0/require.min.js"></script>
<script src="main.js"></script>
</body>
</html>
А вот заготовка main.js:
const CDN = s => `https://cdnjs.cloudflare.com/ajax/libs/${s}`;
const ramda = CDN('ramda/0.21.0/ramda.min');
const jquery = CDN('jquery/3.0.0-rc1/jquery.min');
requirejs.config({ paths: { ramda, jquery } });
requirejs(['jquery', 'ramda'], ($, { compose, curry, map, prop }) => {
// код приложения
});
Мы добавим ramda вместо lodash или какой-либо другой инструментальной библиотеки. Он включает в себя compose
, curry
и другие функции. Я также использовал requirejs, что могло показаться излишним, но мы будем использовать его на протяжении всей книги, и следует поддерживать постоянство.
Теперь, когда подготовка завершена, перейдём к спецификации. Наше приложение будет выполнять 4 действия.
- Формировать url для конкретного поискового запроса
- Обращаться к flickr api
- Преобразовывать полученный json в html с изображениями
- Выводить их на экран
Среди перечисленных действий есть 2 нечистых. Видите их? Те, что получают данные от flickr api и выводят что-то на экран. Давайте реализуем их сразу, чтобы отделить от остального кода. Заодно воспользуемся нашей замечательной фукцией trace
для простоты отладки.
const Impure = {
getJSON: curry((callback, url) => $.getJSON(url, callback)),
setHtml: curry((sel, html) => $(sel).html(html)),
trace: curry((tag, x) => { console.log(tag, x); return x; }),
};
Тут мы просто оборачиваем методы jQuery, чтобы использовать их как каррированные функции, и переставляем аргументы в более удобном порядке. Я выделил эти функции в отдельное пространство имён Impure
, чтобы мы сразу замечали, что это небезопасные функции. В последующих примерах мы сделаем эти функции чистыми.
Далее мы должны сформировать URL, который будет передан функции Impure.getJSON
.
const host = 'api.flickr.com';
const path = '/services/feeds/photos_public.gne';
const query = t => `?tags=${t}&format=json&jsoncallback=?`;
const url = t => `https://${host}${path}${query(t)}`;
Существуют различные причудливые и чересчур сложные способы написания функции url
в бесточечном стиле, с использованием моноидов (мы изучим их позже) или комбинаторов. Мы остановимся на удобочитаемой версии и соберём эту строку обычным способом.
Давайте напишем функцию app
, которая обращается к api и выводит данные на экран.
const app = compose(Impure.getJSON(Impure.trace('response')), url);
app('cats');
Этот код вызывает функцию url
, затем передает полученную строку в функцию getJSON
, которая частично применена с trace
. В результате загрузки приложения ответ api будет выведен в консоль браузера.
Мы хотели бы получить изображения из этого JSON-объекта. Похоже, что их адреса (назовем их mediaUrls
) содержатся в массиве items
и там помещены для каждого объекта в поле media
, а внутри — в поле m
.
Для получения значений вложенных полей мы можем использовать универсальную функцию-геттер из библиотеки ramda, с именем prop
. Вот упрощённая версия такой функции, для наглядности:
const prop = curry((property, object) => object[property]);
На самом деле, она довольно скучная и просто использует []
для чтения поля какого-либо объекта. Давайте воспользуемся ей, чтобы добраться до адресов изображений.
const mediaUrl = compose(prop('m'), prop('media'));
const mediaUrls = compose(map(mediaUrl), prop('items'));
Как только мы добрались до массива items
, мы проходимся по его элементам при помощи функции map
, чтобы получить адрес каждого изображения. На выходе получаем желаемый массив адресов. Давайте используем этот массив в приложении, чтобы вывести эти адреса на экран.
const render = compose(Impure.setHtml('#js-main'), mediaUrls);
const app = compose(Impure.getJSON(render), url);
Всё, что мы сделали, — это очередная композиция функций, которая вызовет mediaUrls
и наполнит ими содержимое <main>
. Мы заменили вызов trace
на render
, так что теперь нам есть что показать помимо содержимого json — сейчас это просто ссылки на изображения.
Последним шагом будет преобразование этих адресов в настоящие картинки. В приложениях большего масштаба нам следовало бы использовать шаблонизатор/библиотеку для работы с DOM, такие, как Handlebars или React. Для данного приложения нам потребуется только тег img, поэтому давайте обойдёмся jQuery.
const img = src => $('<img />', { src });
В jQuery метод html
принимает массив тегов. От нас требуется только преобразовать наши mediaUrls в изображения и затем передать их в setHtml
.
const images = compose(map(img), mediaUrls);
const render = compose(Impure.setHtml('#js-main'), images);
const app = compose(Impure.getJSON(render), url);
Вот и всё!
Вот полный код:
const CDN = s => `https://cdnjs.cloudflare.com/ajax/libs/${s}`;
const ramda = CDN('ramda/0.21.0/ramda.min');
const jquery = CDN('jquery/3.0.0-rc1/jquery.min');
requirejs.config({ paths: { ramda, jquery } });
require(['jquery', 'ramda'], ($, { compose, curry, map, prop }) => {
// -- Utils ----------------------------------------------------------
const Impure = {
trace: curry((tag, x) => { console.log(tag, x); return x; }), // eslint-disable-line no-console
getJSON: curry((callback, url) => $.getJSON(url, callback)),
setHtml: curry((sel, html) => $(sel).html(html)),
};
// -- Pure -----------------------------------------------------------
const host = 'api.flickr.com';
const path = '/services/feeds/photos_public.gne';
const query = t => `?tags=${t}&format=json&jsoncallback=?`;
const url = t => `https://${host}${path}${query(t)}`;
const img = src => $('<img />', { src });
const mediaUrl = compose(prop('m'), prop('media'));
const mediaUrls = compose(map(mediaUrl), prop('items'));
const images = compose(map(img), mediaUrls);
// -- Impure ---------------------------------------------------------
const render = compose(Impure.setHtml('#js-main'), images);
const app = compose(Impure.getJSON(render), url);
app('cats');
});
Смотрите, что получилось: ясная декларативная конструкция, описывающая, что чем является, а не как оно работает. Каждая строка выглядит для нас как уравнение, для которого соблюдаются его свойства. Следовательно, мы можем пользоваться этими свойствами для рефакторинга и рассуждений о нашем приложении.
В нашем коде возможно провести оптимизацию. Сейчас мы проходим функцией отображения (map
) по массиву объектов, чтобы получить адреса изображения, а затем — заново по массиву адресов, чтобы сформировать теги. А для композиции отображений есть закон, которым мы можем воспользоваться.
// Закон композиции отображений
compose(map(f), map(g)) === map(compose(f, g));
Мы можем использовать это свойство для оптимизации кода. Давайте проведём рефакторинг на основании этого.
// Текущий код
const mediaUrl = compose(prop('m'), prop('media'));
const mediaUrls = compose(map(mediaUrl), prop('items'));
const images = compose(map(img), mediaUrls);
Давайте объединим методы, использующие map. Мы можем встроить mediaUrls
в images
благодаря эквациональному рассуждению в отношении этих чистых функций.
const mediaUrl = compose(prop('m'), prop('media'));
const images = compose(map(img), map(mediaUrl), prop('items'));
Теперь, когда мы расположили отображения последовательно, к ним можно применить закон композиции.
/*
compose(map(f), map(g)) === map(compose(f, g));
compose(map(img), map(mediaUrl)) === map(compose(img, mediaUrl));
*/
const mediaUrl = compose(prop('m'), prop('media'));
const images = compose(map(compose(img, mediaUrl)), prop('items'));
Теперь для преобразования ответа api в набор картинок интерпретатору потребуется пройти только один цикл. Напоследок, выделим одну функцию, чтобы немного упростить код.
const mediaUrl = compose(prop('m'), prop('media'));
const mediaToImg = compose(img, mediaUrl);
const images = compose(map(mediaToImg), prop('items'));
Мы рассмотрели, как применить полученные навыки в работе над небольшим, но практичным приложением. Мы применяли законы математики, чтобы осмыслять и рефакторить наш код. А что насчёт обработки ошибок и ветвления кода? Как нам добиться чистоты для кода приложения целиком, не ограничиваясь оформлением небезопасных функций в отдельном пространстве имен? Что может сделать наше приложение более безопасным и выразительным? С этими вопросами мы разберёмся во второй части книги.