Ликбез по Enumerator в Ruby

Updated on May 1, 2021

В очередной раз обнаружил пробел в своих знаниях Ruby и пришлось срочно его заполнить. На этот раз объект изучения это класс Enumerator.

Все это описано в документации и отличных статьях поэтому повторять за ними глупо. Но есть определенный смысл в кратком и сжатом изложении с акцентами на нюансах и тонкостях.

Класс Enumerator

Enumerator - это объект для итерации по другому объекту-коллекции. Он подмешивает модуль Enumerable (т.е. с ним можно работать как с коллекцией) и в то же время является внешним итератором.

Вот так Matz точно и емко описывает его в своей книге:

Enumerator objects are Enumerable objects with an each method that is based on some other iterator method of some other object.

The Ruby programming language. Matz

Enumerator можно использовать:

  • как внутренний итератор,
  • как внешний итератор,
  • для создания псевдо-коллекции

Каждый элемент псевдо-коллекции будет вычисляться при первом обращении к нему. Такая псевдо-коллекция является ленивой и может быть бесконечной.

Создание enumerator’а

Создать Enumerator из Enumerable очень легко. Надо просто вызвать метод to_enum (Object#to_enum). И поскольку Enumerator подмешивает модуль Enumerable, нам доступны все его методы:

enum = [1, 2, 3].to_enum
=> #<Enumerator: [1, 2, 3]:each>

enum.map { |n| n * 2 }
=> [2, 4, 6]

По умолчанию Enumerator обходит коллекцию используя метод each, но можно использовать и другие методы. Например, можно создать enumerator для обхода коллекции в обратном порядке используя метод reverse_each:

enum = [1, 2, 3].to_enum(:reverse_each)
=> #<Enumerator: [1, 2, 3]:reverse_each>

enum.map { |n| n * 2 }
=> [6, 4, 2]

Или, например, можно обойти не элементы самой коллекции, а пройтись по подмассивам, которые получаем вызвав each_cons:

enum = [1, 2, 3, 4, 5].to_enum(:each_cons, 2)
=> #<Enumerator: [1, 2, 3, 4, 5]:each_cons(2)>

enum.take(3)
=> [[1, 2], [2, 3], [3, 4]]

В последнем примере раскрывается идея Enumerator‘а - имея объект (не обязательно Enumerable) и метод для итерации (each_cons) можно создать Enumerable, вычислимый и эфемерный. Ведь на самом деле никакого массива пар [[1, 2], ...] не существует - каждый элемент последовательности вычисляется на лету в методе each_cons.

Enumerable и Enumerator очень тесно связаны. Многие методы модуля Enumerable, вызванные без блока, возвращают enumerator. Например, вызов reverse_each без блока:

['a', 'b', 'c', 'd', 'e'].reverse_each
=> #<Enumerator: ["a", "b", "c", "d", "e"]:reverse_each>

Внешние и внутреннии итераторы

Остановимся на концепции итераторов подробнее.

Enumerator реализует свой метод each, а в Enumerable есть производные от each методы cycle, each_entry, each_with_index, each_with_index и reverse_each. Они позволяют обойти коллекцию и обработать ее элементы. Это можно назвать внутренним итератором. Ведь контроль за итерациями остается у коллекции и клиент не управляет порядком обхода и не выбирает момент, когда обработать очередной элемент:

(1..3).to_enum.reverse_each { |v| p v }
3
2
1

Enumerator также реализует методы next, peek и rewind, которые позволяют пройтись по коллекции, но контроль остается у клиента. Поэтому Enumerator можно назвать и внешним итератором.

enum = [1, 2, 3].each
=> #<Enumerator: [1, 2, 3]:each>

loop do
  p enum.next
end
1
2
3
=> [1, 2, 3]

Создание псевдо-коллекций

Enumerator помогает создать псевдо-коллекции - вычисляемые последовательности элементов.

Можно рассмотреть такие коллекции на примере Enumerable и методов each_cons и each_slice. Эти методы создают новую последовательность на основе базовой коллекции и итерируют по ней.

Создать новую псевдо-последовательность можно вручную либо реализовав метод аналогичный each_cons либо используя Enumerator.

Реализованный вручную метод должен принимать блок, генерировать очередной элемент последовательности и передавать его как параметр в блок. Для примера реализуем метод подобный Integer#times:

def times(n)
  i = 0
  while i < n
    yield i
    i += 1
  end
end

times(3) { |i| puts i }
0
1
2

to_enum(:times, 5).to_a
=> [0, 1, 2, 3, 4, 5]

Можно также посмотреть на реализацию метода each_cons в Rubinius (source)

Но что делать, если нет подходящего класса, в котором можно реализовать такой метод? Посмотрим, как с этим нам поможет Enumerator:

enum = Enumerator.new do |y|
  y << 1
  y << 'foo'
  y << ['bar']
end
=> #<Enumerator: #<Enumerator::Generator:0x00007fa6a806a030>:each>

enum теперь можно использовать точно так же как и обычный Enumerable состоящий из трех элементов:

enum.map { |a| a }
=> [1, "foo", ["bar"]]

Метод to_enum, который мы использовали ранее, это всего лишь короткая запись для:

enum = Enumerator.new(['a'], :each)
=> #<Enumerator: ["a"]:each>

Бесконечные последовательности

Псевдо-коллекция может быть бесконечной. Давайте рассмотрим такие коллекции на примере Enumerable и Range. Метод Enumerable#cycle может обходить коллекцию по кругу бесконечно, а Range можно создать бесконечным.

[1, 2, 3].to_enum(:cycle).take(10)
=> [1, 2, 3, 1, 2, 3, 1, 2, 3, 1]

# Ruby 2.6 syntax
(1 .. nil).to_enum.take(10)
=> [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]

Приведем, наверное, классический для Enumerator‘а пример бесконечной коллекции - числа Фибоначчи:

fib = Enumerator.new do |y|
  a = b = 1
  loop do
    y << a
    a, b = b, a + b
  end
end

Теперь fib ничем не отличается от обычной ленивой бесконечной коллекции:

fib.take(10)
=> [1, 1, 2, 3, 5, 8, 13, 21, 34, 55]

Еще один способ создать бесконечный enumerator появился не так давно в Ruby 2.7 - метод .produce (documentation). Метод возвращает Enumerator, который вычисляет элементы вызывая указанный блок:

Enumerator.produce(1) { |prev| prev + 1 } # enumerator: 1, 2, 3, ...

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

Enumerator.produce { rand(100) }

Цепочки enumerator’ов

Поскольку Enumerator подмешивает Enumerable, а методы Enumerable могут возвращать новые enumerator‘ы, можно создавать целые цепочки enumerator‘ов. Иногда это оказывается очень полезным. Приведем примеры:

array = ['a', 'b', 'c', 'd', 'e']
=> ["a", "b", "c", "d", "e"]

array.each.with_index.map { |name, i| name * i }
=> ["", "b", "cc", "ddd", "eeee"]

array.reverse_each.group_by.each_with_index { |item, i| i % 3 }
=> {0=>["e", "b"], 1=>["d", "a"], 2=>["c"]}

Если первый вызов (each и reverse_each) делается на массиве, то все остальные методы with_index, map, group_by и each_with_index вызываются уже на enumerator‘е.

Рассмотрим первый пример с цепочкой:

['a', 'b', 'c', 'd', 'e'].each.with_index.map { |name, i| name * i }

each возвращает enumerator, который итерирует по исходному массиву

enum = ['a', 'b', 'c', 'd', 'e'].each
=> #<Enumerator: ["a", "b", "c", "d", "e"]:each>

enum.to_a
=> ["a", "b", "c", "d", "e"]

each.with_index тоже возвращает enumerator. Но он уже итерирует по последовательности пар (элемент, индекс):

enum = ['a', 'b', 'c', 'd', 'e'].each.with_index
=> #<Enumerator: #<Enumerator: ["a", "b", "c", "d", "e"]:each>:with_index>

enum.to_a
=> [["a", 0], ["b", 1], ["c", 2], ["d", 3], ["e", 4]]

И теперь все становится на свои места - вызывая map на этом enumerator‘е в блок будут передаваться пары (элемент, индекс):

enum = ['a', 'b', 'c', 'd', 'e'].each.with_index
=> #<Enumerator: #<Enumerator: ["a", "b", "c", "d", "e"]:each>:with_index>

enum.map { |name, i| [name, i] }
=> [["a", 0], ["b", 1], ["c", 2], ["d", 3], ["e", 4]]

Заключение

Enumerator может казаться незаметной и не очень полезной частью стандартной библиотеки, но именно с его помощью можно получить:

  • внешний итератор для коллекции
  • ленивую коллекцию
  • вычислимую псевдо-коллекцию
  • бесконечную коллекцию

Более того, используя Enumerator можно легко и элегантно превратить не-Enumerable сущность в Enumerable.

Enumerator является одновременно и внутренним итератором и внешним.

Так как Enumerable используется практически в любой Ruby-программе нам нужно хорошо разбираться и в тесно связанным с ним Enumerator.

Ссылки по теме: