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) if self is less than the compared object
  • 0 if both are equal
  • 1 (or any positive value) if self is greater than the compared object
  • nil if 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 == and eql? in Ruby:

  • == (from Comparable via <=>) — equality for general, domain-specific comparisons
  • eql? — used by Hash and Set for key/element equality checks; usually stricter
  • equal? — identity comparison, checks whether it’s exactly the same object in memory

If you override eql?, you must also override hash for 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?, and clamp automatically.
  • 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 <=> — return nil if 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.
  • clamp for restricting values to a range — very useful for input validation, UI slider values, and cases where values must stay within bounds.
  • Automatic Enumerable integrationsort, min, max, minmax, and sort_by all work without additional configuration after implementing <=>.
  • eql? and hash must be consistent — if your objects are used as Hash keys or Set elements, implement eql? and hash explicitly; they must be consistent with each other.

← Previous: Set   Next: Pathname →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact