Функциональное программирование в JS: map, filter, reduce (ч.5)

Функциональное программирование в JS: map, filter, reduce (ч.5)

Nuances of programming

Перевод статьи Omer Goldberg: Functional Programming in JS: map, filter, reduce (Pt. 5)

Предыдущие статьи: Часть 1, Часть 2, Часть 3, Часть 4

Давайте перейдем сразу к практике! До этого мы изучали функции высшего порядка. Для тех, кто подзабыл, – это функции, параметром которых может выступать другая функция.

В массивах Javascript существует несколько встроенных методов, представляющих собой функции высшего порядка.

В данной статье мы рассмотрим 3 самых популярных метода: filter, map и reduce.

filter

Метод filter (Array) создает новый массив со всеми элементами, соответствующими требованиям данной функции.

Формулировка взята из учебника. Если говорить простым языком, то filter – это метод, выполняемый в данной коллекции/массиве. А элемент фильтра основан на функции, которая возвращает логическое значение (true или false).

Давайте перейдем к примеру и посмотрим, что там происходит. В качестве примера возьмем следующую коллекцию:

const iceCreams = [
  { flavor: 'pineapple', color: 'white' },
  { flavor: 'strawberry', color: 'red' },
  { flavor: 'watermelon', color: 'red' },
  { flavor: 'kiwi', color: 'green' },
  { flavor: 'mango', color: 'yellow' },
  { flavor: 'pear', color: 'green' }
];


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

const favoriteFlavors = iceCreams
    .filter(iceCream => iceCream.color === 'red');
console.log(favoriteFlavors);


Запуск этой части кода в консоли выдаст следующий результат:

Запутались? Это совершенно нормально. Давайте вместе во всем разберемся :)

Метод .filter принимает функцию с 4 аргументами, расположенными в следующем порядке:

·        element – текущий элемент массива;

·        index – текущий индекс массива (необязательное значение);

·        array ссылка на сам массив (необязательное значение);

·        thisArg – используемое значение при выполнении обратного вызова (необязательное значение).

Итого, функция представляется со следующей сигнатурой:

ОБЪЯСНЕНИЕ СИГНАТУРЫ FILTER ()

Обязательный аргумент (фиол.). Текущий элемент, который обрабатывает массив.

Необязательный аргумент (гол.). Индекс текущего элемента, который обрабатывает массив.

Необязательный аргумент (гол.). Вызов filter () в массиве.

*** обратный вызов, передаваемый в filter, должен возвращать логическое значение


Обратите внимание, что в нашем примере передавалась анонимная функция (не именованная!). Именованная передавалась бы вот так:

const getRed = icecream => icecream.color === 'красный';
const favoriteFlavors = iceCreams
    .filter(getRed);
console.log(favoriteFlavors);


Вывод такой функции:

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

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

map

Метод map() создает новый массив с результатами вызова представленной функции по каждому элементу вызываемого массива. Суть в том, что этот метод берет исходный массив и на основании него создает новый. Краткая сигнатура map():

ОБЪЯСНЕНИЕ СИГНАТУРЫ MAP ()

Обязательный аргумент (фиол.). Текущий элемент, который обрабатывает массив.

Необязательный аргумент (гол.). Индекс текущего элемента, который обрабатывает массив.

Необязательный аргумент (гол.). Вызов map () в массиве.

*** обратный вызов, передаваемый в map, должен возвращать значение для newArray

Оба метода – и  filter(), и map() – это функции высшего порядка. В map ()  мы тоже передаем функцию. Но вместо сортировки исходного массива, мы выполняем преобразование данных.

Давайте разберем все на примере! Нам потребуется массив из предыдущего примера.

const iceCreams = [
  { flavor: 'pineapple', color: 'white' },
  { flavor: 'strawberry', color: 'red' },
  { flavor: 'watermelon', color: 'red' },
  { flavor: 'kiwi', color: 'green' },
  { flavor: 'mango', color: 'yellow' },
  { flavor: 'pear', color: 'green' }
];


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

let flavors = [];
for (let i = 0; i < iceCreams.length; i++) {
    flavors.push(iceCreams[i].flavor)
}
console.log(flavors);


Выходные значения от выполнения участка кода с циклом

С технической точки зрения такие простые примеры реализуются неплохо. Но знаете ли вы о потенциальных проблемах, которые могут возникать при употреблении for в цикле? С моим пессимизмом я вижу сразу 3 недочета 😳😳😳

·        Определение значения итератора

let i = 0; 


·        Определение конечного значения для цикла for 

i < iceCreams.length


·        Увеличение итератора

i++


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

Теперь давайте попробуем сделать тоже самое, но с методом map().

const iceCreams = [
  { flavor: 'pineapple', color: 'white' },
  { flavor: 'strawberry', color: 'red' },
  { flavor: 'watermelon', color: 'red' },
  { flavor: 'kiwi', color: 'green' },
  { flavor: 'mango', color: 'yellow' },
  { flavor: 'pear', color: 'green' }
];
const flavors = iceCreams.map(icecream => icecream.flavor)
console.log(flavors)


ЧУДЕСА?!!

Результат такой же, но обратите внимание, какой чистый и лаконичный получился код. Никаких индексов, точек с запятой, объявления длины данных! Начав программировать с map() и filter(), я заметил, что плюсы от использования этих методов продолжают расти, чего не скажешь о циклах, основанных на сложности коллекции / массива. map () и filter () гарантированно стоит внедрять в свою каждодневную работу.

reduce

Наконец-то! Как метко выразился Кристиан Сакай в своем комментарии, reduce – это прародитель всех наших методов

Официльное определение гласит:

Метод reduce() применяет функцию к аккумулятору и каждому элементу массива (слева направо), сводя их к одному значению.

Звучит как-то малопонятно! Попробуем упростить формулировку для большего понимания. Давайте вернемся к методам filter() и map(). Что у них общего? Все они преобразуют одну коллекцию / массив в другую. А разница в том, как именно происходит преобразование данных. Образно говоря, reduce() – швейцарский армейский нож в любом преобразовании списка. Он используется для любого преобразования! По сути, мы можем применять reduce() для реализации map() и filter(). Хватит разговоров! Перейдем к классическому примеру работы с сокращением массива 🙃

Первый пример с циклом for 

const arr = [10,20,30]
let total = 0;
for(let i = 0; i < arr.length; i++) {
    total += arr[i]
}
console.log(total);


А теперь с reduce

const arr = [10, 20, 30];
const reducerFunction = (acc, currentItem) => acc + currentItem;
const sum = arr.reduce(reducerFunction, 0);
console.log(sum);


Само собой, результат у них одинаковый. Но разберем все в деталях. Встроенный метод массива reduce() требует для своего первого параметра функцию обратного вызова. Эта функция имеет заранее определенное входное значение, принимает 4 аргумента, похожих на обратные вызовы из filter() и map(). Рассмотрим ожидаемую сигнатуру функции reducer().

ОБЪЯСНЕНИЕ СИГНАТУРЫ REDUCER ()

Обязательный аргумент (фиол.). Аккумулятор накапливает возвращенные значения обратного вызова. Это накопленное значение, которое уже возвращалось в предыдущем обращении к функции обратного вызова или initialValue (если задано, см. ниже).

Обязательный аргумент (фиол.). Текущий элемент, который обрабатывает массив.

Необязательный аргумент (гол.). Индекс текущего элемента, который обрабатывает массив. Начинается с индекса 0 (если присутствует initialValue) или 1 (при отсутствие такового).

Необязательный аргумент (гол.). Вызов reduce () в массиве

Второй параметр в reduce() не обязателен. Им является initialValue. В случаях, когда мы перебираем элементы массива и пытаемся сократить его до одного значения, рекомендуют конкретизировать начальное значение. В нашем примере суммирования массивов мы определяли initialValue равным нулю. А что бы произошло, если бы мы не стали конкретизировать начальное значение?

const arr = [10, 20, 30];
const reducerFunction = (acc, currentItem) => acc + currentItem;
// Not instantiating the initial value!
const sum = arr.reduce(reducerFunction);
console.log(sum);


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

Реализация map() и filter() с reduce()

Ранее я говорил о том, что reduce() является прародителем методов преобразования списков, потому как его можно использовать для реализации их всех. Давайте докажем это на практике!

// ************* Map с Reduce *************
const data = [10, 20, 30];
const tripledWithMap = data.map(item => {
  return item * 3;
});

const tripledWithReduce = data.reduce((acc, value) => {
  acc.push(value * 3);
  return acc;
}, []);
console.log(tripledWithMap, tripledWithReduce);


filter с reduce()

// ************* Сортировка с сокращением *************
const data2 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const evenWithFilter = data2.filter(item => {
  return item % 2 === 0;
})
const evenWithReduce = data2.reduce((acc, value) => {
  if (value % 2 === 0) {
    acc.push(value);
  }
return acc;
}, []);
console.log(evenWithFilter, evenWithReduce);


Более практичный пример с reduce() 

Самое время придумать более полезный пример с методом reduce(). Можно рассчитать результаты голосования за лучший вкус мороженого 🍦🍦🍦🍦🍓🍋 🍌 🍉 🍇

const flavours = [
  "strawberry",
  "strawberry",
  "kiwi",
  "kiwi",
  "kiwi",
  "strawberry",
  "mango",
  "kiwi",
  "banana"
];
const votes = {};
const reducer = (votes, vote) => {
  votes[vote] = !votes[vote] ? (votes[vote] = 1) : votes[vote] + 1;
return votes;
};
const outcome = flavours.reduce(reducer, votes);
// Output
console.log("Strawberry: ", outcome.strawberry);
console.log("Kiwi: ", outcome.kiwi);
console.log("Mango: ", outcome.mango);
console.log("Banana: ", outcome.banana);


Каждый раз при операции сокращения необходимо вызвать в массиве метод reduce(),а также обозначить обратный вызов и initialValue. Обратите внимание, что в данном примере мы задаем изначальное значение в виде пустого объекта. Без такого определения ничего не заработает!

Сглаживание данных с reduce

Для начала давайте определим сглаживаемые данные. Сглаживание выглядит так:

[[a, b, c], [d, e, f], [g, h i]] -> [a, b, c, d, e, f, g, h, i]


То есть мы хотим объединить все массивы по порядку их отображения. reduce() элегантно справляется с решением этой проблемы 🤗🤗

const letterArr = [['a', 'b', 'c'], ['d', 'e', 'f'], ['g', 'h', 'i']];
const flattened = letterArr.reduce((acc, val) => {
  return acc.concat(val);
}, []);
console.log(flattened);


Производительность преобразователей списков

Создание цепочки преобразователей списка – явление довольно частое. Несмотря на то, что прочесть сложные преобразования будет легче, этот метод явно проиграет по скорости, особенно при работе с очень большими массивами. Взгляните на пример.

let bigData = [];
for (let i = 0; i < 1000000; i++) {
  bigData[i] = i;
}
// Slow
let filterBegin = Date.now();
const filterMappedBigData = bigData
  .filter(value => value % 2 === 0)
  .map(value => value * 2);
  
let filterEnd = Date.now();
let filtertimeSpent = (filterEnd - filterBegin) / 1000 + "secs";
// Fast
let reducedBegin = Date.now();
const reducedBigData = bigData.reduce((acc, value) => {
  if (value % 2 === 0) {
    acc.push(value * 2);
  }
  return acc;
}, []);
let reducedEnd = Date.now();
let reducedtimeSpent = (reducedEnd - reducedBegin) / 1000 + " secs";
console.log("filtered Big Data:", filtertimeSpent);
console.log("reduced Big Data:", reducedtimeSpent);


очему цепочка filter и map такая медленная? Сначала filter() должен выполнить итерацию всего массива (1,000,000) и отфильтровать половину. Затем уже map() проходит по оставшейся части массива (500,000) и создает новый массив. Для сравнения: в reduce() мы перебираем массив только раз! Отсутствие постоянной итерации одинакового набора данных более результативно в плане качества, но менее эффективно по части производительности.

Перевод подготовила Ольга Сайфудинова