Video transcript & code
Back in Episode #205, we met the "spaceship operator". The spaceship, as you may recall, is an amalgam of the lesser-than, equals, and greater-than methods all rolled into one. Depending on the relationship of the left hand to right-hand operands, it will return -1, 0, or 1.
1 <=> 2 # => -1 2 <=> 2 # => 0 3 <=> 2 # => 1
But that's not the whole story. What I failed to show in that episode is what happens when the two values are not compatible. Let's try comparing a string to an integer. What do you expect to happen? We might expect this to raise a
TypeError. Instead, what we get is
"foo" <=> 123 # => nil
It implements the spaceship operator in terms of the
brewer attribute, in such a way as to indicate that some beers are greater than others.
Having given the class a spaceship operator, we then include
class Beer include Comparable attr_reader :brewer def initialize(brewer) @brewer = brewer end def <=>(other) case other.brewer when self.brewer then 0 when "Victory" then -1 else 1 end end end
We can now compare one beer to another to see if they are equal, even though we didn't explicitly define the equality operator. We can also check if one beer is greater than another.
require "./beer" Beer.new("Victory") == Beer.new("Budweiser") # => false Beer.new("Victory") == Beer.new("Victory") # => true Beer.new("Victory") > Beer.new("Budweiser") # => true
This is fine for comparing beers. But what happens when we accidentally compare a beer to a non-beer object, say, a string? In this case, we get an error. This is because we wrote the spaceship operator in such a away that it expects its argument to respond to the
require "./beer" Beer.new("Victory") <=> "Victory" # => # ~> /home/avdi/Dropbox/rubytapas/218-spaceship-revisited/beer.rb:11:in `<=>': undefined method `brewer' for "Victory":String (NoMethodError) # ~> from -:3:in `<main>'
We might expect this exception to crop up in the derived
Comparable operators as well. But when we do an equality comparison between a
Beer a and a string, we just get
false. This is because, as we discussed in the other episode,
Comparable currently eats exceptions. However, we can't rely on this behavior: it is deprecated and will be removed in a future version of Ruby.
require "./beer" Beer.new("Victory") == "Victory" # => false
Let's look at another application of the spaceship operator: sorting. When we put a beer and a non-beer in an array together and try to sort it, we get our exception again.
require "./beer" [Beer.new("Victory"), "Victory"].sort # => # ~> /home/avdi/Dropbox/rubytapas/218-spaceship-revisited/beer.rb:11:in `<=>': undefined method `brewer' for "Victory":String (NoMethodError) # ~> from -:3:in `sort' # ~> from -:3:in `<main>'
Let's summarize what we've found so far:
First, our spaceship operator may raise a
NoMethodError, which differs from the behavior of Ruby's built-in objects. Ruby core objects return nil when they are compared to incompatible objects.
Second, in some future version of Ruby this will also cause the operators provided by
Comparable to signal an exception as well. This is a problem, since we normally don't expect comparison operators to raise exceptions.
Third, when we try to sort
Beer objects, we get an error which might be helpful to us, but which is likely to be unintelligible to anyone else who uses the class.
Let's update our spaceship definition to behave more like Ruby's built-in implementations. We'll add a guard clause at the top which checks that the other object is compatible. If not, it short-circuits and returns
class Beer include Comparable attr_reader :brewer def initialize(brewer) @brewer = brewer end def <=>(other) return nil unless other.is_a?(self.class) case other.brewer when self.brewer then 0 when "Victory" then -1 else 1 end end end
Let's try our comparisons again. Comparing a Beer to a string results in a
nil value. Doing an equality comparison still results in
When we re-try our
sort, we see something interesting: a new error message saying "comparison of Beer with String failed". What happened here is that Ruby's sort understood the
nil return to mean that the types were incompatible. And it has a special error message for just that scenario. By complying with Ruby's conventions for spaceship return values, we got a more helpful error message in return.
require "./beer2" Beer.new("Victory") <=> "Victory" # => nil Beer.new("Victory") == "Victory" # => false [Beer.new("Victory"), "Victory"].sort # => # ~> -:5:in `sort': comparison of Beer with String failed (ArgumentError) # ~> from -:5:in `<main>'
Now that we've learned about
nil returns from the spaceship operator, let's take a look at the spaceship operator in the
Quantity class we've been developing. Right off the bat, we can see that there is some redundancy here, with the type being checked twice. Oops.
But a bigger problem is that this method raises an exception if the types are incompatible. If we remove that first guard clause, it will return
false if the operands are incompatible, rather than
def <=>(other) raise TypeError unless other.is_a?(self.class) other.is_a?(self.class) && magnitude <=> other.to_f end
Let's fix this. We'll make the guard clause return nil instead of failing when the class of the other object is wrong. And we'll remove the redundant type check.
def <=>(other) return nil unless other.is_a?(self.class) magnitude <=> other.to_f end
This version of the operator correctly complies with Ruby's conventions for the spaceship operator.
And that's all for today. Special thanks to Jacob Swanner for the comment that prompted this follow-up episode. Happy hacking!