Метод reduce в ruby

Метод 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

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

Сокращения при вызове reduce *

* — для любознательных, осторожно, повышенное содержание руби-магии

Вернемся к примеру с суммой. Оператор сложения в 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}

Метод inject

В ruby методы inject и reduce — синонимы.

> numbers  = [1, 2, 3, 4, 5]
 => [1, 2, 3, 4, 5]

> numbers.inject(:*)
 => 120

По смыслу метод reduce (англ. — уменьшать, сокращать, снижать) больше подходит понятию «свертка», нежели inject (англ. — вводить, впрыскивать, внедрять). Однако, в процессе свертки действительно иногда происходит «впрыскивание» элементов в собираемый объект.

Также, метод reduce популярнее в ruby-сообществе, поэтому в этом гайде мы пользуемся преимущественно им.

reduce для диапазонов

Как уже упоминалось, метод 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 для хэшей

Если вызывать метод 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

Метод each_with_object

Допустим, нам надо разбить массив слов на группы по длине слова.

> 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 и т. д.). Пользуйтесь им с удовольствием.

Изучаешь руби?

Заходи в дружелюбный чат, задавай вопросы, делись опытом.

Присоединиться