In Progress
Unit 1, Lesson 21
In Progress

Quantity

Video transcript & code

This is the Mars Climate Orbiter. It was launched in December 1998, at a cost of 327 million dollars. It was intended to study the Martian climate, atmosphere, and surface changes.

On September 23rd, 1999, the orbiter disintegrated during insertion into Mars orbit. In the ensuing investigation, the failure was determined to have resulted from a mix-up in measurement units. Ground-based computer software sent data in pound-seconds, instead of the newton-seconds that the spacecraft expected.

Because programs so often represent numerical values using raw data types such as integers and floats, mistakes involving mismatched units of measure are a common source of defects. The fact that a value is a floating point number says nothing about what that number represents, so it is all to easy feet to be multiplied by meters. Especially when different parts of the system are written by different people or different teams. In mission-critical systems, the results can be disastrous.

As a result, a lot of thought has gone into how to build programs that are resistant to these kinds of unit mistakes. In object-oriented languages, there is a fairly obvious way to ensure we know what units we are working with at all times: instead of using raw numeric types, we can represent measurements using specialized classes for different units of measure.

Consider this very simple class that simply tracks altitude in feet. It is initialized with an altitude, and then can be instructed to update its value by a positive or negative number of feet.

class Altimeter
  def initialize(value)
    @value_in_feet = value
  end

  def change_by(change_amount)
    old_value = @value_in_feet
    @value_in_feet += change_amount
    puts "Altitude changed from #{old_value} to #{@value_in_feet}"
  end
end

Right now, there is nothing to ensure that the initial value and the change amount will be feet, or that they will even both be in the same unit of measure.

Let's change this. We'll create a new class, called Feet. It will be initialized with a magnitude, which is the number of feet being represented. We ensure that the magnitude is represented internally as a floating point number by using the #to_f explicit conversion on the incoming value.

We also provide a custom #to_s method.

class Feet
  def initialize(magnitude)
    @magnitude = magnitude.to_f
  end

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

When we instantiate a Feet object and use it in a string, the result is a nicely readable sentence.

desk_width = Feet.new(6)
"My desk is #{desk_width} wide"
# => "My desk is 6.0 feet wide"

The most obvious and straightforward way to ensure that only feet are used in keeping track of altitude is to simply to assert the type of the argument in both the initializer and the #change_by method. Ruby has a built-in exception, TypeError, for just this kind of error.

Now, ordinarily this kind of explicit type-checking would go against the grain of a Ruby program. But remember: we're keeping spaceships from crashing here!

class Altimeter
  def initialize(value)
    raise TypeError unless Feet === value
    @value_in_feet = value
  end

  def change_by(change_amount)
    raise TypeError unless Feet === change_amount
    old_value = @value_in_feet
    @value_in_feet += change_amount
    puts "Altitude changed from #{old_value} to #{@value_in_feet}"
  end
end

Let's instantiate an altimeter at a height of 10,000 feet, and then try to change the value without specifying the right unit of measure. Our altimeter now refuses to permit this kind of sloppiness.

alt = Altimeter.new(Feet.new(10_000))
alt.change_by(-600)
# ~> -:18:in `change_by': TypeError (TypeError)
# ~>    from -:26:in `<main>'

Unfortunately, when we correct our mistake and explicitly use feet, we run into a new problem. Ruby has no idea how to add Feet objects together.

require "./feet"
require "./altimeter"

alt = Altimeter.new(Feet.new(10_000))
alt.change_by(Feet.new(-600))  
# ~> /home/avdi/Dropbox/rubytapas/200-unit-of-measure/altimeter.rb:11:in `change_by': undefined method `+' for #<Feet:0x00000000ee3ae0 @magnitude=10000.0> (NoMethodError)
# ~>    from -:5:in `<main>'

Happily, that too is a problem we can fix. We return to our Feet class. This need to support arithmetic operations in user-created unit types is one of the principle reasons that the concept of operator overloading was added to older languages like Ada and C++, and fortunately for us Ruby supports operator overloading as well.

We define an addition operator for our feet class. It will create a new Feet object which represents the magnitude of one object plus the magnitude of another. Partway through our definition, we realize that we have no way to get the magnitude of the other value. We add an attr_accessor for the magnitude, and then proceed to finish our definition.

class Feet
  attr_accessor :magnitude

  def initialize(magnitude)
    @magnitude = magnitude.to_f
  end

  def to_s
    "#{@magnitude} feet"
  end

  def +(other)
    Feet.new(@magnitude + other.magnitude)
  end
end

With these changes in place, we can successfully update the altitude in our Altimeter object.

require "./feet2"
require "./altimeter"

alt = Altimeter.new(Feet.new(10_000))
alt.change_by(Feet.new(-600))  
# >> Altitude changed from 10000.0 feet to 9400.0 feet

We now have a rudimentary class representing a quantity. It is woefully incomplete, it is not very convenient, and it has flaws which we have not yet discussed. Yet already it is helping us to avoid unit errors in our program.

In upcoming episodes we will build on this foundation. We will slowly evolve quantity representations which are robust and complete. But for now: happy hacking!

Responses