Class-level attributes behave in ways that surprise programmers coming from C++ or Java.
A field declared in the class body, outside any method, is a class attribute. It is easy to misread one as a per-object default value. It is not.
A class attribute creates a single shared variable across all instances of the class.
If you then create an instance variable of the same name, that instance variable shadows the class attribute. In C++ or Java, the language allocates storage for such a field in each object before the constructor runs, which makes this behavior a surprise.
Here’s an example showing why it can be confusing:
# class_attribute_confusion.py
class Stars:
rating = 5 # Shared across all instances
a, b = Stars(), Stars()
print(a.rating, b.rating) # Both read the same storage
#: 5 5
a.rating = 1 # Assigning makes an instance variable on 'a'
print(a.rating, b.rating) # 'a' shadows it, 'b' sees the class
#: 1 5
Stars.rating = 9 # Change the shared storage
print(a.rating, b.rating) # 'b' reads the class attribute
#: 1 9An instance and its class each have their own attribute
dictionary. Reading an attribute checks the instance first, then
falls back to the class. Assigning always writes to the
instance, creating the instance variable on first assignment. To
show this we can inspect the class with vars(A) and
the instance with vars(a):
# inside_objects.py
class A:
x = 100 # Class attribute
a = A()
print(vars(A)["x"]) # The attribute lives in the class dict
#: 100
print(vars(a)) # The instance has no attributes yet
#: {}
a.x = 1
print(vars(a)) # Assignment created it on the instance
#: {'x': 1}
print(vars(A)["x"])
#: 100A class attribute seems like a default until someone assigns to an instance variable of the same name. Changing the class attribute makes the “default” value seem different for every object that has not shadowed it. This produces bugs that surface far from their cause.
When you genuinely want one shared value, say so with
ClassVar from typing. The checker then
treats it as class-wide, and stops you from accidentally
creating an instance variable that shadows it:
# class_var.py
from typing import ClassVar
from display import display_object
class Tally:
total: ClassVar[int] = 0 # A single shared value
label: str # Declared, not yet assigned
def __init__(self, label: str) -> None:
self.label = label
Tally.total += 1
display_object(Tally)
#: [Attributes]
#: • total: typing.ClassVar[int] = 0 [CV]
#: [Methods]
#: None
a = Tally("a")
display_object(a)
#: [Attributes]
#: • label: str = 'a'
#: • total: typing.ClassVar[int] = 1 [CV]
#: [Methods]
#: None
b = Tally("b")
print(Tally.total)
#: 2
# a.total = 99 # ty: cannot assign ClassVar "total" via instancedisplay_object(Tally) shows what the class
actually holds: total, and nothing called
label. An assignment in the class body is what
creates a class attribute, as
class_attribute_confusion.py showed above.
total: ClassVar[int] = 0 has the = 0,
so it exists on Tally before any instance is built.
label: str has no =, so nothing is
stored under that name anywhere on the class. The annotation
only records, in Tally.__annotations__, that a
Tally will eventually carry a label.
That promise is invisible to display_object(),
which reports attributes that exist, not annotations that merely
describe one to come.
display_object(a) tells a different story once
an instance exists. Both label and
total appear: label: str = 'a' and
total: typing.ClassVar[int] = 1. Constructing
a ran self.label = label, which
created a real label attribute on a,
not on Tally. total shows up too, not
because a has its own copy, but because reading an
attribute checks the instance first, then falls back to the
class, the same rule Stars demonstrated earlier in
this chapter.
A bare annotation, one with no assigned value, is a
promise rather than a placeholder. It states that instances of
this class will carry a label attribute of type
str, set somewhere. Here that somewhere is
__init__(), whose self.label = label
is what display_object(a) above actually found. Had
__init__() never assigned it, no attribute would
exist, on the instance or the class. The checker would not catch
the omission, because it trusts the annotation instead of
verifying that every method actually sets it. The failure would
surface later, as an AttributeError from the first
code that reads the missing label.
The annotation on label is not required here.
Delete it, and ty still infers
label: str correctly from
self.label = label, because the parameter’s own
type carries through to the attribute it initializes. It earns
its place for symmetry with total, so the class’s
two attributes read together at the top instead of one hiding
inside the constructor. Simulation shows
the case where the annotation is not optional. There, an
attribute is set from outside the class, and a bare annotation
is the checker’s only way to know its type.
ClassVar is a hint for the checker, not the
runtime. It records that total belongs to the
class, and it catches the accidental shadowing from the earlier
example before it happens.
Subclasses inherit a ClassVar declared on a base
class like any other class attribute. A subclass that doesn’t
declare its own copy reads straight through to the base’s value,
via the normal method resolution order. A subclass that assigns
its own value creates a separate class attribute, independent of
the base and of sibling subclasses:
# class_var_inheritance.py
from typing import ClassVar
class Base:
shared: ClassVar[int] = 0
class Left(Base):
pass
class Right(Base):
shared = 100 # Its own class attr, separate from Base's
print(Left.shared, Right.shared)
#: 0 100
Base.shared = 9 # Only affects subclasses that haven't overridden
print(Left.shared, Right.shared)
#: 9 100
Left.shared = 5 # Creates Left's own attribute, doesn't touch Base
print(Base.shared, Left.shared, Right.shared)
#: 9 5 100Left has no shared of its own, so
it tracks Base.shared until something assigns to
Left.shared directly. Right overrode
shared at class-definition time, so it never sees
changes made through Base. ClassVar
doesn’t change any of this. It only tells the checker that
shared belongs to the class, not that subclasses
share storage.
For real per-object defaults, write a constructor with
default arguments, or use a @dataclass, which turns
the class-attribute syntax into instance variable defaults. Each
object then gets its own storage for instance variables:
# real_defaults.py
from dataclasses import dataclass
class A:
def __init__(self, x: int = 100) -> None:
self.x = x # An instance variable, one per object
@dataclass
class B:
x: int = 100 # Constructor default, not class attribute
a = A()
a.x = -1
print(a.x, A().x) # The change in a does not leak
#: -1 100
print(B().x, B(7).x)
#: 100 7A @dataclass reads the class-attribute
declarations as a template and generates a constructor from
them. Data
Classes as Types covers the details.
class_attribute_confusion.py, add a third
instance c = Stars() after the
Stars.rating = 9 line, and print
c.rating. Predict its value before running, then
explain why it differs from a.rating.class_var_inheritance.py, add a third
subclass class Middle(Base): pass (no override,
like Left) and print Middle.shared
alongside the others at each step. Confirm Middle
tracks Base the way Left does.real_defaults.py, create
b = B() and assign b.x = -1. Then
create a second instance, b2 = B(), and confirm
b2.x is still 100, unaffected.Tally from class_var.py so
total is a plain (non-ClassVar) class
attribute instead, then have an instance assign to
self.total directly and explain, using
vars() as in inside_objects.py, what
that assignment actually creates.