Classes: when a dict stops being enough
The dict that grew
You have been representing a conversation as a list of dicts, which works. Then you need to know its total token count, trim it when it gets long, save it, and reload it. Each of those is a function that takes the list, and the functions start to need each other, and you find yourself passing around a list, a token count, a model name and a file path together, always together. That is the signal. Data that always travels with the same functions wants to be an object.
class Conversation:
def __init__(self, model, system=None):
self.model = model
self.messages = []
if system:
self.messages.append({"role": "system", "content": system})
def add(self, role, text):
self.messages.append({"role": role, "content": text})
def last(self):
return self.messages[-1]["content"] if self.messages else None
def __repr__(self):
return f"Conversation(model={self.model!r}, turns={len(self.messages)})"c = Conversation("gpt-4o-mini", system="Be brief.")
c.add("user", "What is a class?")
print(c) # Conversation(model='gpt-4o-mini', turns=2)
print(c.last())What each piece is
class Conversation: defines a new type, exactly as int and list are types. Conversation("gpt-4o-mini") creates an instance — an object of that type — and Python calls __init__ on it with the arguments you passed.
self is the instance. It is not a keyword; it is a naming convention so strong that breaking it is rude. When you write c.add("user", "hi"), Python turns it into Conversation.add(c, "user", "hi"). That is the entire mystery of self: it is the object before the dot, passed as the first argument.
self.model = model creates an attribute on that instance. Each instance has its own set; c.model and d.model are separate slots.
__repr__ decides what print(c) and the shell show. Without it you get <__main__.Conversation object at 0x104f3e2d0>, which is useless. Write it on every class you will debug, which is every class.
The trap in the class body
This looks equivalent and is not:
class Conversation:
messages = [] # one list, on the class
def add(self, role, text):
self.messages.append({"role": role, "content": text})messages = [] runs once, when the class is defined, and creates one list that belongs to the class. self.messages looks for an attribute on the instance, does not find one, and falls through to the class — where it finds the shared list. Every instance appends to the same list. Two conversations become one.
A value assigned in the class body is a class attribute, shared by all instances. A value assigned to self.something in __init__ is an instance attribute, one per object. Constants belong on the class; anything mutable belongs in __init__. This is the same lesson as the mutable default argument from module 3, wearing a different coat.
Methods that do not need an instance
Sometimes a function belongs with the class but not with any particular object:
class Conversation:
...
@classmethod
def from_file(cls, path):
data = json.loads(Path(path).read_text())
conv = cls(data["model"])
conv.messages = data["messages"]
return convConversation.from_file("chat.json") builds an instance from saved data. cls is the class itself, so this still works if someone subclasses Conversation later. Alternative constructors are the usual reason for a @classmethod.
Attributes are not private
Nothing stops c.messages = "oops". Python has no private keyword. The convention is a leading underscore: self._cache means "not part of the interface; touch it and you own the consequences". It is a message to readers, not a lock. Most of the time that is enough.
When you need to compute a value on access, or validate on assignment, @property makes a method look like an attribute:
@property
def turns(self):
return sum(1 for m in self.messages if m["role"] != "system")c.turns — no brackets — calls the method. Use it for cheap derived values. A property that makes a network call is a trap for whoever reads c.turns in a loop.
When not to write a class
A class is not a badge of seriousness. If a dict does the job and no functions cluster around it, keep the dict. If you have a function and some fixed settings, a function with default arguments or a closure is simpler than a class with one method. Classes earn their place when data and behaviour genuinely belong together, or when you need several objects of the same shape that carry their own state — one Conversation per user, one Budget per job.
The Budget class from the previous module is the pattern at its smallest: two numbers and the one method that changes them, kept together so that nobody updates one without the other.
Try this now
Write Conversation with add, last, __repr__, and a token_estimate method that sums len(content) // 4 across messages. Then rewrite it with messages = [] in the class body, create two instances, and watch them share. Put it back.
The one thing to keep
A class bundles data with the functions that act on it and gives the bundle a name; self is simply the object the method was called on, and a mutable value placed on the class rather than in __init__ is shared by every instance.
Before you move on
A developer writes `class Conversation:` with `messages = []` declared directly in the class body, and an `add(self, role, text)` method that appends to `self.messages`. They create two conversations, add a message to the first, and find the second conversation contains it too. Why?
Pick the one you would defend. Nobody sees your answer.