Addaly is in open beta. Things will change, and AI answers can be wrong — check anything that matters.

Python, From Zero, For AI

From your first line of code to your first API call.

Lesson 60 of 899 min

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.

python
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)})"
python
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:

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

python
class Conversation:
    ...
    @classmethod
    def from_file(cls, path):
        data = json.loads(Path(path).read_text())
        conv = cls(data["model"])
        conv.messages = data["messages"]
        return conv

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

python
    @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.

No ads. No data sale. No public scores on people. Ever.

© 2026 Addaly