Skip to content

Instance variables are private

Level: 101 · a Python programmer meeting @name for the first time

One line: An @variable belongs to one object and can be read or written only by that object's own methods — from outside there is nothing to call until the class defines a reader or a writer.

In Ruby, @owner is not an attribute of the object in the Python sense. It is a slot that only code running as that object (self) can name; acct.owner is a method call, and if the class has not defined a method called owner the call fails with NoMethodError, exactly as any other unknown method would. attr_reader :owner writes that one-line method for you, attr_writer writes owner=, and attr_accessor writes both. The privacy is not a lock: instance_variable_get(:@owner) reads any slot from outside, instance_variable_set writes one, and instance_variables lists them — but you have to ask for the back door by name, so an accidental read cannot happen. Two more facts fall out of the same design: an @variable that was never assigned reads as nil rather than raising, and any object accepts a new @variable at any moment, because a slot is created by its first assignment. The program below builds an Account and adds the readers one at a time.

Verified output of instance_variables_are_private_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

 1. acct.owner with no reader defined                  NoMethodError: undefined method 'owner' for an instance of Account
 2. after attr_reader :owner, acct.owner               "ann"
 3. after attr_accessor :balance, acct.balance = 10    10
 4. acct.owner = "bob" with no writer                  NoMethodError: undefined method 'owner=' for an instance of Account
 5. the back door: instance_variable_get(:@owner)      "ann"
 6. acct.instance_variables                            [:@owner, :@balance]
 7. unset @nickname read inside a method               nil (instance_variable_defined? false)
 8. @__secret is stored under its own name             :@__secret
 9. a new ivar added after the fact                    true (no way to forbid it)
10. two instances, two sets of ivars                   ann / bob (balance of other: nil)

Reading the output

Row 1 is the claim: @owner is set, but acct.owner is a method call and no such method exists yet, so Ruby reports undefined method 'owner'. Rows 2 and 3 reopen the class and add attr_reader :owner and attr_accessor :balance; the same calls now answer. Row 4 shows the other half — with a reader but no writer, acct.owner = "bob" is a call to a method named owner=, which does not exist either.

Rows 5 and 6 are the back door: instance_variable_get(:@owner) reads the slot without a reader, and instance_variables lists the slots in the order they were first assigned. Row 7 reads @nickname, which no method ever assigned: the value is nil, no warning, no error, and instance_variable_defined? says false. (Ruby 2.x printed a warning for this under -w; 3.0 removed it.) (Not machine-checked here.)

Row 8 is for the reader coming from Python: a leading double underscore means nothing to Ruby — @__secret is stored under its own name. Row 9 shows that a new slot can be added after construction, and that there is no __slots__-style way to forbid it. Row 10 confirms that each instance has its own set: other was built after the class gained balance, but its @balance was never assigned, so it reads nil.

Compared with Python

Python attributes are public: the twin's row 1 reads acct._owner from outside and gets 'ann'. The leading underscore is a convention that tools and readers respect, not something the interpreter enforces. The reader Python adds when it wants control is @property (row 2), and assigning to a property that has no setter is an AttributeError (row 4). No declaration is needed for a plain attribute (row 3), and the back door is simply the instance dictionary, vars(acct) (rows 5 and 6). The two languages diverge on an unset name: Python raises AttributeError where Ruby returns nil (row 7). Python does have a mechanism that looks like privacy: a name with two leading underscores is mangled inside a class body, so self.__secret is stored as _Account__secret (row 8) — still reachable, just hard to hit by accident. And where Ruby can never forbid a new slot, a class with __slots__ refuses one (row 9). The twin prints exception types only, because CPython rewords the messages between releases.

Verified output of instance_variables_are_private_py.py — regenerated by tools/run_examples.py, never hand-typed.

 1. acct._owner with no reader defined                 'ann'
 2. the reader: @property owner, acct.owner            'ann'
 3. no declaration needed, acct.balance = 10           10
 4. acct.owner = "bob" on a property with no setter    AttributeError
 5. the back door: vars(acct)["_owner"]                'ann'
 6. list(vars(acct))                                   ['_owner', 'balance']
 7. unset acct.nickname read from outside              AttributeError (hasattr False)
 8. self.__secret is stored under a mangled name       '_Account__secret'
 9. a new attribute added after the fact               True on Account; AttributeError on a __slots__ class
10. two instances, two sets of attributes              ann / bob (balance of other: AttributeError)

The Python tutorial's own words on this are under Private Variables ↗; @property is documented at property ↗.

Try it

  1. Write a Temperature class that stores @celsius with a reader, a computed fahrenheit reader, and a celsius= writer that raises ArgumentError for a value below absolute zero. Print instance_variables, then show that the writer rejects -300 and that fahrenheit= does not exist.
  2. Reimplement attr_reader as a class method my_reader(*names) using define_method and instance_variable_get, and check with instance_methods(false) that it defines the same methods the real one does. (define_method has the tools.)
Solution to 1

instance_variables_are_private_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Exercise 1: a Temperature that keeps @celsius private, exposes celsius and a
# computed fahrenheit reader, and refuses a value below absolute zero.

class Temperature
  ABSOLUTE_ZERO = -273.15

  attr_reader :celsius

  def initialize(celsius)
    self.celsius = celsius            # go through the writer so the check runs
  end

  def celsius=(value)
    raise ArgumentError, "#{value} is below absolute zero" if value < ABSOLUTE_ZERO

    @celsius = value
  end

  def fahrenheit = @celsius * 9.0 / 5 + 32

  def inspect = "#<Temperature #{@celsius} C>"
end

t = Temperature.new(25)
puts "celsius:               #{t.celsius}"
puts "fahrenheit:            #{t.fahrenheit}"
puts "instance_variables:    #{t.instance_variables.inspect}"

t.celsius = 100
puts "after t.celsius = 100: #{t.fahrenheit}"

begin
  t.celsius = -300
rescue ArgumentError => e
  puts "t.celsius = -300:      #{e.class}: #{e.message}"
end
puts "still:                 #{t.inspect}"

begin
  t.fahrenheit = 0
rescue NoMethodError => e
  puts "t.fahrenheit = 0:      #{e.class} (no writer was defined)"
end

Verified output of instance_variables_are_private_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

celsius:               25
fahrenheit:            77.0
instance_variables:    [:@celsius]
after t.celsius = 100: 212.0
t.celsius = -300:      ArgumentError: -300 is below absolute zero
still:                 #<Temperature 100 C>
t.fahrenheit = 0:      NoMethodError (no writer was defined)

See also