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