CrackedRuby logo

CrackedRuby

Spaceship Operator (<=>)

Comprehensive guide to Ruby's three-way comparison operator and its role in sorting, comparisons, and custom comparable classes.

Core Modules Comparable Module
3.3.1

Overview

The spaceship operator (<=>) performs three-way comparison in Ruby, returning -1, 0, 1, or nil based on the relationship between two objects. Ruby uses this operator as the foundation for all comparison operations and sorting algorithms. The operator enables objects to define custom comparison logic and integrates with the Comparable module to provide the full suite of comparison methods.

When Ruby evaluates a <=> b, it returns:

  • -1 if a is less than b
  • 0 if a equals b
  • 1 if a is greater than b
  • nil if the objects cannot be compared

Most built-in Ruby classes implement the spaceship operator, including Numeric types, String, Array, Time, and Date. The operator serves as the cornerstone for Ruby's sorting mechanisms, powering methods like sort, sort_by, and min/max.

5 <=> 3    # => 1 (5 is greater than 3)
3 <=> 5    # => -1 (3 is less than 5)  
5 <=> 5    # => 0 (5 equals 5)
5 <=> "a"  # => nil (cannot compare Integer and String)

The spaceship operator enables Ruby's Comparable module, which automatically generates <, <=, >, >=, ==, and between? methods when a class implements <=>. This design centralizes comparison logic in a single method while providing the complete comparison interface.

class Version
  include Comparable
  
  def initialize(version)
    @version = version.split('.').map(&:to_i)
  end
  
  def <=>(other)
    @version <=> other.instance_variable_get(:@version)
  end
end

v1 = Version.new("1.2.3")
v2 = Version.new("1.3.0")

v1 < v2   # => true (automatically available via Comparable)
v1 > v2   # => false
v1 == v2  # => false

Basic Usage

The spaceship operator compares objects of the same type using their natural ordering. For numbers, this means mathematical comparison. For strings, lexicographical ordering. For arrays, element-by-element comparison from left to right.

# Numeric comparisons
42 <=> 17     # => 1
3.14 <=> 2.71 # => 1
-5 <=> -10    # => 1

# String comparisons (lexicographical)
"apple" <=> "banana"  # => -1
"zebra" <=> "apple"   # => 1
"hello" <=> "hello"   # => 0

# Array comparisons (element by element)
[1, 2, 3] <=> [1, 2, 4]     # => -1
[1, 2, 3] <=> [1, 2]        # => 1
[1, 2, 3] <=> [1, 2, 3]     # => 0

The operator works with mixed numeric types, handling type coercion automatically. Ruby converts between Integer, Float, and Rational types as needed during comparison.

42 <=> 42.0      # => 0 (Integer compared to Float)
3 <=> 3.5        # => -1
Rational(3, 2) <=> 1.6  # => -1

Time and Date objects use chronological ordering, making the spaceship operator useful for sorting temporal data.

require 'date'

time1 = Time.new(2023, 1, 15)
time2 = Time.new(2023, 2, 10)
time1 <=> time2  # => -1

date1 = Date.new(2023, 12, 25)  
date2 = Date.new(2023, 12, 24)
date1 <=> date2  # => 1

Ruby's sorting methods rely on the spaceship operator internally. The sort method calls <=> on array elements to determine ordering.

numbers = [3, 1, 4, 1, 5, 9, 2, 6]
numbers.sort  # => [1, 1, 2, 3, 4, 5, 6, 9]

words = ["zebra", "apple", "banana", "cherry"]
words.sort    # => ["apple", "banana", "cherry", "zebra"]

# Custom sorting with sort_by
people = [
  { name: "Alice", age: 30 },
  { name: "Bob", age: 25 },
  { name: "Carol", age: 35 }
]

people.sort_by { |person| person[:age] }
# => [{:name=>"Bob", :age=>25}, {:name=>"Alice", :age=>30}, {:name=>"Carol", :age=>35}]

The operator handles case-sensitive string comparison by default. Uppercase letters have lower ASCII values than lowercase letters, affecting sort order.

"Apple" <=> "apple"  # => -1 (uppercase A comes before lowercase a)
"Z" <=> "a"          # => -1 (Z is ASCII 90, a is ASCII 97)

# Case-insensitive comparison requires transformation
"Apple".downcase <=> "apple".downcase  # => 0

Advanced Usage

Custom classes gain powerful comparison capabilities by implementing the spaceship operator and including the Comparable module. The implementation should return consistent results and handle edge cases appropriately.

class Priority
  include Comparable
  
  LEVELS = { low: 1, medium: 2, high: 3, critical: 4 }
  
  def initialize(level)
    @level = level.to_sym
    raise ArgumentError, "Invalid priority level" unless LEVELS.key?(@level)
  end
  
  def <=>(other)
    return nil unless other.is_a?(Priority)
    LEVELS[@level] <=> LEVELS[other.level]
  end
  
  protected
  
  attr_reader :level
end

critical = Priority.new(:critical)
high = Priority.new(:high)
medium = Priority.new(:medium)

critical > high    # => true
high >= medium     # => true
medium.between?(Priority.new(:low), Priority.new(:high))  # => true

[high, critical, medium].sort
# => [#<Priority:medium>, #<Priority:high>, #<Priority:critical>]

Multi-attribute comparison requires careful consideration of attribute precedence. The spaceship operator can chain comparisons, returning the first non-zero result.

class Employee
  include Comparable
  
  def initialize(department, level, name)
    @department = department
    @level = level
    @name = name
  end
  
  def <=>(other)
    return nil unless other.is_a?(Employee)
    
    # Compare by department first, then level, then name
    result = @department <=> other.department
    return result unless result.zero?
    
    result = @level <=> other.level
    return result unless result.zero?
    
    @name <=> other.name
  end
  
  protected
  
  attr_reader :department, :level, :name
end

emp1 = Employee.new("Engineering", 3, "Alice")
emp2 = Employee.new("Engineering", 3, "Bob")  
emp3 = Employee.new("Engineering", 2, "Carol")

[emp1, emp2, emp3].sort
# Sorts by department, then level, then name

The spaceship operator enables sophisticated sorting with custom comparison blocks. These blocks receive two arguments and must return -1, 0, or 1.

data = [
  { name: "file1.txt", size: 1024, modified: Time.new(2023, 1, 1) },
  { name: "file2.txt", size: 2048, modified: Time.new(2023, 2, 1) },
  { name: "file3.txt", size: 512, modified: Time.new(2023, 1, 15) }
]

# Sort by size descending, then by modification time ascending
sorted = data.sort do |a, b|
  result = b[:size] <=> a[:size]  # Reverse for descending
  result.zero? ? a[:modified] <=> b[:modified] : result
end

puts sorted.map { |f| "#{f[:name]}: #{f[:size]} bytes" }
# file2.txt: 2048 bytes
# file1.txt: 1024 bytes  
# file3.txt: 512 bytes

Implementing the spaceship operator for complex nested structures requires recursive comparison logic. This pattern works well for tree-like or hierarchical data.

class TreeNode
  include Comparable
  
  def initialize(value, children = [])
    @value = value
    @children = children.sort  # Keep children sorted
  end
  
  def <=>(other)
    return nil unless other.is_a?(TreeNode)
    
    result = @value <=> other.value
    return result unless result.zero?
    
    @children <=> other.children
  end
  
  protected
  
  attr_reader :value, :children
end

node1 = TreeNode.new(5, [TreeNode.new(3), TreeNode.new(7)])
node2 = TreeNode.new(5, [TreeNode.new(3), TreeNode.new(8)])

node1 < node2  # => true (same value, but child 7 < 8)

Common Pitfalls

The spaceship operator returns nil when objects cannot be compared, not an exception. This behavior catches many developers off guard, especially when sorting heterogeneous arrays.

array = [1, "two", 3.0, :four]

# This raises an ArgumentError
begin
  array.sort
rescue ArgumentError => e
  puts e.message  # comparison of Integer with String failed
end

# Check comparability before sorting
comparable_pairs = array.combination(2).all? do |a, b|
  !(a <=> b).nil?
end

puts "Array is sortable: #{comparable_pairs}"  # => false

The nil return value can cause subtle bugs in custom sorting logic. Code that assumes the spaceship operator always returns -1, 0, or 1 may behave unexpectedly.

class BadSort
  def self.compare(a, b)
    result = a <=> b
    # Bug: result might be nil
    result > 0 ? 1 : (result < 0 ? -1 : 0)
  end
end

# This will raise NoMethodError when result is nil
begin
  BadSort.compare(5, "hello")
rescue NoMethodError => e
  puts "Error: #{e.message}"  # undefined method `>' for nil:NilClass
end

# Correct implementation checks for nil
class GoodSort
  def self.compare(a, b)
    result = a <=> b
    return nil if result.nil?
    result > 0 ? 1 : (result < 0 ? -1 : 0)
  end
end

String comparison with the spaceship operator is case-sensitive and based on ASCII values, leading to counterintuitive results with mixed case text.

words = ["apple", "Banana", "cherry", "Date"]
sorted = words.sort

puts sorted  # => ["Banana", "Date", "apple", "cherry"]
# All uppercase letters come before lowercase letters

# For natural alphabetical order, normalize case
natural_sort = words.sort { |a, b| a.downcase <=> b.downcase }
puts natural_sort  # => ["apple", "Banana", "cherry", "Date"]

Floating point precision issues can affect spaceship operator results with decimal numbers. Values that appear equal may not compare as equal due to representation limits.

a = 0.1 + 0.2
b = 0.3

puts a == b        # => false
puts a <=> b       # => 1 (not 0 as expected)

puts a             # => 0.30000000000000004
puts b             # => 0.3

# Use epsilon comparison for floating point values
def float_compare(a, b, epsilon = 1e-10)
  diff = (a - b).abs
  return 0 if diff < epsilon
  a <=> b
end

puts float_compare(a, b)  # => 0

Implementing the spaceship operator without proper nil handling breaks the Comparable module contract. The operator must return nil for incomparable objects, not raise exceptions.

class BadComparable
  include Comparable
  
  def initialize(value)
    @value = value
  end
  
  def <=>(other)
    # Bug: raises exception instead of returning nil
    raise ArgumentError unless other.is_a?(BadComparable)
    @value <=> other.value
  end
end

class GoodComparable
  include Comparable
  
  def initialize(value)
    @value = value
  end
  
  def <=>(other)
    return nil unless other.is_a?(GoodComparable)
    @value <=> other.value
  end
end

good = GoodComparable.new(5)
result = good <=> "string"  # => nil (correct behavior)

Array comparison with the spaceship operator stops at the first differing element, potentially ignoring significant differences in array length.

short = [1, 2]
long = [1, 1, 9, 9, 9, 9, 9]

puts short <=> long  # => 1 (short[1] > long[1], comparison stops)

# This might be surprising - the longer array with many large values
# is considered "less than" the shorter array due to the second element

Performance & Memory

The spaceship operator's performance varies significantly based on object types and comparison complexity. Numeric comparisons execute fastest, while string and array comparisons require more processing time.

require 'benchmark'

numbers = Array.new(10_000) { rand(1_000_000) }
strings = Array.new(10_000) { ('a'..'z').to_a.sample(10).join }
arrays = Array.new(1_000) { Array.new(100) { rand(1000) } }

Benchmark.bm(10) do |x|
  x.report("numbers") { numbers.sort }
  x.report("strings") { strings.sort }  
  x.report("arrays") { arrays.sort }
end

#                user     system      total        real
# numbers   0.003847   0.000008   0.003855 (  0.003856)
# strings   0.021847   0.000000   0.021847 (  0.021851)
# arrays    0.524691   0.002441   0.527132 (  0.527397)

Memory allocation during sorting depends on the sorting algorithm Ruby uses internally. The built-in sort method uses a stable sort algorithm that may allocate temporary arrays for merge operations.

require 'objspace'

large_array = Array.new(100_000) { rand(1_000_000) }

# Measure memory before sorting
before_sort = ObjectSpace.count_objects

sorted = large_array.sort

after_sort = ObjectSpace.count_objects

puts "Additional arrays allocated: #{after_sort[:T_ARRAY] - before_sort[:T_ARRAY]}"
puts "Additional objects created: #{after_sort[:TOTAL] - before_sort[:TOTAL]}"

Custom spaceship operator implementations can introduce performance bottlenecks. Complex comparison logic executed repeatedly during sorting creates multiplicative performance impact.

class SlowComparable
  def initialize(value)
    @value = value
  end
  
  def <=>(other)
    return nil unless other.is_a?(SlowComparable)
    
    # Simulating expensive comparison operation
    sleep(0.001)  # 1ms delay per comparison
    @value <=> other.value
  end
end

class FastComparable
  def initialize(value)
    @value = value
  end
  
  def <=>(other)
    return nil unless other.is_a?(FastComparable)
    @value <=> other.value
  end
end

slow_objects = Array.new(100) { SlowComparable.new(rand(1000)) }
fast_objects = Array.new(100) { FastComparable.new(rand(1000)) }

Benchmark.bm(10) do |x|
  x.report("slow") { slow_objects.sort }      # ~6 seconds
  x.report("fast") { fast_objects.sort }      # ~0.001 seconds
end

Sorting algorithms have different complexity characteristics. Ruby's sort method uses Timsort, which performs well on partially ordered data but has worst-case O(n log n) complexity.

# Best case: already sorted data
sorted_data = (1..10_000).to_a

# Worst case: reverse sorted data  
reverse_data = (1..10_000).to_a.reverse

# Random case: shuffled data
random_data = (1..10_000).to_a.shuffle

Benchmark.bm(15) do |x|
  x.report("pre-sorted") { sorted_data.dup.sort }    # Fastest
  x.report("reverse") { reverse_data.dup.sort }      # Slowest  
  x.report("random") { random_data.dup.sort }        # Medium
end

String comparison performance depends on string length and common prefix length. The spaceship operator must examine characters until it finds a difference.

short_strings = Array.new(10_000) { rand(36**3).to_s(36) }  # 3 chars avg
long_strings = Array.new(10_000) { rand(36**10).to_s(36) }  # 10 chars avg

# Strings with long common prefixes
prefix_strings = Array.new(10_000) do |i|
  "very_long_common_prefix_here_#{rand(1000)}"
end

Benchmark.bm(15) do |x|
  x.report("short") { short_strings.sort }
  x.report("long") { long_strings.sort }
  x.report("common_prefix") { prefix_strings.sort }
end

Reference

Core Behavior

Expression Condition Result
a <=> b a < b -1
a <=> b a == b 0
a <=> b a > b 1
a <=> b incomparable nil

Built-in Class Support

Class Comparison Method Notes
Integer Mathematical Handles type coercion with Float, Rational
Float Mathematical IEEE 754 comparison rules
Rational Mathematical Precise fractional comparison
String Lexicographical Case-sensitive ASCII value comparison
Symbol Lexicographical Converted to string for comparison
Array Element-wise Left-to-right comparison until difference found
Time Chronological Microsecond precision
Date Chronological Day-level precision
Range Begin/End Compares range boundaries

Comparable Module Methods

Generated automatically when class implements <=> and includes Comparable:

Method Implementation Returns
<(other) (self <=> other) == -1 Boolean
<=(other) (self <=> other) != 1 Boolean
>(other) (self <=> other) == 1 Boolean
>=(other) (self <=> other) != -1 Boolean
==(other) (self <=> other) == 0 Boolean
between?(min, max) self >= min && self <= max Boolean

Sorting Method Integration

Method Usage Behavior
Array#sort array.sort Uses <=> for element comparison
Array#sort! array.sort! In-place sorting with <=>
Enumerable#sort enum.sort Returns array sorted by <=>
Enumerable#sort_by enum.sort_by(&block) Sorts by block result using <=>
Array#min array.min Finds minimum using <=>
Array#max array.max Finds maximum using <=>
Enumerable#min_by enum.min_by(&block) Minimum by block result
Enumerable#max_by enum.max_by(&block) Maximum by block result

Custom Implementation Template

class CustomClass
  include Comparable
  
  def initialize(value)
    @value = value
  end
  
  def <=>(other)
    # Return nil if objects cannot be compared
    return nil unless other.is_a?(self.class)
    
    # Implement comparison logic
    @value <=> other.value
  end
  
  protected
  
  attr_reader :value
end

Error Conditions

Scenario Method Exception Alternative
Mixed types in sort Array#sort ArgumentError Pre-filter homogeneous types
No <=> method Array#sort ArgumentError Define <=> in class
<=> returns non-standard value Comparable methods Undefined behavior Return -1, 0, 1, or nil only
Recursive comparison Custom <=> SystemStackError Implement cycle detection

Performance Characteristics

Operation Time Complexity Space Complexity Notes
Numeric <=> O(1) O(1) Constant time comparison
String <=> O(min(m,n)) O(1) m, n are string lengths
Array <=> O(min(m,n) × c) O(1) c is element comparison cost
Sort with <=> O(n log n) O(n) Worst case for Timsort
Custom <=> Varies Varies Depends on implementation