In Progress
Unit 1, Lesson 21
In Progress

Conversion Protocol

Video transcript & code

In the preceding episode we said we were going to start handling measurements in units of either feet or meters in our application, in order to better support internationalization. Accordingly, we split our Feet class into separate Feet and Meters classes, with a common base class called Quantity.

class Quantity
  include Comparable

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

  attr_reader :magnitude
  alias_method :to_f, :magnitude
  private :magnitude

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

  def to_s
    "#{@magnitude} #{self.class.name.downcase}"
  end

  def to_i
    to_f.to_i
  end

  def inspect
    "#<#{self.class}:#{@magnitude}>"
  end

  def +(other)
    fail TypeError unless other.is_a?(self.class)
    self.class.new(@magnitude + other.to_f)
  end

  def -(other)
    fail TypeError unless other.is_a?(self.class)
    self.class.new(@magnitude - other.to_f)
  end

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

  def /(other)
    fail TypeError unless other.is_a?(self.class)
    self.class.new(@magnitude / other.to_f)
  end

  def <=>(other)
    fail TypeError unless other.is_a?(self.class)
    other.is_a?(self.class) && magnitude <=> other.to_f
  end

  def hash
    [magnitude, self.class].hash
  end

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

  alias_method :eql?, :==
end

class Feet < Quantity
end

class Meters < Quantity
end

def Feet(value)
  case value
  when Feet then value
  else
    value = Float(value)
    Feet.new(value)
  end
end

def Meters(value)
  case value
  when Meters then value
  else
    value = Float(value)
    Meters.new(value)
  end
end

The separate quantity classes for differing units ensure that we won't accidentally mix units when making calculations. But as of yet they don't help us interchange between measurements in different units. Next on our TODO list is adding the ability to safely convert between unit types.

Let's say we're adding Feet to a Meters quantity. At the moment, trying to do this just gets us a TypeError.

require "./quantities"

meters = Meters(23)

meters + Feet(32)
# => 
# ~> /home/avdi/Dropbox/rubytapas/213-conversion-protocol/quanties.rb:36:in `+': TypeError (TypeError)
# ~>    from -:5:in `<main>'

Here's one way we could make this work. We take the type-checking in the addition operator, and extract it out into a new method called #ensure_compatible. We invoke ensure_compatible on the incoming value to be added. This method will be responsible both for rejecting incompatible values, as well as for converting values to a compatible value if such a conversion is possible.

class Quantity
  # ...
  def +(other)
    other = ensure_compatible(other)
    self.class.new(@magnitude + other.to_f)
  end

  def ensure_compatible(other)
    fail TypeError unless other.is_a?(self.class)
    other
  end
  # ...
end

class Meters < Quantity
  # ...
  def ensure_compatible(other)
    case other
    when Feet then self.class.new(other.to_f * 0.3048)
    else super
    end
  end
end

Then, in the Meters class, we override the #ensure_compatible method. We switch on the type of the incoming value. If it is a Feet object, we convert feet to meters and return the resulting Meters. Otherwise, we defer to the superclass implementation of #ensure_compatible.

With these changes in place, we can now add Feet to Meters.

require "./quantities"
require "./quantities2"

meters = Meters(23)

meters + Feet(32)
# => #<Meters:32.7536>

Although, we can't go the other way around.

require "./quantities"
require "./quantities2"

meters = Feet(23)

meters + Meters(32)
# => 
# ~> /home/avdi/Dropbox/rubytapas/213-conversion-protocol/quantities2.rb:10:in `ensure_compatible': TypeError (TypeError)
# ~>    from /home/avdi/Dropbox/rubytapas/213-conversion-protocol/quantities2.rb:5:in `+'
# ~>    from -:6:in `<main>'

Before we make this conversion work both ways, however, let's take a step back and consider this design. Is this really the best way to do it?

Consider what would happen if we were to add a third unit of measure: say, furlongs. We'd have to add a furlong case to this case statement. And the same is true for every new unit we add. We have to modify a method in our Meters class (and probably our Feet class as well) whenever we add another unit.

The "Open/Closed" principle, coined by Bertrand Meyer, states that classes should be "open for extension, but closed for modification". There are a lot of ways to interpret this rule. But one way I like to think of it is this:

For software to be flexible, the ideal situation is that we can add new functionality by adding a new class and making zero changes to existing classes. The next best thing is that we add methods to existing classes, but we don't modify existing methods. The least desirable case is when adding functionality requires us to modify existing methods in existing classes.

In our current design, adding the ability to receive measurements in a new unit requires us to modify existing methods. Let's see if we can do better.

To get an idea for our new design, we take inspiration from Ruby itself. Remember in Episode #210, when we talked about implicit conversion methods? These are methods like #to_int and #to_str that Ruby will sometimes make implicit use of if they exist.

For our purposes, we'll modify the Meters#ensure_compatible method to check for the presence of a #to_meters implicit conversion method. If it is available, we'll invoke it. Otherwise, we'll defer to the superclass version of #ensure_compatible.

Next, over in the Feet class, we define a #to_meters method which performs the appropriate conversion.

class Quantity
  # ...
  def +(other)
    other = ensure_compatible(other)
    self.class.new(@magnitude + other.to_f)
  end

  def ensure_compatible(other)
    fail TypeError unless other.is_a?(self.class)
    other
  end
  # ...
end

class Meters < Quantity
  # ...
  def ensure_compatible(other)
    if other.respond_to?(:to_meters)
      other.to_meters
    else
      super
    end
  end
end

class Feet < Quantity
  def to_meters
    Meters.new(to_f * 0.3048)
  end
end

Having made these changes, we are still able to add Feet to Meters.

require "./quantities"
require "./quantities3"

meters = Meters(23)

meters + Feet(32)
# => #<Meters:32.7536>

If we only need to convert incoming Feet values into meters, then we are done. And any future types can similarly be added without any change to the Meters class. So long as they define their own #to_meters method, they will be compatible.

But even in the worst case, if we also need Meters to be able to convert themselves to Feet, we'd be doing so by adding a new method to it. Remember, for the purposes of extensibility we consider adding a method to be preferable to modifying an existing one.

class Meters < Quantity
  # ...
  def to_feet
    Feet.new(to_f * 3.28084)
  end
end

One last tweak we can add to this design is to also add a #to_meters method to the Meters class. It will simply return self.

class Meters < Quantity
  # ...
  def to_meters
    self
  end
end

This may seem like a pointless addition. But it means that as long as we know that we're dealing with units of measure, we can always be sure to get Meters by sending the #to_meters message—without first checking which unit a measurement is.

require "./quantities.rb"
require "./quantities3.rb"
require "./quantities5.rb"

Feet(23).to_meters              # => #<Meters:7.010400000000001>
Meters(32).to_meters            # => #<Meters:32.0>

I think of conventions like the #to_meters method as "conversion protocols". The existence of the #to_meters method serves two purposes: first, as a flag to let clients know that the object is convertible to Meters. And second, as the means by which the conversion is accomplished. By conditionally sending the #to_meters message if it is available, client objects are able to use this protocol to get Meters objects without knowing the specific type of the starting value. Any client objects which use this protocol are open for extension: we can add new unit types to the system, without having to change a line of existing code.

And that's it for today. Happy hacking!

Responses