In Progress
Unit 1, Lesson 1
In Progress

Coercion

Video transcript & code

Once again we will be working on our slowly evolving class for representing measurements in feet. As a quick review: so far our Feet class can represent a number of feet. It is immutable, like Ruby's core numeric classes. And Feet objects can compare themselves to other Feet objects to see if they are equivalent, greater than, or lesser.

Since the last episode I've also completed the Feet class' set of basic arithmetic methods. We can now add, subtract, multiply, and divide quantities of feet.

class Feet
  include Comparable
  attr_reader :magnitude

  def initialize(magnitude)
    @magnitude = magnitude.to_f
    freeze
  end

  def to_s
    "#{@magnitude} feet"
  end

  def inspect
    "#<Feet:#{@magnitude}>"
  end

  def +(other)
    Feet.new(@magnitude + other.magnitude)
  end

  def -(other)
    Feet.new(@magnitude - other.magnitude)
  end

  def *(other)
    Feet.new(@magnitude * other.magnitude)
  end

  def /(other)
    Feet.new(@magnitude / other.magnitude)
  end

  def <=>(other)
    other.is_a?(Feet) && magnitude <=> other.magnitude
  end

  def hash
    [magnitude, Feet].hash
  end

  alias_method :eql?, :==
end

For most arithmetic operations, we would expect both values involved to be Feet objects. And in fact that was our justification for building this class in the first place: we wanted to make sure that Feet were never inadvertently mixed with other units of measure. So for most operations we are satisfied with the fact that the operation will only work if the values on both sides of the operator are Feet objects.

But multiplication is an interesting case. We might reasonably want to multiply a quantity of Feet by a raw number. For instance, if we have 23 spools of cable, each of which contains 50 feet of cable, what's the total length of the cable?

require "./feet"

Feet.new(50) * 23              # => #<Feet:1150.0>

Somewhat shockingly, this works. As it turns out, it works entirely by accident. And it's not really a happy accident either.

All Ruby numeric objects have a #magnitude method. This method is an alias for the #abs method, which returns the absolute value of a number. So the magnitude of 42 is 42, and the magnitude of -42 is also 42.

42.magnitude                    # => 42
-42.magnitude                   # => 42

By using the method name #magnitude, we've inadvertently set ourselves up for some potentially nasty defects down the road. Let's try multiplying our Feet object by a negative number:

require "./feet"

Feet.new(50) * -23              # => #<Feet:1150.0>

We get the same answer as with the positive number, which is clearly not right.

Let's fix this with a case statement. We'll check the type of the other value. If it is numeric we will use that object as the multiplicand. If it is another Feet object we will use that object's magnitude. Otherwise, we'll raise a type error to indicate that we don't know what to do with the other object.

class Feet
  include Comparable
  attr_reader :magnitude

  def initialize(magnitude)
    @magnitude = magnitude.to_f
    freeze
  end

  def to_s
    "#{@magnitude} feet"
  end

  def inspect
    "#<Feet:#{@magnitude}>"
  end

  def +(other)
    Feet.new(@magnitude + other.magnitude)
  end

  def -(other)
    Feet.new(@magnitude - other.magnitude)
  end

  def *(other)
    multiplicand = case other
                   when Numeric then other
                   when Feet then other.magnitude
                   else 
                     raise TypeError, "Don't know how to multiply by #{other}"
                   end
    Feet.new(@magnitude * multiplicand)
  end

  def /(other)
    Feet.new(@magnitude / other.magnitude)
  end

  def <=>(other)
    other.is_a?(Feet) && magnitude <=> other.magnitude
  end

  def hash
    [magnitude, Feet].hash
  end

  alias_method :eql?, :==
end

This version works much better. We can multiply by a positive integer, and see a result in Feet. We can multiply by a negative floating point number, and get the expected result. We can multiply by another Feet object. But when we try to multiply by a string, we get an error.

require "./feet2"

Feet.new(50) * 23               # => #<Feet:1150.0>
Feet.new(50) * -23.5            # => #<Feet:-1175.0>
Feet.new(50) * Feet.new(2)      # => #<Feet:100.0>
Feet.new(50) * "a hojillion"    # => 
# ~> /home/avdi/Dropbox/rubytapas/206-coercion/feet2.rb:32:in `*': Don't know how to multiply by a hojillion (TypeError)
# ~>    from -:6:in `<main>'

So far, so good. But multiplication is also supposed to be commutative. That means we should be able to switch around the operands and get the same answer. Unfortunately, this is not true of our Feet objects. When we turn an operation around and put the raw numeric type first, we get an exception: "Feet can't be coerced into Fixnum"

require "./feet2"

23 * Feet.new(50)              # => 
# ~> -:3:in `*': Feet can't be coerced into Fixnum (TypeError)
# ~>    from -:3:in `<main>'

In programming languages, the word "coercion" usually refers to the compiler or the runtime automatically converting from one numeric type to another. Now, as we've talked about before, Ruby is a language where types are almost never automatically converted to other types without our explicitly asking for it. So what is this talk of "coercion"?

Well, it turns out that Ruby makes some exceptions to its usual strictness about types when it comes to numbers.

Let's say we try to multiply the integer 10 times the ratio 2/3.

Integers know how to multiply themselves by other integers. And rationals know how to multiply themselves by other rationals. But how can Ruby multiply these two different types of number?

Here's how Ruby resolves the situation. Knowing that an integer can't multiply itself by a Rational, it takes the second operand. It asks that operand: "do you respond to the #coerce method?

If the answer is "yes", it sends the #coerce method to that second operand, with the first operand as its argument.

The #coerce method has a very specific responsibility: it must take the operand it is given, and find a data type which is compatible with both self and the other operand.

In the case of our Rational object and its integer multiplier, the solution it comes up with is to keep itself as a Rational, and convert the integer to a Rational as well. The result of #coerce is a two-element array, containing both converted operands in their original order.

Ruby takes these two converted operands, and then turns around and applies the original operator to them. The result is another Rational number.

All this goes on behind the scenes when we attempt to multiply an integer by a Rational.

require "rational"

10 * Rational(2, 3)
Rational(2, 3).respond_to?(:coerce) # => true
Rational(2, 3).coerce(10)           # => [(10/1), (2/3)]
Rational(10, 1) * Rational(2, 3)    # => (20/3)
10 * Rational(2, 3)                 # => (20/3)

The cool thing about this mechanism is that we are free to hook our own classes into it. All we have to do is to provide the #coerce method.

We start our method by checking that the other operand is a Numeric type. We don't yet have a plan for converting other types, so we want to make sure to fail early if the type isn't recognized.

Then we construct a two-element array consisting of a new Feet object constructed from the numeric operand, and this Feet object in the second position.

class Feet
  include Comparable
  attr_reader :magnitude

  def initialize(magnitude)
    @magnitude = magnitude.to_f
    freeze
  end

  def to_s
    "#{@magnitude} feet"
  end

  def inspect
    "#<Feet:#{@magnitude}>"
  end

  def +(other)
    Feet.new(@magnitude + other.magnitude)
  end

  def -(other)
    raise TypeError unless other.is_a?(Feet)
    Feet.new(@magnitude - other.magnitude)
  end

  def *(other)
    multiplicand = case other
                   when Numeric then other
                   when Feet then other.magnitude
                   else 
                     raise TypeError, "Don't know how to multiply by #{other}"
                   end
    Feet.new(@magnitude * multiplicand)
  end

  def /(other)
    Feet.new(@magnitude / other.magnitude)
  end

  def <=>(other)
    other.is_a?(Feet) && magnitude <=> other.magnitude
  end

  def hash
    [magnitude, Feet].hash
  end

  def coerce(other)
    unless other.is_a?(Numeric)
      raise TypeError, "Can't coerce #{other}" 
    end
    [Feet.new(other), self]
  end

  alias_method :eql?, :==
end

Now when we multiply an integer by Feet, we get the same result we got when the operands were in the opposite order. Our multiplication operation is now commutative..

require "./feet3"

23 * Feet.new(50)              # => #<Feet:1150.0>

Unfortunately, our changes have some less than desirable side effects. Because Ruby automatically applies coercion regardless of which arithmetic operator is being used, we can now perform other arithmetic operations between numerics and Feet. This defeats our original intention of preventing this kind of mixing of core types and quantity types. If the "23" in this scenario represented a quantity of meters instead of feet, this bit of math would represent an undetected bug.

require "./feet3"

23 - Feet.new(15)                # => #<Feet:8.0>

We're going to tackle this problem next. But first, a disclaimer: the solution we're about to go over is kind of ugly. If you're watching this and you have an idea for a better way to handle this, I'd love to hear from you.

We start off by introducing a new nested class expressly for this scenario, called CoercedNumber. This class exists solely to be a holder of raw numbers which are involved in coercion. It defines all of the arithmetic operations that Feet does, but the definitions of most of them simply raise a TypeError. Only the multiplication operation is allowed, and it is implemented by taking the operands, flipping them around, and delegating to the right-hand operand. Which will be a Feet object.

Then we update the #coerce method to return wrap the raw number operand in a CoercedNumber object before returning it as the first element of the array.

class Feet
  include Comparable

  CoercedNumber = Struct.new(:value) do
    def +(other) raise TypeError; end
    def -(other) raise TypeError; end
    def *(other)
      other * value
    end
    def /(other) raise TypeError; end
  end

  attr_reader :magnitude

  def initialize(magnitude)
    @magnitude = magnitude.to_f
    freeze
  end

  def to_s
    "#{@magnitude} feet"
  end

  def inspect
    "#<Feet:#{@magnitude}>"
  end

  def +(other)
    Feet.new(@magnitude + other.magnitude)
  end

  def -(other)
    raise TypeError unless other.is_a?(Feet)
    Feet.new(@magnitude - other.magnitude)
  end

  def *(other)
    multiplicand = case other
                   when Numeric then other
                   when Feet then other.magnitude
                   else 
                     raise TypeError, "Don't know how to multiply by #{other}"
                   end
    Feet.new(@magnitude * multiplicand)
  end

  def /(other)
    Feet.new(@magnitude / other.magnitude)
  end

  def <=>(other)
    other.is_a?(Feet) && magnitude <=> other.magnitude
  end

  def hash
    [magnitude, Feet].hash
  end

  def coerce(other)
    unless other.is_a?(Numeric)
      raise TypeError, "Can't coerce #{other}" 
    end
    [CoercedNumber.new(other), self]
  end

  alias_method :eql?, :==
end

Here's the upshot of these changes. We can still multiply a Feet object by a raw integer. We can multiply a raw integer by a Feet object. But when we try to perform another operation—such as addition—on a raw integer and a Feet object, we get a TypeError thanks to our new CoercedNumber value holder.

Feet.new(50) * 23               # => #<Feet:1150.0>
23 * Feet.new(50)               # => #<Feet:1150.0>
23 + Feet.new(50)               # => 
# ~> -:5:in `+': TypeError (TypeError)
# ~>    from -:71:in `+'
# ~>    from -:71:in `<main>'

Again, this solution is a bit of a kludge and if you have a better idea I'm all ears.

We still have one more loophole to close. Remember earlier, we saw that because core numeric objects happen to also support the #magnitude message, we can do things like add the integer negative five to 50 Feet. This not only goes against our intentions for the Feet class, but the answer that results is wrong.

require "./feet4"

Feet.new(50) + -5                # => #<Feet:55.0>

To fix this, we go through the class, adding type checks to the other operators and raising TypeError unless the other operand is also a Feet object.

By the way, you might notice that I'm being lazy and not adding an error message to these raises. I don't normally omit the message. But on the other hand, this is a case where taking a quick look at the line of code the exception is raised from should make it immediately obvious what the problem is, so I don't feel too bad about it.

class Feet
  include Comparable

  CoercedNumber = Struct.new(:value) do
    def +(other) raise TypeError; end
    def -(other) raise TypeError; end
    def *(other)
      other * value
    end
    def /(other) raise TypeError; end
  end

  attr_reader :magnitude

  def initialize(magnitude)
    @magnitude = magnitude.to_f
    freeze
  end

  def to_s
    "#{@magnitude} feet"
  end

  def inspect
    "#<Feet:#{@magnitude}>"
  end

  def +(other)
    raise TypeError unless other.is_a?(Feet)
    Feet.new(@magnitude + other.magnitude)
  end

  def -(other)
    raise TypeError unless other.is_a?(Feet)
    Feet.new(@magnitude - other.magnitude)
  end

  def *(other)
    multiplicand = case other
                   when Numeric then other
                   when Feet then other.magnitude
                   else 
                     raise TypeError, "Don't know how to multiply by #{other}"
                   end
    Feet.new(@magnitude * multiplicand)
  end

  def /(other)
    raise TypeError unless other.is_a?(Feet)
    Feet.new(@magnitude / other.magnitude)
  end

  def <=>(other)
    raise TypeError unless other.is_a?(Feet)
    other.is_a?(Feet) && magnitude <=> other.magnitude
  end

  def hash
    [magnitude, Feet].hash
  end

  def coerce(other)
    unless other.is_a?(Numeric)
      raise TypeError, "Can't coerce #{other}" 
    end
    [CoercedNumber.new(other), self]
  end

  alias_method :eql?, :==
end

Today we've learned that controlling when number-like objects are and are not automatically converted to other number-like objects is a tricky business. But we've also learned that thanks to Ruby's coercion mechanism, with a little thought and ingenuity it's possible to make our own objects work almost exactly like Ruby's built-in numeric types. Which, if we are hoping to perform all of our application math with objects that model real-world quantities, is a very good thing.

Happy hacking!

Editor's Note: An essential discussion of multiplying Feet by Feet is found in Episode #225.

Responses