10.1 Classes and Objects
A class is a blueprint — it describes what data and behavior something of that kind
will have, but doesn't itself represent any particular one. An object (or
instance) is one concrete thing built from that blueprint, with its own independent
copy of the data the blueprint describes. Every value seen since chapter 1 (int,
list, dict, ...) is already an object of some class — this chapter is about defining
new ones.
class Dog:
pass # the simplest possible class - a blueprint with nothing on it yet
d1 = Dog() # calling the class creates a new instance
d2 = Dog()
print(type(d1)) # <class '__main__.Dog'>
print(isinstance(d1, Dog)) # True
print(d1 is d2) # False -> two separate objects, not the same one
print(id(d1) != id(d2)) # True -> distinct identities (2.4)
<class '__main__.Dog'> True False True
In practice — this is literally what a Django or SQLAlchemy model is. A real
e-commerce backend's User, Order, and Product classes are exactly this pattern —
a blueprint for one kind of domain entity, with each instance holding one specific
record's own data — just with a framework layered on top that also maps each class to
a database table (18.7) instead of only living in memory.
Class docstrings — a string literal placed first in the class body becomes its
__doc__, readable via ClassName.__doc__ or the interactive help():
class A:
"""This is the doc string."""
print(A.__doc__) # This is the doc string.
# help(A) shows the same text, formatted alongside the class's attributes
This is the doc string.
What self actually refers to — inside a method, self isn't a special keyword,
just a reference (2.4) to whichever instance the method was called on. It's the exact
same object as the one the caller is holding — id(obj) == id(self), for that call:
class Dog:
def __init__(self, name):
self.name = name
def show_self_identity(self):
return id(self)
d1 = Dog("Rex")
d2 = Dog("Fido")
print(id(d1) == d1.show_self_identity()) # True -> self, during this call, IS d1
print(id(d1) == d2.show_self_identity()) # False -> during THIS call, self is d2 instead
True False
10.2 Attributes: Instance, Class and Dynamic
Inside a class, a name can be one of three kinds:
| Kind | Where it lives | Varies per object? |
|---|---|---|
| Local variable | inside a method/constructor, with no self./class-name prefix |
n/a — gone the moment that call ends |
| Instance attribute | on self (usually in __init__, 10.4) |
yes — every instance has its own |
| Class attribute | directly in the class body | no — one copy, shared by every instance |
Local variables are the simple case — they work exactly like a local variable in any other function (6.8.3), attached to nothing but that one call:
class Dog:
def __init__(self, name):
self.name = name
def greet(self):
greeting = f"Woof, I'm {self.name}" # local variable - no self., no class name
print(greeting)
d = Dog("Rex")
d.greet() # Woof, I'm Rex
print(vars(d)) # {'name': 'Rex'} -> "greeting" never became part of the object at all
try:
print(d.greeting)
except AttributeError as e:
print("AttributeError:", e) # 'Dog' object has no attribute 'greeting'
Woof, I'm Rex
{'name': 'Rex'}
AttributeError: 'Dog' object has no attribute 'greeting'Instance and class attributes need more care, since both survive past a single
call and a name can even mean either one depending on where it's looked up from.
Looking up obj.name checks the instance's own attributes first; only if it's not
found there does Python fall back to the class:
d1.species
│
▼
in d1's own __dict__?
├── yes → use that value
└── no → look on the class instead (Dog.__dict__)
class Dog:
species = "Canis familiaris" # class attribute - one copy, shared
def __init__(self, name):
self.name = name # instance attribute - unique per object
d1 = Dog("Rex")
d2 = Dog("Fido")
print(d1.name, d2.name) # Rex Fido
print(d1.species, d2.species) # Canis familiaris Canis familiaris
print(d1.species is d2.species) # True -> same shared object
d1.species = "Wolf-adjacent" # this creates a NEW instance attribute on d1
print(d1.species, d2.species) # Wolf-adjacent Canis familiaris -> d1 now shadows the class attribute
Rex Fido Canis familiaris Canis familiaris True Wolf-adjacent Canis familiaris
Common mistake — a mutable class attribute. Reassigning d1.species above created a
new instance attribute, leaving the shared one alone. Mutating a shared mutable
object, though, changes it for everyone — there's only one list, no matter how many
instances point at it:
class Team:
members = [] # BUG: one list, shared by every instance
def add(self, name):
self.members.append(name)
t1, t2 = Team(), Team()
t1.add("Ada")
print(t2.members) # ['Ada'] -> t2 sees t1's addition too, since it's the SAME list
class TeamFixed:
def __init__(self):
self.members = [] # each instance builds its OWN list
def add(self, name):
self.members.append(name)
f1, f2 = TeamFixed(), TeamFixed()
f1.add("Ada")
print(f2.members) # [] -> independent now
['Ada'] []
Dynamic attributes — by default, an object's set of attributes isn't fixed by the
class; new ones can be attached at any time, from anywhere, not just inside the class
body. Each instance's attributes live in its own __dict__ — vars(obj) is just a
shorthand for reading it:
rex = Dog("Rex")
print(vars(rex)) # {'name': 'Rex'}
rex.age = 3 # never declared anywhere in the class - added on the fly
print(vars(rex)) # {'name': 'Rex', 'age': 3}
print(rex.__dict__ is vars(rex)) # True -> vars() is just a shortcut for __dict__
print(list(Dog.__dict__.keys())) # the class's own dict never gets "age" - only rex has it
{'name': 'Rex'}
{'name': 'Rex', 'age': 3}
True
['__module__', '__firstlineno__', 'species', '__init__', '__static_attributes__', '__dict__', '__weakref__', '__doc__']__slots__ is the opt-out of that dynamic behavior. Declaring __slots__ = (...)
in a class body tells Python the exact, fixed set of attribute names instances of
this class are allowed to have — nothing else can be attached later, not even from
inside the class's own methods. As a trade-off, instances no longer get a __dict__ at
all (their attributes are stored in a fixed-size internal layout instead), which is
also why __slots__ classes use less memory per instance:
class Vector:
__slots__ = ("x", "y") # only these two attribute names are allowed, ever
def __init__(self, x, y):
self.x = x
self.y = y
v = Vector(1, 2)
print(v.x, v.y) # 1 2
try:
v.z = 3 # not declared in __slots__ -> contrast with Dog's dynamic rex.age = 3 above
except AttributeError as e:
print("AttributeError:", e) # 'Vector' object has no attribute 'z'
try:
print(v.__dict__)
except AttributeError as e:
print("AttributeError:", e) # 'Vector' object has no attribute '__dict__' -> there isn't one at all
1 2 AttributeError: 'Vector' object has no attribute 'z' and no __dict__ for setting new attributes AttributeError: 'Vector' object has no attribute '__dict__'
In practice — high-volume object creation. A data pipeline that constructs one
object per row while processing millions of records, or a game engine creating an
object per particle/entity, are cases where each instance's __dict__ overhead
genuinely adds up — __slots__ is a standard, real technique for cutting per-instance
memory in exactly this kind of hot path.
An instance attribute doesn't have to be created in __init__ — assigning to
self.x in any method creates it, the first time that method actually runs. Until
then, it simply doesn't exist yet on that instance:
class Dog:
def __init__(self, name):
self.name = name
def start_training(self):
self.trained = True # only exists on this instance AFTER this method runs
d = Dog("Rex")
try:
print(d.trained)
except AttributeError as e:
print("AttributeError:", e) # 'Dog' object has no attribute 'trained'
d.start_training()
print(d.trained) # True -> now it exists
AttributeError: 'Dog' object has no attribute 'trained' True
hasattr() / getattr() / setattr() are the idiomatic, functional alternative to
writing out a try/except AttributeError like the one just above — useful whenever
the attribute name is dynamic, or a one-line check/default reads more clearly than a
whole try block:
d = Dog("Rex")
print(hasattr(d, "trained")) # False -> hasn't been set yet, no exception needed to find out
print(getattr(d, "trained", False)) # False -> same idea as the try/except above, as one expression
setattr(d, "trained", True) # equivalent to d.trained = True
print(hasattr(d, "trained")) # True
print(getattr(d, "trained")) # True -> no default needed now that it exists
False False True True
Removing an instance attribute — del obj.name (2.5) removes it from that
instance's __dict__ only. If a class attribute of the same name exists, the lookup
diagram above means the object falls back to seeing that instead, instead of
disappearing entirely:
class Dog:
species = "Canis familiaris" # class attribute
def __init__(self, name):
self.name = name
self.species = "Dingo" # instance attribute, shadows the class one
d = Dog("Rex")
print(d.species) # Dingo -> instance attribute wins
del d.species # removes only the INSTANCE attribute
print(d.species) # Canis familiaris -> falls back to the class attribute
try:
del d.species # nothing left at the instance level to delete anymore
except AttributeError as e:
print("AttributeError:", e) # 'Dog' object has no attribute 'species'
Dingo Canis familiaris AttributeError: 'Dog' object has no attribute 'species'
That was deletion from outside the class, via an object reference. The same thing
can happen from inside a method too, via self — a method can deliberately forget
one of its own instance's attributes:
class Dog:
def __init__(self, name):
self.name = name
def forget_name(self):
del self.name # removing an instance attribute from WITHIN the class
d = Dog("Rex")
print(d.name) # Rex
d.forget_name()
try:
print(d.name)
except AttributeError as e:
print("AttributeError:", e) # 'Dog' object has no attribute 'name'
Rex AttributeError: 'Dog' object has no attribute 'name'
Where a class attribute can be declared, read, and modified — it can be touched from every kind of method (10.3), each with its own natural spelling:
| Context | How to reach the class attribute |
|---|---|
| directly in the class body | just the bare name |
inside __init__ |
ClassName.attr (self is for instance attributes, not this) |
| inside an instance method | self.attr to read (falls back per the lookup diagram), ClassName.attr to modify |
inside a @classmethod |
cls.attr |
inside a @staticmethod |
ClassName.attr (no self/cls available at all) |
| from outside the class | ClassName.attr (correct) — instance.attr = x looks the same but silently creates a shadowing instance attribute instead (10.2's mutable-attribute gotcha) |
class Counter:
total = 0 # declared directly in the class body
def __init__(self):
Counter.total += 1 # modified inside __init__, via the class name
def show(self):
print(self.total, Counter.total) # read via self OR the class name - both work
@classmethod
def reset(cls):
cls.total = 0 # modified inside a classmethod, via cls
@staticmethod
def describe():
return f"seen {Counter.total} so far" # only the class name is available here
c1 = Counter()
c2 = Counter()
c1.show() # 2 2
print(Counter.describe()) # seen 2 so far
Counter.reset()
print(Counter.total) # 0 -> reset via cls affected the real, shared attribute
c1.total = 99 # looks like the same kind of assignment, but does NOT modify the shared value
print(c1.total, c2.total, Counter.total) # 99 0 0 -> only c1 now has its own shadowing instance attribute
2 2 seen 2 so far 0 99 0 0
Reading (not writing) a class attribute through an object reference is fine — the shadowing problem above only happens on assignment. A plain read falls through to the class attribute exactly as the lookup diagram (10.2) describes, as long as that instance has no attribute of its own by that name yet:
c3 = Counter() # no instance attribute named "total" was ever set on this one
print(c3.total) # 1 -> no shadow, so the read falls through to the (just-reset) class attribute
print(c3.total is Counter.total) # True -> genuinely the same shared object, not a copy
1 True
10.3 Methods: Instance, Class and Static
| Kind | Decorator | Implicit first argument | Typical use |
|---|---|---|---|
| Instance method | none | self — the calling instance |
operates on one object's own state |
| Class method | @classmethod |
cls — the class itself |
alternative constructors; operates on class-level state |
| Static method | @staticmethod |
none | a plain function that's just logically grouped with the class |
class Dog:
population = 0
def __init__(self, name):
self.name = name
Dog.population += 1
def bark(self): # instance method - needs a specific dog
return f"{self.name} says Woof!"
@classmethod
def from_greeting(cls, greeting): # classmethod - an alternative constructor
name = greeting.split()[-1]
return cls(name)
@classmethod
def count(cls):
return cls.population
@staticmethod
def is_valid_name(name): # staticmethod - no self/cls, just grouped here
return isinstance(name, str) and len(name) > 0
rex = Dog("Rex")
fido = Dog.from_greeting("Hi there, Fido")
print(rex.bark()) # Rex says Woof!
print(fido.name) # Fido
print(Dog.count()) # 2
print(Dog.is_valid_name("Rex"), Dog.is_valid_name("")) # True False
Rex says Woof! Fido 2 True False
In practice — Django's Model.objects.create(...). Alternate-constructor
classmethods like from_greeting above are exactly the pattern behind Django's ORM
convenience methods and the standard library's own datetime.fromisoformat(...) — a
named, purpose-built way to build an instance from a specific kind of input, alongside
(not instead of) the plain __init__.
What self actually is — rex.bark() is really shorthand for Dog.bark(rex):
accessing a method through an instance automatically supplies that instance as the
first argument, producing a "bound method." Accessed through the class directly, it's
just an ordinary function, and self has to be passed explicitly:
Note: a "bound method" just means the instance is already baked into the
callable — obj.method (no parens, not called yet) is itself a ready-to-call object
that remembers obj, so calling it needs no self argument. Class.method, in
contrast, is still a plain, unbound function — it needs an instance passed in
explicitly, as the demo below shows.
print(rex.bark()) # Rex says Woof!
print(Dog.bark(rex)) # Rex says Woof! -> identical, self passed explicitly this time
print(type(rex.bark)) # <class 'method'> -> bound to rex specifically
print(type(Dog.bark)) # <class 'function'> -> just a plain function on the class
Rex says Woof! Rex says Woof! <class 'method'> <class 'function'>
10.4 Constructors and Initialization
Creating an instance is actually two steps: __new__ creates the (empty) object,
then __init__ initializes it — sets up its starting attributes. __init__ is
what gets overridden almost all the time; __new__ is rarely touched (it matters more
for immutable types or metaclasses, both out of scope here) but seeing the order makes
the split clear:
class Dog:
def __new__(cls, *args, **kwargs):
print("__new__ creates the instance")
return super().__new__(cls)
def __init__(self, name):
print("__init__ initializes it")
self.name = name
d = Dog("Rex")
# __new__ creates the instance
# __init__ initializes it
print(d.name) # Rex
__new__ creates the instance __init__ initializes it Rex
Note: super() (covered fully in chapter 12) reaches up to the next class
in the inheritance chain — here, plain object itself — so super().__new__(cls) is
what actually allocates the instance's memory; everything above just adds behavior
around that.
__init__ is an ordinary function otherwise — it can take default argument values
(6.2), just like any other function, making some constructor arguments optional:
class Dog:
def __init__(self, name, breed="unknown"):
self.name = name
self.breed = breed
d1 = Dog("Rex", "Labrador")
d2 = Dog("Fido") # breed omitted -> falls back to the default
print(d1.name, d1.breed) # Rex Labrador
print(d2.name, d2.breed) # Fido unknown
Rex Labrador Fido unknown
Common mistake — Python has no constructor overloading. A class body is just
executed top to bottom (9.1's "module is just code that runs" idea, applied to a class):
defining __init__ a second time doesn't add an overload, it simply replaces the
first definition, which is now gone entirely. classmethod-based factories
(from_greeting in 10.3) are the idiomatic way to offer more than one way to build an
object:
class Dog:
def __init__(self, name): # this definition...
self.name = name
def __init__(self, name, breed): # ...is completely replaced by this one
self.name = name
self.breed = breed
d = Dog("Rex", "Labrador") # only the two-argument version exists at all now
print(d.name, d.breed) # Rex Labrador
try:
Dog("Fido") # the one-argument version is simply gone
except TypeError as e:
print("TypeError:", e) # Dog.__init__() missing 1 required positional argument: 'breed'
Rex Labrador TypeError: Dog.__init__() missing 1 required positional argument: 'breed'
10.5 Encapsulation and Access Control
Python has no true "private" attribute that the language physically blocks access to — access control here is convention plus one small mechanical trick:
| Naming | Convention | Actually enforced? |
|---|---|---|
name |
public — part of the intended interface | no restriction at all |
_name |
"internal use" — a hint to other developers, not to Python | no restriction; still fully accessible |
__name |
"private" | name-mangled to _ClassName__name, mainly to avoid accidental clashes in subclasses |
class Account:
def __init__(self, balance):
self.balance = balance # public
self._pin = "1234" # _protected - convention only
self.__secret = "vault-key" # __private - name-mangled
a = Account(100)
print(a.balance) # 100
print(a._pin) # 1234 -> still fully accessible, Python doesn't stop this
try:
print(a.__secret)
except AttributeError as e:
print("AttributeError:", e) # 'Account' object has no attribute '__secret'
print(a._Account__secret) # vault-key -> the mangled name still works
print(vars(a)) # {'balance': 100, '_pin': '1234', '_Account__secret': 'vault-key'}
100
1234
AttributeError: 'Account' object has no attribute '__secret'
vault-key
{'balance': 100, '_pin': '1234', '_Account__secret': 'vault-key'}10.6 Properties and Managed Attributes
A plain attribute accepts anything, silently — there's no way to add a rule later
without changing every caller from obj.attr = x to something like obj.set_attr(x):
class Circle:
def __init__(self, radius):
self.radius = radius # plain attribute, no validation at all
c = Circle(5)
c.radius = -10 # silently accepted, even though a negative radius makes no sense
print(c.radius) # -10
-10
@property fixes this without changing the calling syntax at all — obj.attr and
obj.attr = x still look identical to the caller, but now run actual code: a getter, a
setter that can validate, and (optionally) a read-only computed value with no setter:
class Temperature:
def __init__(self, celsius):
self.celsius = celsius # goes through the setter below, even here in __init__
@property
def celsius(self):
return self._celsius
@celsius.setter
def celsius(self, value):
if value < -273.15:
raise ValueError("temperature below absolute zero")
self._celsius = value
@property
def fahrenheit(self): # read-only - computed on the fly, no setter defined
return self._celsius * 9 / 5 + 32
t = Temperature(25)
print(t.celsius, t.fahrenheit) # 25 77.0
t.celsius = 30 # looks like a plain assignment, actually calls the setter
print(t.celsius, t.fahrenheit) # 30 86.0
try:
t.celsius = -300
except ValueError as e:
print("ValueError:", e) # temperature below absolute zero
try:
t.fahrenheit = 100
except AttributeError as e:
print("AttributeError:", e) # property 'fahrenheit' of 'Temperature' object has no setter
25 77.0 30 86.0 ValueError: temperature below absolute zero AttributeError: property 'fahrenheit' of 'Temperature' object has no setter
In practice — validating a value the moment it's set. An e-commerce Product.price
property that rejects a negative value, or an IoT ingestion pipeline's sensor-reading
class that rejects a physically implausible measurement, uses exactly this pattern —
catching bad data at the single point it enters the object, rather than trusting every
caller to check it first.
Going deeper. fahrenheit above is cheap, so recomputing it on every access is
fine. For a computed property that's genuinely expensive (a database query, a heavy
calculation) and whose backing data doesn't change, functools.cached_property is the
natural next step: it behaves like @property, but stores the result in the instance's
__dict__ the first time it's accessed and just returns that cached value from then
on — no @x.setter boilerplate needed to make that caching happen:
from functools import cached_property
class Report:
def __init__(self, rows):
self.rows = rows
self.compute_count = 0
@cached_property
def total(self):
self.compute_count += 1 # would tick up on every access, with a plain @property
return sum(self.rows)
r = Report([1, 2, 3, 4])
print(r.total) # 10
print(r.total) # 10 again
print(r.compute_count) # 1 -> only computed once, cached in r.__dict__["total"] after that
10 10 1
A property can also define a deleter, run when del obj.attr (2.5) is used on it —
useful for "clearing" a value instead of leaving it undefined:
class Session:
def __init__(self, token):
self._token = token
@property
def token(self):
return self._token
@token.deleter
def token(self):
print("clearing the token")
self._token = None
s = Session("abc123")
del s.token # clearing the token
print(s.token) # None
clearing the token None
10.7 Class Design Basics
A class earns its place when data and the behavior that acts on it belong together —
without that, passing the same values between plain functions works just as well and
adds less machinery. That "belong together" quality is cohesion (the demo below
even calls its class-based version the "cohesive" one); its usual counterpart is
coupling — how much a class, or its callers, has to know about some other class's
internals to get anything done. The goal is high cohesion, low coupling: each class's
own responsibilities packed tightly together, with as little reaching into other
classes' internals as possible. Compare passing a dict around versus bundling the
data and its own method on one object:
# "loose" version: a function has to be handed the same pieces of data every time
def make_point(x, y):
return {"x": x, "y": y}
def distance(p1, p2):
return ((p1["x"] - p2["x"]) ** 2 + (p1["y"] - p2["y"]) ** 2) ** 0.5
p1, p2 = make_point(0, 0), make_point(3, 4)
print(distance(p1, p2)) # 5.0
# cohesive version: the data and the behavior that acts on it live on one object
class Point:
def __init__(self, x, y):
self.x = x
self.y = y
def distance_to(self, other):
return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5
a, b = Point(0, 0), Point(3, 4)
print(a.distance_to(b)) # 5.0
5.0 5.0
A few rules of thumb that keep classes easy to reason about:
- Single responsibility — a class should represent one concept (
Point, notPointAndFileLoggerAndValidator). If describing what it does needs "and," it's probably two classes. - Don't reach for a class just to hold data with no behavior — a plain
dict,tuple, or (10.9) adataclassis simpler when nothing actually needs to act on that data as a unit. - Favor a few well-named methods over exposing raw internals — how
Pointstores its coordinates can change later without breaking callers, as long asdistance_to()keeps working the same way. - How objects relate to each other (one object containing another, one object being a kind of another) is covered in depth in chapters 11 and 12.
10.9 Additional Object-Oriented Programming Concepts
Overflow bucket for this chapter — small or unclassified items that clearly belong to this domain but not yet to a specific section above.
dataclasses — a plain class holding just a few fields (10.7's "data with no
behavior" case) still needs __init__, a readable __repr__, and value-based __eq__
written by hand every time. @dataclass (stdlib) generates all three from type-annotated
field declarations alone:
from dataclasses import dataclass
@dataclass
class Point:
x: float
y: float
class PlainPoint: # the hand-written equivalent, minus the boilerplate methods
def __init__(self, x, y):
self.x = x
self.y = y
p1, p2 = Point(3, 4), Point(3, 4)
pp1, pp2 = PlainPoint(3, 4), PlainPoint(3, 4)
print(p1) # Point(x=3, y=4) -> auto-generated, readable __repr__
print(p1 == p2) # True -> auto-generated __eq__, compares fields
print(pp1) # <__main__.PlainPoint object at 0x...> -> default repr, not useful
print(pp1 == pp2) # False -> default __eq__ falls back to identity (2.4)
Point(x=3, y=4) True <__main__.PlainPoint object at 0x7f2fc8da4980> False
Remember the Team.members = [] bug from 10.2 — a mutable class attribute shared by
every instance? @dataclass refuses to let that happen by accident: giving a field a
bare mutable default (a list, dict, or set literal) is a ValueError at class
definition time, not a subtle runtime surprise. The fix is field(default_factory=...)
— a zero-argument callable that's invoked fresh for each new instance:
from dataclasses import field
try:
@dataclass
class Team:
name: str
members: list = [] # same bug as 10.2's Team.members, just dataclass-flavored
except ValueError as e:
print("ValueError:", e)
# ValueError: mutable default <class 'list'> for field members is not allowed: use default_factory
@dataclass
class TeamFixed:
name: str
members: list = field(default_factory=list) # called fresh for every instance
t1, t2 = TeamFixed("Alpha"), TeamFixed("Beta")
t1.members.append("Ada")
print(t1.members, t2.members) # ['Ada'] [] -> independent lists, no shared state
ValueError: mutable default <class 'list'> for field members is not allowed: use default_factory ['Ada'] []
In practice — the standard shape of a DTO (data transfer object). An API's request
body, a config object loaded at startup, or one row of processed data moving through an
ETL pipeline is very often defined as exactly this: a small @dataclass carrying typed
fields between layers of a system, with no behavior beyond holding the data itself.
Every class inherits from object, even one declared with no base class at all —
object is the root of the class tree (12 covers inheritance and that tree in depth) and
is where the default __repr__/__eq__/__new__ seen throughout this chapter actually
come from:
class Foo:
pass
print(Foo.__bases__) # (<class 'object'>,)
print(issubclass(Foo, object)) # True
print(isinstance(Foo(), object)) # True
(<class 'object'>,) True True