Lazy map

989 views

What is the return value of the following Ruby code?

a = []
upcase   =  ->(c) { a << c; c.upcase }
downcase =  ->(c) { a << c; c.downcase }

%w(a b c).lazy.map(&upcase).map(&downcase).force

a.join # => ???

The correct answer is

"aAbBcC"

"ABCabc"

"abcABC"

"AaBbCc"

Unlock Your Ruby Potential

Subscribe to RubyCademy and get free access to all our courses, plus hundreds of fun Ruby cards, quizzes, guides, and tutorials!

Explanation

We begin with an empty log a = []. This array will help us observe the order of execution.

Two lambdas are created:
- upcase records its input into a and then returns an uppercased version of that input.
- downcase also records its input into a and then returns a lowercased version.

a = []
upcase   = ->(c) { a << c; c.upcase }
downcase = ->(c) { a << c; c.downcase }

enum = %w(a b c).lazy
pipeline = enum.map(&upcase).map(&downcase)

result = pipeline.force

When force is called, the lazy pipeline begins to run. With lazy evaluation, each element is taken, processed through all the transformations, and only then does the next element start.

Step 1: The element "a" is pulled. The upcase lambda logs "a" and returns "A". Then the downcase lambda logs "A" and returns "a".

Step 2: The element "b" is pulled. The upcase lambda logs "b" and returns "B". Then the downcase lambda logs "B" and returns "b".

Step 3: The element "c" is pulled. The upcase lambda logs "c" and returns "C". Then the downcase lambda logs "C" and returns "c".

The result is ["a", "b", "c"]. And the log shows the sequence "aAbBcC".

result # => ["a", "b", "c"]
a.join # => "aAbBcC"

Notice how each character goes through the entire chain before the next character is touched.


The fully expanded lazy version

Here is how Ruby internally behaves, step by step:

a = []
chars = %w(a b c)
new_chars = []
upcase   = ->(c) { a << c; c.upcase }
downcase = ->(c) { a << c; c.downcase }

c  = chars[0]            # "a"
c  = upcase.(c)          # log "a", return "A"
new_chars[0] = downcase.(c) # log "A", return "a"

c  = chars[1]            # "b"
c  = upcase.(c)          # log "b", return "B"
new_chars[1] = downcase.(c) # log "B", return "b"

c  = chars[2]            # "c"
c  = upcase.(c)          # log "c", return "C"
new_chars[2] = downcase.(c) # log "C", return "c"

new_chars # => ["a", "b", "c"]
a.join    # => "aAbBcC"

Each input is handled all the way through before moving on. This saves memory because no big intermediate arrays are created.


Normal (eager) enumeration: what actually happens

Now compare this with normal map. It is not lazy. It processes the whole collection in one step before moving to the next transformation.

a = []
upcase   = ->(c) { a << c; c.upcase }
downcase = ->(c) { a << c; c.downcase }

step1 = %w(a b c).map(&upcase)

At this point step1 is ["A", "B", "C"]. The log a now contains "abc" because only the lowercase inputs were logged.

Now we apply the second map:

step2 = step1.map(&downcase)

This produces ["a", "b", "c"]. The log expands with "ABC" because the inputs to this second map were all uppercase.

step2   # => ["a", "b", "c"]
a.join  # => "abcABC"

Here the log is grouped: all lowercase inputs first, then all uppercase inputs. This is the opposite of the lazy case where they alternated.

The fully expanded eager version

a = []
chars = %w(a b c)
new_chars = []
upcase   = ->(c) { a << c; c.upcase }
downcase = ->(c) { a << c; c.downcase }

c  = chars[0]            # "a"
new_chars[0] = upcase.(c)  # log "a", store "A"
c  = chars[1]            # "b"
new_chars[1] = upcase.(c)  # log "b", store "B"
c  = chars[2]            # "c"
new_chars[2] = upcase.(c)  # log "c", store "C"

c  = new_chars[0]        # "A"
new_chars[0] = downcase.(c) # log "A", store "a"
c  = new_chars[1]        # "B"
new_chars[1] = downcase.(c) # log "B", store "b"
c  = new_chars[2]        # "C"
new_chars[2] = downcase.(c) # log "C", store "c"

new_chars # => ["a", "b", "c"]
a.join    # => "abcABC"

This shows how Ruby first builds the intermediate ["A", "B", "C"] and then reprocesses it.

Key takeaways

Lazy enumeration works element by element, passing each one through the whole chain before continuing. This avoids big intermediate arrays and often improves performance.

Normal enumeration completes each transformation over the whole collection before moving on. This can use more memory and produce different observable side effects.

For beginners, think of lazy enumeration as a factory line: each product goes through all machines before the next product enters. For normal enumeration, the first machine processes the entire batch, then the second machine processes the entire batch, and so on.

Understanding this difference will help you reason about performance, side effects, and when to use lazy evaluation in Ruby.

Unlock Your Ruby Potential

Subscribe to RubyCademy and get free access to all our courses, plus hundreds of fun Ruby cards, quizzes, guides, and tutorials!

RubyCademy ©