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