In Progress
Unit 1, Lesson 1
In Progress

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