In Progress
Unit 1, Lesson 1
In Progress

Spaceship Revisited

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 nil.

"foo" <=> 123                   # => nil

In Episode #205, we discovered how the spaceship operator was important because it makes the Comparable module possible. Here's a simple example. We have a beer class, which has a brewer attribute.

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 Comparable.

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 brewer message.

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 nil.

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 false.

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 nil.

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!

Responses