Функциональное программирование в наши дни делает большой всплеск в мире разработки. И на то есть веская причина: функциональные методы могут помочь вам написать больше декларативного кода, который легче понять с первого взгляда, реорганизовать и протестировать.
Одним из краеугольных камней функционального программирования является его специальное использование списков и операций со списками. И эти вещи точно такие же, как и звучат: массивы вещей и то, что вы с ними делаете. Но функциональное мышление трактует их немного иначе, чем вы могли бы ожидать.
В этой статье мы подробно рассмотрим то, что мне нравится называть «большой тройкой» операций со списками: map
, filter
и reduce
Обдумывание этих трех функций — важный шаг на пути к написанию чистого функционального кода и открывает двери для чрезвычайно мощных методов функционального и реактивного программирования.
Это также означает, что вам больше никогда не придется писать цикл for
.
Любопытно? Давайте погрузимся в.
Карта из списка в список
Часто нам приходится брать массив и модифицировать каждый элемент в нем точно таким же образом. Типичными примерами этого являются возведение в квадрат каждого элемента в массиве чисел, извлечение имени из списка пользователей или запуск регулярного выражения для массива строк.
map
— это метод, созданный именно для этого. Он определен в Array.prototype
, поэтому вы можете вызывать его для любого массива, и он принимает обратный вызов в качестве первого аргумента.
Когда вы вызываете map
для массива, он выполняет этот обратный вызов для каждого элемента в нем, возвращая новый массив со всеми значениями, которые возвращал обратный вызов.
Под капотом map
передает три аргумента вашему обратному вызову:
- Текущий элемент в массиве
- Индекс массива текущего элемента
- Весь массив, который вы назвали map
Давайте посмотрим на некоторый код.
map
на практике
Предположим, у нас есть приложение, которое поддерживает множество ваших задач на день. Каждая task
— это объект, каждый со свойством name
и duration
:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
|
// Durations are in minutes
var tasks = [
{
‘name’ : ‘Write for Envato Tuts+’,
‘duration’ : 120
},
{
‘name’ : ‘Work out’,
‘duration’ : 60
},
{
‘name’ : ‘Procrastinate on Duolingo’,
‘duration’ : 240
}
];
|
Допустим, мы хотим создать новый массив только с именем каждой задачи, поэтому мы можем взглянуть на все, что мы сделали сегодня. Используя цикл for
, мы напишем что-то вроде этого:
1
2
3
4
5
6
7
|
var task_names = [];
for (var i = 0, max = tasks.length; i < max; i += 1) {
task_names.push(tasks[i].name);
}
|
JavaScript также предлагает цикл forEach
. Он работает как цикл for
, но управляет всей сложностью проверки индекса цикла по длине массива для нас:
1
2
3
4
5
6
7
|
var task_names = [];
tasks.forEach(function (task) {
task_names.push(task.name);
});
|
Используя map
, мы можем написать:
1
2
3
4
5
|
var task_names = tasks.map(function (task, index, array) {
return task.name;
});
|
Я включил параметры index
и array
чтобы напомнить, что они есть, если они вам нужны. Так как я не использовал их здесь, вы могли бы их пропустить, и код работал бы просто отлично.
Есть несколько важных различий между двумя подходами:
- Используя
map
, вы не должны сами управлять состоянием циклаfor
. - Вы можете работать с элементом напрямую, вместо того, чтобы индексировать его в массив.
- Вам не нужно создавать новый массив и
push
его.map
возвращает готовый продукт сразу, поэтому мы можем просто присвоить возвращаемое значение новой переменной. - Вы должны помнить, чтобы включить оператор
return
в ваш обратный вызов. Если вы этого не сделаете, вы получите новый массив, заполненныйundefined
.
Оказывается, все функции, которые мы рассмотрим сегодня, имеют эти характеристики.
Тот факт, что нам не нужно вручную управлять состоянием цикла, делает наш код проще и удобнее в обслуживании. Тот факт, что мы можем работать непосредственно с элементом вместо того, чтобы индексировать массив, делает вещи более читабельными.
Использование цикла forEach
решает обе эти проблемы для нас. Но у map
все еще есть по крайней мере два отличных преимущества:
-
forEach
возвращаетundefined
, поэтому он не связывается с другими методами массива.map
возвращает массив, так что вы можете связать его с другими методами массива. -
map
возвращается массив с готовым продуктом, вместо того, чтобы требовать, чтобы мы мутировали массив внутри цикла.
Сохранение количества мест, где вы изменяете состояние до абсолютного минимума, является важным принципом функционального программирования. Это делает для более безопасного и более понятного кода.
Сейчас также самое время указать, что если вы находитесь в Node, тестируете эти примеры в консоли браузера Firefox или используете Babel или Traceur , вы можете написать это более кратко с помощью функций стрелок ES6:
1
|
var task_names = tasks.map((task) => task.name );
|
Функции стрелок позволяют нам исключить ключевое слово return
в однострочниках.
Это не становится намного более читабельным, чем это.
Gotchas
Обратный вызов, который вы передаете в map
должен иметь явный оператор return
, иначе map
будет выдавать массив, полный undefined
. Не трудно запомнить, чтобы включить return
значение, но это не трудно забыть.
Если вы забудете, map
не будет жаловаться. Вместо этого он спокойно вернет массив, полный ничего. Подобные молчаливые ошибки могут быть на удивление сложными для отладки.
К счастью, это единственная проблема с map
Но это достаточно распространенная ловушка, которую я должен подчеркнуть: всегда проверяйте, чтобы ваш обратный вызов содержал оператор return
!
Реализация
Чтение реализаций является важной частью понимания. Итак, давайте напишем нашу собственную облегченную map
чтобы лучше понять, что происходит под капотом. Если вы хотите увидеть качественную реализацию, ознакомьтесь с полифилом Mozilla на MDN .
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
|
var map = function (array, callback) {
var new_array = [];
array.forEach(function (element, index, array) {
new_array.push(callback(element));
});
return new_array;
};
var task_names = map(tasks, function (task) {
return task.name;
});
|
Этот код принимает массив и функцию обратного вызова в качестве аргументов. Затем он создает новый массив; выполняет обратный вызов для каждого элемента в массиве, который мы передали; помещает результаты в новый массив; и возвращает новый массив. Если вы запустите это в своей консоли, вы получите тот же результат, что и раньше. Просто убедитесь, что вы инициализируете tasks
прежде чем проверить это!
В то время как мы используем цикл for под капотом, его завершение в функцию скрывает детали и позволяет нам вместо этого работать с абстракцией.
Это делает наш код более декларативным — он говорит, что делать, а не как это делать. Вы по достоинству оцените, насколько более читабельным, легко обслуживаемым и отлаживаемым может быть ваш код.
Отфильтровать шум
Следующая из наших операций с массивами — это filter
. Он делает именно то, на что это похоже: он берет массив и отфильтровывает нежелательные элементы.
Как и map
, filter
определен для Array.prototype
. Он доступен для любого массива, и вы передаете ему обратный вызов в качестве первого аргумента. filter
выполняет этот обратный вызов для каждого элемента массива и выплевывает новый массив, содержащий только те элементы, для которых обратный вызов вернул true
.
Также как и map
, filter
передает обратному вызову три аргумента:
- Текущий пункт
- Текущий индекс
- Массив, который вы назвали
filter
filter
на практике
Давайте вернемся к нашему примеру задачи. Вместо того, чтобы вытягивать названия каждой задачи, скажем, я хочу получить список задач, на выполнение которых у меня ушло два часа или больше.
Используя forEach
, мы написали бы:
1
2
3
4
5
6
7
|
var difficult_tasks = [];
tasks.forEach(function (task) {
if (task.duration >= 120) {
difficult_tasks.push(task);
}
});
|
С filter
:
1
2
3
4
5
6
|
var difficult_tasks = tasks.filter(function (task) {
return task.duration >= 120;
});
// Using ES6
var difficult_tasks = tasks.filter((task) => task.duration >= 120 );
|
Здесь я пошел дальше и пропустил аргументы index
и array
для нашего обратного вызова, поскольку мы их не используем.
Как и map
, filter
позволяет нам:
- избегайте изменения массива внутри цикла
forEach
илиfor
- присваивать его результат непосредственно новой переменной, а не вставлять в массив, который мы определили в другом месте
Gotchas
Обратный вызов, который вы передаете на map
должен включать оператор return, если вы хотите, чтобы он функционировал должным образом. При использовании filter
вы также должны включить оператор return и убедиться, что он возвращает логическое значение.
Если вы забудете оператор return, ваш обратный вызов вернет undefined
, и этот filter
бесполезно приведет к значению false
. Вместо того, чтобы выдавать ошибку, он будет молча возвращать пустой массив!
Если вы идете другим путем, и вернуть что-то не является явно true
или false
, тогда filter
попытается выяснить, что вы имели в виду, применяя правила принуждения JavaScript . Чаще всего это ошибка. И точно так же, как вы забыли свое заявление о возвращении, оно будет молчаливым.
Всегда убедитесь, что ваши обратные вызовы включают явный оператор возврата. И всегда убедитесь, что ваши обратные вызовы в filter
возвращает true
или false
. Ваше здравомыслие поблагодарит вас.
Реализация
Еще раз, лучший способ понять кусок кода — это … ну, написать его. Давайте свернем наш собственный легкий filter
. У хороших людей в Mozilla есть полифилл промышленной прочности, чтобы вы тоже могли его прочитать.
01
02
03
04
05
06
07
08
09
10
11
12
13
|
var filter = function (array, callback) {
var filtered_array = [];
array.forEach(function (element, index, array) {
if (callback(element, index, array)) {
filtered_array.push(element);
}
});
return filtered_array;
};
|
Сокращение Массивов
map
создает новый массив путем преобразования каждого элемента в массиве по отдельности. filter
создает новый массив, удаляя элементы, которые не принадлежат. reduce
другой стороны, Reduce принимает все элементы в массиве и сводит их в одно значение.
Так же как map
и filter
, Array.prototype
определяется в Array.prototype
и доступен для любого массива, и вы передаете обратный вызов в качестве первого аргумента. Но он также принимает необязательный второй аргумент: значение, из которого начинается объединение всех элементов массива.
reduce
передает вашему обратному вызову четыре аргумента:
- Текущее значение
- Предыдущее значение
- Текущий индекс
- Массив, который вы назвали
Обратите внимание, что обратный вызов получает предыдущее значение на каждой итерации. На первой итерации предыдущего значения нет. Вот почему у вас есть возможность передать reduce
начальное значение: оно действует как «предыдущее значение» для первой итерации, если в противном случае ее не было бы.
Наконец, имейте в виду, что reduce
отдачи одно значение, а не массив, содержащий один элемент. Это важнее, чем может показаться, и я вернусь к этому в примерах.
reduce
на практике
Так как сначала reduce
является функцией, которую люди находят наиболее чуждой, мы начнем с пошагового шага по простому.
Допустим, мы хотим найти сумму списка чисел. Используя цикл, который выглядит так:
1
2
3
4
5
6
|
var numbers = [1, 2, 3, 4, 5],
total = 0;
numbers.forEach(function (number) {
total += number;
});
|
Несмотря на то, что это не плохой вариант использования forEach
, reduce
прежнему имеет то преимущество, что позволяет нам избежать мутаций. При reduce
мы написали бы:
1
2
3
|
var total = [1, 2, 3, 4, 5].reduce(function (previous, current) {
return previous + current;
}, 0);
|
Сначала мы вызываем reduce
в нашем списке numbers.
Мы передаем ему обратный вызов, который принимает предыдущее значение и текущее значение в качестве аргументов и возвращает результат их сложения. Поскольку мы передали 0
в качестве второго аргумента для reduce
, он будет использовать его в качестве значения previous
на первой итерации.
Если мы сделаем это шаг за шагом, это будет выглядеть так:
итерация | предыдущий | Текущий | Общее количество |
---|---|---|---|
1 | 0 | 1 | 1 |
2 | 1 | 2 | 3 |
3 | 3 | 3 | 6 |
4 | 6 | 4 | 10 |
5 | 10 | 5 | 15 |
Если вы не любитель таблиц, запустите этот фрагмент в консоли:
1
2
3
4
5
6
7
8
9
|
var total = [1, 2, 3, 4, 5].reduce(function (previous, current, index) {
var val = previous + current;
console.log(«The previous value is » + previous +
«; the current value is » + current +
«, and the current iteration is » + (index + 1));
return val;
}, 0);
console.log(«The loop is done, and the final value is » + total + «.»);
|
Напомним: reduce
повторяется по всем элементам массива, объединяя их, как вы указали в обратном вызове. На каждой итерации ваш обратный вызов имеет доступ к предыдущему значению , которое является итоговым или накопленным значением ; текущая стоимость ; текущий индекс; и весь массив , если они вам нужны.
Вернемся к примеру с нашими задачами. Мы получили список имен задач из map
и отфильтрованный список задач, которые заняли много времени с … ну, filter
.
Что если бы мы хотели узнать общее количество времени, которое мы потратили на работу сегодня?
Используя цикл forEach
, вы должны написать:
1
2
3
4
5
6
7
|
var total_time = 0;
tasks.forEach(function (task) {
// The plus sign just coerces
// task.duration from a String to a Number
total_time += (+task.duration);
});
|
При reduce
это становится:
1
2
3
4
5
6
|
var total_time = tasks.reduce(function (previous, current) {
return previous + current;
}, 0);
// Using arrow functions
var total_time = tasks.reduce((previous, current) previous + current );
|
Легко.
Это почти все, что нужно сделать. Почти, потому что JavaScript предоставляет нам еще один малоизвестный метод, называемый reduceRight
. В приведенных выше примерах reduce
начинается с первого элемента в массиве, итерация осуществляется слева направо:
1
2
3
4
5
6
|
var array_of_arrays = [[1, 2], [3, 4], [5, 6]];
var concatenated = array_of_arrays.reduce( function (previous, current) {
return previous.concat(current);
});
console.log(concatenated);
|
reduceRight
делает то же самое, но в обратном направлении:
1
2
3
4
5
6
|
var array_of_arrays = [[1, 2], [3, 4], [5, 6]];
var concatenated = array_of_arrays.reduceRight( function (previous, current) {
return previous.concat(current);
});
console.log(concatenated);
|
Я использую reduceRight
каждый день, но мне никогда не было нужно reduceRight
. Я думаю, вы, вероятно, тоже не будете. Но если вы когда-нибудь это сделали, теперь вы знаете, что это там.
Gotchas
Три большие ошибки со reduce
:
- Забыв
return
- Забыть начальное значение
- Ожидание массива при
reduce
возвращает одно значение
К счастью, первых двух легко избежать. Решение, каким должно быть ваше первоначальное значение, зависит от того, что вы делаете, но вы быстро освоитесь.
Последнее может показаться немного странным. Если reduce
только когда-либо возвращает одно значение, почему вы ожидаете массив?
Для этого есть несколько веских причин. Во-первых, reduce
всегда возвращает одно значение , а не всегда одно число . Например, если вы уменьшите массив массивов, он вернет один массив. Если вы привыкли или уменьшаете массивы, было бы справедливо ожидать, что массив, содержащий один элемент, не будет особым случаем.
Во-вторых, если бы при reduce
возвращался массив с одним значением, он, естественно, будет хорошо работать с map
filter
и другими функциями для массивов, которые вы, вероятно, будете использовать с ним.
Реализация
Время для нашего последнего взгляда под капотом. Как обычно, у Mozilla есть пуленепробиваемый полифилл для уменьшения, если вы хотите проверить это.
1
2
3
4
5
6
7
8
9
|
var reduce = function (array, callback, initial) {
var accumulator = initial ||
array.forEach(function (element) {
accumulator = callback(accumulator, array[i]);
});
return accumulator;
};
|
Здесь нужно отметить две вещи:
- На этот раз я использовал имя
accumulator
вместоprevious
. Это то, что вы обычно видите в дикой природе. - Я назначаю
accumulator
начальное значение, если пользователь предоставляет его, и значение по умолчанию0
, если нет. Так ведет себя и реальноеreduce
.
Соединение: карта, фильтр, уменьшение и цепочка
На данный момент, вы не можете быть впечатлены.
Достаточно справедливо: map
, filter
и reduce
сами по себе не очень интересны.
В конце концов, их истинная сила заключается в их цепочности.
Допустим, я хочу сделать следующее:
- Соберите задачи за два дня.
- Преобразуйте продолжительность задачи в часы, а не в минуты.
- Отфильтруйте все, что заняло два часа и более.
- Подведите итог всего этого.
- Умножьте результат на почасовую ставку для выставления счетов.
- Выведите отформатированную сумму в долларах.
Сначала давайте определим наши задачи на понедельник и вторник:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
|
var monday = [
{
‘name’ : ‘Write a tutorial’,
‘duration’ : 180
},
{
‘name’ : ‘Some web development’,
‘duration’ : 120
}
];
var tuesday = [
{
‘name’ : ‘Keep writing that tutorial’,
‘duration’ : 240
},
{
‘name’ : ‘Some more web development’,
‘duration’ : 180
},
{
‘name’ : ‘A whole lot of nothing’,
‘duration’ : 240
}
];
var tasks = [monday, tuesday];
|
А теперь наше прекрасное преобразование:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
|
var result = tasks.reduce(function (accumulator, current) {
return accumulator.concat(current);
}).map(function (task) {
return (task.duration / 60);
}).filter(function (duration) {
return duration >= 2;
}).map(function (duration) {
return duration * 25;
}).reduce(function (accumulator, current) {
return [(+accumulator) + (+current)];
}).map(function (dollar_amount) {
return ‘$’ + dollar_amount.toFixed(2);
}).reduce(function (formatted_dollar_amount) {
return formatted_dollar_amount;
});
|
Или, более кратко:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
|
// Concatenate our 2D array into a single list
var result = tasks.reduce((acc, current) => acc.concat(current))
// Extract the task duration, and convert minutes to hours
.map((task) => task.duration / 60)
// Filter out any task that took less than two hours
.filter((duration) => duration >= 2)
// Multiply each tasks’ duration by our hourly rate
.map((duration) => duration * 25)
// Combine the sums into a single dollar amount
.reduce((acc, current) => [(+acc) + (+current)])
// Convert to a «pretty-printed» dollar amount
.map((amount) => ‘$’ + amount.toFixed(2))
// Pull out the only element of the array we got from map
.reduce((formatted_amount) =>formatted_amount);
|
Если вы сделали это далеко, это должно быть довольно просто. Однако есть две странности, которые нужно объяснить.
Сначала в строке 10 я должен написать:
1
2
3
4
|
// Remainder omitted
reduce(function (accumulator, current) {
return [(+accumulator) + (+current_];
})
|
Здесь нужно объяснить две вещи:
- Знаки плюс перед
accumulator
иcurrent
приводят их значения к числам. Если вы этого не сделаете, возвращаемым значением будет довольно бесполезная строка"12510075100"
. - Если не заключить эту сумму в скобки, при
reduce
будет выделено одно значение, а не массив. В результате вы получитеTypeError
, потому что вы можете использоватьmap
только в массиве!
Вторым моментом, который может сделать вас немного неудобным, является последнее reduce
, а именно:
1
2
3
4
5
6
|
// Remainder omitted
map(function (dollar_amount) {
return ‘$’ + dollar_amount.toFixed(2);
}).reduce(function (formatted_dollar_amount) {
return formatted_dollar_amount;
});
|
Этот вызов map
возвращает массив, содержащий единственное значение. Здесь мы вызываем reduce
чтобы извлечь это значение.
Другой способ сделать это состоит в том, чтобы удалить запрос на уменьшение и индексировать в массив, который выдает map
:
01
02
03
04
05
06
07
08
09
10
11
12
13
|
var result = tasks.reduce(function (accumulator, current) {
return accumulator.concat(current);
}).map(function (task) {
return (task.duration / 60);
}).filter(function (duration) {
return duration >= 2;
}).map(function (duration) {
return duration * 25;
}).reduce(function (accumulator, current) {
return [(+accumulator) + (+current)];
}).map(function (dollar_amount) {
return ‘$’ + dollar_amount.toFixed(2);
})[0];
|
Это совершенно правильно. Если вам удобнее использовать индекс массива, продолжайте.
Но я призываю вас не делать этого. Один из наиболее эффективных способов использования этих функций — в области реактивного программирования, где вы не сможете свободно использовать индексы массивов. Отказ от этой привычки теперь значительно облегчит изучение реактивных методов.
Наконец, давайте посмотрим, как наш друг цикла forEach
сделает это:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
|
var concatenated = monday.concat(tuesday),
fees = [],
formatted_sum,
hourly_rate = 25,
total_fee = 0;
concatenated.forEach(function (task) {
var duration = task.duration / 60;
if (duration >= 2) {
fees.push(duration * hourly_rate);
}
});
fees.forEach(function (fee) {
total_fee += fee
});
var formatted_sum = ‘$’ + total_fee.toFixed(2);
|
Терпимо, но шумно.
Заключение и следующие шаги
Из этого урока вы узнали, как map
, filter
и reduce
работу; как их использовать; и примерно, как они реализованы. Вы видели, что все они позволяют вам избегать мутирующего состояния, которое требуется for
циклов for
и forEach
, и теперь вы должны иметь хорошее представление о том, как объединить их все вместе.
Теперь я уверен, что вы жаждете практики и дальнейшего чтения. Вот три моих предложения о том, куда идти дальше:
- Превосходный набор упражнений Джафара Хусейна по функциональному программированию в JavaScript , дополненный основательным введением в Rx.js
- Курс Envato Tuts + Инструктор Джейсона Роудса по функциональному программированию на JavaScript
- Наиболее адекватное руководство по функциональному программированию , в котором более подробно рассказывается о том, почему мы избегаем мутаций и функционального мышления в целом.
JavaScript стал одним из де-факто языков работы в сети. Это не без кривых обучения, и есть множество фреймворков и библиотек, которые также могут вас занять. Если вы ищете дополнительные ресурсы для обучения или использования в своей работе, посмотрите, что у нас есть на рынке Envato .
Если вы хотите узнать больше о подобных вещах, время от времени проверяйте мой профиль ; поймай меня в твиттере ( @PelekeS ); или нажмите мой блог на http://peleke.me.
Вопросы, комментарии или путаница? Оставьте их ниже, и я сделаю все возможное, чтобы вернуться к каждому из них в отдельности.
Изучите JavaScript: полное руководство
Мы создали полное руководство, которое поможет вам изучить JavaScript , независимо от того, начинаете ли вы как веб-разработчик или хотите изучать более сложные темы.