If you want to make a Ruby class comparable (ex. a > b
), all you have to do is
implement the "spaceship" method, <=>
, and include
the Comparable
module. But how do you implement the spaceship?
If your class is simple, maybe a Die with 6 faces, you could just delegate to an attribute, like the Die's value1.
class Die
attr_reader :value
def <=>(other)
self.value <=> other.value
end
end
But what if the class is more complicated, like a Die that could have any number of faces: how do we write the spaceship if we want to prefer higher values and more faces?
A Common Suggestion
What I've seen suggested most often (ex. stackoverflow) is to use
Array#<=>
.
class Die
attr_reader :faces, :value
def <=>(other)
[self.value, self.faces] <=> [other.value, other.faces]
end
end
This works well, and its quite concise! However, it does have some downsides...
One downside is that the values in the array are eagerly computed. In the Die
example this isn't a problem because value
and faces
are attributes, but
what if the comparison isn't as straightforward?
class Die
attr_reader :value, :color
def <=>(other)
[self.value, color_priority] <=> [self.value, other.color_priority]
end
protected
PRIMARY_COLORS = ["red", "blue", "yellow"]
def color_priority
if PRIMARY_COLORS.include? color
1
else
0
end
end
end
This isn't bad, but it may perform more work than necessary. If the value
s
of the Die are different, then the time spent calculating the
color_priority
s is wasted because they'll never be used.
Additionally, if comparison is a hotspot, then using Array#<=>
isn't great
because it allocates two arrays for each comparison. Bundler used to implement
a spaceship like this, and I measured that these arrays were 60% of all
allocations while running bundle update <gem>
in one of my Rails applications.
I submitted a pull request2 to remove the allocations by rewriting the spaceship to not use arrays:
class Bundler::Resolver::Candidate
def <=>(other)
version_cmp = version <=> other.version
return version_cmp unless version_cmp.zero?
priority <=> other.priority
end
end
And while this solved the allocation issue, the spaceship unfortunately lost some of its simplicity.
The Number of Idiomatic Spaceships is nonzero?
After my Bundler pull request was merged, nobu helpfully shared an even better approach: the idiomatic spaceship.
class Bundler::Resolver::Candidate
def <=>(other)
(version <=> other.version).nonzero? || priority <=> other.priority
end
end
This version uses Numeric#nonzero?
, which was actually implemented for
exactly this purpose!
What's interesting about nonzero?
is that instead of returning true
or
false
, it returns self
or nil
. This distinction is the special sauce
that makes the idiomatic spaceship work.
My first attempt to remove the array allocations from Candidate
's spaceship
did not work:
class Bundler::Resolver::Candidate
def <=>(other)
version <=> other.version || priority <=> other.priority
end
end
The bug happens when the version
s are equal: the first <=>
returns 0
, a
truthy value in Ruby, so the ||
short circuits. nonzero?
solves this by
turning 0
into nil
, a falsy value, allowing the method to correctly fall
back to comparing priority
.
In addition to making spaceships concise and allocation free, nonzero?
also
enables lazily evaluating even the most complex spaceships.
Just this week I found a spaceship in a Rails application that looked like this:
class ClassWithManyFields
def <=>(other)
cmps = [
method(:compare_field_one),
method(:compare_field_two),
method(:compare_field_three),
method(:compare_field_four),
method(:compare_field_five),
]
cmps.each do |cmp|
cmp_value = cmp.call(other)
return cmp_value unless cmp_value == 0
end
0
end
end
Each compare
function was individually complex, so it makes sense that the
author didn't want to eagerly evaluate them. The each
loop also seems like a
very reasonable way to avoid writing four different early return
s.
However, the idiomatic spaceship can make even this complex method concise.
class ClassWIthManyFields
def <=>(other)
compare_field_one(other).nonzero? ||
compare_field_two(other).nonzero? ||
compare_field_three(other).nonzero? ||
compare_field_four(other).nonzero? ||
compare_field_five(other)
end
end
With all of these positive qualities, I'm really surprised that using nonzero?
isn't more common; it certainly seems like the best way to write spaceship
methods. I want to thank nobu for sharing this approach as I will definitely
be using it more often going forward. Hopefully you will too!
-
Spaceships often include a
self.class === other
check as well, but I'm leaving it out of this post for simplicity. ↩ -
The PR was released in Bundler 2.6.6, and some even better optimizations were released in Bundler 2.6.7. Make sure you update for faster
bundle update
s! ↩