JavaScript в последние годы набрал нешуточную популярность, в связи с чем его подводные камни также стали явственно видны. Справедливости ради, стоит отметить, что любой язык в некоторой мере имеет как своё legacy, так и подводные камни.
Конкретно JavaScript обладает целым огородом камней. Подводным огородом.
На практике, подводные камни встречаются не так часто, напротив, хороший код склонен быть описанным в рамках здорового подмножества языка. Это также является и причиной, почему запомнить все заковырки языка достаточно сложно: они не являются необходимыми для каждодневной практики. Тем не менее, разнообразные граничные случаи использования языковых конструкций это отличная разминка для ума, а также стимул узнать язык немного лучше. Сегодняшний экземпляр попался мне на глаза в процессе прохождения JavaScript Puzzlers.
Меня заинтересовал вопрос номер 3:
Каков результат этого выражения (или нескольких)?
[ [3,2,1].reduce(Math.pow), [].reduce(Math.pow) ]
В качестве ответа авторами, на выбор, даются следующие варианты:
* ошибка
* [9, 0]
* [9, NaN]
* [9, undefined]
Попробуйте и вы, без запуска интерпретатора, пользуясь только своим умом ответить на этот вопрос.
Несмотря на то, что пример достаточно отстранённый, аппликация функций и частично определённых функций к коллекциям это распространённая практика для JS, и, при здравом использовании, она способна сделать код чище, как в плане исполнения — избавить его от лишних замыканий, так и в визуальном плане — меньше скобочного мусора (вопрос использования препроцессоров оставим для другой статьи).
А в этой статье вы найдёте:
* Разбор задачки.
* JavaScript reduce
с чисто практической точки зрения.
* Несколько акробатических этюдов с reduce
(reduce
с академической точки зрения).
* Репозиторий с плюшками к статье.
* Несколько других reduce
.
Разбор задачки
Чтож, для начала разберёмся с задачей в начале статьи. А вариантов здесь хватает.
reduce
(здесь и далее имеется ввиду Array.prototype.reduce
), вместе с другими функциями из прототипа Array
: filter
, map
, forEach
, some
, every
, является функцией высшего порядка, то есть она принимает на вход другую функцию (будем называть эту передаваемую функцию f*
). Функция f*
будет вызвана с некоторыми агрументами для каждого элемента коллекции.
Конкретно reduce
, используется для генерации некоторого агрегирующего значения на основе коллекции. Она последовательно применяет f*
к каждому элементу, передавая ей текущее значение переменной, в которой накапливается результат (аккумулятора) и текущий обрабатываемый элемент. Также, в reduce
можно передать начальное значение аккумулятора. Причём, (!) поведение reduce
будет различаться в зависимости от того, передано это значение или нет.
Функция Math.pow
производит возведение в степень, то есть её поведение различается в зависимости от переданной степени: это может быть квадрат, куб, или квадратный корень или любая другая вещественная степень.
При этом остаются открытыми следующие вопросы:
* Как ведёт себя reduce
, если вызвать её на пустом массиве?
* Как ведёт себя Math.pow
, если недодать ей степень?
Для стандартных функций JS нет общей политики обработки ошибок. Некоторые функции могут действовать строго: бросать исключения, если что-то не так в переданных данных, некоторые будут возвращать всяческие пустые значения: null
, undefined
, NaN
, а прочие будут работать пермиссивно: попытаются что-то сделать даже с не совсем корректными данными.
Как много вопросов затронул всего один пример.
А теперь правильный ответ: мы получим TypeError
, в котором виновато второе подвыражение. Функция reduce
на пустом массиве И без переданного начального значения бросает TypeError
.
Почему так? Вчитываемся в спецификацию reduce
Чтож, давайте почитаем что пишет MDN o Array.prototype.reduce. Выясняются следующие тонкости работы функции:
Если initialValue
передано, то на первой итерации функция будет вызвана с этим значением и значением первого элемента массива. Если же, initialValue
не передано, то функция будет вызвана со значениями первого и второго элементов массива. Отсюда также следует, что если начальное значение не передано, то функция вызывается на один раз меньше, иначе ровно столько раз, сколько элементов в массиве.
Можно представлять форму с initialValue
вот так:
array.reduce(fn, initialValue) ⇔ [ initialValue ].concat(array).reduce(fn);
Вторым интересным аспектом является обработка пустого массива. Если массив пустой, и передано начальное значение, то оно является результатом работы функции, а результат f*
игнорируется. Если же массив пуст, а начальное значение не передано, то выбрасывается TypeError
.
[].reduce(fn, initialValue) ⇔ [ initialValue ].reduce(fn) ⇒ initialValue;
[].reduce(fn) ⇒ TypeError;
На самом деле поведение функции достаточно логично: она пытается вызвать f*
со значениями из входных данных. Если начальное значение передано, то оно является элементом данных идущим перед первым элементом. Если не передано ничего (нет элементов и начального значения), то функция не имеет данных для генерации агрегата, и она выбрасывает исключение. Так или иначе, поведение немножко сложное и может стать подводным камнем. reduce
, по сути, перегружается для одного агрумента и для двух, и перегруженные варианты имеют разное поведение на пустом массиве.
Теперь можно понять, почему задачка имеет такой результат, а именно, второе подвыражение бросает исключение: оно вызывается с пустым входным списком и без стартового значения. Но! Первое подвыражение всё-таки вычислилось. Предлагаю в качестве упражнения попытаться разобраться в этом вычислении. Можно пойти двумя путями:
* Джедайский: исполнить код в уме, зная о том как работают reduce
и Math.pow
.
* Ковбойский: вбить в REPL этот код и попытаться подвести рассуждения под результат.
Также, можно ознакомиться с моим примером, который должен помочь понять задачку:
StreetStrider/habrahabr-javascript-reduce:tests/puzzler.js. Он является jasmine-тестом.
Магия и шарм reduce
reduce
примечателен тем, что он может быть использован для того, чтобы описать все остальные функции высшего порядка объекта Array
: forEach
, filter
, map
, some
, every
.
Это станет понятным, если избавиться от мысли, что reduce
обязан аккумулировать значение того же типа, что и значения в массиве. Действительно, логичным кажется мыслить, что если мы берём массив чисел и суммируем их, то получаем также число. Если мы берём массив строк и конкатенируем их, то также получаем строку. Это естественно, но reduce
также способен возвращать массивы и объекты. Причём передача будет происходить из итерации в итерацию благодаря аккумулятору. Это позволяет строить на reduce
функции любой сложности.
Для примера, давайте построим map
:
function map$viaReduce (array, fn)
{
return array.reduce(function (memo, item, index, array)
{
return memo.concat([ fn(item, index, array) ]);
}, []);
};
Здесь через аккумулятор передаётся накапливающийся массив. Он будет того же размера, что и исходный, но со значениями, пропущенными через функцию-трасформатор fn
. Также здесь не забыто, то fn
принимает не только элемент, но индекс и массив последующими параметрами. Параметр функции concat
обёрнут в массив, чтобы избежать «развёртки» значения, если fn
вернёт массив. В качестве начального значения передан пустой массив.
Этот код есть в репозитории, а ещё для него есть тесты.
Тем, кто заинтересовался, предлагаю в качестве упражнения реализовать функции filter
, и одну из кванторных: some
либо every
. Вы заметите, что везде используется возврат накапливаемого массива.
Ещё один нетривиальный пример, который приходит на ум, это реализация функции uniq
. Как известно, JavaScript страдает от отсутствия в стандартной либе многих нужных вещей. В частности, нет функции, которая устраняет дубликаты в массиве, и разработчики используют разные кастомные реализации (лично я советую использовать _.uniq
из LoDash/Underscore).
function uniq$viaReduce (array)
{
return array.reduce(function (memo, item)
{
return (~ memo.indexOf(item) ? null : memo.push(item)), memo;
}, []);
};
Эта реализация использует немного JS-ного джедаизма, для лаконичности. В принципе, этот код не планируется когда-либо менять, поэтому это не наносит существенного ущерба. Здесь используется сайд-эффект внутри тернарного оператора, а именно, мы проталкиваем элемент в массив, если он не найден на текущем куске. Оператор тильда используется для сравнения с -1
. Всё выражение завёрнуто в оператор запятую, который на каждом шаге (после всех действий) возвращает memo
. Примечательно, что эта реализация также сохраняет порядок в массиве.
Код и тесты есть в репозитории.
Ладно, не «немного», этот код был сильно джедайский, меня оправдывает только наличие тестов и то, что это библиотечная функция, поведение которой не будет меняться.
В качестве разминки, я рекомендую реализовать, например, функцию zipObject
суть её в том, что она принимает на вход массив пар (массивов), где нулевой элемент это ключ, а первый — значение, и возвращает сконструированный Object
с соответствующими ключами/значениями.
Подробнее о репозитории.
Репозиторий с примерами является npm-пакетом. Его можно поставить, используя адрес на гитхабе:
npm install StreetStrider/habrahabr-javascript-reduce
В src/
лежат примеры функций, в tests/
— jasmine-тесты. Прогнать все тесты можно с помощью npm test
.
Другие reduce
* В JavaScript у reduce
есть злой брат-близнец правосторонний аналог: reduceRight
. Он нужен, чтобы агрегировать массивы справа-налево, без необходимости в дорогостоящем reverse
.
* LoDash/Underscore есть _.reduce
, _.reduceRight
. Они обладают рядом дополнительных возможностей.
* В Python есть reduce
. Да. Но он официально не рекомендуется к использованию. Вместо него предлагается использовать списковые выражения и конструкции for-in
. Кода получается больше, но он становится намного более читаемым. Это соответствует Дао языка.
* В некоторых языках reduce/reduceRight
называются foldl/foldr
.
В SQL есть пять стандартных агрегирующих функций: COUNT
, SUM
, AVG
, MAX
, MIN
. Эти функции используются, чтобы свести результирующую выборку к одному кортежу. Аналогичные функции можно реализовать на JavaScript (тоже на reduce
).
Кстати, четыре из пяти агрегирующих функций SQL (не считая COUNT
) возвращают NULL
, если выборка пустая (COUNT
возвращает определённое значение: 0
). Это полностью аналогично JS-ному TypeError
на пустом списке.
postgres=# SELECT SUM(x) FROM (VALUES (1), (2), (3)) AS R(x);
sum
-----
6
(1 row)
postgres=# SELECT SUM(x) IS NULL AS sum FROM (VALUES (1), (2), (3)) AS R(x) WHERE FALSE;
sum
-----
t
(1 row)
Ссылки
Благодарности
Спасибо subzey за то, что натолкнул меня на мысль, что reduce
может возвращать что угодно.
Спасибо всем, кто напишет мне в личные сообщения об ошибках и недочётах в статье, а также в репозитории.
Спасибо за внимание.
Автор: StreetStrider