Ликбез по 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
.