S009 · Classes & Objects — the OOP Mental Model
Classes in Python are not what they are in Java. Everything is public, `self` is explicit, dunder methods are the interface. This session installs the mental model of what a class actually IS in Python — plus the three patterns you'll use daily.
🎯 Model real-world things as classes on purpose, know when NOT to (a function is enough), and read any Python class in 60 seconds.
Why this session exists
Every dev has heard of OOP. Very few Python devs use it well. The extremes are equally painful: (1) writing Java in Python — abstract base classes, getters, setters, factories, five levels of inheritance for a script that reads a CSV; (2) writing procedural code that would be five lines clearer as a class with two methods. The truth is Python's OOP is small: a class is a namespace with self, instances share methods, and a handful of __dunder__ hooks let you plug into the language. This session installs the actual model — and the taste to know when to reach for a class vs a function.
- Explain what happens when you write `obj = MyClass(x)` — object creation, __init__, method lookup.
- Distinguish class attributes vs instance attributes and predict which will bite you.
- Implement the dunder methods that matter: __init__, __repr__, __eq__, __hash__, __len__, __iter__, __enter__/__exit__.
- Know when a class is the right tool and when a function or a dataclass is better.
- Read a class you didn't write in under 60 seconds and predict how to use it.
Prerequisites
- S005 · Variables & Types — references and mutability apply to
self. - S007 · Functions — methods are functions with
self. - S008 · Data Structures — you'll implement dict-like and list-like objects.
(a) Intuition · 5 min
A class is like a car blueprint. It describes wheels, an engine, a horn — the shape of a car. An instance is an actual car built from that blueprint: your specific car has a specific paint job, mileage, and a horn you can honk.
Every car built from the same blueprint has the same horn behaviour (‘method’) but its own colour, mileage, current speed (‘attributes’). Change one car's colour — the blueprint and other cars are untouched.
Formally: a class is a callable object that, when called (MyClass()), creates a new instance — a fresh namespace whose __class__ points back to the class. Methods live on the class; each call passes the instance as self.
That's the whole trick. No ‘private’ keyword, no verbose constructors, no factory patterns — just a namespace + a rule for how method calls dispatch.
The three ideas that unlock Python OOP
- Everything is public. Python has no `private` keyword. Convention: `_name` is ‘for internal use’, `__name` triggers name-mangling. Reviewers use their eyes, not the compiler.
- `self` is explicit and always the first parameter of every instance method. `MyClass.method(instance, ...)` and `instance.method(...)` are the same call.
- Dunder methods (`__init__`, `__repr__`, `__eq__`, etc.) are how you plug into Python's syntax. Implement them and your class behaves like a built-in.
A quick history so you know why the world looks like this
- 1991Python 0.9.0 — classesGuido borrows classes from Modula-3 and Simula. `self` is explicit because Guido preferred readability over Java's implicit `this`.
- 2001New-style classes (Python 2.2)`class Foo(object):` unifies types and classes. Enables super(), MRO, __slots__.
- 2004Decorators arrive@staticmethod, @classmethod, @property become the standard way to add behaviour to a class.
- 2017@dataclass (PEP 557)Python 3.7 ships the boilerplate-killer. `class → @dataclass` for anything that's ‘just data’.
- 2022PEP 673 · Self typeType checkers finally have a `Self` type for fluent APIs and `from_json` classmethods.
(b) Visual walkthrough · 15 min
What actually happens when you write p = Point(3, 4)
Three steps every time. You override init 99% of the time; new only for tricks like singletons or immutable subclasses.
Attributes: class vs instance
Shared by all instances
- class Dog: species = 'canis'
- All dogs share species
- Great for constants and defaults
- MUTATION at instance level creates an instance attribute that shadows
- NEVER use for mutables (list, dict, set)
Per-instance state
- def __init__(self): self.name = 'rex'
- Each instance has its own
- The default place for state that varies
- Set in __init__; type-annotate at class level for editor support
- Mutables MUST go here
The dunder methods that make a class ‘Pythonic’
Implement these and your class behaves like a built-in
The three method flavours (@staticmethod, @classmethod, @property)
def m(self, ...) — the default. Receives instance as self. Access instance and class state.
def m(cls, ...) — receives the CLASS as cls. Use for alternate constructors: `@classmethod def from_json(cls, s): ...`
def m(...) — no self, no cls. Just a function that happens to live in the class namespace. Reach for it rarely; a module-level function is usually clearer.
def m(self) — appears to callers as an attribute (`obj.m`) but is really a method. Great for computed values or lazy loading.
The mental model to hold
"self is a keyword — Python passes it magically. And attributes defined in the class body are shared defaults that each instance copies when it's created."
self is an ordinary parameter; the name is convention only. And class-body attributes are not copied — they live on the class object and every instance reads through to the same one. An instance only gets its own copy when you assign to it.
Because the copy model gives the right answer for immutable class attributes, which is what most tutorials show. class C: count = 0 then c.count += 1 genuinely does create a per-instance attribute — but only because += on an int is a rebind, and rebinding through an instance always writes to the instance. Swap the int for a list and c.items.append(x) mutates the shared class-level object, so every instance suddenly shares one list. Same syntax, opposite semantics, and nothing warns you. This is the class-level twin of the mutable-default-argument trap, and it comes from the same root: Python resolves reads through a lookup chain but sends writes to the object you named.
Read goes up the chain, write stops at the instance:
class Job:
tags = [] # lives on the CLASS
def __init__(self, name): self.name = name
a, b = Job('a'), Job('b')
a.tags.append('x') # mutates the shared object
print(b.tags) # ['x'] <- b never touched it
print(a.__dict__) # {'name': 'a'} <- no 'tags' here at all
a.tags = ['y'] # ASSIGNMENT creates an instance attribute
print(a.__dict__, b.tags) # {'name':'a','tags':['y']} ['x']Why must self be written explicitly in every method signature, when Java and C++ get away with an implicit this? This looks like pure boilerplate — derive why Python cannot drop it.
- 1In Python, a method is not a special construct.
definside a class body creates an ordinary function object and stores it as a class attribute.forced by · Python has one function machinery and reuses it everywhere rather than adding a second kind of callable - 2So
obj.methodis just attribute lookup: it finds the plain function on the class, and the descriptor protocol wraps it into a bound method that remembersobj.forced by · attribute access on an instance falls back to the class, and functions implement__get__ - 3Calling that bound method inserts the remembered object as the first positional argument to the underlying function.forced by · binding must pass the instance somehow, and the first parameter is the only place that needs no new syntax
- 4Therefore the function must have a first parameter to receive it — otherwise the call is a plain arity error, which is exactly the message you get when you forget it.forced by · the function object is ordinary and its signature is checked ordinarily
- 5An implicit
thiswould require the interpreter to know at definition time that a function will be used as a method. But Python lets you attach functions to classes after the fact, and detach methods into standalone callables.forced by · classes are mutable objects built at runtime, so "is this a method?" has no answer atdeftime
Therefore explicit self is forced by Python's decision that classes and methods are just objects and functions, with no separate compile-time notion of a method.
And note what this predicts: C.method(obj) and obj.method() must be equivalent — they are, and that identity is how super() calls and monkeypatching work. It also predicts that @staticmethod and @classmethod are not keywords but descriptors that alter what gets bound: nothing, and the class, respectively. Verify with obj.method.__self__ and obj.method.__func__ — the binding is a visible, inspectable object.
An object is a dictionary (__dict__) plus a pointer to its class. A class is a dictionary plus pointers to its bases. Attribute reads walk that chain — instance first, then class, then bases in MRO order — and return the first hit. Attribute writes do not walk anything; they land on the object you named.
That asymmetry between read (searches upward) and write (stops immediately) explains nearly every class-related surprise in Python, including shadowing, shared mutable class attributes, and why setting a value on an instance permanently masks the class version.
- Per-instance state goes in
__init__viaself.x = .... Class-body assignments are shared by every instance — safe for constants, dangerous for mutables. __init__initialises; it does not construct.__new__allocates. This matters the day you subclass an immutable type or implement a singleton.- Dunder methods are Python's interface protocol: implement
__eq__/__hash__together,__repr__for debugging (unambiguous),__str__for humans. - Everything is public; a leading underscore is a message to humans, not the interpreter.
@propertylets you add validation later without changing any call site — which is why Python doesn't need getters up front.
Fire this model the moment you see: two instances sharing a list they shouldn't · an attribute that "disappeared" after assignment · self missing from a signature · objects that compare unequal despite identical fields · a __repr__ that prints the memory address in a log you now have to debug.
You have a record with five fields and a couple of derived values. Do you use a plain dict, a dataclass/NamedTuple, or a full class with behaviour?
__init__/__repr__/__eq__, static type checking and IDE completion, and frozen=True gives immutability and hashability for freeDicts at the system boundary, dataclasses everywhere inside it, full classes only when there is real behaviour or a real invariant. The test for the last one is honest and simple: if the class has no method that could fail on bad input, it is a dataclass wearing a costume.
In data engineering the dict-to-dataclass boundary is where most silent corruption is caught. A dict lets a renamed upstream column flow through as a missing key that surfaces three stages later as a null; a typed record fails at the parse step, next to the change that caused it.
(c) Hands-on · 25 min
Save as s009_classes.py, run.
"""s009_classes.py — the Python class, end to end."""
from __future__ import annotations
from typing import Iterable, Iterator, Self
import json
# =========================
# Part 1 · The smallest useful class
# =========================
print("--- Part 1 ---")
class Point:
"""A 2D point with value semantics."""
def __init__(self, x: float, y: float) -> None:
self.x = x
self.y = y
def __repr__(self) -> str:
return f"Point(x={self.x!r}, y={self.y!r})"
def __eq__(self, other: object) -> bool:
return isinstance(other, Point) and (self.x, self.y) == (other.x, other.y)
def __hash__(self) -> int:
return hash((self.x, self.y))
def distance_to(self, other: Point) -> float:
return ((self.x - other.x) ** 2 + (self.y - other.y) ** 2) ** 0.5
p, q = Point(3, 4), Point(3, 4)
print(p, q, p == q, p is q) # equal by value, not identity
print({p, q}) # only one — same hash
print(p.distance_to(Point(0, 0)))
# =========================
# Part 2 · Class attr vs instance attr — and the trap
# =========================
print("\n--- Part 2 ---")
class Config:
debug: bool = False # class attribute — safe (immutable)
plugins: list[str] = [] # class attribute — MUTABLE, will be shared 😱
c1, c2 = Config(), Config()
c1.plugins.append("logger") # mutates the shared class-level list
print(c2.plugins) # ['logger'] — surprise
# Fix — always initialise mutables in __init__
class ConfigFixed:
debug: bool
plugins: list[str]
def __init__(self, debug: bool = False, plugins: list[str] | None = None) -> None:
self.debug = debug
self.plugins = list(plugins) if plugins else []
c1, c2 = ConfigFixed(), ConfigFixed()
c1.plugins.append("logger")
print(c2.plugins) # []
# =========================
# Part 3 · The three method flavours
# =========================
print("\n--- Part 3 ---")
class Temperature:
"""Value type with alternate constructors."""
def __init__(self, celsius: float) -> None:
self._c = celsius
@classmethod
def from_fahrenheit(cls, f: float) -> Self:
return cls((f - 32) * 5 / 9)
@classmethod
def from_json(cls, s: str) -> Self:
data = json.loads(s)
return cls(data["celsius"])
@staticmethod
def is_valid(c: float) -> bool:
# Doesn't need self or cls — pure function that belongs in this namespace.
return -273.15 <= c <= 1e6
@property
def fahrenheit(self) -> float:
return self._c * 9 / 5 + 32
def __repr__(self) -> str:
return f"Temperature({self._c}°C / {self.fahrenheit:.1f}°F)"
t = Temperature.from_fahrenheit(212)
print(t)
print(t.fahrenheit) # looks like an attribute; runs a method
print(Temperature.is_valid(-300))
print(Temperature.from_json('{"celsius": 100}'))
# =========================
# Part 4 · Make your class iterable
# =========================
print("\n--- Part 4 ---")
class Words:
"""A little collection that pretends to be a list of words."""
def __init__(self, text: str) -> None:
self._words = text.split()
def __len__(self) -> int:
return len(self._words)
def __iter__(self) -> Iterator[str]:
yield from self._words
def __getitem__(self, i: int) -> str:
return self._words[i]
def __repr__(self) -> str:
return f"Words({len(self)} words)"
w = Words("the quick brown fox jumps")
print(w, len(w), w[0], list(w))
print("brown" in w) # True — iteration is enough for `in`
# =========================
# Part 5 · Context manager the hard way (dunder) and the easy way (contextlib)
# =========================
print("\n--- Part 5 ---")
class Timer:
"""Prints elapsed time when used in a with-block."""
def __enter__(self) -> Self:
import time
self.t0 = time.perf_counter()
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
import time
elapsed = (time.perf_counter() - self.t0) * 1000
print(f" Timer: {elapsed:.2f} ms")
with Timer():
_ = sum(i * i for i in range(10_000))
# =========================
# Part 6 · When NOT to use a class
# =========================
print("\n--- Part 6 ---")
# BAD — a class with __init__ + one method is a function
class GreetingBuilder:
def __init__(self, greeting: str) -> None:
self.greeting = greeting
def build(self, name: str) -> str:
return f"{self.greeting}, {name}!"
# GOOD — closure or partial
def make_greeter(greeting: str):
def greeter(name: str) -> str:
return f"{greeting}, {name}!"
return greeter
print(GreetingBuilder("hi").build("ada"))
print(make_greeter("hi")("ada"))
# Rule of thumb: if the class has one meaningful method + __init__,
# and no dunder hooks are needed, use a closure or a partial.What each block teaches
Anatomy of the exercises
Implement a BankAccount class with:
__init__(self, account_id: str, owner: str, balance: float = 0)— validate balance ≥ 0.deposit(self, amount)andwithdraw(self, amount)— validate positive; withdraw raises if insufficient funds.balanceas a read-only@property— external code shouldn't set it directly.__repr__—BankAccount(id='abc', owner='ada', balance=100.00).__eq__and__hash__— two accounts are equal iffaccount_idmatches.
Bonus: @classmethod def from_dict(cls, d) for JSON deserialization. Discuss with a friend or write in comments: which methods should be _private? Which should be @property? Why?
(d) Production reality · 15 min
Same as Part 2 above but in production. A dev added _cache: dict = {} as a class attribute for a service class. Under gunicorn's multi-worker setup, the cache appeared to work sometimes (same worker) and not others. Instances of the class across worker processes saw different states.
__init__ so it's per-instance. If you actually want shared caching, use an external store (Redis, memcached). Class attributes are per-process, and any web deployment has multiple processes.A dev put Django model instances in a set: seen = set(); seen.add(user_a); seen.add(user_a). Length is 2. Both are the same DB row. Head-scratching ensues.
Django models inherit Python's default __eq__ (identity) and __hash__. Two model instances loaded via separate queries are distinct objects even if they point to the same row. Override __eq__ and __hash__ to compare by PK, or work with PK sets: seen = set(); seen.add(user_a.pk).
__eq__ AND __hash__. Never one without the other — that violates Python's data model contract.A junior dev, fresh from a Java course, adds UserService, UserRepository, UserFactory, UserValidator, AbstractUserBase — 400 lines of scaffolding to save + retrieve a user from a DB. In Python, this is 12 lines: a function and a dataclass.
Delete the scaffolding. Use @dataclass for the record, plain functions for the operations, a module namespace for grouping. Bring in a class only when there's real state + behaviour that needs coordination.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
If you can't teach these three without notes, redo the session:
- What is a class in Python? (one sentence, no analogies)
- What's the class-attribute-as-mutable-default bug? (name it AND the fix)
- When do you reach for a class vs a function? (one rule)
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.