March 22, 2020

Ruby's 'self' Versus 'instance variable'

I recently had the pleasure of pairing with my cousin, who just finished her second week at Flatiron (an online coding bootcamp). They had just wrapped up a week learning about Objects. She had made it through all the labs, but still lacked some clarity around the self keyword versus @instance variables. Here's what we discussed.

class Register
  attr_accessor :items, :total
  
  def initialize(discount: 0.2)
    @items = []
    @total = 0
    @discount = discount
  end
  
  def add_item(item_name:, quantity:, price:)
    @total += quantity * price
    quantity.times { @items << item_name }
  end
  
  def apply_discount
    @total *= (1.0 - @discount)
  end
end

register = Register.new
register.add_item(item_name: "apple", quantity: 4, price: 2.0)
register.add_item(item_name: "carrot", quantity: 2, price: 1.0)
register.total # => 10.0
register.apply_discount # => 8.0
register.total # => 8.0
register.items # => ["apple", "apple", "apple", "carrot", "carrot"]
Our example class

Disregard the strangeness of adding an item to a register: it was a coding exercise her cohort had to do, with a preset collection of tests to make pass. I would change the Register object to be called something like Order, as that seems more like what we're doing here, but let's leave it for now and focus on what I want to talk about: self vs @instance_variable.

If we zoom in on the add_item method, we see this:

def add_item(item_name:, quantity:, price:)
  @total += quantity * price
  quantity.times { @items << item_name }
end

That seconds line looks interesting, let's play with it. What if I replace the @total with total? It should still work right? We've defined total as an accessor method, so it should work. Let's try it.

NoMethodError (undefined method `+' for nil:NilClass)```

Whoops! Wait what? what even is going on? This is subtle, so gather 'round!

total += quantity * price

is a shorthand for

total = total + quantity * price

So what happens is Ruby parses the expression and understands it needs to do assignment. The problem is that Ruby sees that equals sign, decides the left hand side is a local variable, and instantiates that local variable as nil. Then it moves to the right side of the equals sign, and since total is set to nil, we end up with nil + quantity * price, and Ruby throws us this beautiful error saying that it can't call + on NilClass!

So, what to do about this? Well, one, we can do what we did before and access the instance variable @total.  Which is an ok solution. But what happens if we typo it?

def add_item(item_name:, quantity:, price:)
  @totel += quantity * price
  quantity.times { @items << item_name }
end

The same thing!

NoMethodError (undefined method `+' for nil:NilClass)```

Ruby, what gives?!?

Ok, another piece of Ruby knowledge for you: Ruby happily instantiates that misspelled local variable to a value of nil, same as that local variable situation!

Ok, ok, so the moment you've all been waiting for. Surely there must be a fix for this whole mess? Well, there is. We can use the self keyword.

We can override Ruby's decision to instantiate a local variable, and get a better error in the case of misspellings, by explicitly telling Ruby to call the correct instance methods using the self keyword.

Here's what I mean by a better error in the case of a misspelling. Let's make the following change. Oops!

def add_item(item_name:, quantity:, price:)
  self.totel += quantity * price
  quantity.times { @items << item_name }
end
NoMethodError (undefined method `totel' for #<Register...

This is a much more informative error than

NoMethodError (undefined method `+' for nil:NilClass)```

because it immediately informs us that the method we tried to call doesn't exist. In this case, it's a simple typo, which is easy to fix. Let's fix it, and dive deeper!

def add_item(item_name:, quantity:, price:)
  self.total += quantity * price
  quantity.times { @items << item_name }
end

So interestingly enough, this will call two different methods, our total= method, and our total method. Both the getter and the setter! You can validate it yourself by rewriting the attr_accessor :total code,

# This getter and setter are equivalent to:
# attr_accessor :total

def total
  @total
end

def total=(value)
  @total = value
end
  
Our example class

then adding some debugging statements:

def total
  puts "Getting the total"
  @total
end

def total=(value)
  puts "Setting the total"
  @total = value
end
  
Our example class

Let's put it all together:

class Register
  attr_accessor :items

  def total
    puts "Getting the total"
    @total
  end

  def total=(value)
    puts "Setting the total"
    @total = value
  end
  
  def initialize(discount: 0.2)
    @items = []
    @total = 0
    @discount = discount
  end
  
  def add_item(item_name:, quantity:, price:)
    self.total += quantity * price
    quantity.times { @items << item_name }
  end
  
  def apply_discount
    @total *= (1.0 - @discount)
  end
end

register = Register.new
register.add_item(item_name: "apple", quantity: 4, price: 2.0)

If you paste the above into an IRB session (use Ruby 2.7, it allows pasting long code like this), you'll see two puts statements:

Getting the total
Setting the total

Those happen when we call add_item, because remember,

self.total += quantity * price

expands to

self.total = self.total + quantity * price

but wait, what? Why does it call both the getter AND the setter, that doesn't make sense!

It does, if you know a little about how Ruby's syntactic sugar works. What the above statement REALLY says is this:

self.total=(self.total()+quantity*price)

Ruby is invoking the self.total=() method, and passing it the value of self.total()! Which is why we see "Getting the total" first, because it has to fetch the value of total() before it can pass that whole equation to total=()! Pretty neat right? Which brings me to my final point: You only need the self keyword on the left side of the equals sign.

Ruby tries to instantiate a local variable if you use total on the left side of the equals sign. It only gets confused if you use the form total = something. In that case, Ruby will create the local variable total, and that's not at all what we want!

So we could write the above as:

self.total = total + quantity * price

And in fact that is my preferred way to do it, because you get all the benefits of better errors given a misspelling, no extraneous self keywords, and no direct access of instance variables outside of the initialize method.

So, quick recap:

total += something will create a local variable in your instance method called total with the value of nil, then try to add nil + something

@total += something will find or create an instance variable named @total, and assign it the value of @total + something. If you typo @totel, or the value of total is nil, it will end up adding nil + something

self.total += something will call the instance method total=(), and pass it the value of @total + something, which will set @total = @total + something.

The first way will give you difficult to understand nil errors, as will the second. Prefer the third option, and you'll save you and your colleagues some headaches.

Here's what our code looked like after our discussion:

class Register
  attr_accessor :total
  attr_reader :discount, :items
  
  def initialize(discount: 0.2)
    @items = []
    @total = 0
    @discount = discount
  end
  
  def add_item(item_name:, quantity:, price:)
    self.total += quantity * price
    quantity.times { items << item_name }
  end
  
  def apply_discount
    self.total *= (1.0 - discount)
  end
end

register = Register.new
register.add_item(item_name: "apple", quantity: 4, price: 2.0)
register.add_item(item_name: "carrot", quantity: 2, price: 1.0)
register.total # => 10.0
register.apply_discount # => 8.0
register.total # => 8.0
register.items # => ["apple", "apple", "apple", "carrot", "carrot"]