Immutable Object
Video transcript & code
In the last episode, we began the process of defining a class to represent a quantity. Specifically, we created a class for representing Feet
. We did this in order to avoid situations where units of measure are improperly mixed-up, leading to program defects.
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
There are many things missing from this class. Today we're going to look at one of the most basic problems with it. It has to do with how this class differs from core Ruby numeric types.
Numbers in Ruby behave a little differently than most other objects. For instance, we cannot instantiate a new Fixnum
using the .new
method:
Fixnum.new(42)
# ~> -:1:in `<main>': undefined method `new' for Fixnum:Class (NoMethodError)
We can tell a String
to clear itself. But we can't tell a number to clear itself.
s = "Hello, world"
s.clear
s # => ""
n = 42
n.clear
n # =>
# ~> -:6:in `<main>': undefined method `clear' for 42:Fixnum (NoMethodError)
We cannot increment a raw number by one, either. Ruby doesn't even recognize it as valid syntax:
3 += 1
# ~> -:1: syntax error, unexpected tOP_ASGN, expecting end-of-input
# ~> 3 += 1
# ~> ^
We can perform an increment if we assign the number to a variable. But it's important to understand that the number object itself isn't being modified here. When we expand out the code to how Ruby views a plus-equals operator, we can see that what is really happening is that the 1 is added to the old value of n
, producing a new number object, and that new object is assigned to the n
variable; replacing the old value it held.
n = 3
n # => 3
n.object_id # => 7
n += 1
n # => 4
n.object_id # => 9
n = (n + 1) # => 5
All of these properties are clues to the special nature of numbers in Ruby code. Numbers are immutable: they cannot be changed. This makes intuitive sense: it really doesn't make sense to write code that updates the number 3 to equal 4.
Our Feet
class, on the other hand, is mutable. For instance, in the last episode we also introduced the Altimeter
class. It is initialized with a starting altitude, and can subsequently be updated by sending it the #change_by
message.
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
What if we had written that class a little differently? What if, instead of replacing the internal @value_in_feet
, we instead updated it in-place?
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.magnitude += change_amount.magnitude
puts "Altitude changed from #{old_value} to #{@value_in_feet}"
end
end
Let's define a constant to hold a default starting altitude. We'll pass this starting value to a new Altimeter object. Then we'll tell it to change by 500 feet.
This still works fine. But when we take a look at the value of the START_ALTITUDE
constant, we can see that it too has been incremented by 500! Assuming we are keeping this constant around to initialize multiple Altimeter
objects, this is surprising behavior, and almost certainly not what we intend to happen!
require "./feet"
require "./altimeter2"
START_ALTITUDE = Feet.new(10_000)
alt = Altimeter.new(START_ALTITUDE)
alt.change_by(Feet.new(500))
START_ALTITUDE # => #<Feet:0x000000017e0e90 @magnitude=10500.0>
# >> Altitude changed from 10500.0 feet to 10500.0 feet
In the interest of eliminating unpleasant surprises, it's desirable that our quantity class behave as similarly as possible to Ruby's core numeric types. One way we can move towards this ideal is by making the Feet
class be immutable.
If we intend for a Feet
object to never change, there is no reason to expose a setter method for the magnitude
attribute. So the most obvious change we can make is to change the #magnitude
accessor to be a an attr_reader
.
class Feet
attr_reader :magnitude
def initialize(magnitude)
@magnitude = magnitude.to_f
end
def to_s
"#{@magnitude} feet"
end
def +(other)
Feet.new(@magnitude + other.magnitude)
end
end
Now when we try to use it with the our modified Altimeter
, it raises an exception because there is no longer a public way to update the #magnitude
attribute.
require "./feet2"
require "./altimeter2"
START_ALTITUDE = Feet.new(10_000)
alt = Altimeter.new(START_ALTITUDE)
alt.change_by(Feet.new(500))
START_ALTITUDE # =>
# ~> /home/avdi/Dropbox/rubytapas/201-immutable-object/altimeter2.rb:11:in `change_by': undefined method `magnitude=' for #<Feet:0x00000002948c28 @magnitude=10000.0> (NoMethodError)
# ~> from -:7:in `<main>'
This is a great way to signal to client classes that the class should be treated as immutable. But what about code inside the Feet
class itself?
Consider a somewhat pathological case in which a new team member arrives who doesn't realize the importance of treating Feet
objects as immutable. Let's say this team member adds a #clear
method which will reset a Feet
object to zero.
class Feet
attr_reader :magnitude
def initialize(magnitude)
@magnitude = magnitude.to_f
end
def to_s
"#{@magnitude} feet"
end
def +(other)
Feet.new(@magnitude + other.magnitude)
end
def clear
@magnitude = 0.0
end
end
We can now modify a Feet
object on-place:
require "./feet3"
f = Feet.new(30_000)
f # => #<Feet:0x000000010c9260 @magnitude=30000.0>
f.clear
f # => #<Feet:0x000000010c9260 @magnitude=0.0>
"But Avdi!" you might be wondering. "Who would actually do that?!". Well, sometimes it's not intentional. Sometimes it can be as simple as a slip of the fingers. Here's an example of a predicate method that checks to see if the number is positive. Unfortunately, we mis-type and leave off the greater-than symbol, leaving a method which unintentionally updates the value in-place whenever it is called.
class Feet
attr_reader :magnitude
def initialize(magnitude)
@magnitude = magnitude.to_f
end
def to_s
"#{@magnitude} feet"
end
def +(other)
Feet.new(@magnitude + other.magnitude)
end
def positive?
@magnitude = 0.0
end
end
These kinds of errors are notoriously difficult to track down when we are in the habit of assuming that Feet
are always immutable just like Ruby numerics.
To avoid this situation, let's take immutability a step further. In the initializer, once the initial magnitude is set, we call the #freeze
method. This tells Ruby to make the object immutable. It will no longer be possible to update the value of any instance variables past this point.
Now when we instantiate a Feet
object and send it the broken #positive?
message, we get an error: "cannot modify frozen Feet".
class Feet
attr_reader :magnitude
def initialize(magnitude)
@magnitude = magnitude.to_f
freeze
end
def to_s
"#{@magnitude} feet"
end
def +(other)
Feet.new(@magnitude + other.magnitude)
end
def positive?
@magnitude = 0.0
end
end
f = Feet.new(30_000)
f.positive? # =>
# ~> -:18:in `positive?': can't modify frozen Feet (RuntimeError)
# ~> from -:23:in `<main>'
We now have a class which ensures that we can't circumvent immutability, even accidentally. We can use this class in much the same way we use Ruby numeric objects, without having to worry about rude surprises resulting from in-place updates.
And that's all for today. Happy hacking!
Responses