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"]
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
!
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!