Enumerators and lazy evaluation

Ruby · Advanced · 14 min

Lazy pipelines handle huge files and infinite sequences without building giant intermediate arrays.

An Enumerator is an object that represents an iteration. You get one by calling an iterator without a block ([1, 2].each, 5.times, File.foreach(path)), or build one from scratch:

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

Enumerators include Enumerable, so fib.take(10) works even though the sequence is infinite: the generator block only runs as far as needed. They also support external iteration: e.next returns the next value and raises StopIteration at the end, which loop rescues for you. peek looks ahead and rewind restarts.

Normally each Enumerable step builds a full intermediate array: map runs over everything, then select runs over the result. Calling `lazy` turns the chain into an Enumerator::Lazy, which pulls one element at a time through all the steps:

(1..Float::INFINITY).lazy.map { it * 2 }.select { it % 3 == 0 }.first(3)

Nothing runs until a terminal method such as first(n), to_a, each or force asks for values. Use lazy chains for infinite sources, huge files (File.foreach(path).lazy) and when you only need the first few results. For small in-memory arrays, the eager version is usually faster because laziness adds per-element overhead.

Enumerator.produce(initial) { |prev| next_value } (Ruby 2.7) is a concise way to generate sequences.

Example

fib = Enumerator.new do |y|
  a, b = 0, 1
  loop { y << a; a, b = b, a + b }
end
p fib.take(8)                       # [0, 1, 1, 2, 3, 5, 8, 13]
p fib.lazy.select(&:even?).first(4) # [0, 2, 8, 34]

e = %w[a b].each
p e.next, e.next                    # "a", "b"
begin
  e.next
rescue StopIteration
  puts "done"                       # done
end

calls = 0
result = (1..Float::INFINITY).lazy
  .map { calls += 1; it * it }
  .reject(&:odd?)
  .first(3)
p result, calls                     # [4, 16, 36], 6

p Enumerator.produce(1) { it * 3 }.take(5)   # [1, 3, 9, 27, 81]

Remember

  • Iterators called without a block return Enumerators
  • Enumerator.new and Enumerator.produce build custom, even infinite, sequences
  • lazy pulls elements through the chain one at a time until a terminal call

Watch out for

  • Calling a non-terminal like map on an infinite range without lazy, which never returns.

Check yourself

  1. What does (1..Float::INFINITY).lazy.map { it * 2 } return?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.ruby-lang.org/en/3.4/Enumerator/Lazy.html