In Progress
Unit 1, Lesson 1
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

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!