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