Skip to content

Class variables are shared with subclasses

Level: 201 · a reader who reached for @@count and got a number that belonged to someone else

One line: @@count is one variable, owned by the class that first assigns it and seen by every subclass and every instance — assign it from a subclass and the parent's value changes — whereas a class-level @count belongs to exactly one class.

Ruby has three variables that start with @, and the two-@ kind is the odd one. An instance variable @x belongs to one object. A class-level instance variable — @count written in a class body, where self is the class — belongs to that one class object, and a subclass has its own, unset, @count. A class variable @@count belongs to the hierarchy: the first class to assign it owns the slot, and every subclass, every class method and every instance method below reads and writes that same slot. So Sub.bump doing @@count += 1 changes what Base.count reports, and Sub never gets a slot of its own. Ruby refuses two things that would make the sharing ambiguous: a subclass that defines @@fresh before its superclass does finds its variable overtaken and raises RuntimeError on the next read, and a @@variable at the top level, where the owner would be Object itself, is a RuntimeError too since Ruby 3.0. The program declares @@count in Base and pokes it from a subclass, from instances and from the top level.

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

 1. @@count set in Base, read through Sub                      0
 2. Sub.bump does @@count += 1 -- Base.count / Sub.count       1 / 1
 3. Sub assigns @@count = 100 -- Base.count / Sub's own?       100 / false (class_variables(false))
 4. an instance method reads @@count                           100
 5. an instance method assigns @@count = 7 -- Base.count       7
 6. class-level @count_ivar -- Base's / Sub's                  0 / nil
 7. @@items = []; Sub.push(1) -- Base.items                    [1]
 8. class_variable_get / class_variables / defined?(:@@nope)   7 / [:@@count, :@@items] / false
 9. Base.class_variable_get(:@@nope)                           NameError: uninitialized class variable @@nope in Base
10. @@fresh set in Early, then in Base -- Early.fresh          RuntimeError: class variable @@fresh of Early is overtaken by Base
11. @@count read at the top level, outside any class           RuntimeError: class variable access from toplevel

Reading the output

Row 1 reads @@count through Sub, which never mentions it: 0, Base's value. Row 2 is the claim: Sub.bump increments @@count, and Base.count reports 1, because there is only one slot. Row 3 assigns 100 through Sub — Base sees 100, and Sub.class_variables(false) confirms Sub owns no slot of its own. Rows 4 and 5 show that instances take part in the same sharing: an instance method reads the current value and an instance method's assignment changes what the class reports.

Row 6 is the contrast the page turns on: @count_ivar, a class-level instance variable, is 0 on Base and nil on Sub, because each class object has its own @ slots and nobody set Sub's. Row 7 makes the point that with @@ it does not even matter whether you assign or mutate — the array in @@items is one array. Row 8 is the reflective API, and row 9 the NameError for a slot nobody assigned: uninitialized class variable @@nope in Base.

Rows 10 and 11 are the two guards. Early < Base assigns @@fresh first; when Base assigns @@fresh afterwards, Early's slot is overtaken — reading it is a RuntimeError whose message says exactly that, rather than a silent choice between two values. And @@count evaluated at the top level, outside any class body, is a RuntimeError too: there is no class for it to belong to. Class variables are widely regarded as a feature to avoid in favour of class-level instance variables, precisely because of rows 2, 3 and 10. (Not machine-checked here.)

Compared with Python

A Python class attribute starts out looking the same — row 1 reads Sub.count and gets Base's 0 — and then behaves like the opposite thing. Assignment through a subclass or an instance never touches the original: it creates a new attribute on the subclass or the instance that shadows it. So Sub.bump() (row 2) leaves Base.count at 0 and puts a 1 on Sub; row 3's 'count' in vars(Sub) is True where Ruby's class_variables(false) said false; and row 5's instance assignment lands in vars(b), leaving the class alone. Row 6 has no per-class kind to contrast with: count_ivar is inherited by Sub like everything else, 0 / 0. The one place Python shares the way @@ does is row 7 — items = [] is one list, and append through a subclass shows up on Base, the classic mutable-class-attribute trap. Row 10 needs no guard, because a subclass attribute simply shadows a later superclass one (Early.fresh stays 1), and row 11 has nothing to refuse: a name outside a class is a module global, and typing.ClassVar is an annotation for type checkers whose one run-time effect is that dataclass leaves the field out.

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

 1. count set in Base, read through Sub                        0
 2. Sub.bump() does cls.count += 1 -- Base.count / Sub.count   0 / 1
 3. Sub assigns cls.count = 100 -- Base.count / Sub's own?     0 / True ('count' in vars(Sub))
 4. an instance method reads self.count                        0
 5. an instance method assigns self.count = 7 -- Base.count    0 (vars(b): {'count': 7})
 6. count_ivar -- Base's / Sub's (inherited, not per class)    0 / 0
 7. items = []; Sub.push(1) -- Base.items                      [1]
 8. getattr / names in vars(Base) / hasattr                    0 / ['count', 'count_ivar', 'items'] / False
 9. getattr(Base, "nope")                                      AttributeError
10. fresh set in Early, then in Base -- Early.fresh            1
11. no class variable outside a class; ClassVar in a dataclass ['x'] (registry is a class attribute)

The tutorial's warning about shared mutable class attributes is at Class and Instance Variables ↗, and ClassVar at typing.ClassVar ↗.

Try it

  1. Write a Counter whose initialize bumps a shared @@total and a per-class @count. Subclass it as A and B, create two As and three Bs, and print Counter.total, A.total, A.count, B.count and Counter.count — the first two should agree and the last three should not.
  2. Replace @@total with a class-level @total on Counter, and add a self.inherited(sub) hook that gives each new subclass its own @count = 0, so that A.count works before any A exists. (Hooks measures inherited.)
Solution to 1

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

# Exercise 1: a Counter with one shared @@total and a per-class @count.

class Counter
  @@total = 0

  def self.total = @@total
  def self.count = @count || 0

  def initialize
    @@total += 1                       # shared by every subclass
    self.class.instance_variable_set(:@count, self.class.count + 1)   # this class only
  end
end

class A < Counter; end
class B < Counter; end

2.times { A.new }
3.times { B.new }

puts "Counter.total: #{Counter.total}"
puts "A.total:       #{A.total}"
puts "A.count:       #{A.count}"
puts "B.count:       #{B.count}"
puts "Counter.count: #{Counter.count}"

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

Counter.total: 5
A.total:       5
A.count:       2
B.count:       3
Counter.count: 0

See also