За всеми этими разговорами о новых стандартах легко забыть о том, что именно 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. Пусть будет.