In Progress
Unit 1, Lesson 1
In Progress

Type and Class

Video transcript & code

Today we return to the Quantity class we've been slowly building over the past several weeks. Just to refresh your memory, we have defined two concrete quantity classes: Feet and Meters. With them we are able to represent physical quantities without the dangers of improperly mixing units.

require "./quantities"

Feet(23)                        # => #<Feet:23.0>
Meters(42)                      # => #<Meters:42.0>

Feet(23) + Meters(42)           # => #<Feet:160.79528>

When we look at the definitions of Feet and Meters, we see something curious. We have been so successful in pulling common functionality up into the base Quantity class that the child classes are empty definitions.

class Feet < Quantity
end

class Meters < Quantity
end

Ordinarily, the purpose of having child classes is to have a place to put behavior which is unique to a subset of instances. But clearly, Feet and Meters have no unique behavior of their own. All of their behavior is inherited from Quantity.

This begs a question: should Feet and Meters be child classes at all? What if we just had a Quantity class, which could be paramaterized with both an amount and a unit of measure, something like this?

Quantity.new(42, :feet)

Let's go through the current Quantity class from beginning to end. Let's see what it would take to transition to using different instances to represent different units, instead of different classes.

We'll add a new attribute, :unit. We expect this attribute to be a symbol representing the unit of measure.

attr_reader :magnitude, :unit

The initializer gets a new unit argument, with which it initializes the attribute.

def initialize(magnitude, unit)
  @magnitude = magnitude.to_f
  @unit      = unit
  freeze
end

Instead of using the class name when converting to a string representation, we use the unit.

def to_s
  "#{@magnitude} #{unit}"
end

We give the same treatment to the #inspect method.

def inspect
  "#<#{unit}:#{@magnitude}>"
end

All of the arithmetic operator methods need to be updated to pass the current unit to the newly created result object. In addition, our multiplication operator can no longer use case statement to switch on the type of its argument, because now we need to check if the argument is a Quantity and of the right unit. We switch to an if statement instead.

def +(other)
  other = ensure_compatible(other) 
  self.class.new(@magnitude + other.to_f, unit)
end

def -(other)
  other = ensure_compatible(other) 
  self.class.new(@magnitude - other.to_f, unit)
end

def *(other)
  multiplicand = if other.is_a?(Numeric)
                   other
                 elsif other.is_a?(Quantity) && other.unit == unit
                   other.to_f
                 else
                   fail TypeError, "Don't know how to multiply by #{other}"
                 end
  self.class.new(@magnitude * multiplicand, unit)
end

def /(other)
  other = ensure_compatible(other) 
  self.class.new(@magnitude / other.to_f, unit)
end

Similarly, we change the guard clause at the top of the spaceship operator to check both class and unit.

def <=>(other)
  return nil unless other.is_a?(self.class) && other.unit == unit
  magnitude <=> other.to_f
end

We need to factor the unit into a quantity's hash value.

def hash
  [magnitude, unit, self.class].hash
end

We add the check for a matching unit to #ensure_compatible. Instead of sending convert_to with the class of the current quantity, it will now send the name of the current unit instead.

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

Speaking of convert_to, we need to make a few changes to this method as well. Everywhere it once dealt with types, it now needs to deal with unit attributes.

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

Now we get to our Feet and Meters conversion functions. We'll keep these, both for convenience and so that our existing code continues to work. But we have to rewrite each of them substantially. Once again, a case statement doesn't cut it. Since we are now checking the given value for both whether it is a Quantity and for what unit it has, we need to switch to an if statement.

def Feet(value)
  if value.is_a?(Quantity) && value.unit == :feet
    value
  else
    value = Float(value)
    Quantity.new(value, :feet)
  end
end

def Meters(value)
  if value.is_a?(Quantity) && value.unit == :meters
    value
  else
    value = Float(value)
    Quantity.new(value, :meters)
  end
end

Finally, we update our ConversionRatio registrations to register unit symbols instead of classes.

ConversionRatio.registry << 
  ConversionRatio.new(:feet, :meters, 0.3048) <<
  ConversionRatio.new(:meters, :feet, 3.28084)

At last, we can remove the Feet and Meters classes.

Wow… this turned out to be a pretty big change! The question is, was it worth it?

I'll be honest. When I first set out to write this episode I thought it would be a good demonstration of how, in the absence of differing behavior, differentiated child classes aren't needed. We can differentiate just as easily between instances with different state.

But as I worked through the necessary changes, I realized something. Having objects be differentiated by class has subtle benefits which aren't always obvious until we try out the alternative. This single-class version of the code is longer. In many places where we previously just checked an object's class, we now have to check both class and unit. We can't leave out the class check, because that would invite a potential NoMethodError when we then check the unit. And of course we can't leave out the unit either. Everywhere we have to check compatibility of another Quantity object is now an opportunity to accidentally leave off part of this dual check.

The worst changes though, to my mind, are all the places we had to replace elegant case statements with longer if/else statements.

One way of looking at the type of an object is as just another attribute. One of our old Feet objects might have a magnitude of 23, and a type (or class) of Feet.

require "./quantities"

f = Feet(23)
f.to_f                          # => 23.0
f.class                         # => Feet

From this point of view, we've just explicitly made the type into a regular attribute, called :unit.

But when we represent type as an ordinary attribute instead of as an object's class, we lose all the special conveniences that Ruby provides around classes. We can no longer use the classname as a shortcut for assigning type to a new object.

Feet.new(23)

And we can no longer use a simple case statement to check that an object is both a Quantity, and a specific sub-type of Quantity, all at once.

case other
when Feet
  # ...
end

The concept of "type" is a tricky one. One of the refactorings in Martin Fowler's book on the subject is "Replace type code with class". In some cases, type codes in classes are hard to miss. It's not uncommon to run across attributes that are actually named "type". For instance, take this class for representing payments. Some payments might be recurring subscription payments, others might be one-time shopping cart payments. This is an obvious candidate for a parent-child class relationship.

Payment = Struct.new(:type, :amount)

Payment.new(:recurring, 9)
Payment.new(:shopping, 15)

But other type codes are less obvious. For instance, the unit attribute in our Quantity class.

attr_reader :magnitude, :unit

In the final analysis, I don't think it made sense to eliminate child classes for our Quantity class, even though their definitions were empty. Classes are useful for separating different behaviors, but that isn't their only value. So long as some kind of object may have different sub-types, and those types affect the logic of a program, we need a way of representing that differentiation. The Ruby way to encode type is with a class (or module), and we might as well take advantage of the benefits of that style of type encoding.

Happy hacking!

Responses