Enumerators and Lazy Evaluation
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18); features newer than Ruby 3.0 name the version that introduced them.
An enumerator is an object that stands for an iteration which has not happened yet: it remembers a receiver, a method and its arguments, and it runs that method only when something asks for the elements.
That small idea explains why [1, 2, 3].each without a block does not fail but returns an Enumerator, why map.with_index(1) works, how loop and next can walk a collection one element at a time, and how an infinite sequence such as the Fibonacci numbers can be an ordinary object that you pass around and take ten elements from.
The page covers where enumerators come from (methods called without a block, to_enum, Enumerator.new, Enumerator.produce, +, Enumerator.product and step), the two ways to consume them (internal iteration with a block and external iteration with next), the lazy variant Enumerator::Lazy that makes a whole pipeline of map, select and take process one element at a time, the three specialised classes Enumerator::Chain, Enumerator::Product and Enumerator::ArithmeticSequence, and the errors and pitfalls that surprise newcomers.
Related pages: every method an enumerator offers beyond its own comes from Enumerable, described in Enumerable, and the blocks that drive it are explained in Blocks, Procs and Lambdas. Infinite and stepped ranges are covered in Ranges, and slicing arrays with a stepped range in Arrays. External iteration is built on fibers, the subject of Fibers and the Fiber Scheduler, and its thread restriction is a consequence of the model in Threads and the GVL. How StopIteration is raised and rescued is a case of Exceptions and Error Handling, and the anonymous block parameter used in some examples is explained in Methods and Parameters. For the same idea in other languages, see Rust iterators (where every adaptor is lazy by default), Python iterators and generators, PHP iterators and generators and Java streams.
Every example on this page was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 without warnings, and linted with RuboCop 1.92 using the configuration of the running shelf gem (target Ruby 4.0, double-quoted strings, NewCops: enable, Style/Documentation off). The output shown in # => comments is the real output of that run. Results that depend on a clock, an object address or a random number are avoided on purpose.
What an enumerator is
Internal and external iteration
There are two ways to walk a sequence. With internal iteration the sequence drives: you hand it a block and it calls the block once per element (each, map, select). With external iteration you drive: you ask for the next element when you are ready (next), keep the position yourself and may do other things in between. Most languages offer one of the two natively and simulate the other; Ruby’s Enumerator offers both on the same object, so the same titles.each can be passed a block or be advanced by hand.
An enumerator is, in essence, a triple: a receiver, the name of an iteration method and the arguments for it. [1, 2, 3].each_slice(2) is the object [1, 2, 3], the method each_slice and the argument 2; calling each on the enumerator with a block calls [1, 2, 3].each_slice(2) { ... }. Nothing is copied and nothing runs until the enumerator is consumed. Enumerator includes Enumerable, so it answers map, select, first, take, zip, each_slice and everything else described in Enumerable.
The family
Enumerator is the base class and several subclasses add behaviour. They are normally created by a method rather than with new, which is why their names are easy to miss in code.
| Class | Created by | What it is for |
|---|---|---|
|
A method without a block ( |
The general case: internal and external iteration, |
|
|
Stages ( |
|
|
Several enumerables treated as one, iterated one after the other (Ruby 2.6). |
|
|
The Cartesian product of several enumerables, computed on demand (Ruby 3.2). |
|
|
A number sequence with a fixed difference that knows its |
|
The block parameter of |
The object whose |
|
|
Internal receivers that run your block. They show up in |
Two of the subclasses are not full cursors. Enumerator::Chain and Enumerator::Product undefine next, peek, next_values, peek_values and feed, so chain.next raises NoMethodError; the section on chains shows the one-line workaround. Enumerator::ArithmeticSequence keeps them.
Methods without a block return enumerators
The convention
Almost every iteration method in the core library has two forms: with a block it iterates and returns its normal result, without a block it returns an enumerator for the same call. [1, 2, 3].map is not an error; it is the object #<Enumerator: [1, 2, 3]:map>, and its inspect shows the receiver, a colon, the method and the arguments. The list includes each, map, select, reject, each_with_index, each_slice, each_cons, each_char, each_line, Integer#times, upto, downto, Hash#each, Kernel#loop, IO.foreach and many more. The convention is what lets methods be chained: the enumerator is the first link, with_index or with_object the second, and the original method’s block the last.
Enumerator#size answers without iterating when the receiver can compute it (an Array, a Range, times, each_slice on an Array) and returns nil when it cannot (a custom each, a select). loop answers Infinity. A nil size does not mean empty; it means "unknown without walking it".
# frozen_string_literal: true
require "shelf"
catalog = Shelf::Catalog.new
[["9780441013593", "Dune", "Frank Herbert"], ["9780261103573", "The Hobbit", "J. R. R. Tolkien"],
["9780553293357", "Foundation", "Isaac Asimov"]].each do |digits, title, name|
catalog.add(Shelf::Book.new(Shelf::Isbn.new(digits), title, Shelf::Author.new(name)))
end
titles = catalog.map(&:title)
p titles.each # => #<Enumerator: ["Dune", "The Hobbit", "Foundation"]:each>
p titles.each.class # => Enumerator
p titles.map # => #<Enumerator: ["Dune", "The Hobbit", "Foundation"]:map>
p titles.each_slice(2) # => #<Enumerator: ["Dune", "The Hobbit", "Foundation"]:each_slice(2)>
p titles.each_with_index # => #<Enumerator: ["Dune", "The Hobbit", "Foundation"]:each_with_index>
p 3.times # => #<Enumerator: 3:times>
p "shelf".each_char # => #<Enumerator: "shelf":each_char>
p((1..4).each_cons(2)) # => #<Enumerator: 1..4:each_cons(2)>
p catalog.each.class # => Enumerator
# An enumerator knows its size when the receiver can compute it without iterating.
p titles.each.size # => 3
p titles.each_slice(2).size # => 2
p 3.times.size # => 3
p catalog.each.size # => nil
p loop.size # => Infinity
# Chaining a method on the enumerator is how Ruby spells "map with an index".
p titles.each.with_index(1).to_a # => [["Dune", 1], ["The Hobbit", 2], ["Foundation", 3]]
p(titles.map.with_index(1) { |title, number| "#{number}. #{title}" }) # => ["1. Dune", "2. The Hobbit", "3. Foundation"]
p(titles.select.with_index { |_title, index| index.even? }) # => ["Dune", "Foundation"]
p(titles.each_with_index.map { |title, index| [index, title.length] }) # => [[0, 4], [1, 10], [2, 10]]
p titles.each.with_object([]).class # => Enumerator
p(titles.each_with_object(Hash.new(0)) { |title, memo| memo[title.length] += 1 }) # => {4 => 1, 10 => 2}
Source: Enumerator (the class overview, size, with_index and with_object), Kernel#loop and Enumerable.
The Catalog#each of the shelf gem follows the convention (return enum_for(:each) unless block_given?), which is why catalog.each.class is Enumerator and catalog.map(&:title) works; its size is nil because the method gives no way to compute it.
with_index, each_with_index and with_object
Three methods attach extra information to an enumeration.
| Method | Behaviour |
|---|---|
|
Yields |
|
The same with the offset fixed at 0. It is defined by |
|
Yields |
Block parameters destructure: hash.map.with_index(1) { |(key, value), index| ... } needs the parentheses around the pair, because the enumerator yields [[key, value], index]. RuboCop reports each_with_object calls that only build a Hash as Style/ReduceToHash and suggests to_h { ... }, which is usually clearer.
to_enum and enum_for
to_enum and enum_for (aliases, defined in Kernel) create an enumerator for any method of any object: obj.to_enum(:method, *args). Calling each on it later calls obj.method(*args) with a block. They are how your own iteration methods follow the convention above, with the guard return to_enum(:name) unless block_given?. Three details matter.
-
Pass the method name with
__method__(the name of the current method) so that renaming the method does not break the guard, and pass every argument the method takes after it; the enumerator replays them. -
A block given to
to_enumcomputes the size lazily, in the context of the receiver. Give one only when the size is cheap and certain; otherwisesizestaysnil, which is honest. -
The guard must be the first statement. A method that does work before it would repeat that work every time the enumerator is iterated.
# frozen_string_literal: true
module Shelf
# A shelf of books that exposes its contents as enumerators.
class Rack
def initialize(*titles) = @titles = titles
def inspect = "#<Shelf::Rack #{@titles.size} titles>"
# The classic guard: no block, return an enumerator that calls this method again.
def each_title(&)
return to_enum(:each_title) { @titles.size } unless block_given?
@titles.each(&)
self
end
# __method__ avoids repeating the method name; extra arguments are replayed to the method.
def each_pair_of(size, &)
return enum_for(__method__, size) { @titles.size.fdiv(size).ceil } unless block_given?
@titles.each_slice(size, &)
end
# A method that cannot compute its size cheaply simply omits the size block.
def each_long_title(min)
return to_enum(__method__, min) unless block_given?
@titles.each { |title| yield title if title.length >= min }
end
end
end
rack = Shelf::Rack.new("Dune", "The Hobbit", "Foundation", "Emma", "Ulysses")
p rack.each_title.size # => 5
p rack.each_title.to_a # => ["Dune", "The Hobbit", "Foundation", "Emma", "Ulysses"]
p rack.each_title # => #<Enumerator: #<Shelf::Rack 5 titles>:each_title>
p rack.each_pair_of(2) # => #<Enumerator: #<Shelf::Rack 5 titles>:each_pair_of(2)>
p rack.each_pair_of(2).size # => 3
p rack.each_pair_of(2).to_a # => [["Dune", "The Hobbit"], ["Foundation", "Emma"], ["Ulysses"]]
p rack.each_long_title(8).size # => nil
p rack.each_long_title(8).to_a # => ["The Hobbit", "Foundation"]
p(rack.each_title.with_index(1).map { |title, number| "#{number}:#{title}" }.first(2)) # => ["1:Dune", "2:The Hobbit"]
p(rack.each_title { nil }.class) # => Shelf::Rack
# An enumerator is lazy about the call itself: nothing runs until something iterates it.
enum = rack.each_title
p enum.next # => "Dune"
p enum.take(2) # => ["Dune", "The Hobbit"]
Source: Object#to_enum, Object#enum_for and Enumerator#size.
A class that defines each this way and includes Enumerable gets the whole Enumerable API for free (see Enumerable). each_title above also shows two forms of block forwarding. def each_title(&) followed by @titles.each(&) forwards the caller’s block without naming it (anonymous block parameter, Ruby 3.1), and each_long_title calls yield directly because it decides per element. RuboCop’s Style/ExplicitBlockArgument pushes you to the first form whenever a method only wraps a block in another block.
Enumerator#each with a block
Calling each on an enumerator and passing a block runs the underlying method with that block, and returns what the method returns. %w[a b c].map.each(&:upcase) is %w[a b c].map(&:upcase), and select.each { ... } filters. This is the mechanism behind map.with_index and select.with_index: the block of with_index is given to the original method, with the index added to its arguments. It also means an enumerator for a mutating or side-effecting method repeats the side effect each time it is iterated.
External iteration
next, peek, rewind and StopIteration
next returns the next element and advances; peek returns it without advancing; rewind starts again. When the elements run out next and peek raise StopIteration (message iteration reached an end), and keep raising it until rewind. The exception carries the return value of the iteration in result, which is the value the underlying method returned (for each on an Array, the array itself).
| Method | Behaviour |
|---|---|
|
Returns the next element and moves on. Raises |
|
Returns the next element without moving. Raises |
|
Like |
|
Resets the position; also calls |
|
Sets the value the next |
|
The number of elements, or |
Kernel#loop rescues StopIteration and returns its result, so the idiomatic way to drain a cursor is loop { ... cursor.next ... }. Written with a plain while true the exception has to be rescued by hand. The exception is also why a StopIteration that escapes from the producer (the block of Enumerator.new) is dangerous: to_a does not swallow it, loop does, and the two can silently disagree (see the pitfalls section).
# frozen_string_literal: true
def outcome
yield.inspect
rescue StandardError => e
"#{e.class}: #{e.message}"
end
titles = %w[Dune Emma Ulysses]
cursor = titles.each
p cursor.peek # => "Dune"
p cursor.next # => "Dune"
p cursor.next # => "Emma"
p cursor.peek # => "Ulysses"
p cursor.next # => "Ulysses"
puts(outcome { cursor.next }) # => StopIteration: iteration reached an end
puts(outcome { cursor.peek }) # => StopIteration: iteration reached an end
# rewind starts again from the first element.
cursor.rewind
p cursor.next # => "Dune"
# The exception carries the return value of the iteration in #result.
cursor = titles.each
3.times { cursor.next }
begin
cursor.next
rescue StopIteration => e
p e.result # => ["Dune", "Emma", "Ulysses"]
p e.message # => "iteration reached an end"
end
# Kernel#loop rescues StopIteration and returns the result: the idiomatic way to drain a cursor.
cursor = titles.each_with_index
seen = []
result = loop do
title, index = cursor.next
seen << "#{index}:#{title}"
end
p seen # => ["0:Dune", "1:Emma", "2:Ulysses"]
p result # => ["Dune", "Emma", "Ulysses"]
# next_values and peek_values keep the difference between yield 1, yield [1] and a bare yield.
generator = Enumerator.new do |y|
y << 1
y.yield 2, 3
y.yield [4, 5]
y.yield
y.yield nil
end
values = []
loop { values << generator.next_values }
p values # => [[1], [2, 3], [[4, 5]], [], [nil]]
generator.rewind
plain = []
loop { plain << generator.next }
p plain # => [1, [2, 3], [4, 5], nil, nil]
p generator.rewind.peek_values # => [1]
When you need a cursor
Internal iteration is simpler and faster, and should be the default. External iteration earns its place when two or more sequences must advance together under your control, which a single block cannot express. The classic case is merging two sorted sources: each source is a cursor, peek compares the heads and next consumes the smaller one. The same shape serves a lexer or parser reading tokens with one-token look-ahead, round-robin scheduling and any "pull" protocol. Note that the merge below is itself an enumerator, so it is lazy and can be taken from with first(3).
# frozen_string_literal: true
# A merge of two sorted sources needs both cursors alive at once: external iteration.
def merge_sorted(left, right)
Enumerator.new do |y|
a = left.each
b = right.each
# loop ends quietly when a.peek or b.peek raises StopIteration.
loop { y << (a.peek <= b.peek ? a.next : b.next) }
# Only one side can have elements left; drain both.
loop { y << a.next }
loop { y << b.next }
end
end
p merge_sorted([1, 4, 9], [2, 3, 10, 11]).to_a # => [1, 2, 3, 4, 9, 10, 11]
p merge_sorted(%w[Dune Ulysses], %w[Emma Hobbit Zorba]).first(3) # => ["Dune", "Emma", "Hobbit"]
# Internal iteration cannot do this: zip over two infinite sources needs the cursor protocol.
evens = Enumerator.produce(0) { it + 2 }
odds = Enumerator.produce(1) { it + 2 }
pairs = Enumerator.new { |y| loop { y << [evens.next, odds.next] } }
p pairs.take(3) # => [[0, 1], [2, 3], [4, 5]]
# The cursors live outside the enumerator, so the second call continues where the first stopped.
p pairs.take(3) # => [[6, 7], [8, 9], [10, 11]]
# Each external enumerator holds a suspended Fiber, so a cursor must not cross threads.
Thread.report_on_exception = false
cursor = [1, 2, 3].each
cursor.next
begin
Thread.new { cursor.next }.join
rescue FiberError => e
puts "#{e.class}: #{e.message}" # => FiberError: fiber called across threads
end
Source: Enumerator#peek and Kernel#loop.
The second part of the example shows a trap: the two cursors evens and odds live outside the generator, so they keep their position between calls, and the second take(3) continues where the first one stopped. State held outside a generator is shared state.
feed
feed(value) is the one way to talk back to a producer. Inside a producer, y.yield(x) (or the block call of map) evaluates to the value that was fed; without a feed it evaluates to nil. For map that value becomes the mapped element, so feeding every step reproduces map by hand. It is rare in application code, but it shows that an external enumerator is a coroutine, and it is how an external iterator can be turned back into an internal one (the class documentation contains that example).
What next costs, and what it forbids
next, next_values, peek and peek_values are the only enumerator methods that use external iteration (the other one in core is Array#zip with a non-Array argument). To suspend the producer in the middle of its loop, Ruby runs it in a fiber, a lightweight coroutine; that has consequences that internal iteration does not have.
-
The fiber adds overhead compared with internal iteration, so do not use
nextin a hot loop over a collection you could simplyeach. -
The enumerator belongs to the fiber that created it. Calling
nextfrom another thread raisesFiberError: fiber called across threads; an enumerator cursor is not something to share between threads (see Threads and the GVL). Give each thread its own enumerator. -
A frozen enumerator cannot advance:
next,peek,rewindandfeedraiseFrozenError. -
The stack trace of an exception raised in the producer shows only the producer’s frames, not the caller’s.
-
Fiber-local variables (
Thread.current[:name]) are not inherited by the producer’s fiber, while fiber storage (Fiber[:name], Ruby 3.2) is inherited as a copy: reads work, writes stay in the producer’s fiber. Internal iteration runs in the caller’s fiber and sees and changes both.
# frozen_string_literal: true
Thread.current[:locale] = "en"
Fiber[:request_id] = "r-17"
report = Enumerator.new do |y|
y << [Thread.current[:locale], Fiber[:request_id]]
Fiber[:request_id] = "changed"
end
# Internal iteration runs in the caller's fiber: it sees fiber-local variables and its writes are visible.
p report.first # => ["en", "r-17"]
p report.to_a # => [["en", "r-17"]]
p Fiber[:request_id] # => "changed"
# External iteration runs the block in a new fiber: fiber-local variables are gone, fiber storage is inherited.
Fiber[:request_id] = "r-17"
cursor = report.each
p cursor.next # => [nil, "r-17"]
begin
cursor.next
rescue StopIteration
p Fiber[:request_id] # => "r-17"
end
Source: Enumerator (the section "External Iteration"), Fiber.[] and FiberError.
Enumerator.new and the yielder
Enumerator.new(size = nil) { |y| ... } builds an enumerator from a block. The block runs when the enumerator is iterated and receives an Enumerator::Yielder; every y << value or y.yield value hands one element to the consumer and suspends the block until the consumer wants more. This is the Ruby spelling of a generator.
-
y << valuereturns the yielder, so it chains:y << 1 << 2. It yields one value. -
y.yield a, bcan pass several values (the consumer sees[a, b]) and returns the value fed withfeed, ornil. -
y.to_procmakes the yielder usable as a block:other.each(&y)forwards every element ofother(Ruby 2.7). -
The optional
sizeargument becomes the result ofEnumerator#size. It can be an Integer,Float::INFINITYor a callable that is evaluated on each call tosize; without it the size isnil. -
Calling
Enumerator.newwithout a block raisesArgumentError, and so does the two-argument formEnumerator.new(object, :method)that very old code used; writeobject.to_enum(:method)instead.
The block is the recipe, and it starts from the top for every iteration. Two external consequences follow. First, a generator that logs or has side effects repeats them for each consumer, and a consumer that stops early (first(1)) stops the block at the y << it was waiting on, so the code after it never runs. Second, variables created inside the block start fresh each time, while variables captured from outside are shared by all iterations (the leaky example). Keep the state of a generator inside the block unless sharing is the point.
# frozen_string_literal: true
require "shelf"
# << returns the yielder (so it chains); yield passes several values.
log = []
numbers = Enumerator.new do |y|
log << :start
y << 1 << 2
y.yield 3, 4
log << :end
:finished
end
p numbers.to_a # => [1, 2, [3, 4]]
p log # => [:start, :end]
p numbers.size # => nil
p(numbers.each { nil }) # => :finished
# The block runs from the top for every iteration, and only as far as the consumer asks.
log.clear
p numbers.first(1) # => [1]
p log # => [:start]
p numbers.next # => 1
p log # => [:start, :start]
# State kept outside the block is shared by every iteration; state created inside it starts fresh.
counter = 0
leaky = Enumerator.new { |y| 3.times { y << (counter += 1) } }
p leaky.to_a # => [1, 2, 3]
p leaky.to_a # => [4, 5, 6]
# Infinite generators are fine because the consumer decides when to stop.
fibonacci = Enumerator.new do |y|
a = 0
b = 1
loop do
y << a
a, b = b, a + b
end
end
p fibonacci.take(10) # => [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
p fibonacci.lazy.select(&:even?).first(5) # => [0, 2, 8, 34, 144]
p(fibonacci.each_with_index.find { |n, _| n > 100 }) # => [144, 12]
p fibonacci.size # => nil
# The optional argument is the size: a number, Float::INFINITY, or a callable evaluated on demand.
sized = Enumerator.new(3) { |y| 3.times { y << it } }
p sized.size # => 3
p(Enumerator.new(Float::INFINITY) { |y| loop { y << 1 } }.size) # => Infinity
calls = 0
dynamic = Enumerator.new(-> { calls += 1 }) { |y| y << 1 }
p [dynamic.size, dynamic.size] # => [1, 2]
# A Yielder can be passed where a block is expected: y.to_proc is what &y calls.
catalog = Shelf::Catalog.new
catalog.add(Shelf::Book.new(Shelf::Isbn.new("9780441013593"), "Dune", Shelf::Author.new("Frank Herbert")))
titles = Enumerator.new { |y| catalog.each { |book| y << book.title } }
direct = Enumerator.new { |y| catalog.map(&:title).each(&y) }
p titles.to_a # => ["Dune"]
p direct.to_a # => ["Dune"]
# Generators are values: pass them to methods that expect any Enumerable.
def numbered(source) = source.each_with_index.map { |item, index| "#{index + 1}. #{item}" }
p numbered(titles) # => ["1. Dune"]
Source: Enumerator.new, Enumerator::Yielder and Yielder#to_proc.
Enumerator.new is the right tool when the sequence is naturally written as a loop with some local state: Fibonacci numbers, a paginated API that you want to hide behind a flat stream, a tree traversal, a file read in chunks. When each element is a function of the previous one, Enumerator.produce below says the same thing with less code.
Enumerator.produce
Enumerator.produce(initial = nil) { |previous| next_value } (Ruby 2.7) builds an infinite enumerator in which each element is the block applied to the previous one. With an initial value the first element is that value; without one the block is called with no argument to get the first. The sequence ends when the block raises StopIteration, which is the only way to make it finite. It is the functional "unfold": a seed and a step function.
Ruby 4.0 adds the size: keyword. The default is Float::INFINITY (which is wrong for a sequence that stops by StopIteration, as the Collatz example shows), nil means unknown, an Integer states the size, and a callable computes it when size is called. On Ruby 3.4 the keyword does not exist and the call raises ArgumentError.
# frozen_string_literal: true
def outcome
yield.inspect
rescue StandardError => e
"#{e.class}: #{e.message}"
end
# produce(initial) yields initial, then block(initial), block(block(initial)), and so on.
p(Enumerator.produce(1) { it * 2 }.take(6)) # => [1, 2, 4, 8, 16, 32]
p Enumerator.produce(1, &:succ).lazy.select(&:even?).first(3) # => [2, 4, 6]
# Without an initial value the first element is the first call of the block (called with no argument).
counter = 0
ticks = Enumerator.produce { counter += 1 }
p ticks.take(3) # => [1, 2, 3]
# Raising StopIteration inside the block ends the sequence: this is how finite sequences are made.
countdown = Enumerator.produce(3) { it.zero? ? raise(StopIteration) : it - 1 }
p countdown.to_a # => [3, 2, 1, 0]
# The Collatz sequence: each element depends on the previous one, which is what produce is for.
collatz = Enumerator.produce(6) do |n|
raise StopIteration if n == 1
n.even? ? n / 2 : (3 * n) + 1
end
p collatz.to_a # => [6, 3, 10, 5, 16, 8, 4, 2, 1]
p collatz.size # => Infinity
# Due dates of a loan that is renewed every 14 days, as Time values.
lent_on = Time.utc(2026, 10, 1)
due_dates = Enumerator.produce(lent_on + (14 * 86_400)) { it + (14 * 86_400) }
p(due_dates.first(3).map { it.strftime("%Y-%m-%d") }) # => ["2026-10-15", "2026-10-29", "2026-11-12"]
labels = due_dates.lazy.map { it.strftime("%b %-d") }
p(labels.take_while { it != "Dec 24" }.to_a) # => ["Oct 15", "Oct 29", "Nov 12", "Nov 26", "Dec 10"]
# Walking up a parent chain: stop by raising StopIteration when there is no parent.
Node = Struct.new(:name, :parent)
root = Node.new("library")
floor = Node.new("first floor", root)
aisle = Node.new("aisle 7", floor)
path = Enumerator.produce(aisle) { it.parent || raise(StopIteration) }
p path.map(&:name) # => ["aisle 7", "first floor", "library"]
# Ruby 4.0: the size keyword. The default is Float::INFINITY, nil means "unknown".
p Enumerator.produce(1, &:succ).size # => Infinity
p Enumerator.produce(1, size: nil, &:succ).size # => nil
p(Enumerator.produce(3, size: 4) { it.zero? ? raise(StopIteration) : it - 1 }.size) # => 4
p(Enumerator.produce(1, size: -> { 42 }, &:succ).size) # => 42
puts(outcome { Enumerator.produce }) # => ArgumentError: no block given
Source: Enumerator.produce and the Ruby 4.0 NEWS (the size: keyword).
The unfold shape fits many real problems: the due dates of a recurring loan, a retry back-off (Enumerator.produce(1) { [_1 * 2, 60].min }), following parent or next links until nil, the Collatz or Newton iterations, and pagination by cursor. Combine it with lazy, take_while or first(n) to stop, and remember that without a stopping condition to_a on it never returns.
Lazy enumerators
Eager and lazy pipelines
A normal pipeline, (1..5).map { ... }.select { ... }.first(2), is eager: map finishes the whole collection and builds an intermediate Array, then select processes that Array and builds another, and only then does first(2) take two elements. That is wasteful when most results are thrown away, and impossible when the source is infinite. Enumerable#lazy returns an Enumerator::Lazy whose stages do not run when called. Each stage wraps the previous one, and a terminal call such as first(n), to_a or each pulls elements through the whole chain one at a time, stopping as soon as the terminal call is satisfied.
The example below records every call in a trace array. The eager run does five map calls and five select calls; the lazy run alternates them and stops after the fourth element. Building the lazy pipeline runs nothing at all (its trace stays empty, and inspect shows the nested stages map and select).
# frozen_string_literal: true
trace = []
double = lambda do |n|
trace << "map #{n}"
n * 2
end
big = lambda do |n|
trace << "select #{n}"
n > 4
end
# Eager: every stage finishes before the next one starts.
eager = (1..5).map(&double).select(&big).first(2)
p eager # => [6, 8]
p trace # => ["map 1", "map 2", "map 3", "map 4", "map 5", "select 2", "select 4", "select 6", "select 8", "select 10"]
# Lazy: one element travels through all the stages before the next one is fetched.
trace.clear
lazy = (1..5).lazy.map(&double).select(&big).first(2)
p lazy # => [6, 8]
p trace # => ["map 1", "select 2", "map 2", "select 4", "map 3", "select 6", "map 4", "select 8"]
# Building the pipeline runs nothing; only a terminal call does.
trace.clear
pipeline = (1..5).lazy.map(&double).select(&big)
p pipeline # => #<Enumerator::Lazy: #<Enumerator::Lazy: #<Enumerator::Lazy: 1..5>:map>:select>
p trace # => []
p pipeline.first(1) # => [6]
p trace # => ["map 1", "select 2", "map 2", "select 4", "map 3", "select 6"]
p pipeline.class # => Enumerator::Lazy
p pipeline.to_a # => [6, 8, 10]
p pipeline.force # => [6, 8, 10]
Source: Enumerator::Lazy and Enumerable#lazy.
Creating and ending a lazy pipeline
-
enumerable.lazystarts a pipeline from anything that isEnumerable(an Array, a Range, a Hash, anIO, aCatalog, anEnumerator). Callinglazyon a lazy enumerator returns the same object. -
Stage methods return another
Enumerator::Lazyand compute nothing:map/collect,select/filter/find_all,reject,filter_map,flat_map/collect_concat,take,take_while,drop,drop_while,grep,grep_v,zip,uniq,compact(Ruby 3.1),with_index/each_with_index,chunk,chunk_while,slice_before,slice_after,slice_when, andeach_slice/each_cons, which keep the pipeline lazy too. -
Terminal methods run the pipeline:
first(one element) andfirst(n),to_a/force(everything),eachwith a block,find,include?,next/peek, and every otherEnumerablemethod that returns a value rather than an enumerator (sum,min,count,tally). -
eager(Ruby 2.7) converts back to an ordinaryEnumerator: the next stage called on it is no longer lazy, but it still pulls elements from the lazy chain on demand. -
sizeis known only when no stage can change the number of elements:lazy,mapandwith_indexkeep it;select,reject,take_whileandflat_mapmake itnil;take(n)bounds it. -
Methods that need every element before they can answer (
sort,sort_by,group_by,partition,tally,sum,each_with_object,reverse_each) return an Array or a value: the laziness ends there, and on an infinite source they never return.
# frozen_string_literal: true
def outcome
yield.inspect
rescue StandardError => e
"#{e.class}: #{e.message}"
end
naturals = (1..Float::INFINITY).lazy
p naturals # => #<Enumerator::Lazy: 1..Infinity>
p(naturals.map { it * 2 }.first(3)) # => [2, 4, 6]
p(naturals.select(&:even?).map { it * it }.first(3)) # => [4, 16, 36]
p naturals.reject(&:odd?).first(2) # => [2, 4]
p naturals.take(3) # => #<Enumerator::Lazy: #<Enumerator::Lazy: 1..Infinity>:take(3)>
p naturals.take(3).to_a # => [1, 2, 3]
p(naturals.take_while { it < 4 }.to_a) # => [1, 2, 3]
p naturals.drop(2).first(2) # => [3, 4]
p(naturals.drop_while { it < 5 }.first(2)) # => [5, 6]
p(naturals.filter_map { it * 10 if it.odd? }.first(3)) # => [10, 30, 50]
p(naturals.flat_map { [it, -it] }.first(4)) # => [1, -1, 2, -2]
p(naturals.zip(naturals.map { it * 100 }).first(2)) # => [[1, 100], [2, 200]]
p(naturals.with_index.map { |n, i| n * i }.first(3)) # => [0, 2, 6]
p naturals.each_slice(2).first(2) # => [[1, 2], [3, 4]]
p(naturals.chunk_while { |_, b| (b % 3).positive? }.first(2)) # => [[1, 2], [3, 4, 5]]
p(naturals.slice_when { |a, _| (a % 3).zero? }.first(2)) # => [[1, 2, 3], [4, 5, 6]]
p naturals.grep(2..4).first(3) # => [2, 3, 4]
p naturals.grep_v(2..4).first(3) # => [1, 5, 6]
p [3, 1, 3, 2, 1].lazy.uniq.to_a # => [3, 1, 2]
p [1, nil, 2, nil].lazy.compact.to_a # => [1, 2]
p naturals.lazy.equal?(naturals) # => true
# The terminal methods: first(n) and to_a/force evaluate; first without an argument returns one element.
p(naturals.map { it * 3 }.first) # => 3
p(naturals.find { it > 3 && it.even? }) # => 4
p naturals.include?(1000) # => true
p naturals.each_with_index.first(2) # => [[1, 0], [2, 1]]
# Not lazy: methods that need every element return an Array (or hang on an infinite source).
p((1..4).lazy.sort_by(&:-@)) # => [4, 3, 2, 1]
p((1..4).lazy.group_by(&:odd?)) # => {true => [1, 3], false => [2, 4]}
p((1..4).lazy.sum) # => 10
p((1..4).lazy.tally) # => {1 => 1, 2 => 1, 3 => 1, 4 => 1}
p((1..4).lazy.partition(&:even?)) # => [[2, 4], [1, 3]]
p((1..4).lazy.each_with_object([]) { |n, memo| memo << n }) # => [1, 2, 3, 4]
# eager turns it back into a plain Enumerator, so the next call is not lazy.
p((1..3).lazy.map { it * 2 }.eager) # => #<Enumerator: #<Enumerator::Lazy: #<Enumerator::Lazy: 1..3>:map>:each>
p((1..3).lazy.map { it * 2 }.eager.map { it + 1 }) # => [3, 5, 7]
p((1..3).lazy.map { it * 2 }.eager.class) # => Enumerator
# size is only known when no stage can change the number of elements.
p((1..3).lazy.size) # => 3
p((1..3).lazy.map { it }.size) # => 3
p((1..3).lazy.select { it }.size) # => nil
p((1..3).lazy.take(2).size) # => 2
p(naturals.map { it }.size) # => Infinity
Source: Enumerator::Lazy, Lazy#eager, Lazy#force and Lazy#compact.
Lazy over files and over the shelf
The best use of lazy is a source that is large, slow or unbounded: lines of a file or socket, pages of an API, rows of a query cursor, a generator. IO#each_line (and File.foreach) read on demand, so a lazy pipeline over them reads only as many lines as the consumer needs. The example parses a catalogue export held in a StringIO: asking for two books reads two lines (lineno shows it), and asking for all of them reads all five. Invalid lines are turned into nil by parse and removed by compact, and uniq with a block removes the repeated ISBN, all without building a single intermediate array.
# frozen_string_literal: true
require "shelf"
require "stringio"
# A catalogue export arriving as lines of "isbn;title;author". IO#each_line reads on demand,
# so a lazy pipeline over it never loads more lines than the consumer asks for.
export = StringIO.new(<<~LINES)
9780441013593;Dune;Frank Herbert
9780261103573;The Hobbit;J. R. R. Tolkien
not-an-isbn;Broken;Nobody
9780553293357;Foundation;Isaac Asimov
9780441013593;Dune;Frank Herbert
LINES
def parse(line)
digits, title, name = line.chomp.split(";")
Shelf::Book.new(Shelf::Isbn.new(digits), title, Shelf::Author.new(name))
rescue ArgumentError
nil
end
books = export.each_line.lazy.map { parse(it) }.compact.uniq(&:isbn)
p books.first(2).map(&:title) # => ["Dune", "The Hobbit"]
p export.lineno # => 2
export.rewind
p books.map(&:title).to_a # => ["Dune", "The Hobbit", "Foundation"]
p export.lineno # => 5
# A Catalog is Enumerable, so lazy works on it too, and Catalog#each without a block gives an enumerator.
catalog = Shelf::Catalog.new
export.rewind
books.each { catalog.add(it) }
p(catalog.lazy.select { it.title.length > 4 }.map(&:to_s).first(1)) # => ["The Hobbit by J. R. R. Tolkien"]
p(catalog.each.lazy.map { it.author.name }.to_a) # => ["Frank Herbert", "J. R. R. Tolkien", "Isaac Asimov"]
# Enumerator::Lazy.new builds a custom stage: the block receives a yielder and the incoming values.
def doubled(source)
Enumerator::Lazy.new(source) { |yielder, value| yielder << (value * 2) }
end
p doubled([1, 2, 3]).first(2) # => [2, 4]
p(doubled(1..Float::INFINITY).select { (it % 3).zero? }.first(2)) # => [6, 12]
# The size argument keeps size information available.
p(Enumerator::Lazy.new([1, 2, 3], 3) { |yielder, value| yielder << value }.size) # => 3
Source: Lazy.new, Lazy#uniq and Enumerable#lazy.
Enumerator::Lazy.new(source, size = nil) { |yielder, *values| ... } builds a custom stage: the block receives the yielder and the incoming value for each element, and decides what to yield. It is rarely needed because map, select and flat_map cover nearly every case, but it is the primitive they are built from, and passing a size keeps size meaningful.
Pitfalls of laziness
Lazy evaluation changes when code runs, and that surfaces in a few predictable ways.
-
A lazy pipeline is a recipe, not a cache. Every terminal call runs the whole chain again from the source, so calling
to_atwice calls the stages twice. Store the result ofto_aif you need it more than once. -
Side effects follow element order, not stage order. With two
mapstages that print, the output isa1 b1 a2 b2, nota1 a2 b1 b2. Code that relied on the first stage finishing for all elements before the second starts breaks whenlazyis added. -
An early exit never reaches later elements. That is the point, but a stage with a required side effect (updating a counter, closing a resource) will not run for elements that were never pulled.
-
Laziness ends at the first eager method. After
sort_byorgroup_byyou hold an Array. -
An infinite source with a filter that never matches never ends.
(1..).lazy.select { false }.first(1)loops forever; bound the search withtake(n)ortake_whilefirst,. -
It is not automatically faster. For a small collection every element passes through several layers of enumerator machinery, so a lazy chain is usually slower than the eager one that builds two small arrays. Use
lazywhen the source is infinite, large, expensive per element, or when the consumer stops early, and measure (see Performance, Benchmarking and Profiling) before turning it on "for speed". -
with_indexwith a block is a side effect. On a lazy enumerator it calls the block for each element and passes the original element on; usewith_indexwithout a block andmapto compute with the index.
# frozen_string_literal: true
calls = []
stage = (1..3).lazy.map do |n|
calls << n
n * 10
end
# Pitfall 1: a lazy pipeline is a recipe, not a cache; every terminal call runs it again.
p stage.to_a # => [10, 20, 30]
p stage.to_a # => [10, 20, 30]
p calls # => [1, 2, 3, 1, 2, 3]
cached = stage.to_a
p [cached.sum, cached.max] # => [60, 30]
p calls.size # => 9
# Pitfall 2: side effects inside stages happen in element order, not stage order.
log = []
(1..3).lazy
.map { |n| log.push("a#{n}") && n }
.map { |n| log.push("b#{n}") && n }
.to_a
p log # => ["a1", "b1", "a2", "b2", "a3", "b3"]
# Pitfall 3: lazy with_index with a block runs the block per element as a side effect and passes elements on.
seen = []
p((1..3).lazy.with_index { |n, i| seen << [n, i] }.to_a) # => [1, 2, 3]
p seen # => [[1, 0], [2, 1], [3, 2]]
p((1..3).lazy.with_index(1).map { |n, i| n * i }.to_a) # => [1, 4, 9]
# Pitfall 4: laziness stops at the first eager method. Everything after sort or group_by is an Array.
p((1..6).lazy.map { it % 3 }.sort.class) # => Array
p((1..6).lazy.select(&:even?).map { it * 2 }.to_a.reverse) # => [12, 8, 4]
# An empty result from an infinite source never ends, so this call is shown but not executed:
# (1..Float::INFINITY).lazy.select(&:negative?).first(1)
p((1..Float::INFINITY).lazy.select(&:negative?).first(0)) # => []
Source: Lazy#with_index and Lazy#force.
Enumerator::Chain
first + second on an enumerator, Enumerable#chain(*others) (Ruby 2.6) and Enumerator::Chain.new(*enumerables) all build an Enumerator::Chain: an enumerator that iterates the first enumerable to its end, then the second, and so on. Nothing is concatenated in memory, and the parts can be of different kinds (an Array, a Range, an infinite generator). The + operator exists on Enumerator only; for an Array use chain, because Array#+ concatenates eagerly into an Array and expects an Array.
The size of a chain is the sum of the sizes of its parts when all of them are known, nil when one is unknown, and Infinity when one is infinite. Parts are started lazily: the second one is not touched until the first has been exhausted, as the log in the example shows. Calling chain on a lazy enumerator returns a lazy chain. A chain is built for internal iteration; next, peek and feed are undefined on it, and to_enum gives a plain cursor when you need one. rewind rewinds the parts (in reverse order) that respond to rewind.
# frozen_string_literal: true
fiction = %w[Dune Emma]
reference = %w[Atlas]
both = fiction.each + reference
p both # => #<Enumerator::Chain: [#<Enumerator: ["Dune", "Emma"]:each>, ["Atlas"]]>
p both.class # => Enumerator::Chain
p both.to_a # => ["Dune", "Emma", "Atlas"]
p both.size # => 3
p both.with_index(1).to_a # => [["Dune", 1], ["Emma", 2], ["Atlas", 3]]
p fiction.chain(reference, 1..2) # => #<Enumerator::Chain: [["Dune", "Emma"], ["Atlas"], 1..2]>
p fiction.chain(reference, 1..2).to_a # => ["Dune", "Emma", "Atlas", 1, 2]
p fiction.chain.to_a # => ["Dune", "Emma"]
p Enumerator::Chain.new(fiction, reference).to_a # => ["Dune", "Emma", "Atlas"]
# The size is the sum when every part knows its size, nil when one does not, Infinity when one is infinite.
p fiction.chain(reference).size # => 3
p(fiction.chain(Enumerator.new { |y| y << 1 }).size) # => nil
p fiction.chain(1..).size # => Infinity
# A chain is lazy about its parts: they are iterated one after the other and only as far as needed.
log = []
second = Enumerator.new do |y|
log << :second_started
y << 3
y << 4
end
chain = [1, 2].each + second
p chain.first(2) # => [1, 2]
p log # => []
p chain.first(3) # => [1, 2, 3]
p log # => [:second_started]
# Enumerable#chain on a lazy enumerator keeps the result lazy.
p((1..Float::INFINITY).lazy.chain([0]).class) # => Enumerator::Lazy
p([1, 2].lazy.chain([3]).map { it * 2 }.to_a) # => [2, 4, 6]
# A chain is not a cursor: next, peek and feed are undefined on Enumerator::Chain. Wrap it to get one.
p both.respond_to?(:next) # => false
p both.respond_to?(:rewind) # => true
cursor = both.to_enum
p [cursor.next, cursor.next] # => ["Dune", "Emma"]
p cursor.peek # => "Atlas"
Source: Enumerator::Chain, Enumerator, the plus method and Enumerable#chain.
Enumerator.product
Enumerator.product(*enumerables) (Ruby 3.2) yields every combination, one element from each enumerable, as an Array, without building them all. The first enumerable is the outermost loop and the last one varies fastest, exactly like nested each calls and like Array#product. Enumerator::Product.new is the same thing as a constructor. It differs from Array#product in three ways: it is lazy, it accepts any object with each_entry (a Range, a Hash, a Set, an infinite generator), and its size is computed from the sizes of its arguments (Infinity if one is infinite, nil if one is unknown).
Edge cases follow the mathematics: with no arguments the product has exactly one element, the empty array []; with any empty argument it has none. A non-enumerable argument fails when the product is iterated (NoMethodError for each_entry), not when it is created. Remember that with an infinite first argument the later arguments are fully cycled before the first one advances, and with an infinite last argument the earlier ones never advance.
Known limitation: on Ruby 3.3, 3.4 and 4.0, calling with_index or each_with_index directly on an Enumerator::Product raises TypeError: wrong argument type product (expected enumerator). Wrap it first with to_enum or lazy as the last lines of the example do. Like Enumerator::Chain, a Product has no next.
# frozen_string_literal: true
def outcome
yield.inspect
rescue StandardError => e
"#{e.class}: #{e.message}"
end
formats = %i[paperback ebook]
languages = %w[en es]
editions = Enumerator.product(formats, languages)
p editions # => #<Enumerator::Product: [[:paperback, :ebook], ["en", "es"]]>
p editions.class # => Enumerator::Product
p editions.to_a # => [[:paperback, "en"], [:paperback, "es"], [:ebook, "en"], [:ebook, "es"]]
p editions.size # => 4
p(editions.map { |format, language| "#{format}/#{language}" }.first(2)) # => ["paperback/en", "paperback/es"]
p Enumerator.product(formats, languages, 1..2).first(2) # => [[:paperback, "en", 1], [:paperback, "en", 2]]
# Compare with Array#product: the array is built immediately, the enumerator is not.
p formats.product(languages) == editions.to_a # => true
# Edge cases: no arguments gives one empty combination; an empty argument gives none.
p Enumerator.product.to_a # => [[]]
p Enumerator.product(formats, []).to_a # => []
p Enumerator::Product.new(formats, languages).to_a == editions.to_a # => true
# The first enumerable is the outermost loop, and the last one varies fastest.
log = []
inner = Enumerator.new do |y|
log << :inner_started
y << :a
y << :b
end
Enumerator.product([1, 2], inner).each { |number, letter| log << [number, letter] }
p log # => [:inner_started, [1, :a], [1, :b], :inner_started, [2, :a], [2, :b]]
# Infinite arguments: the size is Infinity and the combinations are produced on demand.
members = Enumerator.product(1.., %w[Ana Luis])
p members.first(3) # => [[1, "Ana"], [1, "Luis"], [2, "Ana"]]
p members.size # => Infinity
p Enumerator.product(1..3, 1..).first(4) # => [[1, 1], [1, 2], [1, 3], [1, 4]]
# The arguments only need each_entry, so a hash, a range or a Set all work.
p(Enumerator.product({ a: 1 }, Set[10, 20]).to_a) # => [[[:a, 1], 10], [[:a, 1], 20]]
puts(outcome { Enumerator.product(nil).to_a }) # => NoMethodError: undefined method 'each_entry' for nil
# On Ruby 3.3 to 4.0 with_index and each_with_index fail directly on a Product, so wrap it first.
puts(outcome { editions.with_index.to_a }) # => TypeError: wrong argument type product (expected enumerator)
p editions.to_enum.with_index(1).first(2) # => [[[:paperback, "en"], 1], [[:paperback, "es"], 2]]
p editions.lazy.each_with_index.first(2) # => [[[:paperback, "en"], 0], [[:paperback, "es"], 1]]
Source: Enumerator.product, Enumerator::Product and Array#product.
Enumerator::ArithmeticSequence
Numeric#step and Range#step (and its operator alias Range#%) return an Enumerator::ArithmeticSequence when called without a block on numbers (Ruby 2.6). Its inspect shows how it was built: (1.step(10, 3)), 1..10).step(3, 1..10).%(3, (1.step(by: 3, to: 10)). Unlike a generic enumerator it knows its begin, end, step and exclude_end?, can compute size, first(n), last(n), minmax and sum without iterating, compares equal to another sequence with the same parameters (== and eql?), is hashable, and is a full cursor (next, peek). It can also be used as an array index, which takes every n-th element (Ruby 3.0).
The step rules are those of step: a step of 0 raises ArgumentError: step can't be 0; a negative step walks downward and a step whose direction does not reach the end yields nothing; the sequence is endless when the end is omitted (1.step, (1..).step(2)), where size is Infinity and last raises RangeError; Float sequences compute each element as begin + (i * step) instead of accumulating, so they do not drift, and their size is computed with the same rounding-aware formula. With a block step iterates and returns the receiver. The finer points of Range#step (the Ruby 3.4 change to + for non-numeric ranges, String ranges) are in Ranges.
# frozen_string_literal: true
def outcome
yield.inspect
rescue StandardError => e
"#{e.class}: #{e.message}"
end
# Three ways to get an Enumerator::ArithmeticSequence (a number sequence with a fixed difference).
by_numeric = 1.step(10, 3)
by_range = (1..10).step(3)
by_percent = (1..10) % 3
by_keywords = 1.step(by: 3, to: 10)
p by_numeric # => (1.step(10, 3))
p by_range # => ((1..10).step(3))
p by_percent # => ((1..10).%(3))
p by_keywords # => (1.step(by: 3, to: 10))
p by_numeric.class # => Enumerator::ArithmeticSequence
p [by_numeric, by_range, by_percent, by_keywords].map(&:to_a).uniq # => [[1, 4, 7, 10]]
p by_numeric == by_range # => true
p by_numeric.eql?(by_range) # => true
# It answers questions about itself without iterating.
sequence = (1...20).step(4)
p [sequence.begin, sequence.end, sequence.step, sequence.exclude_end?] # => [1, 20, 4, true]
p [sequence.first, sequence.first(2), sequence.last, sequence.last(2), sequence.size] # => [1, [1, 5], 17, [13, 17], 5]
p sequence.to_a # => [1, 5, 9, 13, 17]
p [sequence.minmax, sequence.sum, sequence.include?(9)] # => [[1, 17], 45, true]
# Descending, endless and floating-point sequences.
p 10.step(1, -3).to_a # => [10, 7, 4, 1]
p 1.step(by: -1).first(3) # => [1, 0, -1]
p(1.step(by: 2).lazy.map { it * it }.first(3)) # => [1, 9, 25]
p 1.step.size # => Infinity
p 1.0.step(2.0, 0.25).to_a # => [1.0, 1.25, 1.5, 1.75, 2.0]
p 1.0.step(2.0, 0.1).size # => 11
p 1.0.step(2.0, 0.1).last # => 2.0
p 1r.step(2r, 1/3r).to_a # => [(1/1), (4/3), (5/3), (2/1)]
# Rules and errors.
p 3.step(1).to_a # => []
puts(outcome { 1.step(10, 0) }) # => ArgumentError: step can't be 0
puts(outcome { 1.step.last }) # => RangeError: cannot get the last element of endless arithmetic sequence
puts(outcome { (1..10).step("x").to_a }) # => TypeError: String can't be coerced into Integer
p(1.step(10, 3) { nil }) # => 1
# An ArithmeticSequence is a cursor too, unlike Chain and Product.
cursor = 1.step(10, 3)
p [cursor.next, cursor.next, cursor.peek] # => [1, 4, 7]
# Arrays accept a sequence as an index (Ruby 3.0) and take every n-th element.
shelves = %w[A B C D E F]
p shelves[(0..).step(2)] # => ["A", "C", "E"]
p shelves[(1..4).step(2)] # => ["B", "D"]
p shelves[(-1..0).step(-2)] # => ["F", "D", "B"]
Source: Enumerator::ArithmeticSequence, Numeric#step, Range#step and Array#[].
Choosing the right tool
| Situation | Use |
|---|---|
Your class has an |
Return |
Number sequences with a step |
|
Join several collections in order without copying |
|
All combinations of several lists |
|
Each value is a function of the previous one |
|
A loop with local state or I/O that should look like a collection |
`+Enumerator.new { |
y |
… }+`. |
A big or infinite source and an early exit |
|
Two sequences must advance together |
External iteration: |
A paged source behind one enumerator
A common real use of an enumerator is hiding paging. The caller sees a flat stream of titles; the method fetches a page only when the previous one has been consumed. With first(3) only the pages needed are requested (two requests for pages of two), with a lazy select and first(2) the same two requests suffice, and to_a makes all four (the last page is empty and ends the stream). The same shape wraps a database cursor or a message queue. The last lines also show the identifier-generator idiom, produce for the sequence and next for the cursor, which is safe as long as the cursor stays in one thread.
# frozen_string_literal: true
# A paged remote catalogue simulated in memory: fetch_page counts how many "requests" were made.
class PagedSource
PAGE_SIZE = 2
attr_reader :requests
def initialize(titles)
@titles = titles
@requests = 0
end
def fetch_page(number)
@requests += 1
@titles.each_slice(PAGE_SIZE).to_a.fetch(number - 1, [])
end
# Hide the paging behind one enumerator: callers see a flat, lazy stream of titles.
def each_title(&)
return to_enum(__method__) unless block_given?
(1..).each do |number|
page = fetch_page(number)
break if page.empty?
page.each(&)
end
end
end
source = PagedSource.new(["Dune", "The Hobbit", "Foundation", "Emma", "Ulysses"])
p source.each_title.first(3) # => ["Dune", "The Hobbit", "Foundation"]
p source.requests # => 2
source = PagedSource.new(["Dune", "The Hobbit", "Foundation", "Emma", "Ulysses"])
p(source.each_title.lazy.select { it.length > 4 }.map(&:upcase).first(2)) # => ["THE HOBBIT", "FOUNDATION"]
p source.requests # => 2
source = PagedSource.new(["Dune", "The Hobbit", "Foundation", "Emma", "Ulysses"])
p source.each_title.to_a.size # => 5
p source.requests # => 4
# An identifier generator: produce for the sequence, next for the cursor. Keep a cursor in one thread.
ids = Enumerator.produce(1000, &:succ)
book_ids = Array.new(3) { ids.next }
p book_ids # => [1000, 1001, 1002]
p ids.peek # => 1003
Source: Enumerator.produce, Object#to_enum and Enumerator#peek.
Edge cases and error behaviour
The next example collects the remaining behaviour of the core methods that is easy to get wrong: what each with a block returns on an enumerator, how feed sets the result of map, the errors of the constructors, what happens when a producer raises StopIteration, which offsets with_index accepts, and how rewind is forwarded to the receiver.
# frozen_string_literal: true
def outcome
yield.inspect
rescue StandardError => e
"#{e.class}: #{e.message}"
end
# Enumerator#each with a block calls the original method with that block, so the return value is the
# one of the underlying method. This is what makes map.with_index and select.with_index work.
titles = %w[Dune Emma Ulysses]
p titles.map.each(&:upcase) # => ["DUNE", "EMMA", "ULYSSES"]
p(titles.select.each { it.length > 4 }) # => ["Ulysses"]
p(titles.each_slice(2).each { nil }) # => ["Dune", "Emma", "Ulysses"]
# feed sets the value the next yield receives. For map that value becomes the mapped element.
mapper = titles.map
mapper.next
mapper.feed("D")
mapper.next
mapper.feed("E")
mapper.next
mapper.feed("U")
begin
mapper.next
rescue StopIteration => e
p e.result # => ["D", "E", "U"]
end
second = [1].each
second.feed(1)
puts(outcome { second.feed(2) }) # => TypeError: feed value already set
# Constructor errors.
puts(outcome { Enumerator.new }) # => ArgumentError: tried to create Proc object without a block
puts(outcome { Enumerator.new(1, 2) { nil } }) # => ArgumentError: wrong number of arguments (given 2, expected 0..1)
puts(outcome { [1].to_enum(:missing).to_a }) # => NoMethodError: undefined method 'missing' for an instance of Array
# A StopIteration raised inside the generator is not swallowed by to_a, only by loop.
broken = Enumerator.new do |y|
y << 1
raise StopIteration, "stopped early"
end
puts(outcome { broken.to_a }) # => StopIteration: stopped early
p broken.next # => 1
puts(outcome { broken.next }) # => StopIteration: stopped early
# with_index accepts any offset that converts to an Integer, and nil means 0.
p titles.each.with_index(10).to_a.last # => ["Ulysses", 12]
p titles.each.with_index(nil).to_a.last # => ["Ulysses", 2]
p titles.each_with_index.with_index(1).to_a.last # => [["Ulysses", 2], 3]
# rewind forwards to the receiver when it defines rewind.
class Tape
def each
yield :a
yield :b
end
def rewind = puts("tape rewound")
end
reader = Tape.new.to_enum
p reader.next # => :a
reader.rewind # => tape rewound
p reader.next # => :a
Source: Enumerator#feed, Enumerator#with_index, Enumerator#rewind and Enumerator.new.
Two behaviours deserve emphasis. A StopIteration raised inside the block of Enumerator.new is an ordinary exception for to_a and each (it propagates), but next also reports it as the end of iteration, and loop swallows it silently, so a bug in a producer can look like a short sequence. Raise StopIteration deliberately only inside Enumerator.produce (where it is the documented way to stop), and let Enumerator.new finish by returning from the block. And each_slice and each_cons with a block return the receiver since Ruby 3.1 (they returned nil before), which is what the each_slice(2).each { nil } line shows.
Common exceptions and warnings
All of these were reproduced on Ruby 4.0.7.
| Error | Message (4.0) | Cause and fix |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
The removed |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
Calling |
RuboCop cops for enumerators
These cops fired while the examples on this page were written and are worth knowing; all are enabled in the shelf configuration.
| Cop | What it checks |
|---|---|
|
A method that only wraps its block in another block (`+each { |
x |
yield x }+ |
|
Prefers |
|
|
|
An |
|
|
|
A generator with too many lines in one method; split the producer into small methods. |
|
|
Where older books and tutorials are outdated
Books and articles written for Ruby 2.x or early 3.x still explain enumerators correctly in outline, but several details moved:
| Older statement | Current behaviour |
|---|---|
"Use |
|
There is no way to build an infinite sequence from a seed except |
|
|
|
|
Both exist since Ruby 2.6 and do not build an intermediate array; the result can be infinite. |
All combinations need |
|
|
It returns an |
|
|
|
Raises |
Error messages quote the method name with a backtick and end in |
Ruby 3.3 changed the receiver text and 3.4 the quote: |
|
Ruby 3.4 prints |
Material that treats Enumerable and each as the whole of "the Iterator pattern" (including the shelf-style include Enumerable plus each) is still right; it just stops short of the fact that returning an enumerator when no block is given is what makes the iteration chainable.
Version notes
| Ruby | Enumerator changes |
|---|---|
2.6 |
|
2.7 |
|
3.0 |
|
3.1 |
|
3.2 |
|
3.3, 3.4 |
No new |
4.0 |
|
4.1 |
Not released as of 2026-10-18 (expected 2026-12-25). Nothing from the development NEWS about enumerators is used on this page. |
References
-
Ruby 4.0 documentation: Enumerator, Enumerator::Lazy and Enumerator::Yielder
-
Enumerator::Chain, Enumerator::Product and Enumerator::ArithmeticSequence
-
Enumerable, Kernel (
loop), Object (to_enum,enum_for), StopIteration and FiberError -
Fiber (fiber storage,
Fiber.[]), Numeric#step and Range#step