Lazy enumerators

An infinite range

In Ruby, (1..Float::INFINITY) is a range with no end. Without lazy, any method that walks it runs forever; with lazy, a method that asks for three items gets three items and stops:

ruby -e 'p (1..Float::INFINITY).lazy.map { _1 * 2 }.first(3)'

[2, 4, 6]

The chain is one item at a time: take one from the range, double it, hand it to first, repeat. Nothing after the third item is ever computed, which is the whole idea of a lazy enumerator.

take and take_while

take(n) asks for n items and take_while { } asks until a condition breaks. On an infinite lazy range both are safe, and first is the same call with n = 1:

ruby -e 'p (1..100).lazy.select(&:even?).take(3).to_a'

[2, 4, 6]

ruby -e 'p [1, 2, 3, 10].take_while { _1 < 5 }'

[1, 2, 3]

take_while stops at the first item that fails, so the ten after it is never seen. That differs from select, which would keep going and return [1, 2, 3] only because ten is not smaller than five.

Eager against lazy

On a finite collection the two forms print the same answer, and the difference is memory: the eager version builds the whole array before first takes one item from it.

ruby -e 'p (1..100).select(&:even?).first(3); p (1..100).lazy.select(&:even?).first(3).to_a'

[2, 4, 6]
[2, 4, 6]

With a hundred items the eager version allocates a hundred-element array for three answers. With a log of a million lines read through File.foreach, the lazy chain is the difference between a script that runs in constant memory and one that does not.

What to watch for

A lazy chain has to end in something that consumes it: first, take, to_a or each. A p of the lazy object prints the enumerator, not the items, which surprises the first time.

Lazy is not free: each item goes through a chain of blocks, so a small collection is faster in the eager form. Reach for lazy when the source is large or infinite, not by habit.

Next: Methods

Article author: Arthur Isaev

Banner image