In Progress
Unit 1, Lesson 21
In Progress

More Of Same

Video transcript & code

Yes, today we're once again working on representations of physical measurements. As I'm sure you recall, we have a class that represents quantities of feet. So far we've been mainly focused on preventing inadvertent mixing of Feet objects and measurements represented by raw numeric values. We are concerned about this, because we don't want to accidentally mix two different units of measure in one calculation.

But what about deliberate mixing of units? An application that is available internationally may well need to accept measurements in either meters or feet. So maybe it's time we added a class to represent meters as well.

Such a class is going to be identical to the Feet class in almost every detail. So we decide to just rename the Feet class to a more generic Quantity, and then inherit Feet and Meters classes from it. We also add a new Meters conversion function.

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} feet"
  end

  def to_i
    to_f.to_i
  end

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

  def +(other)
    raise TypeError unless other.is_a?(Feet)
    Feet.new(@magnitude + other.to_f)
  end

  def -(other)
    raise TypeError unless other.is_a?(Feet)
    Feet.new(@magnitude - other.to_f)
  end

  def *(other)
    multiplicand = case other
                   when Numeric then other
                   when Feet then other.to_f
                   else 
                     raise TypeError, "Don't know how to multiply by #{other}"
                   end
    Feet.new(@magnitude * multiplicand)
  end

  def /(other)
    raise TypeError unless other.is_a?(Feet)
    Feet.new(@magnitude / other.to_f)
  end

  def <=>(other)
    raise TypeError unless other.is_a?(Feet)
    other.is_a?(Feet) && magnitude <=> other.to_f
  end

  def hash
    [magnitude, Feet].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
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

Now let's try using some Meters objects.

We run into one issue immediately: Meters still identify themselves as Feet. We can also see this when converting Meters to a string.

require "./quantity"

Meters(23)                      # => #<Feet:23.0>
Meters(23).to_s                 # => "23.0 feet"

We could fix this by specializing the inspect method in both Meters and Feet subclasses. But there's another way, that takes less code. We can just update those methods in the parent class to dynamically discover the name of their concrete child class. For the #to_s case, we use the lowercased version of the class name.

require "./quantity"

class Quantity
  # ...
  def inspect
    "#<#{self.class}:#{@magnitude}>"
  end

  def to_s
    "#{@magnitude} #{self.class.name.downcase}"
  end
  # ...
end

Now meters show the correct type on inspection and conversion.

require "./quantity2"

Meters(23)                      # => #<Meters:23.0>
Meters(23).to_s                 # => "23.0 meters"

Next, let's test out some math. We'll add two Meters objects together.

require "./quantity2"

Meters(23) + Meters(32)         # => 
# ~> /home/avdi/Dropbox/rubytapas/212-more-of-same/quantity.rb:36:in `+': TypeError (TypeError)
# ~>    from -:3:in `<main>'

Whoops, we get a type error. Let's go check the code… ah, yes, we hardcoded the type check for Feet. Let's change that to also use the dynamically-discovered class.

require "./quantity2"

class Quantity
  # ...
  def +(other)
    raise TypeError unless other.is_a?(self.class)
    Feet.new(@magnitude + other.to_f)
  end
  # ...
end

Let's try the addition again… well, we get no exception this time, but the result is a Feet object, instead of meters!

require "./quantity3"

Meters(23) + Meters(32)         # => #<Feet:55.0>

So back we go to the method definition, where we can see that we also hardcoded the class to use for creating the result object. We update this to use the dynamic class instead.

By the way, you may have noticed that we always write self.class instead of just class. This is because class is a keyword in Ruby, and to use it alone would make the parser think we were trying to declare a new class. We have to qualify the class method with an explicit self receiver in order to make it unambiguous for the parser.

require "./quantity3"

class Quantity
  # ...
  def +(other)
    raise TypeError unless other.is_a?(self.class)
    self.class.new(@magnitude + other.to_f)
  end
  # ...
end

Now when we add Meters together we get more Meters.

require "./quantity4"

Meters(23) + Meters(32)         # => #<Meters:55.0>

The next obvious step would be to go through the whole Quantity class with search-and-replace, replacing every instance of Feet with self.class. This is a straightforward-enough step. But it still means our version control diff for this change is going to be a Christmas tree of little modifications.

Could we have avoided this? Yes, we could have. For the sake of example, up till now I've hardcoded the name Feet everywhere in this class. But ordinarily I wouldn't do this. Instead, my habit is to always prefer writing self.class rather than reiterating the name of a class inside itself. In this case, that would only have saved us some messy diffs. But the real value of this habit shines through when we start moving or copying individual methods from one class to another; for instance, when pushing a method up from a child class to a parent class. In those cases it can be all too easy to miss a hardcoded class reference.

And that's all I wanted to get across today: that in my opinion, it's best to make a habit of typing self.class instead of referencing a class' literal name inside itself. Happy hacking!

Responses