In Ruby, how do <=> and Comparable give a Temperature class <, ==, between? and clamp, and what should <=> return for incomparable values?
answer
- one method, five operators
- negative, zero or positive
- nil means not comparable
- < raises, == returns false
- clamp takes two bounds or a range
basics
~20 sDefine <=> to return a negative number, zero or a positive number, or nil when the argument is not comparable, then include Comparable. It builds <, <=, ==, >=, >, between? and clamp from <=>.
solid answer
~40 s`Comparable` is a module whose methods are all written in terms of `<=>` (the spaceship operator). A class defines `<=>` to return a negative number, `0` or a positive number (conventionally `-1`, `0`, `1`), and **`nil` when the other object cannot be compared**, then writes `include Comparable`. It gains `<`, `<=`, `==`, `>=`, `>`, `between?(min, max)` and `clamp(min, max)` or `clamp(range)`. When `<=>` returns `nil`, `<` and friends raise `ArgumentError` (comparison of Temperature with 5 failed), while `==` returns `false`. `clamp` returns the bound object itself when the value lies outside. Comparable does not define `eql?` or `hash`, so it does not make instances equal Hash keys.
code
ruby · 8 lineshot = Temperature.new(30)
hot == 5 # => false: <=> returned nil
hot < 5 # ArgumentError: comparison of Temperature with 5 failed
hot.clamp(Temperature.new(0)..Temperature.new(25)).degrees # => 25
{hot => :x}[Temperature.new(30)] # => nil: Comparable adds no eql? or hashgo deeper
Define <=> returning -1, 0 or 1, include Comparable, and list the operators and between? and clamp that you get for free.
Explain returning nil for incomparable values, why < then raises ArgumentError while == returns false, and clamp's two forms and exclusive-range error.
Keep <=> consistent with == and, where objects are hash keys, with eql? and hash; notice that Comparable alone does not make equal objects share a key.
Decide whether a domain type has one natural order worth building into <=>, or whether callers should pass explicit comparison keys instead.
## The spaceship operator `<=>` is Ruby's general comparison method. `a <=> b` returns: - a **negative** Integer if `a` is less than `b`, - **`0`** if they are equal, - a **positive** Integer if `a` is greater, - **`nil`** if the two cannot be compared at all. Core classes define it: `1 <=> 2` is `-1`, `"b" <=> "a"` is `1`, and `1 <=> "a"` is `nil`. The default on `Object` returns `0` for the same object (or `==` objects) and `nil` otherwise. ## Comparable builds the rest `Comparable` is a module that implements the comparison operators on top of your `<=>`: ```ruby class Temperature include Comparable attr_reader :degrees def initialize(degrees) @degrees = degrees end def <=>(other) return nil unless other.is_a?(Temperature) degrees <=> other.degrees end end hot = Temperature.new(30) mild = Temperature.new(20) hot > mild # => true hot == Temperature.new(30) # => true hot.between?(Temperature.new(0), mild) # => false hot.clamp(Temperature.new(0), mild).degrees # => 20 ``` | Method from Comparable | Defined as | |---|---| | `<`, `<=`, `>=`, `>` | the sign of `self <=> other` | | `==` | `true` if same object or `<=>` returns `0`; `false` if `<=>` is `nil` | | `between?(min, max)` | not below `min` and not above `max`, both inclusive | | `clamp(min, max)` | `min` if below, `max` if above, otherwise `self` | | `clamp(range)` | the same with `range.begin` and `range.end` | Note that `hot.clamp(...)` returns the **bound object itself** when `hot` is out of range, here the `mild` object, not a new Temperature. ## Returning nil, and what it triggers Returning `nil` for a foreign type is part of the contract, and the two kinds of operator react differently: 1. The ordering operators `<`, `<=`, `>`, `>=` (and `between?`, `clamp`) raise **`ArgumentError`**, with a message such as `comparison of Temperature with 5 failed`. 2. `==` returns **`false`**, so `hot == 5` is simply false rather than an error. Raising an exception from `<=>` instead, or returning `0` or `-1` for unrelated objects, breaks both of those behaviours and makes mixed collections misbehave in confusing ways. ## between? is inclusive `between?(min, max)` returns true when the receiver is equal to either bound, because it only rejects values strictly below `min` or strictly above `max`. So `mild.between?(mild, hot)` is `true`. If you need a half-open check, compare explicitly with `>=` and `<`, or use a Range and its own methods, which is a separate topic. Both `between?` and `clamp` call your `<=>` against each bound, so a `<=>` that returns `nil` for a bound of the wrong type makes them raise `ArgumentError` just like `<` does. ## Edge cases of clamp - `clamp(min, max)` raises `ArgumentError` if `min` is greater than `max`. - `clamp(range)` accepts an endless or beginless range: `20.clamp(..15)` is `15`. - An **exclusive** range with an end, like `0...100`, raises `ArgumentError` (cannot clamp with an exclusive range), because there is no largest value below the end to return. - A `nil` bound means unbounded on that side. ## What Comparable does not give you - It does **not** define `eql?` or `hash`, so two equal Temperatures are still different `Hash` keys and are not collapsed by `uniq` until you override those two as well. - If the class already defines its own `==`, that one is used, because the class's method is found before the module's. - Sorting with `sort` also relies on `<=>`, but works without `Comparable`; how sorting uses it is a topic of its own. ## Why interviewers ask it It checks that a candidate uses Ruby's mechanism instead of hand-writing five operators, knows the `nil` return for incomparable values and its consequences, and does not assume that `Comparable`'s `==` also makes objects equal as hash keys.
- Why does Temperature#<=> check the argument's class before comparing degrees?Without the check, `hot <=> 5` would call `5.degrees` and raise `NoMethodError` from inside `<=>`, so `hot == 5` would blow up instead of returning `false`. Returning `nil` for foreign types lets Comparable's `==` answer `false` and its ordering operators raise a clear `ArgumentError`.
- What does clamp return when the value is already inside the bounds?It returns `self`, the receiver, unchanged. Only when the value is below the lower bound or above the upper bound does it return that bound object instead. Nothing new is constructed, so the result is always one of the three objects involved.
saying these in an interview costs you the question
- Including Comparable is enough; Ruby works out the ordering without <=>.
- <=> should raise TypeError when the other object is a different type.
- Comparable also defines eql? and hash, so equal instances become one Hash key.
- Comparable's == raises ArgumentError when <=> returns nil.
- clamp builds and returns a new object equal to the nearest bound.