How to Create a Custom Enumerable

Ruby 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.