Video

Want to see the full-length video right now for free?

Sign In with GitHub for Free Access

Notes

Enumerable & Comparable

On this week's episode, Chris is joined by fellow thoughtbotter, Melanie Gilman, to discuss the wonder of Ruby's Enumerable and Comparable interfaces and how you can incorporate them into your own classes.

Enumerable Refresher

Enumerable is the Ruby module that gives us all of the magical collection methods like each, select, reduce, sort, etc. Enumerable is included in both Array and Hash out of the box, but stick with us to see how we can supercharge our own classes with the power of Enumerable.

The Enumerable documentation is a good thing to bookmark and regularly review as Enumerable is a part of Ruby you'll want to know well.

Also, make sure to check out or revisit the Mastering Enumerable flashcard deck to get a focused overview of how to best make use of these powerful methods.

Dangers of Inheriting From Core Classes

Often we'll want to build custom objects that act as collections and we'd like them to implement Enumerable methods like each and select. The obvious approach is to inherit from a Ruby core object like Array of Hash as this gives us all the Enumerable methods essentially for free, but it turns out this approach is fraught with peril! The following code sample demonstrates the sort of issues one might run into:

class SolutionList < Array
  def initialize(*)
    super
  end

  def longest
    max_by { |solution| solution.length }
  end
end

user_solutions = SolutionList.new(["one", "three", "fourteen"])
user_solutions.select { |solution| solution.length > 3 }
# returns ["three", "fourteen"]
user_solutions.longest
# returns "fourteen"

admin_solutions = SolutionList.new(["a", "b", "c"])

all_solutions = (user_solutions + admin_solutions)
all_solutions.longest
# NoMethodError !!!

[user_solutions.class, admin_solutions.class]
# returns [SolutionList, SolutionList]
all_solutions.class
# returns Array

It turns out that while inheriting from Array is a nice shortcut to the Enumerable methods, but there are a number of edge cases and subtleties related to the implementation of core classes like Array and Hash that make inheriting from these core classes problematic.

Steve Klabnik has a great blog that covers the pitfalls and subtleties that come with inheriting from core classes.

The core of the issue comes from the fact that the core library is written in C and in many places includes optimizations and assumptions that break when used as a parent class.

Include Enumerable

Luckily for us there is an alternate way to include all the Enumerable methods while avoiding the pitfalls of inheriting from core classes, and it barely takes more code. There are two simple steps:

  1. Include Enumerable within the class with include Enumerable
  2. Implement the each method on your class to define how enumeration should run.

The following code sample demonstrates including Enumerable within our SolutionList class:

class SolutionList
  include Enumerable

  def initialize(solutions)
    @solutions = solutions
  end

  def longest
    max_by { |solution| solution.length }
  end

  def each(&block)
    @solutions.each(&block)
  end
end

It turns out that all of the Enumerable methods are implemented in terms of each, so implementing each is all we need to do to gain access to all the Enumerable methods.

In addition, since we are wrapping an Array instance, we can simply delegate our each implementation to the Array, passing the block on:

def each(&block)
  @solutions.each(&block)
end

Restoring The Plus Method

While we found above that it is easy to include the Enumerable methods, by no longer inheriting from Array we lose the plus method. Admittedly, the Array inherited version of the method was subtly broken, so this could be considered a feature, but still, we want to support the + method to combine SolutionList instances. The following code sample adds support for the + method:

# within SolutionList class
class SolutionList
  ...

  def +(other)
    self.class.new(@solutions + other.solutions)
  end

  protected

  attr_reader :solutions
end

Note: this uses the protected keyword within the class to allow the @solutions instance variable to be accessible by all instances of our SolutionList class, but not fully publicly available.

Custom Enumeration

The above example shows how easy it is to create a class that includes the Enumerable interface, but often you'll want to go a bit further and define a custom order for the enumeration behavior.

As an example, in the Upcase Exercises app we use a custom enumerable implementation to list the solutions to an exercise in the following order:

  1. A solution to be reviewed
  2. The Upcase "Featured" solution(s)
  3. All remaining solutions

With a custom Enumerable class we can easily decorate an existing collection to ensure enumeration will always happen in the desired order. Check out the FeaturedSolutionsHighlighter to see this in the exercises codebase.

Note: You must be an Upcase subscriber and have been added as a collaborator to the Upcase repo to view the above sample.

Comparable

Similar to the Enumerable module, we can use Comparable to add all the desired comparison methods such as <, >, !=, between?, etc. Check out the Comparable Documentation for a full list of the methods.

It is often useful to include Comparable in value objects, e.g. money, color, etc. Check out the Weekly Iteration on Value Objects, as well as the "Extract Value Object" exercise in the Refactoring Trail for more detail about value objects. Likewise, check out the Code Climate Grade implementation for another example of adding the Comparable module to a value object.

Below is an example value object, a Vector class representing a mathematical vector, the includes the Comparable module:

class Vector
  include Comparable

  def initialize(x, y)
    @x = x
    @y = y
  end

  def length
    Math.sqrt(@x ** 2 + @y **2)
  end

  def <=>(other)
    length <=> other.length
  end
end

As with each in Enumerable classes, Comparable classes only need to implement the <=> method, aka the "spaceship operator", to support all of the Comparable methods. Typically the <=> can be delegated to the wrapped value as String, Fixnum, Float, etc all implement <=>.