Comparable and Enumerable in Ruby
Ruby gives you two powerful modules for free: Comparable and Enumerable. They let you add comparison operations and iteration methods to any class you write, with almost no code. Understanding how they work will make you a better Ruby developer, whether you’re building custom objects or working with the built-in collections.
What Comparable Gives You
Comparable is a mixin module that adds comparison operators to a class. Once you include it, you get <, <=, >, >=, ==, between?, and clamp for free. The only thing you have to implement yourself is the spaceship operator, <=>.
The spaceship operator is the backbone of comparison in Ruby. It takes two values and returns:
-1when the left side is smaller0when both sides are equal1when the left side is largernilwhen the values cannot be compared
5 <=> 3 # => 1
5 <=> 5 # => 0
3 <=> 5 # => -1
"apple" <=> "banana" # => -1
Notice that strings compare alphabetically. Ruby core classes like Numeric, String, and Time already include Comparable, which is why you can compare them directly.
Implementing the Spaceship Operator
To make your own objects comparable, include Comparable and define the <=> method. Everything else follows automatically.
Here is a Temperature class that compares by degrees:
class Temperature
include Comparable
attr_reader :celsius
def initialize(celsius)
@celsius = celsius
end
def <=>(other)
return nil unless other.is_a?(Temperature)
celsius <=> other.celsius
end
def to_s
"#{celsius}°C"
end
end
t1 = Temperature.new(20)
t2 = Temperature.new(30)
t1 < t2 # => true
t2 >= Temperature.new(25) # => true
t1.between?(Temperature.new(10), Temperature.new(25)) # => true
t1.clamp(Temperature.new(0), Temperature.new(50)) # => 20°C
The <=> method must return exactly -1, 0, 1, or nil. Returning a boolean will break everything. Also, returning nil for incompatible types is the standard way to signal that comparison is not possible — Ruby handles it gracefully.
The Enumerable Module
Enumerable is where Ruby iteration gets its real power. It provides dozens of methods — map, select, find, reject, inject, sort, and many more — as long as your class defines an each method.
That is the only requirement. Define each, and you get everything else for free.
class SquareSequence
include Enumerable
def initialize(count)
@count = count
end
def each
@count.times { |n| yield (n + 1) ** 2 }
end
end
squares = SquareSequence.new(5)
squares.each { |s| puts s }
# 1
# 4
# 9
# 16
# 25
squares.map { |s| s * 2 } # => [2, 8, 18, 32, 50]
squares.select { |s| s > 10 } # => [16, 25]
squares.find { |s| s > 20 } # => 25
squares.reject { |s| s.even? } # => [1, 9, 25]
squares.first(2) # => [1, 4]
Because each yields values one at a time, every Enumerable method works. map builds a new array by transforming each element. select keeps only the elements that return true. find returns the first match. reject does the opposite of select. The list goes on.
Ruby core classes that include Enumerable are Array, Hash, Range, Enumerator, and Set. They all define each, which is why they all share this common interface.
Filtering and Searching
These are the methods you will reach for most often:
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
numbers.select { |n| n.even? } # => [2, 4, 6, 8, 10]
numbers.reject { |n| n.even? } # => [1, 3, 5, 7, 9]
numbers.find { |n| n > 7 } # => 8
numbers.any? { |n| n > 10 } # => false
numbers.all? { |n| n > 0 } # => true
numbers.none? { |n| n == 0 } # => true
numbers.count { |n| n.even? } # => 5
any?, all?, none?, and count are particularly useful for boolean checks. They short-circuit where possible — any? stops as soon as it finds a truthy result, so they are efficient even on large collections.
Transformation with map and flatten
map transforms each element and always returns an array. When you chain operations that also return arrays, you often end up with nested arrays. flatten solves that:
words = ["hello", "world"]
words.map { |w| w.chars } # => [["h", "e", "l", "l", "o"], ["w", "o", "r", "l", "d"]]
words.map { |w| w.chars }.flatten # => ["h", "e", "l", "l", "o", "w", "o", "r", "l", "d"]
Ruby also provides flat_map as a shortcut for this common pattern:
words.flat_map { |w| w.chars } # => ["h", "e", "l", "l", "o", "w", "o", "r", "l", "d"]
Aggregation with inject
inject (also called reduce) accumulates a running value across all elements. It takes an initial value and a block, or just a symbol:
[1, 2, 3, 4, 5].inject(0) { |sum, n| sum + n } # => 15
[1, 2, 3, 4, 5].inject(:*) # => 120
This is especially handy for building hashes or aggregating data.
Combining Comparable and Enumerable
The real pattern that shows up in real Ruby codebases is combining both modules in the same class. Define each for Enumerable and <=> for Comparable, and you get sorting, searching, and comparison in one package.
A PlayingCard class shows this well:
class PlayingCard
RANKS = %w[2 3 4 5 6 7 8 9 10 J Q K A].freeze
SUITS = %w[clubs diamonds hearts spades].freeze
include Comparable
attr_reader :rank, :suit
def initialize(rank, suit)
@rank = RANKS.index(rank)
@suit = SUITS.index(suit)
end
def <=>(other)
return nil unless other.is_a?(PlayingCard)
result = rank <=> other.rank
result.zero? ? suit <=> other.suit : result
end
def to_s
"#{RANKS[@rank]} of #{SUITS[@suit]}"
end
end
class CardDeck
include Enumerable
def initialize
@cards = []
RANKS.each do |rank|
SUITS.each do |suit|
@cards << PlayingCard.new(rank, suit)
end
end
end
def each(&block)
@cards.each(&block)
end
def shuffle!
@cards.shuffle!
end
end
deck = CardDeck.new
deck.shuffle!
# Sort uses Comparable on PlayingCard
sorted = deck.sort
sorted.first # => 2 of clubs
sorted.last # => A of spades
# Enumerable methods work on the deck
deck.find { |c| c.to_s == "A of spades" } # => A of spades
deck.select { |c| c.suit == 2 }.count # => 13 (hearts)
Without <=>, sort would not know how to order cards. Without each, none of the Enumerable methods would work. Together they give the deck a rich interface with almost no boilerplate.
A Few Gotchas Worth Knowing
Floats and the Spaceship Operator
Floating-point arithmetic introduces precision errors that affect comparison:
(0.1 + 0.2) <=> 0.3 # => nil
This returns nil because 0.1 + 0.2 produces 0.30000000000000004, which Ruby correctly identifies as incomparable to 0.3. For float comparisons in production code, use a tolerance:
def roughly_equal?(a, b, epsilon: 1e-9)
(a - b).abs < epsilon
end
sort vs sort_by
Both sort a collection, but sort calls <=> on every pair of elements, while sort_by calls the block once per element and then sorts on those computed values. For expensive comparisons, sort_by is significantly faster:
# Calling to_s on every comparison — wasteful
deck.sort { |a, b| a.to_s <=> b.to_s }
# Calls to_s once per card, then sorts those values
deck.sort_by { |c| c.to_s }
The between? Gotcha
between? is inclusive on both ends. There is no exclusive version built in. If you need to check exclusive bounds, use manual comparison:
5.between?(1, 10) # => true
5.between?(5, 5) # => true — endpoints included
clamp Is Not the Same as between?
clamp caps a value to a range, while between? checks whether a value falls within a range. This distinction matters:
age = 75
age.between?(0, 18) # => false
age.clamp(0, 18) # => 18 — clamps to the maximum
See Also
- ruby-comparable — A deeper look at implementing the spaceship operator
- ruby-enumerable — The full Enumerable module and its many methods
- ruby-blocks-procs-lambdas — Understanding blocks, which power both modules