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

  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