Comparable and Enumerable Mixins
A mixin is Ruby’s way of sharing a bundle of behavior across unrelated classes without inheritance, and Comparable and Enumerable are the two mixins you will reach for constantly. Both work on the same trick: you implement one small, well-defined method on your class, and the module hands you a whole toolbox of higher-level methods for free — sorting, clamping, searching, mapping, filtering, and more. Comparable needs a spaceship operator method and gives you comparison operators, between?, and clamp. Enumerable needs an each method and gives you map, select, sort, reduce, and dozens more. Understanding these two mixins is understanding a huge slice of idiomatic Ruby.
Overview: How Mixins Work
In Ruby, a module is a bag of methods that cannot be instantiated on its own. When a class calls include SomeModule, Ruby inserts that module into the class’s ancestor chain — the ordered list of places Ruby searches when you call a method on an object. You can see this chain yourself with SomeClass.ancestors. The search order is: the object’s singleton class, then its own class, then any included modules (most recently included first), then the superclass and its modules, all the way up to Object, Kernel, and BasicObject. This is exactly how Comparable and Enumerable plug in: the module’s methods sit in the chain right above your class, so calling my_object.sort finds Enumerable#sort even though you never wrote a sort method yourself.
Both mixins follow the same design idea, sometimes called a template method pattern: the module provides dozens of derived methods, all implemented in terms of one primitive method that you supply.
- Comparable requires your class to define
<=>(the "spaceship operator"). It must return-1ifselfis less than the argument,0if they are equal,1ifselfis greater, andnilif the two are not comparable at all. From that single method,Comparablederives<,<=,==,>,>=,between?, andclamp. - Enumerable requires your class to define
each, yielding one element at a time. From that single method,Enumerablederives over fifty methods, includingmap,select,reject,reduce,sort,sort_by,min,max,min_by,max_by,count,find,any?,all?,none?,group_by,sum,first, andto_a.
This is also why Ruby feels so consistent across different classes: Array, Hash, Range, and Struct all include Enumerable under the hood, and Integer, String, and Time all include Comparable. Once you understand the mixin, you understand why (1..10).select and a call to select on your own custom collection behave identically — they are the exact same Enumerable#select method running on top of two different each implementations.
Syntax
The general shape for each mixin looks like this:
class ClassName
include Comparable
def <=>(other)
# compare self to other, return -1, 0, 1, or nil
end
end
class OtherClassName
include Enumerable
def each
# yield each element to the block
end
end
| Mixin | Method you must define | What it must return | Sample of methods you get for free |
|---|---|---|---|
Comparable |
<=>(other) |
-1, 0, 1, or nil |
<, <=, ==, >, >=, between?, clamp |
Enumerable |
each (yields elements) |
the receiver itself, or an Enumerator if no block is given |
map, select, reject, reduce, sort, sort_by, min, max, group_by, sum, find, any?, all?, first, to_a |
Examples
Example 1: Comparable for a value-like object
Any class that represents a single comparable value — a temperature, a version number, a card rank — is a great fit for Comparable. You only write <=>; every comparison operator, between?, and clamp come along automatically.
class Temperature
include Comparable
attr_reader :degrees
def initialize(degrees)
@degrees = degrees
end
def <=>(other)
degrees <=> other.degrees
end
def to_s
"#{degrees}F"
end
end
freezing = Temperature.new(32)
boiling = Temperature.new(212)
room = Temperature.new(72)
puts freezing < boiling
puts room.between?(freezing, boiling)
temps = [boiling, freezing, room]
puts temps.sort.map(&:to_s).join(", ")
puts temps.max
puts room.clamp(freezing, Temperature.new(50))
Output:
true
true
32F, 72F, 212F
212F
50F
Every one of those calls — <, between?, sort, max, clamp — is a method Comparable supplies. Ruby only ever calls your <=> method underneath. clamp(min, max) is worth noting specially: it returns min if the receiver is less than min, max if it is greater than max, and the receiver itself otherwise — here room (72) is above the given max of 50, so it gets pulled down to 50.
Example 2: Enumerable for a custom collection
Any class that wraps a group of items — a todo list, a deck of cards, a request queue — is a great fit for Enumerable. You only write each; map, select, count, any?, and dozens of others come along automatically.
class TodoList
include Enumerable
def initialize
@tasks = []
end
def add(name, done: false)
@tasks << { name: name, done: done }
self
end
def each
return enum_for(:each) unless block_given?
@tasks.each { |task| yield task }
end
end
list = TodoList.new
list.add("Buy milk").add("Write Ruby lesson", done: true).add("Walk dog")
puts list.count
puts list.map { |task| task[:name] }.join(", ")
puts list.select { |task| task[:done] }.map { |task| task[:name] }.join(", ")
puts list.any? { |task| task[:done] }
puts list.first[:name]
Output:
3
Buy milk, Write Ruby lesson, Walk dog
Write Ruby lesson
true
Buy milk
TodoList never defines count, map, select, any?, or first — all five come from Enumerable, and all five work by repeatedly calling the one each method you wrote. Notice add returns self, which is what makes the chained list.add(...).add(...).add(...) call possible.
Example 3: Combining both mixins
In real code, Comparable and Enumerable usually show up together: a container class includes Enumerable to be iterable, and the elements inside it include Comparable so the container can be sorted.
class Song
include Comparable
attr_reader :title, :duration
def initialize(title, duration)
@title = title
@duration = duration
end
def <=>(other)
duration <=> other.duration
end
def to_s
"#{title} (#{duration}s)"
end
end
class Playlist
include Enumerable
def initialize(*songs)
@songs = songs
end
def each(&block)
@songs.each(&block)
end
end
playlist = Playlist.new(
Song.new("Intro", 45),
Song.new("Chorus Loop", 210),
Song.new("Outro", 30)
)
puts playlist.sort.map(&:to_s).join(" | ")
puts playlist.max
puts playlist.sum(&:duration)
grouped = playlist.group_by { |song| song.duration > 60 ? :long : :short }
grouped.each { |type, songs| puts "#{type}: #{songs.map(&:title).join(', ')}" }
Output:
Outro (30s) | Intro (45s) | Chorus Loop (210s)
Chorus Loop (210s)
285
short: Intro, Outro
long: Chorus Loop
Playlist#sort and Playlist#max both come from Enumerable, but they only work because each Song inside the playlist supplies its own <=> via Comparable. Two different mixins, two different classes, working together seamlessly.
How It Works Step by Step
Walk through playlist.sort from Example 3:
- Ruby looks up
sortonplaylist.Playlistdoesn’t define it, butEnumerableis in its ancestor chain, soEnumerable#sortruns. Enumerable#sortcallseachinternally to pull every element into a plain array.- It sorts that array by comparing elements pairwise with their own
<=>method — here,Song#<=>, supplied becauseSongincludesComparable. - The sorted array is returned.
Playlistitself never implements any sorting logic at all.
Now walk through what happens when each is called without a block, as in list.each from Example 2:
- Ruby executes your
eachmethod body as normal. - It reaches
return enum_for(:each) unless block_given?, sees no block was passed, and exits immediately, handing back a lazyEnumeratorbound to the object and the method name:each. Nothing has iterated yet. - Calling
.nexton thatEnumeratorresumes execution of youreachmethod behind the scenes, pausing right after the firstyieldand handing back that value. - Without that guard line,
eachwould run straight into its internalyieldwith no block attached, and Ruby raisesLocalJumpError: no block given (yield)instead of returning something useful.
Common Mistakes
Mistake 1: Including Comparable without defining <=>
include Comparable by itself does nothing useful — every method it provides delegates to <=>, and if your class doesn’t define one, Ruby falls back to Object#<=>, which only returns 0 for identical objects and nil otherwise. Any comparison then raises.
class Box
include Comparable
end
small = Box.new
large = Box.new
small < large
# => NoMethodError: comparison of Box with Box failed (Box doesn't define <=>)
The fix is to give the class an attribute to compare by and implement <=> in terms of it:
class Box
include Comparable
attr_reader :volume
def initialize(volume)
@volume = volume
end
def <=>(other)
volume <=> other.volume
end
end
small = Box.new(10)
large = Box.new(100)
puts small < large
Output:
true
Mistake 2: Forgetting the block_given? guard in each
Many Enumerable methods, and useful patterns like .each.with_index, rely on being able to call your each without a block to get back an Enumerator. If your each jumps straight to yield, calling it without a block crashes instead of degrading gracefully.
class Numbers
include Enumerable
def initialize(*nums)
@nums = nums
end
def each
@nums.each { |n| yield n }
end
end
nums = Numbers.new(3, 1, 2)
enum = nums.each
puts enum.next
# => LocalJumpError: no block given (yield), because `each` has no
# block_given? guard and jumps straight to `yield`
Adding the guard fixes it and restores normal Enumerable behavior:
class Numbers
include Enumerable
def initialize(*nums)
@nums = nums
end
def each
return enum_for(:each) unless block_given?
@nums.each { |n| yield n }
end
end
nums = Numbers.new(3, 1, 2)
enum = nums.each
puts enum.next
puts enum.next
puts nums.sort.inspect
Output:
3
1
[1, 2, 3]
Mistake 3: Forgetting that Comparable silently redefines ==
Once you include Comparable, == is no longer Ruby’s default identity check — it becomes "does <=> return 0?" If your <=> only looks at one attribute, two objects that differ in every other way can compare as equal.
class Money
include Comparable
attr_reader :cents, :currency
def initialize(cents, currency)
@cents = cents
@currency = currency
end
def <=>(other)
cents <=> other.cents
end
end
usd = Money.new(500, "USD")
eur = Money.new(500, "EUR")
puts usd == eur
Output:
true
That true is a bug: 500 US cents and 500 Euro cents are not the same amount of money, but <=> never looked at currency. The fix is to make <=> return nil when the objects aren’t truly comparable — Comparable#== treats a nil result as "not equal" rather than raising:
class Money
include Comparable
attr_reader :cents, :currency
def initialize(cents, currency)
@cents = cents
@currency = currency
end
def <=>(other)
return nil unless currency == other.currency
cents <=> other.cents
end
end
usd = Money.new(500, "USD")
eur = Money.new(500, "EUR")
usd2 = Money.new(300, "USD")
puts usd == eur
puts usd > usd2
Output:
false
true
Best Practices
- Always make
<=>return-1,0,1, ornil— never raise an exception inside it and never returntrue/false. - Always guard
eachwithreturn enum_for(:each) unless block_given?so your object plays well withEnumerator-based code like.each.with_indexor.lazy. - Only include
Comparablewhen a true, total ordering makes sense for the class, and remember it silently redefines==; if two objects should be equal only when truly identical, keep<=>narrow and returnnilfor anything it can’t honestly compare. - Only include
Enumerablewhen your class genuinely represents a collection; if you’re just wrapping an existingArrayorHash, delegatingeachto it (asPlaylistdoes above) is simpler than reinventing iteration. - For multi-key comparisons, compare arrays instead of chaining conditionals:
[last_name, first_name] <=> [other.last_name, other.first_name]reads cleanly and Array’s own<=>handles the tie-breaking. - Prefer
sort_by,min_by, andmax_byoversort,min, andmaxwith a custom<=>when you’re comparing by a computed key — they compute the key once per element instead of recomputing it on every comparison. - Remember that
<,>,between?, and friends raiseArgumentErrorwhen<=>returnsnil, while==just quietly returnsfalse— don’t assume the two behave the same way on incomparable objects.
Practice Exercises
- Write a
Cardclass withrank(2–14) andsuitattributes that includesComparable, ordering strictly byrank. Build a five-card hand as an array ofCardobjects, sort it, and print the highest card. Hint:hand.maxshould give the same card ashand.sort.last. - Write a
Bookshelfclass that wraps an array of book records (use aStruct.new(:title, :pages, :year)) and includesEnumerableby delegatingeachto the internal array. Using onlyEnumerablemethods, compute the total page count withsum, find the newest book withmax_by, and list every book published before 2000 withselect. - The
<=>method below is broken:def <=>(other) age > other.age # WRONG: returns true/false, not -1/0/1 endExplain why this breaks
sortand<even though it looks reasonable, then rewrite it correctly using the spaceship operator onageinstead.
Summary
ComparableandEnumerableare mixins: modules included into a class that add methods to its ancestor chain.Comparableneeds exactly one method,<=>, returning-1,0,1, ornil; in exchange it provides<,<=,==,>,>=,between?, andclamp.Enumerableneeds exactly one method,each, that yields elements; in exchange it providesmap,select,sort,reduce,group_by,sum, and dozens more.- Guard custom
eachmethods withreturn enum_for(:each) unless block_given?so they can be called without a block. - Including
Comparableredefines==to mean "<=>returns 0" — make sure that matches what equality should really mean for your class. - Container classes typically include
Enumerablewhile the elements they hold includeComparable, and the two work together automatically.
