За всеми этими разговорами о новых стандартах легко забыть о том, что именно ECMAScript 5 подарил нам ряд инструментов, благодаря которым мы сегодня можем использовать функциональное программирование в JavaScript. Например, нативные методы map() и reduce() на базе JS-объекта
Array. Если вы до сих пор не пользуетесь map() и reduce(), то сейчас самое время начать. Наиболее современные JS-платформы нативно поддерживают ECMAScript 5. Использование этих методов позволит сделать ваш код гораздо чище, читабельнее и удобнее в обслуживании. map() и reduce() помогут вам встать на путь более элегантной функциональной разработки. Замечание по производительности
Безусловно, читабельность и обслуживаемость кода не должны снижать производительность, если этого требует ситуация. Современные браузеры эффективнее выполняют более громоздкие традиционные конструкции, например, циклы.
Попробуйте следующую методику: сначала напишите код, исходя из критериев читабельности и обслуживаемости, а затем оптимизируйте его производительность, если в этом есть реальная потребность. Избегайте преждевременной оптимизации.
Также стоит отметить, что применение методов наподобие
map() и reduce() позволит извлечь больше преимуществ из улучшений JS-движка, по мере того, как браузеры будут оптимизироваться для их использования. Если у вас нет проблем с производительностью, то лучше писать код с оптимистическим расчётом на будущее. А приёмы повышения производительности, делающие код менее опрятным, оставьте на потом, когда в них возникнет потребность.Использование map
Маппинг — фундаментальная методика в функциональном программировании. Она применяется для оперирования всеми элементами массива с целью создания другого массива той же длины, но с преобразованным содержимым.
Чтобы было понятнее, давайте рассмотрим простой пример. Допустим, у вас есть массив слов, и вам нужно преобразовать его в массив, содержащий длины всех слов исходного массива. Да, это не самый актуальный случай, но понимание того, как работает этот инструмент, поможет вам применять его в дальнейшем для улучшения своего кода.
Вероятно, вы уже знаете, как выполнить описанную задачу с помощью цикла
for. Например, так:var animals = ["cat","dog","fish"];
var lengths = [];
var item;
var count;
var loops = animals.length;
for (count = 0; count < loops; count++){
item = animals[count];
lengths.push(item.length);
}
console.log(lengths); //[3, 3, 4]Здесь определено несколько переменных:
- массив
animalsсодержит исходные слова, - пустой массив
lengthsбудет содержать результаты выполнения операции, - переменная
itemиспользуется для временного хранения каждого из элементов массива, которым мы манипулируем во время выполнения каждого цикла, - массив
forсодержит внутреннюю переменнуюcountи оптимизирован с помощью переменной loops.
Далее мы итерируем каждый элемент массива
animals: вычисляем длину и помещаем в массив lengths.Примечание: эту задачу можно было бы решить лаконичнее, без переменной элемента и промежуточного присваивания, передавая длину
animals[count] напрямую в массив lengths. Код стал бы немного короче, но и менее читабельным даже в этом простом примере. Аналогично, чтобы слегка повысить производительность, можно было бы использовать известную длину массива animals для инициализации массива lengths как new Array(animals.length), а затем вместо применения push внести элементы по индексу. Но это тоже сделало бы код немного менее понятным. В общем, всё зависит от того, как вы будете использовать свой код в реальных проектах.Технически это правильный подход. Он будет работать на любом стандартном JS-движке. Но когда вы узнаете про
map(), то классический способ сразу покажется слишком громоздким.Вот как можно решить нашу задачу с помощью
map():var animals = ["cat","dog","fish"];
var lengths = animals.map(function(animal) {
return animal.length;
});
console.log(lengths); //[3, 3, 4]Здесь мы опять начинаем с переменной для массива
animals. Но кроме неё мы объявляем только lengths, и напрямую присваиваем ей результат, полученный при маппинге анонимной встраиваемой функции в каждый элемент массива animals. Анонимная функция выполняет операцию по каждому животному и возвращает длину. В конце концов массив lengths, содержащий длины каждого слова, становится такой же длины, как и исходный animals.Обратите внимание, что при таком подходе:
- Код получается гораздо короче.
- Нужно объявлять гораздо меньше переменных. Следовательно, мы создаём меньше шума в глобальном пространстве имён, снижая вероятность возникновения коллизий, если другая часть того же кода использует переменные с теми же именами.
- Ни одной переменной не нужно менять своё значение от начала и до конца цикла. По мере изучения функционального программирования вы будете всё больше ценить изящную силу использования констант и неизменяемых переменных. И начинать никогда не поздно.
Ещё одно преимущество этого подхода заключается в том, что мы можем сделать его гибче, разделив именованную функцию. При этом код станет чище. Анонимные встраиваемые функции затрудняют повторное использование кода и могут выглядеть неопрятно. Можно было бы определить именованную функцию
getLength() и использовать её следующим образом: var animals = ["cat","dog","fish"];
function getLength(word) {
return word.length;
}
console.log(animals.map(getLength)); //[3, 3, 4]Посмотрите, насколько чище стал выглядеть код. Просто начните применять маппинг, и сразу выйдете на новый уровень функционального программирования.
Что такое функтор?
Любопытно, что при добавлении маппинга в объект массива, ECMAScript 5 превращает основной тип массива в полный функтор. Это делает функциональное программирование ещё более доступным.
Согласно классическим определениям функционального программирования, функтор удовлетворяет трём критериям:
- Содержит набор значений.
- Реализует функцию
mapдля оперирования каждым элементом. - Функция
mapвозвращает функтор того же размера.
Если хотите больше узнать о функторах, можете посмотреть видео Маттиаса Питера Йоханссона.
Использование reduce
Метод reduce() впервые появился в ECMAScript 5. Он аналогичен
map(), за исключением того, что вместо создания другого функтора reduce() производит единичный результат, который может быть любого типа. Например, вам нужно получить в виде числа сумму длин всех слов в массиве animals. Вероятно, вы сразу напишете примерно так:var animals = ["cat","dog","fish"];
var total = 0;
var item;
for (var count = 0, loops = animals.length; count < loops; count++){
item = animals[count];
total += item.length;
}
console.log(total); //10После описания начального массива, мы создаём переменную
total для подсчёта суммы, и присваиваем ей ноль. Также создаём переменную item, в которой, по мере выполнения цикла for, сохраняется результат каждой итерации над массивом animals. В качестве счётчика циклов используется переменная count, а loops применяется для оптимизации итераций. Запускаем цикл for, итерируем все слова в массиве animals, присваивая значение каждого из них переменной item, и прибавляем длины слов к нарастающему итогу.Опять же, чисто технически здесь всё в порядке. Обработали массив, получили результат. Но с помощью метода
reduce() можно это сделать гораздо проще:var animals = ["cat","dog","fish"];
var total = animals.reduce(function(sum, word) {
return sum + word.length;
}, 0);
console.log(total);Здесь мы определяем новую переменную
total и присваиваем ей значение результата, полученного после применения reduce к массиву animals с использованием двух параметров: анонимной встраиваемой функции и нарастающего итога. reduce берёт каждый элемент массива, применяет к нему функцию и добавляет получаемый результат к нарастающему итогу, которая затем передаётся в следующую итерацию. Подставляемая функция получает два параметра: нарастающий итог и текущее обрабатываемое слово из массива. К длине этого слова функция добавляет текущее значение total.Обратите внимание, что мы обнуляем второй аргумент
reduce(), значит total является числом. Метод reduce() будет работать и без второго аргумента, но результат может отличаться от ожидаемого. Попробуйте сами определить, какую логику использует JavaScript при исключении total.Возможно, описанный подход выглядит слишком сложным. Это следствие интегрированного определения встраиваемой функции в вызываемом методе
reduce(). Давайте зададим именованную функцию вместо анонимной встраиваемой:var animals = ["cat","dog","fish"];
var addLength = function(sum, word) {
return sum + word.length;
};
var total = animals.reduce(addLength, 0);
console.log(total);Получается немного длиннее, но это не всегда недостаток. В данном случае становится понятнее, что происходит с методом
reduce(). Он получает два параметра: функцию, которая применяется к каждому элементу массива, и начальное значение нарастающего итога. В данном случае мы передаём имя новой функции addLength начальное значение (нулевое) нарастающего итога. Функция addLength() также получает два параметра: нарастающий итог и строковое значение.Заключение
Привыкнув регулярно использовать
map() и reduce(), вы сможете сделать свой код чище, гибче и легче в обслуживании. Это откроет вам дорогу к использованию других функциональных подходов в JavaScript.Помимо
map() и reduce() в ECMAScript 5 появились и другие новые методы. Вероятно, улучшение качества кода и удовольствие от разработки, которое вы прочувствуете, намного перевесят временное ухудшение производительности. Используйте функциональные подходы и измеряйте влияние на производительность в реальных проектах, вместо того, чтобы думать, а нужны ли map() и reduce() в вашем приложении.Комментарии (20)

JPEG
20.03.2017 13:46А если бы в конце статьи вы немного коснулись композиции, то можно было бы вдохновенно говорить о map-reduce как о базовом примитиве вычисления всего на свете.

samsergey
20.03.2017 14:10Увы, о композиции в С-подобном синтаксисе говорить очень неудобно. А рассуждать о её изяществе даже опасно, засмеют и композицию заругают. Недаром у Lisp, Haskell, F# и J такой "странный" синтакис :)

JPEG
20.03.2017 14:17Согласен, но если далеко не забегать, то современный синтаксис читается вполне хорошо:
const length = str => str.length; const sum = (sum, n) => sum + n; animals.map(length).reduce(sum, 0);
S_A
20.03.2017 16:41+1Периодически у меня в голове всплывает вопрос, а любой ли частично-рекурсивный алгоритм (эквивалентно алгоритмам на тьюринговой машине) можно уложить в filter-map-reduce? Изначально мне представлялось да. Потом загуглил на автомате по привычке :(
Толкового ответа не нашел, только кучу пространных рассуждений и понимание, что я не одинок в своих заморочках. Наиболее ценное что нашел, это что если добавить рекурсию, то похоже что да.
Но наличие рекурсии это очень сильное требование. По сути, (и частично-, и без этого расширения) рекурсивные функции можно выполнять на машине тьюринга. Вопрос у меня скорее теперь такой — можно ли сделать и filter, и map, и reduce рекурсиями?
Навскидку, на пальцах, и не претендуя на 100%-ую точность.
// псевдоязык, "+" это сложение массивов
map = function(array, method, index = 0) => array.length == 0 ? [] : map(array[0, index - 1], method, 0) + [method(array[index])] + map(array[index + 1, array.length], method, 0); filter = function(array, predicate, index = 0) => array.length == 0 ? [] : filter(array[0, index - 1], predicate, 0) + predicate(array[index]) ? array[index] : [] + filter(array[index + 1, array.length, predicate, 0); reduce = function(array, method, initial, index = array.length) => array.length == 0 ? initial : method(reduce(array[0, index - 1], method, initial, index + 1), array[index]);
Это для иллюстрации хода мыслей, не более. Я не скажу, что очень глубоко в вопросе. Буду рад, если кто просветлит конкретно. Смущает наличие сравнения, так как возможности программировать условные переходы на основе сравнения — это всё, уже и есть машина Тьюринга. То есть добавление рекурсии по сути — это и есть создание машины Тюринга, а не map-filter-reduce.
Еще раз, буду очень рад если кто разъяснит вопрос, потому что иногда как-будто ежа под череп загнали с этим вопросом: по идее map — это биективное отображение, filter — отображение с потерей (проекция), reduce — взятие координаты. В таком ключе может показаться что есть всё, что нужно. Беда похоже только в том, что это нельзя это повторять, не имея операции ветвления/прерывания. И это похоже то что отличает машину Тьюринга (и класс реализуемых на ней алгоритмов) от класса map-filter-reduce.S_A
20.03.2017 17:54Возможно, я не прав (буду только рад ошибиться), но простое
while (true) { console.log("Hello!"); }
Уже не укладывается в filter-map-reduce, исходя из моих рассуждений.
Это конечно не есть пример полезной программы, но как идея.

samsergey
20.03.2017 22:35+1В рамках чистого ФП функции
mapиfilterреализуются черезreduce, так что ваш вопрос сводится к такому: какой класс задач можно решить с помощью свёртки (катаморфизма)? Не претендуя на исчерпывающий и математически точный ответ можно сказать, что одна только свёртка позволяет обрабатывать конечные индуктивные данные. С её помощью можно построить конечный автомат, или автомат с магазинной памятью. Они не эквивалентны машине Тьюринга, но способны вычислять примитивно рекурсивные функции. На вскидку, с помощьюreduceи без изменяемых состояний можно построить программу, эквивалентную программе, использующей только циклfor(точнее,foreach, C-шныйforэто переодетыйwhile). Это хороший и полезный класс программ, гарантирующий тотальность. Для реализации циклаwhileпотребуется ленивый генератор данных для свёртки: анаморфизм. Их композиция — хиломорфизм, уже Тьюринг полна. Повторюсь, все эти рассуждения имеют смысл при отсутствии изменяемого состояния, так как если сворачивающая функция может получить доступ к ссылке на сворачиваемые данные и изменять их на ходу, то это уже не катаморфизм, а бардак. В таком случае ответ на ваш вопрос будет таким: да, свёртка тьюринг-полна, но она тут ни при чём, это просто цикл. Кстати, в С# изменять итератор циклаforeachзапрещено на уровне языка.S_A
21.03.2017 03:43Спасибо!!! Ну раз через reduce и map и filter, то и действительно, получаем класс алгоритмов конечного автомата (примитивно-рекурсивные функции). И это значит что HTML (или какую другую БНФ) в общем случае на нём уже не распарсить, например.
Насчет foreach в C# я в курсе, но while(true); там сделать можно, и в этом выражении нет изменяемых данных. Насчет «просто цикл» — просто или не просто, но уже тьюринговая полнота.
samsergey
21.03.2017 05:24+1Используя свёртку можно построить детерминированный автомат с магазинной памятью, а значит, с его помощью можно распознать и транслировать любую контекстно-свободную грамматику. Вот, например, анализатор языка Дика (правильных скобочных выражений) с использованием левой свёртки:
brackets :: String -> Bool brackets expr = foldl pda [0] expr == [0] where pda [] _ = [] pda (x:xs) c = delta c x ++ xs delta '(' 0 = [1,0] delta '(' 1 = [1,1] delta ')' _ = []
Это Haskell, на JS можно написать как-то так:
function brackets(s) { return s.reduce(f,[0]) function f(stack,x) { return stack == [] ? [] : stack.concat(delta(stack.pop(),x)) } function delta(s,x) { if (x == ')') return [] if (s == 0) return [0,1] if (s == 1) return [1, 1] } }S_A
21.03.2017 05:38Ну вы же уже сказали, что свёртка Тьринг-полная. Поэтому у меня уже этот вопрос отлип.
Уже другим голову грею — проблемой остановки, точнее её альтернативной формулировкой.
Ушел в прострацию :)
Например, если представить МТ вот как-то так
data = initial; while (predicate(data)) data = method(data);
Если взять за A всю область определения method, то множество (method(A), predicate(method(A))) похоже не должно содержать замкнутых путей. Вряд ли я тут что-то решил (или даже переформулировал), но в качестве прокрастинации, сидя на работе, сгодится :)
samsergey
21.03.2017 07:14+1Свёртка не тьюринг-полна, она как раз и гарантирует тотальность при обработке конечных данных тотальной функцией. В этом её преимущество и ограничение: за пределы КС-языков со свёрткой уже не выйти, нужна машина Тьюринга, цикл
whileили генерация данных для свёртки.
А вот что вы понимаете под множеством
(method(A), predicate(method(A))) и замкнутыми путями я не очень понял.
Если рассматриваемый язык строго типизирован и все функции чистые, то
data : A method : A -> A predicate : A -> Bool
а значит, область значения функции
methodи область определения предиката совпадают.
Описанный вами паттерн это полноценный вычислитель, в котором анаморфизмом является итератор, а катаморфизмом — функция
dropWhile, определяемая через свёртку:
head . dropWhile predicate . iterate method $ initial
Тотальность тут, увы, ничем не гарантирована, зато есть тьюринг-полнота. Если в качестве типа
Aвзять некоторое окружение, то получим модель императивной программы в чистом функциональном исполнении.
Ну, и для полноты картины, стоит сказать, что эту модель можно свести к поиску наименьшей неподвижной точки слегка модифицированной функции
method. А именно оператор фиксированной точки (Y-комбинатор) делает нетипизированное лямбда-исчисление тьюринг-полным. С этих рассуждений, когда-то и начали Алонзо Чёрч и его ученик Алан Тьюринг.S_A
21.03.2017 13:15Перечитал, да, не свертка делает Тьюринг-полноту… И да, и действительно ошибся (знал же что КС-грамматики парсятся через КА, свой же язык писал на Flex/Bison когда-то). 15 лет как не математик :( Просто помню, что КС нельзя распарсить стандартной регуляркой (без нежадных операторов), на чем и ошибся.
Собственно моя идея заключалась не в неподвижной точке как раз (и не в функторах), а в том, X = множество пар для всех a из A (a, predicate(method(a))) — согласен, с учетом области определения можно «сократить», но не теряя одного шага вычисления, — не должно иметь замкнутых путей, то есть, определяя путь как подмножество X, такое что для всех x из пути верно method(x.a) всегда имеет второй координатой true. Возможно, это может означать, что множество не компактно, не знаю, как гипотеза.
Это всё что я имел в виду.

samsergey
20.03.2017 13:46Вот про функторы автор зря начал говорить. В контексте статьи речь идёт о контейнерах. Слово функтор, конечно, красивое, но оно накладывает бОльшие ограничения, чем указано в статье и даёт больше преимуществ, чем в ней указано. Понятно, что это перевод и что из песни слов не выкинешь, но для понимания того как использовать
mapв JS этот термин и его общность ни к чему. Вот если объяснить как построитьreduceа вместе с ним иmapдля дерева, очереди, объектов (не всяких), или каких-либо других самописных стуктур, тогда становится понятнее зачем может понадобиться этот термин. Впрочем, хорошо что говоря оreduceавтор не упоминает катаморфизмы.
iShatokhin
20.03.2017 14:34+4Современные браузеры эффективнее выполняют более громоздкие традиционные конструкции, например, циклы.
По ссылке очень плохой пример, если циклы просто молотят массив, то map единственный, кто создает новый массив — логично, что map будет сильно медленнее.

surefire
20.03.2017 14:58+3И совершенно бесполезное создание бесполезной функции:
// Бесполезная анонимная функция создается на каждой итерации бенчмарка, отбирая баллы arr.map(function (item) { someFn(item); });
Можно упростить, до
arr.map(someFn);
К тому же агрегатные функции не равнозначны простому циклу без проверок из-за того, что они пропускают дырки.

sy0
22.03.2017 05:44Ну, на самом деле, упростить таким образом может быть нельзя, потому что map передаёт в коллбэк несколько аргументов.
Довольно широко известен такой пример:
['10','10','10','10','10'].map(parseInt) // [10, NaN, 2, 3, 4]
surefire
22.03.2017 08:28Замечание верное и это конечно нужно иметь ввиду.
Но в контексте бенчмарка someFn не чужая тестовая функция, которая принимает ровно 1 аргумент. В общем такие бенчмарки больше вредны, чем полезны.

sy0
22.03.2017 05:53Другое дело, что сам коллбэк написан некорректно: нет возвращаемого значения, а значит у нас на выходе получится массив из undefined.
Верный вариант:
arr.map(function (item) { return someFn(item); });
Вариант для ES6:
arr.map(item => someFn(item));
surefire
22.03.2017 08:35А просто здесь автор теста не ставил целью использовать map по своему назначению, использовал для простого обхода массива. Не окрепший студент насмотрится страшилок и потом всю жизнь будет избегать map.
Evgeny42
Спасибо, очень полезная статья, в нашем то, 2011 году.
stardust_kid
Вообще, да. Но в нем живет столь много людей из мира IT. Пусть будет.