Предлагаем вашему вниманию переводной материал об использовании map и reduce в функциональном JavaScript. Эта статья будет интересна в первую очередь начинающим разработчикам.

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

Согласно классическим определениям функционального программирования, функтор удовлетворяет трём критериям:

  1. Содержит набор значений.
  2. Реализует функцию map для оперирования каждым элементом.
  3. Функция 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)


  1. Evgeny42
    20.03.2017 12:25
    +17

    Спасибо, очень полезная статья, в нашем то, 2011 году.


    1. stardust_kid
      20.03.2017 13:23
      +1

      Вообще, да. Но в нем живет столь много людей из мира IT. Пусть будет.


  1. JPEG
    20.03.2017 13:46

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


    1. samsergey
      20.03.2017 14:10

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


      1. JPEG
        20.03.2017 14:17

        Согласен, но если далеко не забегать, то современный синтаксис читается вполне хорошо:

        const length = str => str.length;
        const sum = (sum, n) => sum + n;
        animals.map(length).reduce(sum, 0);
        


    1. 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.


      1. S_A
        20.03.2017 17:54

        Возможно, я не прав (буду только рад ошибиться), но простое

        while (true) { console.log("Hello!"); }
        

        Уже не укладывается в filter-map-reduce, исходя из моих рассуждений.
        Это конечно не есть пример полезной программы, но как идея.


      1. samsergey
        20.03.2017 22:35
        +1

        В рамках чистого ФП функции map и filter реализуются через reduce, так что ваш вопрос сводится к такому: какой класс задач можно решить с помощью свёртки (катаморфизма)? Не претендуя на исчерпывающий и математически точный ответ можно сказать, что одна только свёртка позволяет обрабатывать конечные индуктивные данные. С её помощью можно построить конечный автомат, или автомат с магазинной памятью. Они не эквивалентны машине Тьюринга, но способны вычислять примитивно рекурсивные функции. На вскидку, с помощью reduce и без изменяемых состояний можно построить программу, эквивалентную программе, использующей только цикл for (точнее, foreach, C-шный for это переодетый while). Это хороший и полезный класс программ, гарантирующий тотальность. Для реализации цикла while потребуется ленивый генератор данных для свёртки: анаморфизм. Их композиция — хиломорфизм, уже Тьюринг полна. Повторюсь, все эти рассуждения имеют смысл при отсутствии изменяемого состояния, так как если сворачивающая функция может получить доступ к ссылке на сворачиваемые данные и изменять их на ходу, то это уже не катаморфизм, а бардак. В таком случае ответ на ваш вопрос будет таким: да, свёртка тьюринг-полна, но она тут ни при чём, это просто цикл. Кстати, в С# изменять итератор цикла foreach запрещено на уровне языка.


        1. S_A
          21.03.2017 03:43

          Спасибо!!! Ну раз через reduce и map и filter, то и действительно, получаем класс алгоритмов конечного автомата (примитивно-рекурсивные функции). И это значит что HTML (или какую другую БНФ) в общем случае на нём уже не распарсить, например.

          Насчет foreach в C# я в курсе, но while(true); там сделать можно, и в этом выражении нет изменяемых данных. Насчет «просто цикл» — просто или не просто, но уже тьюринговая полнота.


          1. 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]
              }
            }


            1. S_A
              21.03.2017 05:38

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

              Ушел в прострацию :)

              Например, если представить МТ вот как-то так

              data = initial;
              while (predicate(data)) data = method(data);
              


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


              1. 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-комбинатор) делает нетипизированное лямбда-исчисление тьюринг-полным. С этих рассуждений, когда-то и начали Алонзо Чёрч и его ученик Алан Тьюринг.


                1. S_A
                  21.03.2017 13:15

                  Перечитал, да, не свертка делает Тьюринг-полноту… И да, и действительно ошибся (знал же что КС-грамматики парсятся через КА, свой же язык писал на Flex/Bison когда-то). 15 лет как не математик :( Просто помню, что КС нельзя распарсить стандартной регуляркой (без нежадных операторов), на чем и ошибся.

                  Собственно моя идея заключалась не в неподвижной точке как раз (и не в функторах), а в том, X = множество пар для всех a из A (a, predicate(method(a))) — согласен, с учетом области определения можно «сократить», но не теряя одного шага вычисления, — не должно иметь замкнутых путей, то есть, определяя путь как подмножество X, такое что для всех x из пути верно method(x.a) всегда имеет второй координатой true. Возможно, это может означать, что множество не компактно, не знаю, как гипотеза.

                  Это всё что я имел в виду.


  1. samsergey
    20.03.2017 13:46

    Вот про функторы автор зря начал говорить. В контексте статьи речь идёт о контейнерах. Слово функтор, конечно, красивое, но оно накладывает бОльшие ограничения, чем указано в статье и даёт больше преимуществ, чем в ней указано. Понятно, что это перевод и что из песни слов не выкинешь, но для понимания того как использовать map в JS этот термин и его общность ни к чему. Вот если объяснить как построить reduce а вместе с ним и map для дерева, очереди, объектов (не всяких), или каких-либо других самописных стуктур, тогда становится понятнее зачем может понадобиться этот термин. Впрочем, хорошо что говоря о reduce автор не упоминает катаморфизмы.


  1. iShatokhin
    20.03.2017 14:34
    +4

    Современные браузеры эффективнее выполняют более громоздкие традиционные конструкции, например, циклы.

    По ссылке очень плохой пример, если циклы просто молотят массив, то map единственный, кто создает новый массив — логично, что map будет сильно медленнее.


    1. surefire
      20.03.2017 14:58
      +3

      И совершенно бесполезное создание бесполезной функции:


      // Бесполезная анонимная функция создается на каждой итерации бенчмарка, отбирая баллы
      arr.map(function (item) {
        someFn(item);
      });

      Можно упростить, до


      arr.map(someFn);

      К тому же агрегатные функции не равнозначны простому циклу без проверок из-за того, что они пропускают дырки.


      1. sy0
        22.03.2017 05:44

        Ну, на самом деле, упростить таким образом может быть нельзя, потому что map передаёт в коллбэк несколько аргументов.

        Довольно широко известен такой пример:

        ['10','10','10','10','10'].map(parseInt)
        // [10, NaN, 2, 3, 4]
        


        1. surefire
          22.03.2017 08:28

          Замечание верное и это конечно нужно иметь ввиду.
          Но в контексте бенчмарка someFn не чужая тестовая функция, которая принимает ровно 1 аргумент. В общем такие бенчмарки больше вредны, чем полезны.


      1. sy0
        22.03.2017 05:53

        Другое дело, что сам коллбэк написан некорректно: нет возвращаемого значения, а значит у нас на выходе получится массив из undefined.

        Верный вариант:

        arr.map(function (item) {
          return someFn(item);
        });
        


        Вариант для ES6:
        arr.map(item => someFn(item));
        


        1. surefire
          22.03.2017 08:35

          А просто здесь автор теста не ставил целью использовать map по своему назначению, использовал для простого обхода массива. Не окрепший студент насмотрится страшилок и потом всю жизнь будет избегать map.