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¶
- Write a
Versionclass that parses"1.10","1.9"and"1.2.1"into Integer parts, includesComparableand defines only<=>. Sort the strings as Strings and as Versions and compare the two orders, take the newest and oldest withmaxandmin, and runbetween?andclampagainst the range1.5–1.10. Finish by showing whetherv1.10equalsv1.10.0, and what[1, 10] <=> [1, 10, 0]says about why. - Take the
Moneyvalue from Struct is mutable, Data is not, give it a<=>that compares cents when the currencies match and answersnilwhen they do not, includeComparable, and show that[eur, usd].sortraisesArgumentErrorwhileeur == usdanswersfalse— then decide whethereur < usdshould 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¶
- Two dots include the end, three dots exclude it —
cover?andclamp(range)are<=>against two ends. - Set is a core class in 4.0 and keeps insertion order —
Set#<=>answersnilfor two sets neither of which contains the other: a partial order. - Struct is mutable, Data is not — the record that exercise 2 makes comparable.
- Sort stability and
sort_by—sort_bycomputes each key once, and what "stable" means for equal keys. - Operators are methods —
<=>and==are methods you define like any other. - Four kinds of equality — Comparable's
==besideequal?,eql?and===. - Comparing int and float —
1 <=> 1.0is0while1.eql?(1.0)is false. - Sorting is not comparing ↗ — the Python library on code-point order against a human alphabet.
- Rust: the comparison traits ↗ —
PartialOrdis the trait whosepartial_cmpanswersNonethe way<=>answersnil. - Mixins:
include,extend,prepend—includeinserts a module into the class's ancestor chain behind the class,prependinserts it *in… - Common error messages — Twenty-nine errors you will meet in your first month of Ruby, each with the code that raises it, the…
- There is more than one way, and one obvious way — Ruby's idioms