Методы reduce / each_with_object
Метод reduce
в языке ruby используется для свертки массива (или другой коллекции). С его помощью можно «свернуть» все элементы массива, собрав с их помощью какой-то новый объект.
Самый простой пример — посчитать сумму чисел в массиве.
> numbers = [3, 5, 1, 4]
> numbers.reduce(0) { |sum, number| sum + number }
=> 13
Или, например, собрать аббревиатуру из слов.
> words = %w[Московский государственный университет]
> words.reduce('') { |abbr, word| abbr + word[0].upcase }
=> "МГУ"
Как each
, map
и другие методы работы с коллекцией, reduce
в цикле проходит по всему массиву и для каждого элемента выполняет действия в блоке, который вы ему передадите. Только в случае с reduce
, в блок в качестве параметра кроме текущего элемента передается ещё и объект, который мы «собираем».
Он называется аккумулятор (от глагола аккумулировать, англ. accumulate — собирать, накапливать).
Метод reduce
на каждом витке цикла берет текущее значение аккумулятора, передает его в первую переменную в блоке, текущий элемент — во вторую, а результат выполнения блока записывает в аккумулятор. То есть на следующем витке цикла в нём будет уже новое значение.
На примере с суммой в блоке распечатаем обе переменные и то, что получается при сложении.
> numbers.reduce(0) do |sum, number|
> puts "sum: #{sum}, number: #{number}, result = #{sum + number}"
> sum + number
> end
Увидим четыре захода в цикл.
sum: 0, number: 3, result = 3
sum: 3, number: 5, result = 8
sum: 8, number: 1, result = 9
sum: 9, number: 4, result = 13
Результат операции сложения в каждом витке цикла становится значением аккумулятора sum
на следующем.
Перед первой итерацией аккумулятор равен 0, это число мы передали методу reduce
при вызове первым аргументом (второй аргумент — блок с кодом). Если передать другое число, оно будет добавлено к сумме элементов, т.к. именно с него начнется отcчёт.
> numbers.reduce(100) { |sum, number| sum + number }
=> 113
Если не передать первым аргументом ничего, в качестве начального значения аккумулятора будет использован первый элемент массива.
> numbers.reduce do |sum, number|
> puts "sum: #{sum}, number: #{number}, result = #{sum + number}"
> sum + number
> end
Увидим три захода в цикл.
sum: 3, number: 5, result = 8
sum: 8, number: 1, result = 9
sum: 9, number: 4, result = 13
=> 13
Итераций будет на одну меньше, потому что для первого элемента ничего выполняться не будет, а результат (в данной ситуации) будет таким же.
* — для любознательных, осторожно, повышенное содержание руби-магии
Вернемся к примеру с суммой. Оператор сложения в ruby — это метод, который вызывается у числа (первого слагаемого) и которому передается другое число (второе слагаемое).
> [3, 5, 1, 4].reduce { |sum, element| sum.+(element) }
=> 13
В таких простых случаях, блок для метода reduce
можно сгенерировать с помощью сокращения &:method
. Получится так.
> [3, 5, 1, 4].reduce(&:+)
=> 13
Наконец, в reduce
можно просто передать символ с названием метода. Метод будет вызван у аккумулятора и ему будет передан элемент.
> [3, 5, 1, 4].reduce(:+)
=> 13
> [{a: 1}, {b: 2}, {c: 3}].reduce({}, :merge)
=> {:a=>1, :b=>2, :c=>3}
В ruby методы inject
и reduce
— синонимы.
> numbers = [1, 2, 3, 4, 5]
=> [1, 2, 3, 4, 5]
> numbers.inject(:*)
=> 120
По смыслу метод reduce
(англ. — уменьшать, сокращать, снижать) больше подходит понятию «свертка», нежели inject
(англ. — вводить, впрыскивать, внедрять). Однако, в процессе свертки действительно иногда происходит «впрыскивание» элементов в собираемый объект.
Также, метод reduce
популярнее в ruby-сообществе, поэтому в этом гайде мы пользуемся преимущественно им.
Как уже упоминалось, метод reduce
можно вызвать не только у массива, но и у диапазона или хэша, т.к. классы Hash
и Range
включают модуль Enumerable
в котором и определен метод reduce
.
Проверим на примере с факториалом.
> (1..5).inject(:*)
=> 120
Работает!
Для каждой буквы алфавита найдем все слова из массива, начинающиеся на эту букву.
> words = %w[достижение прогресс продвижение шаг вперед успешность
успеваемости удача сдвиг прорыв удар хит исход ход радость
обеспечение аванс реализации развития счастье наступление]
> ('а'..'я').reduce({}) do |result, letter|
> result[letter.to_sym] = words.select { _1.start_with?(letter) }
> result
> end
=>
{:а=>["аванс"],
:б=>[],
:в=>["вперед"],
:г=>[],
:д=>["достижение"],
...
:я=>[]}
Если вызывать метод reduce
у хэша, то элементом коллекции будет пара ключ-значение. При передаче в блок их можно удобно раскрыть с помощью скобок.
> {a: 1, b: 2, c: 3}.reduce(0) { |sum, (k, v)| sum + v }
=> 6
Пусть в некоторой командной игре есть три конкурса. И у нас есть хэш, где записаны (в виде массива) результаты каждого из игроков команды за каждый конкурс.
> team = {vasya: [1, 3, 5], katya: [2, 2, 1], vadik: [1, 2, 8]}
Так с помощью reduce
можно посчитать результат всей команды.
> team.reduce(0) { |sum, (player, scores)| sum + scores.sum }
=> 25
Допустим, нам надо разбить массив слов на группы по длине слова.
> words = %w[рязань такси почасовая купонов море таганрог
солянка сборная мясная эгида ткани каталог]
> words.reduce({}) do |hash, word|
> hash[word.size] ||= []
> hash[word.size] << word
> hash
> end
=> {6=>["рязань", "мясная"], 5=>["такси", "эгида", "ткани"], 9=>["почасовая"], 7=>["купонов", "солянка", "сборная", "каталог"], 4=>["море"], 8=>["таганрог"]}
Чтобы все получилось, блок метода reduce
в конце должен вернуть новое значение аккумулятора для следующей итерации. Что делает блок чуть более громоздким. Если мы хотим, чтобы аккумулятор автоматически передавался в следующую итерацию, есть метод each_with_object
.
> words.each_with_object({}) do |word, hash|
> hash[word.size] ||= []
> hash[word.size] << word
> end
=> {6=>["рязань", "мясная"], 5=>["такси", "эгида", "ткани"], 9=>["почасовая"], 7=>["купонов", "солянка", "сборная", "каталог"], 4=>["море"], 8=>["таганрог"]}
Обратите внимание, что блоку метода each_with_object
аргументы передаются в обратном порядке: сначала элемент коллекции, потом аккумулятор 🤷♂️.
Аккумулятор при использовании метода each_with_object
должен быть изменяемым объектом (mutable object — массивом, хэшом, строкой и т. д.). И внутри блока мы должны именно изменить аккумулятор, а не вернуть новый объект (то, что возвращает блок — игнорируется).
> [{a: 1}, {b: 2}, {c: 3}].each_with_object({}) { |e, hash| hash.merge(e) }
=> {}
> [{a: 1}, {b: 2}, {c: 3}].each_with_object({}) { |e, hash| hash.merge!(e) }
=> {:a=>1, :b=>2, :c=>3}
Получить сумму элементов массива с помощью each_with_object
не выйдет, потому что числа в ruby являются неизменяемыми объектами (immutable objects).
> [3, 5, 1, 4].each_with_object(0) { |sum, number| sum += number }
=> 0
У нас есть массив хэшей с данными о пассажирах самолета: имя, вес и вес багажа. Посчитаем вес всех пассажиров и отдельно вес всего багажа.
> passengers = [
{name: 'Вадик', weight: 74, luggage_weight: 5},
{name: 'Саша', weight: 105, luggage_weight: 8},
{name: 'Катя', weight: 50, luggage_weight: 20}
]
> passengers.reduce([0, 0]) do |weights, passenger|
> [weights.first + passenger[:weight], weights.last + passenger[:luggage_weight]]
> end
=> [229, 33]
Налоговая считает доходы юридических лиц поквартально. Чтобы видеть и доход за каждый квартал (пусть, у нас есть такой массив) и суммарный доход по состоянию на конец каждого квартала, посчитаем т. н. накопительный итог (cumulative sum) за каждый квартал.
> incomes = [150_800, 345_100, 501_200, 689_500]
> incomes.inject([]) do |sum_array, income|
> sum = sum_array.last || 0
> sum_array.push(sum + income)
> end
=> [150800, 495900, 997100, 1686600]
Теперь вы в курсе, как работает метод reduce
— удобный и полезный в разработке элемент перочинного ножа методов коллекций в ruby (наравне со всякими map
, sort
, select
, find_by
и т. д.). Пользуйтесь им с удовольствием.