Ruby's 'self' Versus 'instance variable'
I recently had the pleasure of pairing with my cousin, who just finished her second
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"]
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
then adding some debugging statements:
def total
puts "Getting the total"
@total
end
def total=(value)
puts "Setting the total"
@total = value
end
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"]