Comparable #
When you write your own classes in Ruby, objects from those classes can’t be directly compared to each other — Ruby doesn’t know what “greater” or “less” means for your problem domain. The Comparable module solves this elegantly: implement one operator, <=> (the spaceship operator), and Ruby automatically gives you <, >, <=, >=, between?, and clamp. No need to write six comparison methods separately. Comparable is a perfect example of Ruby’s philosophy — one minimal contract providing maximum functionality.
How Comparable Works #
Comparable works based on a single assumption: if you can define the “greater than”, “less than”, and “equal to” relationships between two objects via <=>, then all other comparisons can be derived from it.
The spaceship operator <=> must return:
-1(or any negative value) ifselfis less than the compared object0if both are equal1(or any positive value) ifselfis greater than the compared objectnilif the comparison can’t be performed (incompatible types)
# How Comparable is used internally
# When you call a < b, Ruby does this:
# (a <=> b) < 0
# When you call a >= b, Ruby does this:
# (a <=> b) >= 0
# When you call a.between?(min, max), Ruby does this:
# min <= a && a <= max
# which translates to:
# (min <=> a) <= 0 && (a <=> max) <= 0
# The spaceship operator on built-in Ruby types
1 <=> 2 # => -1
2 <=> 2 # => 0
3 <=> 2 # => 1
"a" <=> "b" # => -1
"b" <=> "a" # => 1
1 <=> "a" # => nil (can't be compared)
flowchart TD
A["include Comparable"] --> B["Implement <=>"]
B --> C["Comparable provides automatically"]
C --> D["< less than"]
C --> E["> greater than"]
C --> F["<= less than or equal"]
C --> G[">= greater than or equal"]
C --> H["between? within a range"]
C --> I["clamp restrict to a range"]
B --> J["Integration with Enumerable"]
J --> K["sort / sort_by"]
J --> L["min / max / minmax"]Basic Implementation #
Let’s build a real class using Comparable. The best example is an object that naturally has an order.
class Version
include Comparable
attr_reader :major, :minor, :patch
def initialize(version_string)
parts = version_string.split(".").map(&:to_i)
@major = parts[0] || 0
@minor = parts[1] || 0
@patch = parts[2] || 0
end
# The only method that must be implemented
def <=>(other)
return nil unless other.is_a?(Version)
# Compare major first, then minor, then patch
return major <=> other.major if major != other.major
return minor <=> other.minor if minor != other.minor
patch <=> other.patch
end
def to_s
"#{major}.#{minor}.#{patch}"
end
end
v1 = Version.new("1.0.0")
v2 = Version.new("1.2.0")
v3 = Version.new("2.0.0")
v4 = Version.new("1.2.3")
# All comparison operators are automatically available
v1 < v2 # => true
v3 > v2 # => true
v1 <= v1 # => true
v2 >= v1 # => true
# between? — is it within a range?
v2.between?(v1, v3) # => true
v4.between?(v1, v3) # => true
# Automatic sorting via Enumerable
version_list = [v3, v1, v4, v2]
version_list.sort
# => ["1.0.0", "1.2.0", "1.2.3", "2.0.0"]
version_list.min # => 1.0.0
version_list.max # => 2.0.0
Examples: Classes with Comparable #
Here are several classes from different domains showing Comparable’s flexibility.
Currency Values #
class Money
include Comparable
attr_reader :amount, :currency
def initialize(amount, currency = "IDR")
@amount = amount.to_r # Rational for precision
@currency = currency
end
def <=>(other)
return nil unless other.is_a?(Money) && currency == other.currency
amount <=> other.amount
end
def +(other)
raise "Different currencies" unless currency == other.currency
Money.new(amount + other.amount, currency)
end
def to_s
format("#{currency} %.2f", amount)
end
end
price_a = Money.new(150_000)
price_b = Money.new(200_000)
price_c = Money.new(75_000)
price_a < price_b # => true
price_b > price_c # => true
# Directly sortable and min/max-able
price_list = [price_b, price_a, price_c]
price_list.sort.map(&:to_s)
# => ["IDR 75000.00", "IDR 150000.00", "IDR 200000.00"]
price_list.min.to_s # => "IDR 75000.00"
price_list.max.to_s # => "IDR 200000.00"
# between? for price range validation
lower_bound = Money.new(50_000)
upper_bound = Money.new(300_000)
price_a.between?(lower_bound, upper_bound) # => true
Task Priorities #
class Task
include Comparable
PRIORITIES = { critical: 4, high: 3, medium: 2, low: 1 }.freeze
attr_reader :name, :priority, :deadline
def initialize(name, priority, deadline)
@name = name
@priority = priority
@deadline = deadline
end
def <=>(other)
return nil unless other.is_a?(Task)
# Higher priority = "greater"
# Closer deadline = "greater" (more urgent)
priority_score = PRIORITIES[@priority] <=> PRIORITIES[other.priority]
return -priority_score if priority_score != 0 # descending priority
# If priorities match, the closer deadline is more urgent
other.deadline <=> @deadline
end
def to_s
"[#{priority}] #{name} — #{deadline}"
end
end
task_list = [
Task.new("Deploy production", :critical, Date.today + 1),
Task.new("Update README", :low, Date.today + 7),
Task.new("Fix login bug", :high, Date.today + 2),
Task.new("Code review", :medium, Date.today + 3),
Task.new("Hotfix payment", :critical, Date.today)
]
# sort orders from the most urgent
task_list.sort.each { |t| puts t }
# [critical] Hotfix payment — today
# [critical] Deploy production — tomorrow
# [high] Fix login bug — 2 days away
# [medium] Code review — 3 days away
# [low] Update README — 7 days away
task_list.max # the most urgent task
task_list.min # the least urgent task
Spaceship Operator: Special Cases #
There are several things to watch out for when implementing <=>.
Returning nil for Incompatible Types #
class Temperature
include Comparable
attr_reader :value, :unit
def initialize(value, unit = :celsius)
@value = value
@unit = unit
end
def to_celsius
case unit
when :celsius then value
when :fahrenheit then (value - 32) * 5.0 / 9
when :kelvin then value - 273.15
end
end
def <=>(other)
# Return nil if not a Temperature — this is important!
return nil unless other.is_a?(Temperature)
# Convert to celsius before comparing
to_celsius <=> other.to_celsius
end
def to_s
"#{value}°#{unit.to_s[0].upcase}"
end
end
hot = Temperature.new(100, :celsius)
warm = Temperature.new(212, :fahrenheit) # = 100°C too
cold = Temperature.new(300, :kelvin) # = 26.85°C
hot == warm # => true (both 100°C)
hot > cold # => true (100°C vs 26.85°C)
# nil is returned for incompatible types
hot <=> "hot" # => nil
# Type checking with nil-awareness
begin
hot < "very hot" # ArgumentError because <=> returns nil
rescue ArgumentError => e
puts "Can't compare: #{e.message}"
end
Delegating to Attributes #
The most common way to implement <=> is delegating to attributes that already know how to compare themselves.
class Employee
include Comparable
attr_reader :name, :years_of_service, :salary
def initialize(name, years_of_service, salary)
@name = name
@years_of_service = years_of_service # in years
@salary = salary
end
# Sort by years of service, then salary if equal
def <=>(other)
return nil unless other.is_a?(Employee)
# Delegating to Integer#<=> and Float#<=>
[years_of_service, salary] <=> [other.years_of_service, other.salary]
end
def to_s
"#{name} (#{years_of_service}y, #{salary})"
end
end
employees = [
Employee.new("Alice", 5, 15_000_000),
Employee.new("Bob", 3, 12_000_000),
Employee.new("Charlie", 5, 18_000_000),
Employee.new("Diana", 7, 20_000_000)
]
employees.sort.each { |e| puts e }
# Bob (3y, 12000000)
# Alice (5y, 15000000)
# Charlie (5y, 18000000)
# Diana (7y, 20000000)
clamp — Restricting Values to a Range #
clamp is an often-forgotten but very useful Comparable method. It ensures a value stays within a certain range — if too small, the lower bound is returned; if too large, the upper bound is returned.
# clamp on built-in types
5.clamp(1, 10) # => 5 (within the range)
0.clamp(1, 10) # => 1 (too small, return the lower bound)
15.clamp(1, 10) # => 10 (too large, return the upper bound)
# clamp with a Range (Ruby 2.7+)
5.clamp(1..10) # => 5
0.clamp(1..10) # => 1
15.clamp(1..10) # => 10
# clamp with endless/beginless Ranges
5.clamp(1..) # => 5 (lower bound only)
0.clamp(1..) # => 1
5.clamp(..10) # => 5 (upper bound only)
15.clamp(..10) # => 10
# Practical example: form input validation
def process_age(input)
input.to_i.clamp(0, 150) # age can't be negative or > 150
end
process_age(-5) # => 0
process_age(25) # => 25
process_age(999) # => 150
# clamp for UI — slider values
def set_volume(value)
@volume = value.clamp(0, 100)
end
set_volume(-10) # @volume = 0
set_volume(50) # @volume = 50
set_volume(150) # @volume = 100
# clamp on custom classes including Comparable
v = Version.new("2.5.0")
min_v = Version.new("1.0.0")
max_v = Version.new("3.0.0")
v.clamp(min_v, max_v).to_s # => "2.5.0"
Version.new("0.1.0").clamp(min_v, max_v).to_s # => "1.0.0"
Version.new("5.0.0").clamp(min_v, max_v).to_s # => "3.0.0"
Integration with Enumerable #
When a class implements Comparable, it automatically works well with Enumerable — especially for sorting and extreme-value searches.
# The Version class from the earlier example already includes Comparable
releases = [
Version.new("3.1.0"),
Version.new("2.7.6"),
Version.new("3.0.0"),
Version.new("2.6.10"),
Version.new("3.2.1")
]
# sort uses <=> automatically
releases.sort.map(&:to_s)
# => ["2.6.10", "2.7.6", "3.0.0", "3.1.0", "3.2.1"]
# min and max use <=>
releases.min.to_s # => "2.6.10"
releases.max.to_s # => "3.2.1"
# minmax returns [min, max] at once
releases.minmax.map(&:to_s)
# => ["2.6.10", "3.2.1"]
# sort_by for a different criterion
releases.sort_by { |v| -v.major }.map(&:to_s)
# => ["3.1.0", "3.0.0", "3.2.1", "2.7.6", "2.6.10"]
# select based on comparisons
threshold = Version.new("3.0.0")
releases.select { |v| v >= threshold }.map(&:to_s)
# => ["3.1.0", "3.0.0", "3.2.1"]
Comparable vs Manual Comparison #
Without Comparable, you’d have to write every comparison method manually — more code, more chances for inconsistency.
# ANTI-PATTERN: manually implementing all comparison operators
class ManualTemperature
attr_reader :celsius
def initialize(celsius)
@celsius = celsius
end
def <(other)
celsius < other.celsius
end
def >(other)
celsius > other.celsius
end
def <=(other)
celsius <= other.celsius
end
def >=(other)
celsius >= other.celsius
end
def ==(other)
celsius == other.celsius
end
# And you still don't have between? and clamp!
# And sort doesn't work correctly!
end
# CORRECT: include Comparable and implement only <=>
class ComparableTemperature
include Comparable
attr_reader :celsius
def initialize(celsius)
@celsius = celsius
end
def <=>(other)
return nil unless other.is_a?(ComparableTemperature)
celsius <=> other.celsius
end
end
# With Comparable: <, >, <=, >=, between?, clamp are all available
# And sort, min, max, minmax from Enumerable also work!
Comparable with eql? and hash #
When you use objects as Hash keys or Set elements, Ruby also needs consistent eql? and hash. Comparable provides == based on <=>, but eql? and hash must be defined yourself if needed.
class Coordinate
include Comparable
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
end
# Distance from the origin as the comparison basis
def distance
Math.sqrt(x**2 + y**2)
end
def <=>(other)
return nil unless other.is_a?(Coordinate)
distance <=> other.distance
end
# Need eql? and hash to be usable as a Hash key or Set element
def eql?(other)
other.is_a?(Coordinate) && x == other.x && y == other.y
end
def hash
[x, y].hash
end
def to_s
"(#{x}, #{y})"
end
end
a = Coordinate.new(3, 4) # distance = 5
b = Coordinate.new(0, 5) # distance = 5
c = Coordinate.new(1, 1) # distance = ~1.41
a == b # => true (same distance, == from Comparable)
a.eql?(b) # => false (different coordinates, our own eql?)
a > c # => true (5 > 1.41)
# Sort by distance
[a, b, c].sort.map(&:to_s)
# => ["(1, 1)", "(3, 4)", "(0, 5)"] -- c, then a and b (equal distances)
The difference between
==andeql?in Ruby:
==(from Comparable via<=>) — equality for general, domain-specific comparisonseql?— used by Hash and Set for key/element equality checks; usually stricterequal?— identity comparison, checks whether it’s exactly the same object in memoryIf you override
eql?, you must also overridehashfor consistency — this is a Ruby contract that must not be violated.
Summary #
- One
<=>, six methods — implement only the spaceship operator and Comparable gives you<,>,<=,>=,between?, andclampautomatically.- The spaceship operator must return -1, 0, 1, or nil — negative for “less than”, zero for “equal”, positive for “greater than”, nil for invalid comparisons.
- Always check types in
<=>— returnnilif the compared object isn’t a compatible type; this prevents confusing errors.- Delegate to already-Comparable attributes — the cleanest way to implement
<=>is delegating to attributes (Integer,String,Array) that already know how to compare themselves.clampfor restricting values to a range — very useful for input validation, UI slider values, and cases where values must stay within bounds.- Automatic Enumerable integration —
sort,min,max,minmax, andsort_byall work without additional configuration after implementing<=>.eql?andhashmust be consistent — if your objects are used as Hash keys or Set elements, implementeql?andhashexplicitly; they must be consistent with each other.