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¶
- Write a
Counterwhoseinitializebumps a shared@@totaland a per-class@count. Subclass it asAandB, create twoAs and threeBs, and printCounter.total,A.total,A.count,B.countandCounter.count— the first two should agree and the last three should not. - Replace
@@totalwith a class-level@totalonCounter, and add aself.inherited(sub)hook that gives each new subclass its own@count = 0, so thatA.countworks before anyAexists. (Hooks measuresinherited.)
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.
See also¶
- Instance variables are private — the one-
@kind, which belongs to one object - Constants are found lexically, then through ancestors — the other class-level name, with a lookup rule of its own
- Singleton classes — where a class-level
@countandclass << selfaccessors actually live - Mutex, Queue and ConditionVariable — a
@@count += 1from two threads is the lost update; this is the lock - The lost update ↗ — the same shared-counter hazard, measured in six languages
constandstatic↗ — Rust's one shared program-wide slot, and the rules it must obey to be mutated