...

суббота, 10 мая 2014 г.

Функция reduce

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)





Ссылки



  1. JavaScript Puzzlers.

  2. MDN: Array.prototype.reduce().

  3. github:StreetStrider/habrahabr-javascript-reduce.

  4. JavaScript.ru: Массив: Перебирающие методы.


Благодарности




Спасибо subzey за то, что натолкнул меня на мысль, что reduce может возвращать что угодно.

Спасибо всем, кто напишет мне в личные сообщения об ошибках и недочётах в статье, а также в репозитории.

Спасибо за внимание.


This entry passed through the Full-Text RSS service — if this is your content and you're reading it on someone else's site, please read the FAQ at http://ift.tt/jcXqJW.


Комментариев нет:

Отправить комментарий