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
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)
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
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)
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!
Responses