How to Create a Custom Enumerable
27 Sep 2015Ruby is a wonderfully flexible language. An example of this flexibility is in the ability to define a custom collection class that acts as an Enumerable object. In Ruby, a collection that acts as an Enumerable
is basically a class which holds a list of objects and exposes helpful methods for iteration and collection. An example of this pattern built into Ruby is the Array class.
In keeping with a theme, let us assume that an application about dogs exists. In this example, a DogKennel
class exists that will hold information about each dog in the kennel and detailed information about said kennel. We can also assume that this class is meant to be used as a collection of dogs, exposing helper methods for information about the kennel. Why would we use this collection class over a typical Array
? One reason might be that the consumer of this class expects a list of dogs and some additional metadata. That metadata can be easily exposed in this class without having to wrap the class or extract the metadata from an included Hash
.
class DogKennel
attr_reader :dogs, :location, :operating_hours
def initialize(dogs, location, operating_hours)
@dogs = dogs
@location = location
@operating_hours = operating_hours
end
end
1. Add include Enumerable
To make our DogKennel
into something that can iterate over its dogs easily, we can add the Enumerable
module. The desired functionality will have iterative functions as instance methods, so include
will be used:
class DogKennel
include Enumerable
attr_reader :dogs, :location, :operating_hours
def initialize(dogs, location, operating_hours)
@dogs = dogs
@location = location
@operating_hours = operating_hours
end
end
This include
will allow the DogKennel
class to inherit a large number of useful methods; however, none of these methods are able to be used until a basic iterative each
method is defined. If a method such as map
is called in this state, the DogKennel
class will throw a NoMethodError
until the each
method is defined.
2. Define an each
Method
The each
method is the building block for all other iterative methods the Enumerable
module includes. For this example, we can assume that the consumer of DogKennel
will want to iterate over the dogs in the kennel. The each
method must accept a block and either yield or pass the block along to another method:
class DogKennel
include Enumerable
# ...
def each(&block)
@dogs.each(&block)
end
end
Hooray! Now, the DogKennel
class can respond to each
, map
, select
and all the other documented Enumerable
iterative methods.
Some examples of how this class can now be used:
dog_kennel = DogKennel.new(['Fido', 'Spot', 'Bandit'], 'Tallahassee', '09:00 - 18:00')
dog_kennel.each { |dog| puts "#{dog} is here!"}
# => Fido is here!
# => Spot is here!
# => Bandit is here!
dog_kennel.select { |dog| dog.length == 4 }
#=> ['Fido', 'Spot']
As always, this is not the only way to achieve this solution in Ruby. Ruby-ists are blessed with a plethora of tools to do any task. I am of the opinion that for this specific task, making a custom collection class is clean and concise. Delegation might be an attractive option but could prove to be overly verbose for this use case.
3. Consider Comparisons
In this new collection class, the sort
method will default using the <=>
(sometimes called spaceship) operator. This combined comparison operator returns 0
if first operand equals the second, 1
if first operand is greater than the second and -1
if first operand is less than the second.
This will work fine for strings and other basic object types; however, if a more complex object held the information about each dog, the <=>
method must be defined on that object.
A Dog
class is created to represent a dog’s attributes:
class Dog
attr_reader :breed, :name, :color
def initialize(breed, name, color)
@breed = breed
@name = name
@color = color
end
end
Then, instances of Dog
are then given to DogKennel
on initialization and sorted:
fido = Dog.new('Daschund', 'Fido', 'brown')
bandit = Dog.new('Labrador', 'Bandit', 'black')
dog_kennel = DogKennel.new([fido, bandit], 'Tallahassee', '09:00 - 18:00')
dog_kennel.sort
#=> ArgumentError: comparison of Dog with Dog failed
This error is to be expected since the Dog
class has no way to compare one instance of itself against another. To maintain feature parity with the first example, we can define the <=>
method that introspects on each Dog
’s name:
class Dog
# ...
def <=>(other_dog)
self.name <=> other_dog.name
end
end
Now, we can sort those puppies!
dog_kennel.sort
#=> [#<Dog:0x007fcb4a01c800 @name="Bandit", @breed="Labrador", @color="black">,
#<Dog:0x007fcb4914b880 @name="Fido", @breed="Daschund", @color="brown">]
It’s Raining Iterators and Dogs
Having the Enumerable
module included and the <=>
comparison operator defined, the DogKennel
class can now be used in the same convenient way as other collection classes.
The strength behind this approach is expressed through its maintainability. Since no long list of methods was defined, and no duplicative code was written, this code can exist alongside active development of the Enumerable
module. For the example in question, this could be an overkill solution. But, in a more complex and sophistiacted system, using the Enumerable
could be the best option.