Dinesh/ Blog
python

Classes & Objects — the OOP Mental Model

Trace Python construction, bound methods, attributes, protocols, equality and hash contracts through executable object examples.

25 min readChecking device speech…
Lesson preparation & details

Level: beginner

Python and software engineering · Lesson 9 of 15 ↗
Loading this browser’s progress…

By the end, you should be able to

  • A mutable value-equality Point must not expose a hash based on fields that can change

Sources checked: . A source check does not change the publication or update date.

Editorial review: · What review means

In this article · 23 sections

Review boundary and corrected contracts

A mutable value-equality Point must not expose a hash based on fields that can change. It is now explicitly unhashable; identity and equality remain separately demonstrated. Defining eq does not require inventing hash. Property, classmethod and context-manager examples retain their original subjects.

The examples below teach the stated input contracts; fragments and deliberately incorrect counterexamples are exercises, not production implementations. Complexity counts elementary operations unless a bit-cost or allocation caveat is stated. Practice times are suggestions, not measured performance or hiring guarantees. Programs target Python 3.11+; verification uses CPython 3.12.3. Optional third-party tools and frameworks are not required for the standard-library examples.

Learning objective: 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 defines shared attributes and behavior, ordinary instance methods receive a bound receiver, 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


Pre-read · skim before the session (choose readings within your time budget)

Optional original media pointers (not watched or independently verified here):

Use the official sources below for the contracts. Original channel/duration claims are not treated as verified attribution.

Further reading: Python Tutorial · Classes

Further reading: Python Language Reference · Data Model

Further reading: Object-Oriented Programming (OOP) in Python 3


(a) Intuition · 5 min

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

Real: 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.

 
    For ordinary user classes, MyClass(...) requests an instance. The class holds
    shared behavior; the instance holds its state. Functions stored on a class
    bind the instance as their first argument when accessed through that instance.
    Allocation, descriptors and __slots__ qualify the namespace analogy below.
  

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.

The class-attribute-as-mutable-default bug

class Cart:
    items = []  # intentionally shared counterexample
    def add(self, x):
        self.items.append(x)
 
a, b = Cart(), Cart()
a.add("apple")
assert b.items == ["apple"]
 
class CartFixed:
    def __init__(self):
        self.items = []
    def add(self, x):
        self.items.append(x)
 
a, b = CartFixed(), CartFixed()
a.add("apple")
assert b.items == []

Class attributes are shared until ordinary instance assignment shadows them. Mutating a shared list does not create an instance list. Initialize per-instance mutable state in __init__; deliberately shared registries can instead be class attributes with an explicit ownership policy.

A quick history so you know why the world looks like this

Python's early object model supported classes; the first public Python release was in 1991. Python 2.2 introduced the type/class unification and descriptor machinery behind properties, static methods and class methods. Decorator syntax arrived in 2.4; it did not invent those three descriptors. Dataclasses arrived in Python 3.7 (2018), not 2017 (the PEP proposal year). typing.Self arrived in 3.11 (2022) to express methods that return the current class, including alternate constructors. These milestones explain the API, not a requirement to use inheritance or a historical claim about Java influencing early Python.

(b) From a class call to instance behaviour · 15 min

What actually happens when you write p = Point(3, 4)

A class definition describes behaviour, but a call to that class has to produce an object. Trace Point(3, 4) from allocation to initialisation and finally to the name p before deciding where its attributes should live.

What actually happens when you write p = Point(3, 4)
Figure text equivalent — labels and relationships
  • Node “p = Point(3, 4)” (square).
  • Node “1. type.__call__(Point, 3, 4)” (square).
  • Node “2. __new__ creation step with Point and call arguments” (square).
  • Node “3. if Point instance: __init__(instance, 3, 4)” (square).
  • Node “4. return instance → bound to name p” (square).
  • “p = Point(3, 4)” leads to “1. type.__call__(Point, 3, 4)”. Line: normal; ends: arrow point.
  • “1. type.__call__(Point, 3, 4)” leads to “2. __new__ creation step with Point and call arguments”. Line: normal; ends: arrow point.
  • “2. __new__ creation step with Point and call arguments” leads to “3. if Point instance: __init__(instance, 3, 4)”. Line: normal; ends: arrow point.
  • “3. if Point instance: __init__(instance, 3, 4)” leads to “4. return instance → bound to name p”. Line: normal; ends: arrow point.

For an ordinary class using the default metaclass, type.__call__ coordinates __new__ and then __init__ if the returned object is an instance of the requested class. __init__ initializes an already created object and must return None; __new__ can return another type, in which case that initialization is skipped. A custom metaclass can change this path. Most application classes need only __init__; immutable subclasses sometimes customize __new__.

Attributes: class vs instance

Name: Class attributes

Tagline: Shared by all instances

  • class Dog: species = 'canis'

  • All dogs share species

  • Great for constants and defaults

  • Ordinary obj.attr = value can create a shadowing instance attribute; obj.items.append(x) mutates the existing shared object

  • Use mutables here only when deliberately shared, with a clear ownership/concurrency policy

Name: Instance attributes

Tagline: 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

  • Per-instance mutables belong here; shared mutable state is a separate deliberate design

The dunder methods that make a class ‘Pythonic’

Implement these and your class behaves like a built-in

Name: __init__(self, ...)

Desc: Initializer, not allocator. Set instance state and validate invariants; avoid surprising external work.

Name: __repr__(self)

Desc: Developer-facing string (repr(obj), REPL output). Should look like a constructor call: Point(x=3, y=4). Implement a useful representation when inspecting the object matters.

Name: __eq__(self, other)

Desc: Value equality (a == b). Default is identity (is). Implement if instances represent values.

Name: __hash__(self)

Desc: Required if you set __eq__ AND want instances in sets / dict keys. Rule: equal objects must have equal hashes.

Name: __len__(self)

Desc: len(obj) returns a nonnegative integer. It also controls truthiness when __bool__ is absent; empty then means false.

Name: __iter__(self) / __next__(self)

Desc: Makes for x in obj: work. Yield from a generator inside __iter__ is the shortest path.

Name: __getitem__(self, key)

Desc: obj[key]. A sequence additionally needs the integer-index, negative-index, bounds and often slicing contracts callers expect; two method names alone do not prove conformance.

Name: __enter__(self) / __exit__(self, ...)

Desc: with obj: — context manager. Great for resources (files, DB connections, locks).

Name: __call__(self, ...)

Desc: obj() — makes instances callable. See S007 callable classes.

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

instance method

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

@classmethod

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

@staticmethod

Desc: 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.

@property

Desc: 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

When to reach for a class vs a function

Function: stateless transformation, one-off, no cross-call state. @dataclass: named record with typed fields, mostly data. Class: state + behaviour, several methods coordinating over shared state, or you need to implement dunder hooks for language integration. If you're writing a class with a single method + init, you probably wanted a closure or a function.

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

Truth: self is an ordinary parameter name used by convention. Class-body attributes are not copied into instances. An ordinary assignment such as obj.tags = existing_list stores a reference on that instance, not a copy of the list. A property setter or custom attribute hook can intercept assignment.

Why: 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 ordinary assignment stores an instance attribute (unless a descriptor or custom hook intervenes). Swap the int for a list and c.items.append(x) mutates the shared class-level object, so every instance suddenly shares one list. Different operations—rebinding an integer versus mutating a list—produce different sharing behavior. 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.

Test the ordinary-attribute case:

class Job:
    tags = []
    def __init__(self, name):
        self.name = name
 
a, b = Job("a"), Job("b")
a.tags.append("x")
assert b.tags == ["x"] and "tags" not in vars(a)
a.tags = ["y"]
assert vars(a) == {"name": "a", "tags": ["y"]}
assert b.tags == ["x"]

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 — explain why this explicit parameter fits Python's method-binding design.

Premise: 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.

Because: Python has one function machinery and reuses it everywhere rather than adding a second kind of callable

Premise: 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.

Because: attribute access on an instance falls back to the class, and functions implement __get__

Premise: Calling that bound method inserts the remembered object as the first positional argument to the underlying function.

Because: binding must pass the instance somehow, and the first positional argument is the convention implemented by function descriptors

Premise: 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.

Because: the function object is ordinary and its signature is checked ordinarily

Premise: Python permits attaching an ordinary function to a class after definition and calling the same function directly with a supplied receiver. Explicit receiver binding makes both uses share one signature.

Because: function descriptors provide binding at attribute access, not a distinct method-only declaration syntax

Conclusion: Explicit self is a language design choice consistent with reusing function objects; it is not a proof that no language could provide implicit receiver syntax. For ordinary methods, C.method(obj) and obj.method() must be equivalent — they are, and that equivalence helps explain ordinary method binding; super() additionally selects a starting point in the MRO. 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.

Name: Namespace with a lookup chain

Picture: For ordinary attributes, a read can find per-instance state or a class/base attribute. The fuller default lookup order is: a data descriptor found through the class MRO (such as a property), then the instance dictionary, then non-data descriptors or other class attributes. Missing attributes can trigger __getattr__; custom __getattribute__ can change lookup. __slots__ can remove the instance dictionary entirely. Writes can invoke a descriptor's setter or __setattr__, so the simple “write always stops at the instance dictionary” rule is not universal.

  • 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 value equality deliberately and leave mutable value objects unhashable unless equality and hashing use stable identity fields, __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.

Trigger: 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.

Decision: 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?

Name: dict

Gain: arbitrary/dynamic keys and familiar mapping operations; JSON-compatible keys and values can be serialized at a boundary

Cost: an unvalidated plain dict has no enforced schema; TypedDict can describe one statically but does not validate runtime data

Pick: the shape is genuinely dynamic or externally defined — parsing arbitrary JSON, config passthrough, or a short-lived intermediate inside one function

Name: dataclass / NamedTuple

Gain: declared fields and generated methods; dataclasses and NamedTuple support static tooling. frozen=True prevents ordinary field rebinding, not mutation of nested values; a generated hash still requires hashable field values

Cost: a definition to maintain and boundary conversion. Ordinary dataclasses still have a mutable instance dictionary unless configured otherwise; annotations alone do not check runtime values

Pick: 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

Name: Full class with methods

Gain: invariants can be enforced by explicit validation at construction, behaviour lives with the data it operates on, and internal representation can change without breaking callers

Cost: the heaviest option; encourages state and mutation; over-applied it produces classes that are only ever constructed and immediately have one method called

Pick: there are invariants worth enforcing, or meaningful state transitions, or several operations that always take the same bundle of fields as arguments

Verdict: Prefer a named record when the shape needs to be clear; use a class when behavior or invariants belong with the state. A dataclass can have methods and validation too. Constructor keyword errors can catch missing/renamed fields, but type annotations alone will not reject age="old"; explicit parsing or a runtime-validation library is needed. A one-method object can be justified by a framework protocol or lifecycle, so the function-first rule is a heuristic, not a ban.


(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)
 
    __hash__ = None  # mutable value-equality objects must not change a stored hash
 
    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)                           # mutable Points deliberately are not set keys
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 — initialize per-instance 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 ---")
 
# Unnecessary machinery here: a closure can express this one-method example
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

Name: Part 1 · smallest useful class

Desc: __init__, __repr__, __eq__, __hash__ — the ‘value type’ starter kit. Provide a useful __repr__; save reviewers from cryptic <Point object at 0x…>.

Name: Part 2 · attribute trap

Desc: Class-level mutables are shared. Fix: initialise per-instance in __init__. Reviewers WILL catch this.

Name: Part 3 · method flavours

Desc: @classmethod for alt constructors (from_x). @staticmethod for namespace-only functions (consider a module function instead). @property for computed / lazy attributes.

Name: Part 4 · dunder-driven collection

Desc: These methods support the shown length, iteration and indexing operations. Full sequence compatibility still requires the caller's bounds, slicing and mutation contracts.

Name: Part 5 · context manager

Desc: __enter__ / __exit__ is the with-statement protocol. contextlib.contextmanager (a decorator) is a shorter alternative for simple cases.

Name: Part 6 · when not to class

Desc: A function/closure can simplify this example. One-method classes remain useful for required protocols, lifecycles or meaningful state; the rule is a heuristic.

What to observe

In Part 1, p == q is true and p is q is false. {p, q} raises TypeError: the mutable value-equality Point is deliberately unhashable. Use coordinate tuples as keys only when those coordinates are stable values for your use case. Part 2's Config bug: c1's append silently changed c2. That's not a Python quirk — it's a shared class attribute. Part 3's t.fahrenheit looks like an attribute; it's actually a method call (@property magic). Part 4's "brown" in w works even though we didn't implement contains — Python falls back to iteration.

Goal: Design a BankAccount class that survives a code review

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). Record successful changes in a local teaching audit trail; production logging must apply privacy and retention rules.

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 of the same concrete type are equal iff account_id matches; that identifier must remain stable while stored in a hashed collection.

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?


Worked BankAccount solution: stable identity, checked state

Balance is mutable, so hashing it would be unsafe. This example instead compares and hashes a stable, read-only public account_id. Python's underscore convention is not a security boundary: callers must not bypass the API by rewriting _account_id. A production ledger also needs authorization, concurrency, persistence and an external audit policy; this is a single-process teaching object.

The original float-shaped input is converted with Decimal(str(value)) so binary floating-point arithmetic does not accumulate inside the account. We use the default Decimal context and require finite whole-cent amounts and balances no greater than one trillion; this is not a complete currency/rounding model. A rejected validation leaves both balance and the local audit trail unchanged; this is not transactional protection against process or allocation failures.

from decimal import Decimal, InvalidOperation
 
class BankAccount:
    def __init__(self, account_id: str, owner: str, balance=0):
        if not isinstance(account_id, str) or not account_id:
            raise ValueError("account_id must be a nonempty string")
        if not isinstance(owner, str) or not owner:
            raise ValueError("owner must be a nonempty string")
        self._account_id = account_id
        self.owner = owner
        self._balance = self._money(balance)
        self._events = [("open", self._balance)]
 
    @staticmethod
    def _money(value):
        try:
            amount = Decimal(str(value))
        except (InvalidOperation, ValueError):
            raise ValueError("amount must be numeric") from None
        if not amount.is_finite() or not 0 <= amount <= Decimal("1000000000000"):
            raise ValueError("amount must be finite and within the teaching limit")
        if amount.as_tuple().exponent < -2 and amount != amount.quantize(Decimal("0.01")):
            raise ValueError("amount must use whole cents")
        return amount
 
    @property
    def account_id(self):
        return self._account_id
 
    @property
    def balance(self):
        return self._balance
 
    @property
    def events(self):
        return tuple(self._events)
 
    def deposit(self, amount):
        amount = self._money(amount)
        if amount <= 0:
            raise ValueError("deposit must be positive")
        self._balance = self._money(self._balance + amount)
        self._events.append(("deposit", amount))
 
    def withdraw(self, amount):
        amount = self._money(amount)
        if amount <= 0 or amount > self._balance:
            raise ValueError("withdrawal must be positive and within balance")
        self._balance -= amount
        self._events.append(("withdraw", amount))
 
    def __repr__(self):
        return (f"BankAccount(id={self.account_id!r}, owner={self.owner!r}, "
                f"balance={self.balance:.2f})")
 
    def __eq__(self, other):
        if type(other) is not type(self):
            return NotImplemented
        return self.account_id == other.account_id
 
    def __hash__(self):
        return hash((type(self), self.account_id))
 
    @classmethod
    def from_dict(cls, data):
        return cls(data["account_id"], data["owner"], data.get("balance", 0))
 
a = BankAccount("abc", "ada", "100.00")
same = BankAccount.from_dict({"account_id": "abc", "owner": "ada"})
assert a == same and len({a, same}) == 1
old_hash = hash(a)
a.deposit("0.10")
a.withdraw("0.05")
assert a.balance == Decimal("100.05") and hash(a) == old_hash

Here _money is an internal helper, not access-controlled private code. balance, account_id and events expose read-only public views; owner stays ordinarily writable. from_dict validates through the constructor; it is not an arbitrary-object deserializer. A subclass that changes identity semantics must reconsider equality and hashing together.

Descriptor and context-manager boundaries in executable form

A property is a data descriptor, so it takes precedence over a same-named dictionary entry. Ordinary methods are non-data descriptors: they bind a receiver but can be shadowed by an instance attribute. __slots__ means an object need not have a __dict__ at all. The default lookup model is useful only with these qualifications.

from contextlib import contextmanager
from io import StringIO
 
class Nonnegative:
    def __init__(self, value):
        self.value = value
    @property
    def value(self):
        return self._value
    @value.setter
    def value(self, x):
        if x < 0:
            raise ValueError("negative value")
        self._value = x
 
v = Nonnegative(3)
v.__dict__["value"] = -100
assert v.value == 3  # property wins over instance dictionary
 
@contextmanager
def text_buffer():
    stream = StringIO()
    try:
        yield stream
    finally:
        stream.close()
 
with text_buffer() as stream:
    stream.write("hello")
assert stream.closed

The main lab's Words is an iterable: every iter(w) obtains a generator with its own __next__. The Words object itself needs no __next__. len(w) provides truth testing only when __bool__ is absent. Timer.__exit__ returns None, so it reports elapsed time without suppressing an exception. Temperature's is_valid is a separate predicate, not validation automatically applied by its constructor; a property computes on each access unless caching is explicitly implemented.

(d) Production reality · 15 min

Three concrete failure mechanisms

Shared caches have a scope. A class-level cache is shared by instances using that same class object in one interpreter. Moving it into __init__ isolates instances; it does not create cross-process sharing. Independent workers need an explicitly coordinated cache or database when they require shared state. Not every deployment has multiple workers, and caches also need eviction and concurrency policies.

Equality is not row identity by default. An ordinary identity-keyed class keeps two separately constructed objects distinct even if their fields match. Adding the same object twice to a set does not produce two elements. ORM equality is framework-specific; the earlier claim that Django simply inherits object identity was not justified. Do not override an ORM's equality blindly. An explicit set of stable primary-key values expresses row deduplication without assuming its object contract:

class Record:
    def __init__(self, pk):
        self.pk = pk
 
a, b = Record(7), Record(7)
assert len({a, a}) == 1
assert len({a, b}) == 2
assert {a.pk, b.pk} == {7}

Scaffolding can hide the invariant. For a user record plus one formatting operation, a dataclass and a function may be enough. A repository object is justified if it owns a connection lifecycle or interchangeable persistence contract. Count responsibilities and test seams, not lines or class names; no claimed company incident or “400 versus 12 lines” benchmark is needed.

Where this shows up in the rest of the plan

Root: Classes bleed into everything ‘object-shaped’ in the plan

Label: Inheritance & composition

Desc: The next session — when to subclass vs when to embed. Answer: usually compose.

Label: Errors & debugging

Desc: Exceptions are classes. Custom exceptions are just class Foo(Exception).

Label: Dataclasses & pydantic

Desc: @dataclass is a class boilerplate killer; pydantic adds runtime validation.

Label: FastAPI

Desc: Path operations, dependencies, response models — all classes and functions.

Label: SQLAlchemy / ORMs

Desc: ORM object equality and identity policies are framework-specific; inspect their documented primary-key and unsaved-object rules.

Label: PyTorch nn.Module

Desc: Neural nets are classes with __call__. Same dunder machinery you learned today.


(e) Recall + stretch · 10 min

Self-check (answer follows each question).

Recall — questions and worked answers

Q: What's the difference between a class attribute and an instance attribute?

A: A class attribute lives on the class and can be shared through lookup. An ordinary obj.attr = x stores an instance reference that shadows a non-data class attribute; mutation of a shared value does not. Data descriptors such as properties and custom attribute hooks qualify this rule.

Q: Why is class Cart: items = [] a bug?

A: items is a class attribute — all Cart instances share the same list. Mutating one cart's items via self.items.append(x) changes every cart. Fix: initialise in __init__: self.items = [].

Q: When would you use @classmethod instead of @staticmethod?

A: Use @classmethod when the method needs the CLASS (typically as an alternate constructor: @classmethod def from_json(cls, s): return cls(...)). Use @staticmethod when it needs neither self nor cls — often a signal you should just use a module-level function.

Q: You defined eq. Must you also define hash?

A: No. Mutable value objects should usually stay unhashable (hash = None). Only provide a compatible stable hash when the object can safely be a key for its whole stored lifetime.

Q: Implement __repr__ for a class Point(x, y) such that eval(repr(p)) == p works.

A: Return a useful constructor-like representation; do not evaluate untrusted representations.

class ReprPoint:
    def __init__(self, x, y):
        self.x, self.y = x, y
    def __repr__(self):
        return f"Point({self.x!r}, {self.y!r})"
 
assert repr(ReprPoint(3, 4)) == "Point(3, 4)"

With the earlier Point constructor in scope and these trusted numeric fields, that string denotes an equal Point. A general repr is not required to be executable.

Q: What does @property do, and why is it useful?

A: Makes a method callable as an attribute (obj.foo instead of obj.foo()). Useful for (1) computed values (.fahrenheit derived from ._c), (2) lazy loading, (3) validation on set (with @x.setter), (4) exposing read-only state.

Q: When is a class NOT the right tool?

A: Consider a function/closure for one operation, a module for stateless operations and a dataclass/NamedTuple for a named record. These are heuristics: a one-method class may implement a required protocol or lifecycle, and dataclasses can also have behavior.

Q: What dunder methods make a class usable in a with statement?

A: __enter__(self) returns the object bound in with x as y:. __exit__(self, exc_type, exc_val, exc_tb) runs on leaving the block if __enter__ completed successfully. A truthy return suppresses an exception from the block; None or another false value lets it propagate.

Q: Explain the difference between MyClass.method(instance, x) and instance.method(x).

A: They're the same call. instance.method is a bound method — Python passes instance as self automatically. MyClass.method is unbound; you provide self yourself. Here MyClass.method is an ordinary function, not a special Python-2-style unbound-method object.

Q: Stretch — design a LazyDict class that computes values on first access and caches them. What dunder methods do you need, and what's the API?

Stretch: true

A: __getitem__ computes once per key after a successful factory call; __contains__ checks declared keys without computing them. A failed factory is not cached. This is a small mapping-like API, not a full Mapping implementation or thread-safe cache.

from collections.abc import Callable
 
class LazyDict:
    def __init__(self, factories: dict[str, Callable[[], object]]):
        self._factories = dict(factories)
        self._cache = {}
    def __getitem__(self, key):
        if key not in self._cache:
            self._cache[key] = self._factories[key]()
        return self._cache[key]
    def __contains__(self, key):
        return key in self._factories
    def __repr__(self):
        return f"LazyDict(keys={list(self._factories)!r}, computed={list(self._cache)!r})"

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)

Model answers: (1) A class is an object defining instance behavior and attributes; an ordinary call coordinates creation and initialization. (2) A mutable class attribute is shared through lookup; initialize a fresh list in __init__ for per-instance state. (3) Use a function for a stateless transformation, a dataclass for a named record, and a class for coordinated state or a protocol/lifecycle requirement.


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.

Corrective source notes

Official Python 3.12 documentation snapshots checked 2026-10-09 support these specific claims; they are mutable maintenance pages, not whole-lesson certification. Local tests are finite; optional media, static type checking, external frameworks, application rendering and deployment are separate unperformed checks.

Pause / Recall / Apply

Can you explain it without the page?

Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.

Stored in this browser only. No account, no sync. Clearing browser data removes your record.