In Progress
Unit 1, Lesson 21
In Progress

Tell, Don’t Ask

Video transcript & code

Hey, so remember back in Episode #214, we created a registry of ConversionRatio objects? Each one has a unit that it converts from, a unit it converts to, and a number representing the ratio.

class Feet; end
class Meters; 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

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

The code that uses this registry looks like this:

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

First this method looks up the appropriate conversion ratio for the units it is trying to convert. Then it uses the ratio's number as a multiplier.

This works great up until we decide to start representing temperature measurements using Quantity subclasses. The conversion from Celsius to Fahrenheit, and vice-versa, isn't a simple multiplication. The math is slightly more complex than that. And our current design doesn't accommodate this new twist.

Now, in designing object we don't have a crystal ball. We can't predict every new complication that might get thrown our way. However, we do have some broad guidelines to help us avoid common design traps. In this case, we've ignored one of these guidelines, and now we're suffering the consequences.

The guideline in question is the "tell, don't ask" principle. Meaning that as much as possible, we should tell objects what to do, rather than asking them about themselves.

Our #convert_to method uses ConversionRatio objects as simple data holders, reaching in to get the ratio and then using it in hardcoded calculation. It is asking, not telling.

What would it look like to tell instead of asking? Let's rewrite this code. ConversionRatio becomes UnitConversion. It loses the number attribute, and gains a new method named #call. This method is expected to be implemented in derived classes; we document this intention using a NotImplementedError, which we first saw in Episode #166. By the way, if you're curious why we opt for the name "call", see Episode #35.

We derive a RatioConversion subclass from this base class. It adds a number to the mix, and overrides #call to perform the conversion by using the number as a multiplier.

class Feet; end
class Meters; end

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

  def self.find(from, to)
    registry.detect{|ratio| ratio.from == from && ratio.to == to}
  end

  def call(from_value)
    raise NotImplementedError
  end
end

class RatioConversion < UnitConversion
  attr_reader :number
  def initialize(from, to, number)
    super(from, to)
    @number = number
  end

  def call(from_value)
    from_value * number
  end
end

UnitConversion.registry << 
  RatioConversion.new(Feet, Meters, 0.3048) <<
  RatioConversion.new(Meters, Feet, 3.28084)

Then we rewrite the #convert_to method. Instead of looking up a ratio, it's now looking up a conversion. And instead of reaching into the resulting object, it simply tells that object to perform its conversion, passing in the current object's magnitude.

def convert_to(target_type)
  conversion = UnitConversion.find(self.class, target_type) or 
    fail TypeError, "Can't convert #{self.class} to #{target_type}"
  target_type.new(conversion.call(magnitude))
end

Now that we've switched from asking to telling, we easily add code to deal with temperature conversions. We add a new UnitConversion subclass we call BlockConversion. This class adds a block to the initializer, which it stows away for later use. Then, in the #call method, it invokes the block to perform the conversion.

To add a Celsius to Fahrenheit conversion, we create a new BlockConversion from Celsius, to Fahrenheit, and we pass a block to it. Inside the block, we perform the appropriate calculation: multiply by 9, divide by 5, and then add 32.

require "./conversion"

class Celsius; end
class Fahrenheit; end

class BlockConversion < UnitConversion
  def initialize(from, to, &block)
    super(from, to)
    @block = block
  end

  def call(from_value)
    @block.call(from_value)
  end
end

UnitConversion.registry << BlockConversion.new(Celsius, Fahrenheit) { 
  |from_value|
  ((from_value * 9) / 5) + 32
}

UnitConversion.find(Celsius, Fahrenheit).call(32)
# => 89

Should we have used this tell-don't-ask design from the get-go? That's debatable. While it's undoubtedly more flexible, and that flexibility turns out to be essential for a general-purpose unit conversion library, the new code is also more complex than the original version. There are more lines of code, and more classes.

There's one thing about which I have little doubt: once we identified the need for more flexibility in our conversion types, rewriting to this new design was the right thing to do. Any solution which kept the original value-holder design while attempting to accommodate temperature unit conversions would have pushed more and more complexity into the Quantity class. By redesigning the code to respect tell-don't-ask, we've kept separate responsibilities of representing quantities on the one hand, and converting between units on the other. Note that we ensured that the conversion objects deal only in raw values, so they don't even need to know the interface for Quantity objects. All they need to know about is the calculation needed to perform a conversion from one unit to another.

And that's all for today. Happy hacking!

Responses