Не стоит бояться функционального программирования

в 3:22, , рубрики: javascript, underscore, визуализация, визуализация данных, переводы, функциональное программирование

Представляю вашему вниманию перевод проскользнувшей недавно в ссылках дайджеста статьи Джонатана Моргана о функциональном программировании на примере JavaScript. Материал рассчитан на начинающих, но тем не менее она достаточно интересна.

Буду признателен за конструктивные замечания и предложения по опечаткам, переводу и/или оформлению. Приятного чтения!

Не стоит бояться функционального программирования

Функциональное программирование — усатый хипстер от парадигм программирования. Будучи продуктом времён академического становления компьютерных наук, функциональное программирование не так давно пережило возрождение благодаря большой пользе при использовании в распределённых системах (и, вероятно, благодаря тому, что «чистые» функциональные языки вроде Haskell непросто понять, что также накладывает свой отпечаток).

Более строгие функциональные языки программирования, как правило, используются, когда производительность и слаженность критичны — например, если программа всегда должна делать именно то, что от неё ожидается, и должна работать в среде, где задачи могут быть разделены между сотнями и тысячами объединённых в сеть компьютеров. Clojure, например, лежит в основе Akamai, огромной сети доставки контента, используемой компаниями вроде Facebook, а Twitter отлично адаптировал Scala для своих самых требовательных к производительности компонентов, а AT&T использует Haskell для систем безопасности своих сетей.

Для большинства фронтенд-разработчиков эти языки имеют крутую кривую обучения; однако, многие более простые и доступные языки имеют черты функционального программирования — хотя бы тот же Python с его стандартной библиотекой и её функциями вроде map и reduce (о которых мы ещё поговорим) и сторонними библиотеками вроде Fn.py, или же JavaScript, активно использующий методы работы с коллекциями и имеющий библиотеки вроде Underscore.js и Bacon.js.

Не стоит бояться функционального программирования

Функциональное программирование, конечно, может казаться сложным, но нужно помнить, что оно создано не только для докторов наук, приверженцев науки о данных и гуру архитектуры систем. Для большинства настоящая польза от функционального стиля в том, что программы могут быть разделены на мелкие и простые части, более надёжные и значительно более понятные одновременно. Если вы — фронтенд-разработчик, работающий с данными, особенно если используете D3, Raphael или похожие библиотеки для визуализации, то функциональное программирование может стать серьёзным оружием в вашем арсенале.

Найти строгое определение функционального программирования непросто, и большая часть литературы полагается на утверждения вроде «ну, там функции — объекты первого класса» и «исчезают побочные эффекты». Если это ещё не порвало ваш мозг на кусочки, то ближе к теории функциональное программирование часто объясняется в терминах лямбда-исчисления (хотя некоторые утверждают, что функциональное программирование, в принципе, просто математика), но прошу, не беспокойтесь. На самом деле новичку достаточно понять только две идеи для того, чтобы использовать функциональное программирование для своих ежедневных задач (и без всяких лямбд!).

Во-первых, при функциональном подходе данные должны быть неизменны (иммутабельны), что звучит сложновато, но это просто значит, что они не могут меняться. На первый взгляд это может выглядеть странно (в конце концов, кому нужна программа, которая ничего не меняет?), но на деле вы просто постоянно создаёте новые структуры данных вместо изменения существующих. Например, если вам нужно сделать что-то с массивом, вы создаёте новый массив с новыми значениями вместо изменения старого массива. Просто же!

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

Неизменяемость данных и отсутствие состояния — основы функционального программирования, и крайне важно их понять, но не переживайте, если для вас всё по-прежнему туманно. К концу статьи вы поймёте суть этих принципов, и я обещаю, что красота, аккуратность и мощь функционального программирования превратят ваш код в яркую, блестящую, грызущую данные радугу. Но пока начнём с простых функций, возвращающих данные (или другие функции), а затем объединим их для выполнения более сложных задач.

К примеру, пусть у нас есть некоторый ответ от API:

var data = [{
    name: "Jamestown",
    population: 2047,
    temperatures: [-34, 67, 101, 87]
}, {
    name: "Awesome Town",
    population: 3568,
    temperatures: [-3, 4, 9, 12]
}, {
    name: "Funky Town",
    population: 1000000,
    temperatures: [75, 75, 75, 75, 75]
}];

Если мы хотим использовать графическую библиотеку для сопоставления средней температуры и населения, нам нужно написать немного кода, который готовит данные к визуализации. Пусть наша библиотека для построения графиков ждёт на вход массив x — и y-координат вроде такого:

[
    [x, y],
    [x, y]
    // ... и т.д.
]

где x — средняя температура, а y — население.

Без функционального программирования (или без его применения, т.е. в императивном стиле) наш код выглядел бы как-то так:

var coords = [],
    totalTemperature = 0,
    averageTemperature = 0;

for (var i = 0; i < data.length; i++) {
    totalTemperature = 0;

    for (var j = 0; j < data[i].temperatures.length; j++) {
        totalTemperature += data[i].temperatures[j];
    }

    averageTemperature = totalTemperature / data[i].temperatures.length;
    coords.push([averageTemperature, data[i].population]);
}

Даже в этом высосанном из пальца примере непросто за все уследить. Что ж, посмотрим, как сделать всё понятнее.

Для функционального подхода всегда характерен поиск простых, часто повторяемых действий, которые могут быть выделены в функции. Затем можно делать более сложные действия, вызывая эти функции в определённом порядке (что также называется композицией функций) — скоро я остановлюсь на этом подробнее. Определим, что нужно сделать, чтобы привести исходные данные в формат, требуемый библиотекой. Посмотрев код краем глаза, можно выделить следующие действия:

  • добавить каждое число в список;
  • посчитать среднее значения;
  • извлечь каждое свойство из списка объектов.

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

  • все ваши функции должны принимать хотя бы один параметр;
  • все ваши функции должны возвращать или данные, или другую функцию;
  • никаких циклов!

Что ж, начнём разработку. Шаг первый — добавим каждое число в список. Сделаем функцию, принимающую параметр — массив чисел, и возвращающую некоторые данные.

function totalForArray(arr) {
    // любой код
    return total;
}

Пока всё хорошо, но… как же нам получить доступ к каждому элементу списка, если мы не можем циклически его обойти? Скажите привет вашему новому товарищу — рекурсии! Когда вы используете рекурсию, вы создаёте функцию, которая вызывает себя, если не выполнилось специальное условие выхода — в этом случае просто возвращается текущее значение. Может быть непонятно, но посмотрите на этот простейший пример:

// Обратите внимание, что мы принимаем два значения — список и текущий результат
function totalForArray(currentTotal, arr) {
    currentTotal += arr[0];

    // Заметка для опытных JavaScript-программистов: я не использую Array.shift,
    // потому что в статье мы ведём себя с массивами так, как будто они неизменяемые
    var remainingList = arr.slice(1);

    // Вызываем эту же функцию, передавая остаток (хвост) списка как параметр,
    // а currentTotal — текущее значение
    if (remainingList.length > 0) {
        return totalForArray(currentTotal, remainingList);
    }

    // Конечно, если список не пуст — в этом случае мы просто возвращаем currentTotal
    else {
        return currentTotal;
    }
}

Осторожно: Рекурсия делает код более удобочитаемым и естественна для программирования в функциональном стиле. Однако, в некоторых языках (включая JavaScript) возникнут проблемы, если программа сделает слишком много рекурсивных вызовов (на момент написания статьи «слишком много» — это примерно 10000 вызовов в Chrome, 50000 вызовов в Firefox и 11000 вызовов в Node.js). Детальный разбор выходит за границы этой статьи, но суть в том, что по крайней мере до окончательного релиза ECMAScript 6 JavaScript не будет поддерживать кое-что, называемое «хвостовой рекурсией» — более эффективно организованную форму рекурсии. Эта сложная и нечасто встречающаяся тема, но тем не менее о ней полезно знать.

Теперь вернёмся к нашей задаче — нам нужно подсчитать общую температуру по массиву температур, чтобы затем вычислить среднюю. Теперь вместо обхода массива в цикле мы можем просто написать:

var totalTemp = totalForArray(0, temperatures);

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

function addNumbers(a, b) {
    return a + b;
}

Теперь наша функция totalForArray выглядит как-то так:

function totalForArray(currentTotal, arr) {
    currentTotal = addNumbers(currentTotal, arr[0]);
    var remainingArr = arr.slice(1);

    if (remainingArr.length > 0) {
        return totalForArray(currentTotal, remainingArr);
    } else {
        return currentTotal;
    }
}

Превосходно! Получение единственного значения по массиву настолько часто встречается в функциональном программировании, что даже имеет собственное название — "свёртка" — вы, может, слышали в качестве глагола — что-то вроде «свернуть список к одному значению». В JavaScript есть специальный метод для выполнения этой задачи. На Mozilla Developer Network есть подробное описание, но для нашей задачи пока достаточно примера:

// Функция свёртки принимает первым аргументом некоторую функцию, которая ожидает
// в качестве параметров текущий элемент и текущий результат вычислений
var totalTemp = temperatures.reduce(function(previousValue, currentValue) {
    // После завершения вычисления следующее значение currentValue будет равно previousValue + currentValue,
    // а следующее значение previousValue будет равно следующему элементу массива
    return previousValue + currentValue;
});

Но подождите-ка, мы же уже определили функцию addNumber, поэтому мы можем использовать её:

var totalTemp = temperatures.reduce(addNumbers);

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

function totalForArray(arr) {
    return arr.reduce(addNumbers);
}

var totalTemp = totalForArray(temperatures);

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

Что ж, двигаемся дальше; следующая задача по нашему списку — подсчёт среднего значения. Это очень просто.

function average(total, count) {
    return total / count;
}

Как далеко мы зайдём для подсчёта среднего значения по всему массиву?

function averageForArray(arr) {
    return average(totalForArray(arr), arr.length);
}

var averageTemp = averageForArray(temperatures);

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

Теперь нужно извлечь по одному свойству из каждого объекта массива. Вместо того, чтобы показывать примеры рекурсии, я сосредоточусь на важном и покажу ещё один встроенный в JavaScript метод: map. Этот метод нужен, если надо отобразить массив структур одного типа в список структур другого типа:

// Метод принимает единственный параметр — функцию, ожидающую параметр,
// равный текущему элементу списка
var allTemperatures = data.map(function(item) {
    return item.temperatures;
});

Всё это круто, но извлечение свойства из коллекции объектов — что-то, что делается постоянно, поэтому напишем функцию и для этого.

// Параметр — имя свойства, которое нужно получить
function getItem(propertyName) {
    // Возвращаем функцию, которая извлекает значение, но не выполняем её сразу.
    // Вызывать её будет метод, который будет выполнять действия над нашим массивом.
    return function(item) {
        return item[propertyName];
    }
}

Ого, мы написали функцию, которая возвращает функцию! Теперь мы можем передать её в метод map:

var temperatures = data.map(getItem('temperature'));

Если вы любите детали, то объясню: причина, по которой мы можем это делать, в том, что в JavaScript функции — «объекты первого класса», что означает, что вы можете использовать функцию так же, как и любое другое значение. Хотя так можно делать во многих языках, это одно из требований для того, чтобы язык мог использоваться для программирования в функциональном стиле. Между прочим, это также причина, по которой вы можете делать штуки вроде $('#my-element').on('click', function(e) { //... }). Второй параметр метода on — функция, и когда вы передаёте функции как параметры, вы используете их так же, как использовали бы обычные значения в императивных языках. Изящно.

Наконец, обернём вызов метода map в собственную функцию, чтобы сделать более удобочитаемым.

function pluck(arr, propertyName) {
    return arr.map(getItem(propertyName));
}

var allTemperatures = pluck(data, 'temperatures');

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

var data = [{
    name: "Jamestown",
    population: 2047,
    temperatures: [-34, 67, 101, 87]
  }, {
    // ...
}];

Нам нужно преобразовать массив объектов вроде того, что указан выше, в массив пар x, y:

[
    [75, 1000000],
    // ...
];

где x — средняя температура, а y — население. Во-первых, извлечём нужные нам данные:

var populations = pluck(data, 'population');
var allTemperatures = pluck(data, 'temperatures');

Теперь посчитаем список средних значений. Нужно помнить, что функция, которую мы передаём в метод map, будет вызвана для каждого элемента массива; возвращаемое переданной функцией значение будет добавлено в новый массив, и этот новый массив будет присвоен нашей переменной averageTemps.

var averageTemps = allTemperatures.map(averageForArray);

Пока всё OK, но теперь у нас два массива:

// populations
[2047, 3568, 1000000]

// averageTemps
[55.25, 5.5, 75]

Но нам же нужен только один массив, так что напишем функцию для их объединения. Наша функция будет проверять, что элемент по индексу 0 из первого массива состоит в паре с элементом по индексу 0 из второго массива, и так далее для остальных индексов с 1 по n (где n — общее количество элементов каждого из массивов) [поскольку в чистом JavaScript отсутствует zip — прим. переводчика].

function combineArrays(arr1, arr2, finalArr) {
    // Устанавливаем значение по умолчанию
    finalArr = finalArr || [];

    // Добавляем текущий элемент каждого из массивов в результирующий
    finalArr.push([arr1[0], arr2[0]]);

    var remainingArr1 = arr1.slice(1),
    remainingArr2 = arr2.slice(1);

    // Если оба массива пусты, то мы закончили
    if ((remainingArr1.length === 0) && (remainingArr2.length === 0)) {
        return finalArr;
    } else {
        // Рекурсия!
        return combineArrays(remainingArr1, remainingArr2, finalArr);
    }
};

var processed = combineArrays(averageTemps, populations);

Однострочники — это весело:

var processed = combineArrays(pluck(data, 'temperatures').map(averageForArray), pluck(data, 'population'));

// [
//  [ 55.25, 2047 ],
//  [ 5.5, 3568 ],
//  [ 75, 1000000 ]
// ]

Ближе к жизни

Напоследок рассмотрим более близкий к реальной жизни пример, на этот раз добавив к нашему инструментарию Underscore.js — JavaScript-библиотеку, предоставляющую множество замечательных вспомогательных функций. Мы будем получать данные с CrisisNET — сервиса для сбора информации о столкновениях и катастрофах, и визуализировать их с помощью потрясающей библиотеки D3.

Цель заключается в том, чтобы дать посетителям CrisisNET картинку о всех типах информации, предлагаемых сервисом. Для этого мы будем подсчитывать количество полученных через API сервиса документов, соответствующих некоторой категории (например, «физическое насилие» или «вооружённый конфликт»). Так пользователь сможет увидеть, как много информации доступно по тем темам, которые кажутся ему наиболее интересными.

Пузырьковая диаграмма может быть отличным вариантом, потому что они часто используются для представления относительных размеров больших групп людей. К счастью, D3 поддерживает специальный тип диаграмм, именуемый pack. Что ж, давайте сделаем такой график, и пусть он показывает количество раз, которое указанное имя категории появляется в ответе от CrisisNET API.

Перед тем, как мы начнём, следует напомнить, что D3 — сложная библиотека, требующая отдельного изучения. Поскольку эта статья посвящена функциональному программированию, мы не будем тратить время на описание того, как работает D3. Не переживайте — если вы ещё не знакомы с этой библиотекой, вы всегда можете скопипастить код разобрать его. Самоучитель по D3 Скотта Мюррея — отличный источник знаний по этой библиотеке.

Первым делом убедимся, что у нас есть DOM-элемент, в который D3 сможет поместить сгенерированный по нашим данным график.

<div id="bubble-graph"></div>

Теперь создадим нашу диаграмму и поместим её в DOM.

// ширина диаграммы
var diameter = 960,
    format = d3.format(",d"),
    // Создаёт шкалу с 20 цветами
    color = d3.scale.category20c(),

// Объект диаграммы, в который мы будем помещать данные
var bubble = d3.layout.pack()
    .sort(null)
    .size([diameter, diameter])
    .padding(1.5);

// Добавляем в DOM SVG-объект, который D3 будет использовать для рисования
var svg = d3.select("#bubble-graph").append("svg")
    .attr("width", diameter)
    .attr("height", diameter)
    .attr("class", "bubble");

Объект pack принимает массив объектов следующего формата:

{
    children: [{
        className: ,
        package: "cluster",
        value:
    }]
}

CrisisNET API возвращает данные следующего формата:

{
    data: [{
        summary: "Example summary",
        content: "Example content",
        // ...
        tags: [{
            name: "physical-violence",
            confidence: 1
        }]
    }]
}

Мы видим, что каждый документ имеет свойство «тег» (tag), и это свойство содержит массив элементов, каждый из которых имеет свойство name, содержащее имя — оно-то нам и нужно. Нам придётся подсчитать количество того, сколько раз имя каждого тега встречается в результатах, возвращаемых CrisisNET API. Начнём с того, что извлечём информацию, которая нам нужна, используя уже написанную функцию pluck.

var tagArrays = pluck(data, 'tags');

Теперь у нас есть массив такого формата:

[
    [{
        name: "physical-violence",
        confidence: 1
    }], [{
        name: "conflict",
        confidence: 1
    }]
]

Но нам нужен единственный массив всех тегов. Хм, воспользуемся удобной функцией flatten из Underscore.js — она извлечёт значения из вложенных массивов и вернёт нам один общий массив без вложенности.

var tags = _.flatten(tagArrays);

Что ж, теперь с нашим массивом немного проще работать:

[{
    name: "physical-violence",
    confidence: 1
}, {
    name: "conflict",
    confidence: 1
}]

Теперь снова воспользуемся функцией pluck для получения того, что нам нужно — простого списка имён тегов.

var tagNames = pluck(tags, 'name');

[
    "physical-violence",
    "conflict"
]

Да, так намного лучше.

Теперь мы займёмся относительно прямолинейной задачей — подсчётом количества раз, которое каждое имя тега встречается в нашем списке, и преобразованием этого списка в структуру, которая нужна для построения диаграммы. Как вы могли заметить, массивы весьма популярны в функциональном программировании — большая часть инструментов спроектирована с расчётом на массивы [автор часто пишет «массивы» («arrays»), но имеет в виду, очевидно, списки, что в общем случае совсем не обязательно одно и то же — прим. переводчика]. Для начала мы создадим вот такой массив:

[
  ["physical-violence", 10],
  ["conflict", 27]
]

Здесь каждый элемент массива представляет собой массив, в которой нулевой элемент — имя тега, а первый элемент — количество, которое тег встречается в результатах. Для начала создадим массив, в котором каждое имя тега встречается единственный раз. К счастью, Underscore.js предоставляет метод для этой цели.

var tagNamesUnique = _.uniq(tagNames);

Также избавимся от всех false-значений (false, null, "" и т.д.) y-координаты, применив другую функцию из Underscore.js:

tagNamesUnique = _.compact(tagNamesUnique);

Теперь накатаем функцию, которая сгенерирует наш массив, используя другой встроенный в JavaScript метод — filter, который выбирает только те значения, которые удовлетворяют некоторому условию.

function makeArrayCount(keys, arr) {
    // Для каждого уникального имени тега
    return keys.map(function(key) {
        return [
            key,
            // Фильтруем по имени тега и возвращаем количество
            arr.filter(function(item) {
                return item === key;
            }).length
        ]
    });
}

Теперь мы легко можем создать структуру данных, которая требуется D3 для построения диаграммы pack, отобразив структуру функцией map.

var packData = makeArrayCount(tagNamesUnique, tagNames).map(function(tagArray) {
    return {
        className: tagArray[0],
        package: "cluster",
        value: tagArray[1]
    }
});

Наконец, мы можем передать эти данные D3 и сгенерировать нужные DOM-элементы в нашем SVG — под одному кружку на каждое имя тега с размерами, пропорциональными общему количеству раз, которое имя этого тега встречается в ответе API CrisisNET.

function setGraphData(data) {
var node = svg.selectAll(".node")
    // Сюда мы передаём наши данные
    .data(bubble.nodes(data)
    .filter(function(d) {
        return !d.children;
    }))
    .enter().append("g")
    .attr("class", "node")
    .attr("transform", function(d) {
        return "translate(" + d.x + "," + d.y + ")";
    });

// Добавляем кружок для каждого имени тега
node.append("circle")
    .attr("r", function(d) {
        return d.r;
    })
    .style("fill", function(d) {
        return color(d.className);
    });

// Добавляем подпись с именем тега к каждому кружку
node.append("text")
    .attr("dy", ".3em")
    .style("text-anchor", "middle")
    .style("font-size", "10px")
    .text(function(d) {
        return d.className
    });
}

Объединив всё вместе, мы можем посмотреть работу функций setGraphData и makeArray в общем контексте, включая запрос к API CrisisNET с помощью jQuery (правда, для этого нужно получить API-ключ). Я также разместил полностью рабочий код этого примера на GitHub.

function processData(dataResponse) {
    var tagNames = pluck(_.flatten(pluck(dataResponse.data, 'tags')), 'name');
    var tagNamesUnique = _.uniq(tagNames);

    var packData = makeArrayCount(tagNamesUnique, tagNames).map(function(tagArray) {
        return {
            className: tagArray[0],
            package: "cluster",
            value: tagArray[1]
        }
    });

    return packData;
}

function updateGraph(dataResponse) {
    setGraphData(processData(dataResponse));
}

var apikey = // Получить API-ключ можно здесь: http://api.crisis.net
var dataRequest = $.get('http://api.crisis.net/item?limit=100&apikey=' + apikey);

dataRequest.done(updateGraph);

Это было непростое и глубокое погружение, поэтому остаётся только поздравить с завершением! Как я говорил раньше, проникнуться представленными идеями вначале может быть непросто, однако всеми силами противьтесь соблазну вернуться к императивным циклам!

За несколько недель использования принципов функционального программирования вы быстро построите простые, пригодные к повторному использованию функции, которые повысят удобочитаемость вашего кода. К тому же, вы сможете значительно быстрее работать со структурами, несколькими строчками делая то, что раньше приходилось напряжённо отлаживать по полчаса. А раз ваши данные будут правильно преобразованы, у вас останется больше времени на приятную часть — на то, чтобы всё выглядело шикарно!

Автор: devlato

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js