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 b0
if a equals b1
if a is greater than bnil
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 |