Crafting Enumerator helpers in Ruby

9 août 2024

Among all the powerful abilities of Ruby Enumerators, one of their most useful usage is to customize what gets enumerated.

For instance, by default #each will yield the elements of the enumeration, one by one:

array = ["apple", "banana", "grape"]
array.each do |value|
  puts "${value}"
end
# "apple"
# "banana"
# "grape"

In some cases, however, we may also need the index of the element being enumerated.

For this, we can use Enumerator#with_index. It turns an existing enumerator into one that also yields the index:

array.each.with_index do |value, index|
  puts "${index}: ${value}"
end
# "1: apple"
# "2: banana"
# "3: grape"

The neat thing: this works for any enumerator! For instance, if you’re not enumerating using #each, but rather using #map or #filter, the usage is the same:

array.map.with_index do |value, index|
  "${index}. ${value.uppercase}"
end  
# ["1. APPLE", "2. BANANA", "3. GRAPE"]

How to craft your own enumerator helpers

Recently, I wanted to enumerate the pixels of an image.

The pixels are represented a single-dimensional array of integers:

image.pixels
# [998367, 251482, 4426993, 777738, ... ]

However, in my case, I want to perform different operations depending on the pixel coordinates.

Of course, we can compute the coordinates in the loop itself:

pixels.map.with_index do |pixel, i|
  x = i % image.width
  y = i / image.width
  pixel * ((x + y) / 100.0) # brighten from top-left to bottom-right
end

But there has to be a better way. What if we could substitute the enumerator’s .with_index by something like .with_coordinates?

First, I needed a quick refresher on how to write a method that enumerates on values. AppSignal’s article on Enumerators was quite a good read there.

So, our method just needs to yield the values one-by-one, and that’s it? Let’s try this.

We’re going to re-open the Enumerator class, and add a #with_coordinates(width, &block) method:

class Enumerator
  def with_coordinates(width, &block)
    each.with_index do |value, i|
      x = i % width
      y = i / width
      yield value, x, y
    end
  end
end

When called, Enumerator#with_coordinates will invoke its block once for each of the enumerator values - passing the coordinates along.

Let’s see how it is used:

pixels.map.with_coordinates(image.width) do |pixel, x, y|
  pixel * ((x + y) / 100.0) # brighten from top-left to bottom-right
end

The coordinates computation are pushed away from the block, the code is nicer… Good job.

Plus, #with_coordinates works not only for #each, but for any enumerator – juste like #with_index!

Method chaining on enumerators

There’s only one caveat though: in Ruby, enumerators support method chaining.

That is, instead of passing a block to the enumerator, we can instead call methods on it. Like this:

pixels
  .each
  .with_index
  .with_object("filename.png") do |pixel, i, path|
    puts "Pixel at #{path}:#{i} => #{pixel}" if i = 5
  end
# "Pixel at filename.png:5 => 1962883"

But if we try this with our current implementation of Enumerator#with_coordinates, we get:

pixels
  .each
  .with_coordinates(width)
  .with_object("filename.png") do |pixel, x, y, path|
    puts "Pixel at #{path}:#{x}:#{y} => #{pixel}" if x == 2 && y == 2
  end
# in `block in with_coordinates': no block given (yield)
# (LocalJumpError)

Makes sense: our helper yields to a block, but Ruby complains that none was provided.

To fix this, we need to return an Enumerator instance when our #with_coordinates function is called without a block.

Let’s modify our implementation of Enumerator#with_coordinates:

class Enumerator
  def with_coordinates(width, &block)
+   if block_given?
      each.with_index do |value, i|
        x = i % width
        y = i / width
        yield value, x, y
      end
+   else
+     Enumerator.new do |y|
+       with_coordinates(width, &y)
+     end
    end
  end
end

And there we have it: using the block-less form will return a new Enumerator.

pixels.each.with_coordinates(width)
# <#Enumerator: ...>

Which means we can properly chain #with_coordinates with further methods now:

pixels
  .each
  .with_coordinates(width)
  .with_object("filename.png") do |pixel, x, y, path|
    puts "Pixel at #{path}:#{x}:#{y} => #{pixel}" if x == 2 && y == 2
  end
# "Pixel at filename.png:2:2 => 1962883"

And that concludes our short side-quest on implementing Enumerator helpers in Ruby. It feels very expressive; and I like how we can make our custom helpers as powerful as the native ones.

Happy enumerating!

Kudos

Discussion, liens, et tweets

J’écris des sites web, des logiciels, des applications mobiles. Vous me trouverez essentiellement sur ce blog, mais aussi sur Mastodon, parmi les Codeurs en Liberté, ou en haut d’une colline du nord-est de Paris.