Skip to content

Define <=> and Comparable gives you the rest

Level: 201 · anyone making a class sortable, or wondering why [3, nil].sort raises

One line: <=> answers -1, 0, 1 or nil ("these do not compare"); include Comparable turns that one method into <, <=, ==, >, >=, between? and clamp; and sort, min and max call <=> directly and raise ArgumentError the moment it answers nil — where Python has six rich-comparison methods, functools.total_ordering to derive four from two, and a TypeError in place of Ruby's nil.

The spaceship is an ordinary method: 1 <=> 2 is 1.<=>(2), and it answers -1 for less, 0 for equal, 1 for greater and nil for "not comparable". Integer, Float, String and Symbol define it, and so does Array, lexicographically — element by element, a shorter prefix first, nil as soon as two elements do not compare. Comparable is a mixin that asks for nothing but <=> and gives back seven methods; its == is "<=> returned 0" and answers false when <=> returns nil, while its < and friends raise ArgumentError — comparison of Version with String failed. Array has <=> but does not include Comparable, so [1, 2] < [1, 3] is a NoMethodError even though [1, 2] <=> [1, 3] is -1. sort, min, max and minmax do not go through Comparable at all: they call <=> on the elements, which is why a class with only <=> already sorts, and why [3, nil].sort raises comparison of Integer with nil failed — nil <=> 3 is nil. Float::NAN <=> 1.0 is nil too, so a NaN in an array makes sort raise. clamp checks that its bounds are in order, 1 <=> 1.0 is 0, and max keeps the first of two equal elements.

Verified output of comparable_and_spaceship_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

 1. 1 <=> 2, 2 <=> 2, 3 <=> 2                                    [-1, 0, 1]
 2. 1 <=> "a", nil <=> 1, nil <=> nil                            [nil, nil, 0]
 3. [1, 2] <=> [1, 3], [1, 2] <=> [1, 2, 0], [1, "a"] <=> [1, 2] [-1, -1, nil]
 4. Version a = v1.10, b = v1.9: a > b, a < b, a == v1.10, a >= b [true, false, true, true]
 5. a.between?(b, v2.0), a.clamp(b, b), a.clamp(v0.1..v1.0)      [true, v1.9, v1.0]
 6. [a, b, v1.2].sort, [a, b].min, [a, b].max                    [[v1.2, v1.9, v1.10], v1.9, v1.10]
 7. a < "1.0"; a == "1.0"                                        ArgumentError: comparison of Version with String failed; false
 8. Comparable.instance_methods.sort                             [:<, :<=, :==, :>, :>=, :between?, :clamp]
 9. Integer, String, Float, Symbol, Array, NilClass: Comparable? [true, true, true, true, false, false]
10. [1, 2] < [1, 3]  (Array has <=> but not Comparable)          NoMethodError: undefined method '<' for an instance of Array
11. [3, nil].sort; [3, nil].max                                  ArgumentError: comparison of Integer with nil failed; ArgumentError: comparison of Integer with nil failed
12. [3, "a"].sort                                                ArgumentError: comparison of Integer with String failed
13. [3, nil].compact.sort, [3, nil].sort_by(&:to_i)              [[3], [nil, 3]]
14. OnlySpaceship: [s2, s1].sort, .min; s1 < s2; s1 == S1        [S1, S2], S1; NoMethodError: undefined method '<' for an instance of OnlySpaceship; false
15. Float::NAN <=> 1.0; [Float::NAN, 1.0].sort                   nil; ArgumentError: comparison of Float with 1.0 failed
16. "a" <=> "B", "abc" <=> "ab", :a <=> :b                       [1, 1, -1]
17. 1 <=> 1.0, 1 == 1.0, [1, 1.0].max.class, [1.0, 1].max.class  [0, true, Integer, Float]
18. [3, 1, 2].sort { |x, y| y <=> x }, .max(2), .minmax          [[3, 2, 1], [3, 2], [1, 3]]
19. 2.clamp(3, 1)                                                ArgumentError: min argument must be less than or equal to max argument

Reading the output

Rows 1–3 are <=> on the built-ins: the three answers on Integers; nil for an Integer against a String and for nil against anything but itself; and Arrays compared element by element, with the shorter Array first when it is a prefix and nil when two elements do not compare.

Rows 4–6 are what include Comparable buys Version, a class that defines only <=> (over its Array of Integer parts): the four ordering operators and ==, then between? and clamp — clamp takes two bounds or a Range — then sort, min and max, which order 1.10 after 1.9 because they compare parts, not text.

Row 7: Version#<=> answers nil for a String, so < raises comparison of Version with String failed and == answers false.

Rows 8–10: the seven methods Comparable defines; which built-in classes include it — Array and NilClass do not — and the consequence for Array: < is a NoMethodError on the class whose <=> row 3 just used.

Rows 11–13: sort on [3, nil] and [3, "a"] raises, naming the two classes, and max on [3, nil] raises the same way; compact first, or sort_by with a key that every element has, is the fix.

Row 14: OnlySpaceship defines <=> and nothing else — it sorts and has a min, but < is a NoMethodError and == is identity, because Comparable is not included.

Row 15: Float::NAN <=> 1.0 is nil, so sorting an Array that contains NaN raises.

Rows 16–17: Strings compare by code point ("a" <=> "B" is 1, because B is 66 and a is 97), a longer String sorts after its prefix, Symbols compare like their names; 1 <=> 1.0 is 0 and 1 == 1.0 is true, and max of two equal elements keeps the first — an Integer or a Float depending on order.

Rows 18–19: sort takes a block for a different order, max(2) takes the top two, minmax returns a pair; clamp with a lower bound above its upper bound raises rather than answering something.

Compared with Python

The twin asks rich comparisons the same nineteen questions. Python has no <=> — the idiom is (a > b) - (a < b) (row 1) — and no nil answer: an Integer against a String, None against anything, or a tuple whose elements do not compare, is a TypeError (rows 2–3); None == None is the one comparison None supports. functools.total_ordering is Comparable's counterpart, and it derives more from more: given __eq__ and __lt__, it adds __le__, __gt__ and __ge__ to the class (row 8), and the results match Ruby's row by row (rows 4–7, with between? spelled lo <= x <= hi and clamp spelled min(max(x, lo), hi)). Every built-in sequence already supports <, so [1, 2] < [1, 3] is True (row 10) where Ruby's Array has no <. sorted, min and max need only __lt__ (row 14: OnlyLt sorts and has a min and a max, and l1 > l2 works too — Python tries the reflected l2 < l1 (Not machine-checked here.) — but <= is a TypeError); mixed types raise TypeError from sort (rows 11–12), and a key that puts the Nones last is the fix (row 13). Returning NotImplemented from __lt__ is Python's nil: < becomes a TypeError and == a False (row 7). Two differences cut the other way. NaN compares False in every direction, so sorted([nan, 1.0]) returns without complaint (row 15) — a silent wrong order where Ruby raises — and min(max(2, 3), 1) answers 1 without checking that the bounds are in order (row 19). Strings compare by code point in both (row 16); the Python library's Sorting is not comparing ↗ measures what that does to a human alphabet.

Verified output of comparable_and_spaceship_py.py — regenerated by tools/run_examples.py, never hand-typed.

 1. cmp(1, 2), cmp(2, 2), cmp(3, 2)  with cmp = (a > b) - (a < b) [-1, 0, 1]
 2. 1 < "a"; None < 1; None == None                              TypeError, TypeError, True
 3. (1, 2) < (1, 3), (1, 2) < (1, 2, 0), (1, "a") < (1, 2)       True, True, TypeError
 4. Version a = v1.10, b = v1.9: a > b, a < b, a == v1.10, a >= b [True, False, True, True]
 5. b <= a <= v2.0, min(max(a, b), b), min(max(a, v0.1), v1.0)   [True, v1.9, v1.0]
 6. sorted([a, b, v1.2]), min(a, b), max(a, b)                   [[v1.2, v1.9, v1.10], v1.9, v1.10]
 7. a < "1.0"; a == "1.0"                                        TypeError; False
 8. comparison methods in vars(Version)  (total_ordering added 3) ['__eq__', '__ge__', '__gt__', '__le__', '__lt__']
 9. 1 < 2, 'a' < 'b', 1.0 < 2.0, [1] < [2]; None < None          [True, True, True, True]; TypeError
10. [1, 2] < [1, 3]                                              True
11. [3, None].sort(); max([3, None])                             TypeError; TypeError
12. [3, "a"].sort()                                              TypeError
13. sorted(xs, key=lambda x: (x is None, x)); without the Nones  [[3, None], [3]]
14. OnlyLt: sorted, min, max; l1 > l2; l1 <= l2; l1 == L1        [L1, L2], L1, L2; False; TypeError; False
15. nan < 1.0, nan > 1.0; sorted([nan, 1.0])  (no error)         [False, False]; [nan, 1.0]
16. "a" < "B", "abc" > "ab"  (no symbols)                        [False, True]
17. 1 == 1.0, type(max([1, 1.0])), type(max([1.0, 1]))           [True, 'int', 'float']
18. sorted(xs, reverse=True), heapq.nlargest(2, xs), (min, max)  [[3, 2, 1], [3, 2], (1, 3)]
19. min(max(2, 3), 1)  (no check that lo <= hi)                  1

Try it

  1. Write a Version class that parses "1.10", "1.9" and "1.2.1" into Integer parts, includes Comparable and defines only <=>. Sort the strings as Strings and as Versions and compare the two orders, take the newest and oldest with max and min, and run between? and clamp against the range 1.5–1.10. Finish by showing whether v1.10 equals v1.10.0, and what [1, 10] <=> [1, 10, 0] says about why.
  2. Take the Money value from Struct is mutable, Data is not, give it a <=> that compares cents when the currencies match and answers nil when they do not, include Comparable, and show that [eur, usd].sort raises ArgumentError while eur == usd answers false — then decide whether eur < usd should raise or be false, and say why.
Solution to 1

comparable_and_spaceship_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Kata: a Version that sorts numerically -- "1.10" after "1.9" -- from one <=>
# and Comparable; then the newest of a list, and a clamp into a supported range.

class Version
  include Comparable
  attr_reader :parts

  def initialize(text) = @parts = text.split(".").map { Integer(it) }
  def <=>(other) = other.is_a?(Version) ? parts <=> other.parts : nil
  def to_s = parts.join(".")
  def inspect = "v#{self}"
end

strings = %w[1.10 1.9 1.2.1 2.0 1.2]
puts "sorted as strings:  #{strings.sort.inspect}"
versions = strings.map { Version.new(it) }
puts "sorted as versions: #{versions.sort.inspect}"
puts "newest: #{versions.max}, oldest: #{versions.min}"

lo, hi = Version.new("1.5"), Version.new("1.10")
versions.sort.each do |v|
  puts "  #{v.to_s.ljust(5)} between? #{v.between?(lo, hi).to_s.ljust(5)} clamp(#{lo}, #{hi}) -> #{v.clamp(lo, hi)}"
end

puts "v1.10 == v1.10.0? #{Version.new('1.10') == Version.new('1.10.0')}  ([1, 10] <=> [1, 10, 0] is #{[1, 10] <=> [1, 10, 0]})"

Verified output of comparable_and_spaceship_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

sorted as strings:  ["1.10", "1.2", "1.2.1", "1.9", "2.0"]
sorted as versions: [v1.2, v1.2.1, v1.9, v1.10, v2.0]
newest: 2.0, oldest: 1.2
  1.2   between? false clamp(1.5, 1.10) -> 1.5
  1.2.1 between? false clamp(1.5, 1.10) -> 1.5
  1.9   between? true  clamp(1.5, 1.10) -> 1.9
  1.10  between? true  clamp(1.5, 1.10) -> 1.10
  2.0   between? false clamp(1.5, 1.10) -> 1.10
v1.10 == v1.10.0? false  ([1, 10] <=> [1, 10, 0] is -1)

See also