In Progress
Unit 1, Lesson 1
In Progress

# Conversion Ratio Video transcript & code

A few days ago we added some conversion protocols to our `Feet` and `Meters` classes for representing physical measurements. In that episode, we were only concerned with converting incoming values into `Meters`, and not the other direction. But since then we've fleshed out the code to handle conversions both from meters to feet, and from feet to meters.

``````class Quantity
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

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)
raise TypeError unless other.is_a?(self.class)
self.class.new(@magnitude + other.to_f)
end

def -(other)
raise 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
raise TypeError, "Don't know how to multiply by #{other}"
end
self.class.new(@magnitude * multiplicand)
end

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

def <=>(other)
raise 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)
raise TypeError, "Can't coerce #{other}"
end
[CoercedNumber.new(other), self]
end

alias_method :eql?, :==
end

class Feet < Quantity
def to_meters
Meters.new(magnitude * 0.3048)
end
def to_feet
self
end
end

class Meters < Quantity
def to_meters
self
end
def to_feet
Feet.new(magnitude * 3.28084)
end
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
``````

Looking at this code, it's easy to see that if we add new units of measure, and if we want all units to be convertible to all other units, it will lead to a proliferation of methods. Conversion methods for each new unit will have to be added to every existing class, and the new unit will have to have a conversion for every existing unit.

We might get around writing all these methods by using some clever metaprogramming. But let's hold up for a second. We've now used two phrases in a row that both make nervous. The first is "proliferation of methods". And the second is "clever metaprogramming". Let's see if we can find an alternative design that doesn't require us to use either of these scary phrases.

When we need to come up with a new object design, there are two ways we can set to work. We can get out some 3x5 cards, go for a walk, brew a fresh pot of coffee, or whatever other rituals we go through in order to get our creative and analytic juices flowing. Or we can cheat, by consulting the literature and cribbing off of other people's designs.

In this case, we are dealing with a problem space that has been encountered in numerous software projects over the years. In his book "Analysis Patterns", Martin Fowler describes a whole pattern language for working with quantities, units, measurements, and physical observations. We'll use these patterns as a guide as we implement a more flexible design for unit conversions.

We start off by removing the conversion methods from our `Feet` and `Meters` classes.

Then we define a new `ConversionRatio` class. ConversionRatios will be very simple value objects, so we use Struct to define it. Each `ConversionRatio` has three attributes: a `from` class, a `to` class, and a numeric multiplier.

We also add a couple of class-level methods. One establishes a global conversion ratio registry, which is simply an array. The other is a method to help look up ratios in the registry. Given a `from` class and a `to` class, it will find the corresponding ratio (if any).

In our `Quantity` base class, we update the implementation of the `#ensure_compatible` method that we wrote the other day. First, it will check to see if the other value is of the same class as self; if so, it returns it unchanged. Second, it checks to see if the value can be converted using a new `#convert_to` protocol. If so, it sends the `#convert_to` message with the desired target type. Finally, if all else fails it signals a type error.

Now we need to implement the `#convert_to` method. This method is straightforward: first, it looks up a `ConversionRatio` which can convert from the self class to the target type, bailing out if no such ratio can be found. Then it performs the conversion using the found ratio.

The last change we make is to register two conversion ratios: one from feet to meters, and one from meters to feet.

``````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)
other = ensure_compatible(other)
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

def ensure_compatible(other)
if other.is_a?(self.class)
return other
elsif other.respond_to?(:convert_to)
other.convert_to(self.class)
else
fail TypeError
end
end

def convert_to(target_type)
ratio = ConversionRatio.find(self.class, target_type) or
fail TypeError, "Can't convert #{self.class} to #{target_type}"
target_type.new(magnitude * ratio.number)
end

alias_method :eql?, :==
end

ConversionRatio = Struct.new(:from, :to, :number) do
def self.registry
@registry ||= []
end

def self.find(from, to)
registry.detect{|ratio| ratio.from == from && ratio.to == to}
end
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

ConversionRatio.registry <<
ConversionRatio.new(Feet, Meters, 0.3048) <<
ConversionRatio.new(Meters, Feet, 3.28084)
``````

Let's test out these changes. We can convert feet to meters. We can convert meters to feet. And, we can still add feet to meters, relying on implicit conversion to accurately deliver the results in `Feet`.

``````meters = Feet(32).convert_to(Meters) # => #<Meters:9.7536>
meters.convert_to(Feet)              # => #<Feet:32.000001024>

Feet(32) + Meters(23)           # => #<Feet:107.45932>
``````

This new design has tackled the problem of arbitrary unit conversions without resorting to either a proliferation of conversion methods, or fancy metaprogramming. To add a new conversion from one unit to another, we need make a change in only one place, by registering a new `ConversionRatio`. By building on the collective experience captured in "Analysis Patterns", we've made our design more flexible without much added complexity.

This is going to sound contradictory, but to me the most interesting thing about this new design is how boring it is. The newly-added `ConversionRatio` class couldn't be much simpler: it's just a value holder with three slots. And yet pulling out the idea of conversion ratios into a new class and registry has drastically reduced the impact of adding new conversions.

The key design insight here is the recognition that a conversion ration is not a property of a unit type; it's a property of the relationship between two unit types. By explicitly modeling those relationships in the form of a new class, we've improved our design considerably. This is one of the reasons I love reading patterns books: sure, if we had puzzled over this long enough, we probably would have come to this realization eventually. But by standing on the shoulders of others who already went through that process, no doubt with much trial and error, we can skip straight to the epiphany, and move on to other problems.

And that's it for today. Happy hacking!