◆ Chapter 06
Classes, objects, and dataclasses (OOP the Pythonic way)
Classes, dunder methods and dataclasses — OOP the Pythonic way, and how to read the object models LLM SDKs are built on.
~2,393 words · chapter 6 of 15
You already know classes from JS. Python's version is close in spirit but differs in three ways that matter constantly when reading AI code: self is written out explicitly, "magic methods" (__like_this__) let objects plug into language syntax, and @dataclass removes almost all the boilerplate you're used to writing by hand. Master those three and most GenAI codebases become readable.
Classes, __init__, and the explicit self
class ChatSession:
"""Holds a running conversation with an LLM."""
def __init__(self, model: str, system: str = "You are helpful."):
# __init__ is the constructor. It runs when you call ChatSession(...).
# It does NOT return the object — Python creates the object and hands
# it to you as `self`; __init__ just fills it in.
self.model = model # instance attribute
self.system = system
self.messages: list[dict] = [] # each session gets its OWN list
def add(self, role: str, content: str) -> None:
# Every method takes `self` as its first parameter, explicitly.
self.messages.append({"role": role, "content": content})
session = ChatSession("claude-opus-4")
session.add("user", "Hello")
print(session.messages)
# => [{'role': 'user', 'content': 'Hello'}]
Why is self a written-out parameter when JS gets this for free? Because Python has one rule instead of two: session.add(...) is just sugar for ChatSession.add(session, ...). The instance is passed as the first argument like any other. Nothing is implicit or bound by call-site magic, so there is no this-rebinding foot-gun. You never call add with self yourself — Python fills it in — but you always write it in the definition.
JS → Python:
class Foo { constructor(x){ this.x = x } }becomesclass Foo:withdef __init__(self, x): self.x = x. JSthis(dynamically bound, easy to lose in callbacks) is Python'sself(an ordinary explicit parameter that can never be the "wrong" object). Python has nonewkeyword — you call the class directly:Foo(3). Python classes are real objects created at definition time, not desugared prototype chains, so there's noFoo.prototypeto reach for.
Instance vs class attributes — and a classic bug
An attribute assigned inside __init__ via self.x belongs to that instance. An attribute assigned in the class body is shared by all instances:
class Agent:
provider = "anthropic" # class attribute — one copy, shared
tools = [] # DANGER: also shared across all instances!
def __init__(self, name: str):
self.name = name # instance attribute — one per object
a, b = Agent("planner"), Agent("coder")
a.tools.append("search")
print(b.tools) # => ['search'] ← b sees a's change; they share ONE list
provider as a shared constant is fine. tools = [] is a bug: both agents mutate the same list. The fix is to create a fresh list per instance inside __init__ (self.tools = []), which is exactly what @dataclass automates with default_factory below.
Reading & judging: A mutable class attribute (
= [],= {}) that instances then mutate is one of the most common real Python bugs. When you see a list or dict assigned directly in a class body and later.append-ed or index-assigned, suspect accidental sharing. Constants (strings, numbers, tuples) in the class body are safe because they're immutable.
Dunder / magic methods — the heart of readable objects
"Dunder" = double-underscore. These methods let your object hook into Python syntax. This is where Python's OOP earns its keep in AI libraries.
class Prompt:
def __init__(self, text: str, tokens: list[str]):
self.text = text
self.tokens = tokens
def __repr__(self) -> str:
# Unambiguous, for developers. Shown in the REPL, debuggers, logs,
# and inside lists. Aim to make it look like valid constructor code.
return f"Prompt(text={self.text!r}, tokens={self.tokens})"
def __str__(self) -> str:
# Human-friendly, for print() / f-strings / end users.
return self.text
def __eq__(self, other) -> bool:
# Defines what == means. Without it, == is identity (same object).
return isinstance(other, Prompt) and self.tokens == other.tokens
def __len__(self) -> int:
return len(self.tokens) # enables len(prompt)
def __getitem__(self, i):
return self.tokens[i] # enables prompt[0] and slicing
def __iter__(self):
return iter(self.tokens) # enables `for tok in prompt`
p = Prompt("hi there", ["hi", "there"])
print(repr(p)) # => Prompt(text='hi there', tokens=['hi', 'there'])
print(str(p)) # => hi there
print(len(p)) # => 2
print(p[0]) # => hi
print([t.upper() for t in p]) # => ['HI', 'THERE'] (works because __iter__)
__repr__ vs __str__ trips everyone up. __str__ is the pretty face for users; __repr__ is the debugging face for you. If you define only one, define __repr__ — Python falls back to it when __str__ is missing, and it's what shows up in logs, stack traces, and when the object sits inside a list. {self.text!r} uses the !r conversion to insert the repr of text (with quotes), so the output is copy-pasteable.
Two more dunders that appear all over agent frameworks:
class Tool:
"""A callable tool an agent can invoke."""
def __init__(self, name: str, fn):
self.name = name
self.fn = fn
def __call__(self, **kwargs):
# Makes the INSTANCE callable like a function: tool(query="...").
return self.fn(**kwargs)
search = Tool("search", lambda query: f"results for {query!r}")
print(search(query="python")) # => results for 'python'
print(callable(search)) # => True
class Timer:
def __enter__(self): # runs at `with` entry
print("start"); return self
def __exit__(self, exc_type, exc, tb): # runs at `with` exit, even on error
print("done")
with Timer(): # context manager (full detail: Ch. 7)
print("working")
# => start / working / done
__call__ is why an agent's tool object can be used like tool(...) while still being an object with a .name, schema, and state. __enter__/__exit__ power the with statement — the standard way to manage resources like open clients or streaming responses.
Operator overloading is the same idea: __add__ defines +, __lt__ defines <, and so on. Libraries use it sparingly (e.g. combining prompt templates with +); don't reach for it unless the operator's meaning is obvious.
Reading & judging: An object with no
__repr__prints as<myapp.Agent object at 0x10f3c2a50>— undebuggable. When logs or error dumps show those hex-address blobs, the class is missing a__repr__; that's a real code-quality smell in anything you'll have to operate. Conversely, a clean__repr__is a sign someone expected the object to be debugged in production.
@property, @classmethod, @staticmethod
class Usage:
def __init__(self, prompt_tokens: int, completion_tokens: int):
self.prompt_tokens = prompt_tokens
self.completion_tokens = completion_tokens
@property
def total_tokens(self) -> int:
# A computed attribute. Accessed as `u.total_tokens` (NO parentheses),
# but recomputed on each access. Great for derived values.
return self.prompt_tokens + self.completion_tokens
@classmethod
def from_response(cls, resp: dict) -> "Usage":
# Alternative constructor. `cls` is the class itself, so subclasses
# get the right type back. This is the `Model.from_config(...)` pattern.
return cls(resp["input_tokens"], resp["output_tokens"])
@staticmethod
def price(tokens: int, per_million: float) -> float:
# No self, no cls — just a plain function grouped with the class
# for namespacing. Doesn't touch instance or class state.
return tokens / 1_000_000 * per_million
u = Usage.from_response({"input_tokens": 100, "output_tokens": 50})
print(u.total_tokens) # => 150 (no parentheses!)
print(Usage.price(150, 15.0)) # => 0.00225
@property turns a method into a read-only attribute (add a matching @total_tokens.setter if you need assignment). @classmethod is the idiomatic "named constructor" — you'll see Model.from_config(...), Client.from_env(), Message.from_dict(...) everywhere because Python allows only one __init__. @staticmethod is just a function that lives in the class's namespace.
JS → Python:
@property≈ JSget total() {...}/set total(v) {...}.@classmethodhas no direct JS equivalent — the closest is astatic from(...)factory method, but Python'sclsmakes it subclass-aware automatically.@staticmethod≈ JSstaticmethod.
Inheritance, super(), MRO — and why composition often wins
class Agent:
def __init__(self, name: str):
self.name = name
def act(self) -> str:
return f"{self.name} thinking"
class ToolAgent(Agent): # ToolAgent IS-A Agent
def __init__(self, name: str, tools: list[str]):
super().__init__(name) # call parent __init__ first
self.tools = tools
def act(self) -> str: # override
return super().act() + f" with {len(self.tools)} tools"
ta = ToolAgent("researcher", ["search", "calc"])
print(ta.act()) # => researcher thinking with 2 tools
print(isinstance(ta, Agent)) # => True (ToolAgent instances are Agents too)
super().__init__(...) runs the parent's constructor — forget it and self.name never gets set. Python supports multiple inheritance (a class can have several parents). When an attribute could come from more than one parent, Python resolves it using the MRO (Method Resolution Order) — a deterministic linearization of the class graph you can inspect with ClassName.__mro__. In practice, deep multiple inheritance is hard to follow; most modern AI libraries prefer shallow hierarchies plus composition — an object holds collaborators rather than inheriting from them:
class RagAgent:
# Composition: RagAgent HAS-A retriever and HAS-A model, rather than
# inheriting from both. Easier to test, swap, and reason about.
def __init__(self, retriever, model):
self.retriever = retriever
self.model = model
Use isinstance(x, Agent) to check type membership (it respects inheritance). Prefer it over type(x) == Agent, which rejects subclasses.
Reading & judging: Inheritance more than 2–3 levels deep, or a subclass that overrides most of its parent, usually signals inheritance being used where composition fits — a maintenance trap. A single class doing retrieval and prompting and HTTP and parsing is a "god-class"; good code splits those into collaborators. When you see
super().__init__missing in a subclass constructor, expect half-initialized objects.
@dataclass — the modern default for data holders
Most classes in AI code just bundle data. Writing __init__, __repr__, and __eq__ by hand for those is pure boilerplate. @dataclass generates all three from your type-annotated fields:
from dataclasses import dataclass, field
@dataclass
class Message:
role: str
content: str
name: str | None = None # field with a default
@dataclass(frozen=True) # frozen=True → immutable (can't reassign)
class ModelConfig:
name: str
temperature: float = 0.7
stop: list[str] = field(default_factory=list) # SAFE mutable default
m = Message("user", "hello")
print(m) # => Message(role='user', content='hello', name=None)
print(m == Message("user", "hello")) # => True (field-by-field __eq__, free)
cfg = ModelConfig("claude-opus-4")
# cfg.temperature = 0.9 # would raise FrozenInstanceError — it's frozen
Two things to internalize. First, field(default_factory=list) is the correct way to default to an empty list — it calls list() fresh for each instance, dodging the shared-mutable-default bug from earlier (dataclasses actually raise an error if you try stop: list = [] directly, which is a nice guardrail). Second, frozen=True makes instances immutable and hashable — ideal for config objects and cache keys, and aligned with the "new objects, never mutate" discipline these codebases favour.
JS → Python: A dataclass is roughly a TS
interfaceplus a generated constructor,equals, andtoString, in one declaration. There's no JS built-in equivalent — the nearest is hand-writing a class or using a library. Where TSreadonly/Object.freezeare advisory,frozen=Trueis enforced at runtime.
Dataclasses are for internal, trusted data — they annotate types but do not validate them (Message(role=123, content=None) constructs happily). When data crosses a trust boundary — API request bodies, LLM tool-call arguments, config files — you want Pydantic, which validates and coerces against the types. Chapter 8 covers Pydantic; reach for it at the edges and dataclasses in the core.
Reading & judging: A hand-written class whose entire body is
__init__assigningself.x = xplus a__repr__should almost always be a@dataclass— verbose hand-rolled versions often hide subtle bugs (a forgotten field in__eq__, a mutating default). Conversely, a@dataclassused where inputs are untrusted is a validation gap — that's Pydantic's job.
Protocols and duck typing vs explicit interfaces
Python's default typing philosophy is duck typing: "if it walks like a duck…" — code doesn't care about an object's class, only that it has the method being called. typing.Protocol makes that idea checkable by tools, describing a shape without inheritance:
from typing import Protocol, runtime_checkable
@runtime_checkable
class ChatModel(Protocol):
# Anything with a matching .chat() method counts as a ChatModel —
# NO base class, NO registration. This is structural typing.
def chat(self, prompt: str) -> str: ...
class AnthropicModel: # note: does NOT inherit ChatModel
def chat(self, prompt: str) -> str:
return f"[claude] {prompt}"
def run(model: ChatModel, prompt: str) -> str:
return model.chat(prompt) # type-checkers accept AnthropicModel
print(run(AnthropicModel(), "hi")) # => [claude] hi
print(isinstance(AnthropicModel(), ChatModel)) # => True (runtime_checkable)
Contrast with abc.ABC, an explicit interface a class must inherit and whose abstract methods it must implement — enforced at instantiation:
from abc import ABC, abstractmethod
class Retriever(ABC):
@abstractmethod
def search(self, query: str) -> list[str]: ...
# Retriever() → TypeError: Can't instantiate abstract class ... 'search'
Use a Protocol when you want to accept anything shaped right (great for pluggable model/tool backends you don't own). Use an ABC when you're defining a base others must explicitly extend and you want a hard error if they forget a method.
JS → Python: A
Protocolis the direct analogue of a TSinterface— structural, no inheritance needed, checked statically. Anabc.ABCis a nominal interface like anabstract classyou mustextends. TS interfaces vanish at runtime; a@runtime_checkableProtocol can also be tested live withisinstance.
When not to reach for a class
Many excellent GenAI codebases are function-first. A class buys you little if there's no persistent state — a single function that takes inputs and returns a result is clearer than a class instantiated once and called once. Use a class (or dataclass) when you have state that several methods share, multiple interchangeable implementations behind one interface, or objects that benefit from __repr__/__eq__. Use a plain function for a stateless transformation, and a plain dict for loose, short-lived bags of data.
Reading & judging: A class with one method (besides
__init__) and no meaningful state is a function wearing a costume — mentally rewrite it asdef. A dict passed through ten functions with keys accessed everywhere would be safer as a@dataclass(typos become errors, not silentKeyErrors). Judge for the right altitude of OOP: enough structure to make state and interfaces explicit, not so much that a two-line transform hides inside three classes.
Try it: Write a
@dataclass(frozen=True)Message(role, content), then a mutable@dataclass Conversationwhosemessagesfield usesfield(default_factory=list). Add atoken_count@property, aConversation.from_pairs(...)@classmethodalternative constructor, and__len__/__iter__solen(convo)andfor msg in convowork. Then define aChatModelProtocolwith.chat(prompt)and a fake class satisfying it without inheriting — confirmisinstancepasses with@runtime_checkable. You'll have touched every tool in this chapter.