Python from First PrinciplesPost 7 of 20 · 20 of 20 written

7. Classes and the data model

Python · 7 of 204 min readOct 2026

A class is an object that makes objects, and Python’s syntax is a set of hooks that call special (“dunder”) methods on them. len(x) calls x.__len__(), x[i] calls x.__getitem__(i), and model(x) calls model.__call__(x). Learn the hooks and every framework becomes readable.

Classes, instances and self

class Counter:
    instances = 0                  # class attribute: shared by all instances

    def __init__(self, start=0):   # initializer; __new__ is the actual constructor
        self.value = start         # instance attribute: one per object
        Counter.instances += 1

    def inc(self, by=1):
        self.value += by
        return self

c = Counter()
c.inc()                            # sugar for Counter.inc(c)

self is not a keyword. It is the first parameter, filled with the instance when you call a method through it. A method is a plain function stored on the class; accessing it through an instance produces a “bound method” that remembers the instance.

How attribute lookup works

For obj.attr, Python checks roughly in this order:

  1. Data descriptors on the class, such as properties.
  2. The instance’s own dict, obj.__dict__.
  3. The class, then its base classes in MRO order (below).
  4. __getattr__, if the class defines it, as a last resort.

The trap that follows: a mutable class attribute is shared by every instance.

class Buggy:
    items = []        # ONE list for all instances; create it in __init__ instead

Inheritance, super() and the MRO

  • class Child(Parent): inherits attributes and methods. Redefining a method overrides it.
  • super().__init__(...) calls the next class in the method resolution order. In nn.Module subclasses it is mandatory.
  • The MRO is the lookup chain, visible as Child.__mro__. Multiple inheritance is linearized by the C3 algorithm.

Prefer composition (objects holding other objects) over deep inheritance. PyTorch models are composition: modules containing modules.

The special methods that matter

Method Triggered by Where you see it in AI code
__init__ Cls(...) configs, layers, clients
__repr__ repr(x), the REPL, debuggers print(model) shows the layer tree
__len__ len(x) dataset size
__getitem__ x[i], x[a:b] dataset items, tensor indexing
__iter__, __next__ for loops data loaders, streams
__call__ x(...) model(inputs), tokenizers, pipelines
__enter__, __exit__ with torch.no_grad(), files, locks
__eq__, __hash__ ==, dict keys, sets deduplication, caching
__add__, __matmul__, … +, @, … tensor math such as x @ W
__getattr__, __setattr__ attribute read, assignment how nn.Module registers layers
__contains__ in vocabulary checks
__bool__ if x: the “ambiguous truth value” tensor error

Operator overloading is just this table: a @ b runs a.__matmul__(b). NumPy and PyTorch implement these methods to dispatch into C and CUDA.

Why model(x) works, and why not to call forward directly

import torch
import torch.nn as nn

class MLP(nn.Module):
    def __init__(self, d_in, d_hidden, d_out):
        super().__init__()                      # creates the internal registries
        self.fc1 = nn.Linear(d_in, d_hidden)    # __setattr__ registers the submodule
        self.fc2 = nn.Linear(d_hidden, d_out)

    def forward(self, x):
        return self.fc2(torch.relu(self.fc1(x)))

model = MLP(784, 256, 10)
y = model(x)        # nn.Module.__call__ runs hooks, then self.forward(x)
  • Forgetting super().__init__() causes an error as soon as you assign a layer, because the registries do not exist yet.
  • self.fc1 = nn.Linear(...) is intercepted by Module.__setattr__, which stores the layer in self._modules. That is how model.parameters() finds every weight recursively.
  • Layers kept in a plain Python list are invisible to that machinery: they are not trained, saved or moved to the GPU. Use nn.ModuleList or nn.ModuleDict.
  • Calling model.forward(x) skips registered hooks. Always call model(x).

Properties

@property turns a method into a computed attribute that reads like a field:

class RunStats:
    def __init__(self, tokens, seconds):
        self.tokens, self.seconds = tokens, seconds

    @property
    def tokens_per_second(self):
        return self.tokens / self.seconds

Properties, bound methods, classmethod and staticmethod are all built on descriptors: objects with a __get__ method. You will rarely write one, but they explain how Pydantic fields and ORM columns work.

classmethod and staticmethod

  • @classmethod receives the class as its first argument. It is the idiom for alternative constructors: Model.from_pretrained(path), Config.from_yaml(path).
  • @staticmethod is a plain function that lives in the class namespace.

Dataclasses

from dataclasses import dataclass, field

@dataclass(frozen=True)
class TrainConfig:
    lr: float = 3e-4
    batch_size: int = 32
    epochs: int = 10
    tags: list[str] = field(default_factory=list)   # avoids a shared mutable default

The decorator generates __init__, __repr__ and __eq__ from the annotations. frozen=True makes instances immutable and hashable; slots=True saves memory. Use dataclasses for trusted internal data and Pydantic (Part 10) for data crossing a trust boundary.

Abstract base classes and Protocols

  • ABC: class Retriever(ABC) with an @abstractmethod def search(...). Subclasses must implement it, enforced when you instantiate them.
  • Protocol: structural typing. “Anything with a search(query) -> list[Doc] method qualifies”, with no inheritance needed. Type checkers enforce it, not the runtime.

Protocols are ideal for plug-in seams: embedding providers, vector stores and LLM clients you want to swap or replace with fakes in tests.

Reading unfamiliar classes

Use vars(obj), dir(obj), type(obj).__mro__ and inspect.getsource(cls). When a framework class confuses you, read its __init__ and __call__ first.

Questions to ask:

  • Which special method does this syntax call?
  • Is this attribute on the instance, or shared on the class?
  • Is this submodule registered, or hidden in a plain list?