How I Think About Ruby’s Enumerable
Call the lawyers; it’s a contract.
I’d like to give a high level demonstration of how Ruby’s Enumerable
module requires an #each
method from classes that include it.
I heard Armando Fox, an EECS professor at UC Berkeley, describe Enumerable
as a ‘contract’ between the class that includes it and the module itself. That is, Enumerable
makes the following deal with the classes that include it:
“If you provide me with a way to iterate, or enumerate, over each instance of yourself, I will give you various useful methods in return.”
The way classes provide this iteration to Enumerable
is through an #each
method. It’s a contract that exchanges #each
for use of the methods in Enumerable
. This includes map
, select
, and inject
.
From The Ruby Docs:
The class must provide a method
#each
, which yields successive members of the collection.
It makes sense that for a class to use Enumerable,
the class must provide an #each
method; otherwise, Enumerable
wouldn’t know how to successively access each element in a collection.
Diving Into The Contract
Let’s see what happens when a class does not provide an #each
method and an instance of the class tries to call select
and map
. To do this, we’ll create a FakeArrayWrapper
class that omits the #each
method. We can think about FakeArrayWrapper
as a wrapper on the Array
class.
class FakeArrayWrapper include Enumerable def initialize(*args)
@fake_array = args.flatten
end
#we omit the #each method hereend
When we try to call anEnumerable
method, select
, on an instance ofFakeArrayWrapper
, the invocation returns an error.
fake_array_instance = FakeArrayWrapper.new([1,2,3,4])fake_array_instance.select {|n| n == 1}
# =>fake_array.rb:17:in `select': undefined method `each' for #<FakeArrayWrapper:0x007fed5e816c00 @fake_array=[1, 2, 3, 4]> (NoMethodError)
from fake_array.rb:17:in `<main>'
Similarly, we get an error when calling map
:
fake_array_instance = FakeArrayWrapper.new([1,2,3,4])fake_array_instance.map {|n| n + 1}
# =>fake_array.rb:20:in `map': undefined method `each' for #<FakeArrayWrapper:0x007f87908179e0 @fake_array=[1, 2, 3, 4]> (NoMethodError)
from fake_array.rb:20:in `<main>'
These NoMethodError
errors tell us that select
and map
from Enumerable
tried to use a method called #each
on the calling object (in this case fake_array_instance
), but that this method doesn’t exist for instances of FakeArrayWrapper
.
We knew this already; we didn’t define #each
in the FakeArrayWrapper
class.
Ruby was saying here:
“I tried to call
select
andmap
on yourfake_array_instance
, but in order to do that, I needed you to give me an#each
method for thefake_array_instance
.”
Note that the FakeArrayWrapper
class does not change the behavior of the real Array
class. Calling the same Enumerable
methods on a real Array
instance still returns the expected:
real_array = [1,2,3,4]real_array.select {|n| n == 1}
# =>[1]real_array.map {|n| n + 1}
# =>[2,3,4,5]
Now, if we implement the #each
method in FakeArrayWrapper
:
class FakeArrayWrapper include Enumerable def initialize(*args)
@fake_array = args.flatten
end #now we implement #each
def each(&block)
@fake_array.each(&block)
self #return the original array
endend
the Enumerable
methods return the expected values for the fake_array_instance
:
fake_array_instance = FakeArrayWrapper.new([1,2,3,4])fake_array_instance.select {|n| n == 1}
# =>[1]fake_array_instance.map {|n| n + 1}
# =>[2,3,4,5]
We no longer receive the NoMethodError
because now FakeArrayWrapper#each
is implemented, and select
and map
can call fake_array_instance.each(&block)
without a problem.
So, in order for classes including Enumerable
to use Enumerable
’s methods, the including class must provide an #each
method that allows Enumerable
to iterate over the class instance’s elements.
In another post, I implement my own version of Enumerable
, called MyEnumerable
, with map
and select
, to shed some light on the inner workings of Enumerable
.