Want to see the full-length video right now for free?
Sign In with GitHub for Free AccessOn 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
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.
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.
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:
Enumerable
within the class with include Enumerable
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
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.
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:
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.
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 <=>
.