Python from First PrinciplesPost 7 of 20 · 20 of 20 written
- How to use this guide
- 01What Python actually is
- 02Names, objects and references
- 03Built-in types and what they cost
- 04Control flow and expressions
- 05Functions in depth
- 06Iteration and generators
- 07Classes and the data model
- 08Context managers and exceptions
- 09Modules, packages, imports and environments
- 10Type hints and Pydantic
- 11The performance model
- 12Concurrency and parallelism
- 13NumPy: the array model everything else copies
- 14Data and serialization
- 15PyTorch mental model
- 16AI systems engineering in Python
- 17Tooling, debugging and testing
- 18Asking the right questions
- 19Roadmap and build-to-learn projects
- 20Glossary and resources
7. Classes and the data model
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:
- Data descriptors on the class, such as properties.
- The instance’s own dict,
obj.__dict__. - The class, then its base classes in MRO order (below).
__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__ insteadInheritance, 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. Innn.Modulesubclasses 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 byModule.__setattr__, which stores the layer inself._modules. That is howmodel.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.ModuleListornn.ModuleDict. - Calling
model.forward(x)skips registered hooks. Always callmodel(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.secondsProperties, 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
@classmethodreceives the class as its first argument. It is the idiom for alternative constructors:Model.from_pretrained(path),Config.from_yaml(path).@staticmethodis 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 defaultThe 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?