Search Tech Journey

Find topics, journeys and posts

6-month learning plan9 / 130
back to blog
pythonbeginner 55m read

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.

🧠SoftwareM01 · Python Foundations· Session 009 of 130 90 min

🎯 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.

You will be able to
  • 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



(a) Intuition · 5 min

A class is a factory blueprint, an instance is the machine it produces
🌍 Real world

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.

💻 Code world

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

If these three don't feel solid, every class you write will be Java-in-Python
  • 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

  1. 1991
    Python 0.9.0 — classes
    Guido borrows classes from Modula-3 and Simula. `self` is explicit because Guido preferred readability over Java's implicit `this`.
  2. 2001
    New-style classes (Python 2.2)
    `class Foo(object):` unifies types and classes. Enables super(), MRO, __slots__.
  3. 2004
    Decorators arrive
    @staticmethod, @classmethod, @property become the standard way to add behaviour to a class.
  4. 2017
    @dataclass (PEP 557)
    Python 3.7 ships the boilerplate-killer. `class → @dataclass` for anything that's ‘just data’.
  5. 2022
    PEP 673 · Self type
    Type 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

Class attributes

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)
Instance attributes

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

__init__(self, ...)
Constructor. Set instance attributes. Don't do heavy work here.
essential
__repr__(self)
Developer-facing string (`repr(obj)`, REPL output). Should look like a constructor call: `Point(x=3, y=4)`. ALWAYS implement.
essential
__eq__(self, other)
Value equality (`a == b`). Default is identity (`is`). Implement if instances represent values.
value
__hash__(self)
Required if you set __eq__ AND want instances in sets / dict keys. Rule: equal objects must have equal hashes.
value
__len__(self)
`len(obj)` and truthiness (empty → False). Implement if your class is a collection.
collection
__iter__(self) / __next__(self)
Makes `for x in obj:` work. Yield from a generator inside __iter__ is the shortest path.
collection
__getitem__(self, key)
`obj[key]`. Together with __len__ makes your class a sequence.
collection
__enter__(self) / __exit__(self, ...)
`with obj:` — context manager. Great for resources (files, DB connections, locks).
resource
__call__(self, ...)
`obj()` — makes instances callable. See S007 callable classes.
advanced

The three method flavours (@staticmethod, @classmethod, @property)

1default
instance method

def m(self, ...) — the default. Receives instance as self. Access instance and class state.

2class
@classmethod

def m(cls, ...) — receives the CLASS as cls. Use for alternate constructors: `@classmethod def from_json(cls, s): ...`

3static
@staticmethod

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.

4computed
@property

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


Common misconception
✗ What most people think

"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."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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']
From first principles
Start with the question

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.

  1. 1
    In Python, a method is not a special construct. def inside 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
  2. 2
    So obj.method is just attribute lookup: it finds the plain function on the class, and the descriptor protocol wraps it into a bound method that remembers obj.
    forced by · attribute access on an instance falls back to the class, and functions implement __get__
  3. 3
    Calling 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
  4. 4
    Therefore 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
  5. 5
    An implicit this would 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 at def time
⇒ Therefore

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.

Mental modelNamespace with a lookup chain

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__ via self.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. @property lets you add validation later without changing any call site — which is why Python doesn't need getters up front.
🔔 Fires when you see

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.

The tradeoff

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?

dict
+ you gain zero definition cost, arbitrary and dynamic keys, serialises straight to and from JSON, and every data library speaks it natively
− you pay no schema, so typos create new keys silently; no type checking, no autocomplete, no validation; readers must reverse-engineer the shape from the code that produced it
pick when the shape is genuinely dynamic or externally defined — parsing arbitrary JSON, config passthrough, or a short-lived intermediate inside one function
dataclass / NamedTuple
+ you gain a declared schema in one place, free __init__/__repr__/__eq__, static type checking and IDE completion, and frozen=True gives immutability and hashability for free
− you pay a definition to maintain, and it resists dynamic fields by design; conversion at every boundary where the outside world hands you dicts
pick when the record crosses a function or module boundary, or appears in more than about three places — the point where "what fields does this have?" becomes a real question
Full class with methods
+ you gain invariants enforced at construction, behaviour lives with the data it operates on, and internal representation can change without breaking callers
− you pay the heaviest option; encourages state and mutation; over-applied it produces classes that are only ever constructed and immediately have one method called
pick when there are invariants worth enforcing, or meaningful state transitions, or several operations that always take the same bundle of fields as arguments
What a senior engineer actually does

Dicts 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

Part 1 · smallest useful class
__init__, __repr__, __eq__, __hash__ — the ‘value type’ starter kit. Always implement __repr__; save reviewers from cryptic `<Point object at 0x…>`.
essential
Part 2 · attribute trap
Class-level mutables are shared. Fix: initialise per-instance in __init__. Reviewers WILL catch this.
gotcha
Part 3 · method flavours
@classmethod for alt constructors (from_x). @staticmethod for namespace-only functions (consider a module function instead). @property for computed / lazy attributes.
methods
Part 4 · dunder-driven collection
Implement __len__ + __iter__ + __getitem__ and your class behaves like a list/tuple to any code that just wants a sequence.
collection
Part 5 · context manager
__enter__ / __exit__ is the with-statement protocol. `contextlib.contextmanager` (a decorator) is a shorter alternative for simple cases.
resource
Part 6 · when not to class
One-method class = function or closure. Class + no state = static methods = probably a module. Reserve classes for real state + behaviour.
taste
Try itDesign a `BankAccount` class that survives a code review

Implement a BankAccount class with:

  1. __init__(self, account_id: str, owner: str, balance: float = 0) — validate balance ≥ 0.
  2. deposit(self, amount) and withdraw(self, amount) — validate positive; withdraw raises if insufficient funds.
  3. balance as a read-only @property — external code shouldn't set it directly.
  4. __repr__BankAccount(id='abc', owner='ada', balance=100.00).
  5. __eq__ and __hash__ — two accounts are equal iff account_id matches.

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?

💡 Hint · Instance attrs for balance and owner. `deposit` and `withdraw` methods that validate. @property for balance (read-only view). __repr__ + __eq__ (equal accounts have same id, not same balance). Log every mutation.

(d) Production reality · 15 min

War story Common failure mode · every Python codebase over 2 yearsBug reports labelled ‘cannot reproduce locally’
🔥 What broke

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.

🧯 The fix
Move the cache into __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.
🎓 Lesson to steal
Class attributes = per-process shared state. Instance attributes = per-instance state. Anything cross-process needs an out-of-process store. Confusing these ships heisenbugs.
War story Django ORM · Common failure modeDuplicate rows in a set that ‘should be unique’
🔥 What broke

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.

🧯 The fix

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).

🎓 Lesson to steal
If your class has ‘value semantics’ (two instances with the same data should be equal), implement __eq__ AND __hash__. Never one without the other — that violates Python's data model contract.
War story Common failure mode · code reviewOverengineered ‘manager’ / ‘factory’ / ‘service’ classes
🔥 What broke

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.

🧯 The fix

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.

🎓 Lesson to steal
Python is not Java. Design patterns that fit Java's type system are often noise in Python. Prefer flat modules of functions + dataclasses; escalate to classes only when justified.

Where this shows up in the rest of the plan

Classes bleed into everything ‘object-shaped’ in the plan
S010 · Inheritance & composition
The next session — when to subclass vs when to embed. Answer: usually compose.
S011 · Errors & debugging
Exceptions are classes. Custom exceptions are just class Foo(Exception).
S014 · Dataclasses & pydantic
@dataclass is a class boilerplate killer; pydantic adds runtime validation.
S057 · FastAPI
Path operations, dependencies, response models — all classes and functions.
S095 · SQLAlchemy / ORMs
Models are classes with a metaclass. __eq__/__hash__ pitfall in every ORM.
S110 · PyTorch nn.Module
Neural nets are classes with __call__. Same dunder machinery you learned today.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

If you can't teach these three without notes, redo the session:

  1. What is a class in Python? (one sentence, no analogies)
  2. What's the class-attribute-as-mutable-default bug? (name it AND the fix)
  3. 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.