Functions & Classes

Classes & Objects

Classes And Objects

A class is a blueprint. An object is one thing built from it.

If you come from Java or C#, you already know this idea thoroughly — skip to the comparison near the bottom and note the differences. If you have never programmed before, the honest guidance is that you need less of this than you expect. Most agent code is functions and dictionaries. Classes turn up when you are holding state.

The shape

class Agent:
    def __init__(self, name, model):
        self.name = name
        self.model = model
        self.history = []

    def ask(self, question):
        self.history.append(question)
        return f"{self.name} answering with {self.model}: {question}"

bot = Agent("triage", "deepseek-chat")
print(bot.ask("why is the server down?"))
print(bot.history)

Reading it:

  • class Agent: starts the blueprint
  • __init__ is the constructor. It runs when you create an object
  • self is the object itself, and it is written out explicitly every time
  • self.name = name stores a value on this particular object
  • Agent("triage", "deepseek-chat") builds one. There is no new

self, which is the part that looks odd

Every method takes self as its first parameter, and you never pass it when calling.

bot.ask("hello")

Python turns that into Agent.ask(bot, "hello") behind the scenes. The object goes in as self.

Two rules follow, and forgetting either is the classic beginner error:

  • every method must declare self as its first parameter
  • every reference to the object's own data needs self. — plain name inside a method means a local variable, not self.name
class Agent:
    def __init__(self, name):
        self.name = name

    def greet(self):
        print(f"I am {self.name}")

Java and C# let you write name and infer this. Python does not. It is more typing and less ambiguity.

Instance data versus class data

class Agent:
    provider = "deepseek"

    def __init__(self, name):
        self.name = name

a = Agent("triage")
b = Agent("summary")

print(a.provider, b.provider)
print(a.name, b.name)

provider is defined on the class, so every object shares it. name is set on self, so each object has its own.

The same trap as mutable default arguments applies here. A list defined at class level is shared by every instance:

class Agent:
    history = []

Every agent would append to the same list. Put mutable state in __init__ instead.

When a class earns its place

Use one when you have data and behaviour that belong together, and the data persists between calls.

Good reasons:

  • an API client holding a key, a base URL and a session
  • a conversation holding its message history
  • anything you will create several of, each with its own state

Poor reasons:

  • a place to put functions that do not share state — use a module
  • one method and no data — use a function
  • because you are used to everything living in a class

A useful test: if every method would work identically as a plain function taking the same arguments, you do not need a class.

A realistic example

class Conversation:
    def __init__(self, system_prompt):
        self.messages = [{"role": "system", "content": system_prompt}]

    def add_user(self, text):
        self.messages.append({"role": "user", "content": text})

    def add_assistant(self, text):
        self.messages.append({"role": "assistant", "content": text})

    def last(self):
        return self.messages[-1]["content"]

    def __len__(self):
        return len(self.messages)

chat = Conversation("You are a triage assistant.")
chat.add_user("The database is down.")
chat.add_assistant("Checking the logs now.")

print(len(chat))
print(chat.last())

This is a class worth writing. The message list has to persist, several methods act on it, and you may want more than one conversation at a time.

Note __len__. Defining it makes len(chat) work. Python has a set of these dunder methods — __str__ for printing, __eq__ for comparison — that let your object behave like a built-in one.

Inheritance, lightly

class Tool:
    def run(self, **kwargs):
        raise NotImplementedError

class WeatherTool(Tool):
    def run(self, city):
        return f"28 degrees in {city}"

class WeatherTool(Tool) means WeatherTool is a Tool and gets everything it has.

You will meet this when subclassing something a framework provides — a custom retriever, a custom callback handler. You will rarely need deep hierarchies of your own. Python favours composition, and for tools it favours plain decorated functions over classes entirely.

Privacy, or the lack of it

class Agent:
    def __init__(self):
        self.name = "triage"
        self._retry_count = 0

There is no private. A leading underscore means "internal, please leave alone". Nothing enforces it.

Coming from an enterprise codebase this feels reckless. In practice it causes far less trouble than you would expect, and it makes debugging and testing considerably easier.

Coming from Java or C#

ConceptJava / C#Python
Constructorsame name as the class__init__
This objectthis, usually implicitself, always explicit
Creatingnew Agent()Agent()
Inheritanceextends, :class B(A):
Access controlprivate, publicconvention only, _name
InterfacesinterfaceProtocol, or duck typing
One class per fileusually enforcednot at all
Propertiesgetters and setters@property
toStringtoString, ToString__str__

The habits to drop:

  • You do not need a class to hold functions. A module of functions is normal.
  • You do not need an interface to accept something. If it has the right method, it works.
  • Getters and setters are not the default. Public attributes are fine; add @property later if you need logic, without changing how callers use it.

Common mistakes

  • Forgetting self in a method signature, giving takes 0 positional arguments but 1 was given.
  • Forgetting self. inside a method, so you silently create a local variable.
  • Mutable class attributes shared across every instance.
  • Writing a class where a function would do.
  • Expecting _name to be private. It is not.

Practise this

  • Write a Ticket class with an id, a severity and a method that returns whether it is urgent.
  • Create two objects from it and confirm they have separate data.
  • Add __str__ so that print(ticket) reads well.
  • Take a class you wrote and ask honestly whether a function would have been simpler.

Try It Yourself

Copy this into a new Colab notebook and run it. Nothing to install.

example.py
1from typing import Optional
2
3class AIAgent:
4 """A simple AI agent class."""
5
6 def __init__(self, name: str, model: str = "deepseek-chat"):
7 self.name = name
8 self.model = model
9 self.messages: list[dict] = []
10 self.tools: list[dict] = []
11
12 def add_system_message(self, content: str):
13 """Set the system prompt."""
14 self.messages.append({
15 "role": "system",
16 "content": content
17 })
18
19 def add_user_message(self, content: str):
20 """Add a user message to the conversation."""
21 self.messages.append({
22 "role": "user",
23 "content": content
24 })
25
26 def register_tool(self, name: str, description: str, func):
27 """Register a tool the agent can use."""
28 self.tools.append({
29 "name": name,
30 "description": description,
31 "function": func
32 })
33
34 def get_context(self) -> dict:
35 """Get the current agent context."""
36 return {
37 "agent": self.name,
38 "model": self.model,
39 "message_count": len(self.messages),
40 "tools": [t["name"] for t in self.tools]
41 }
42
43# Create and use the agent
44agent = AIAgent("ResearchBot", "deepseek-reasoner")
45agent.add_system_message("You are a helpful research assistant.")
46agent.add_user_message("Find information about Python.")
47
48# Register a tool
49def search(query: str) -> str:
50 return f"Results for: {query}"
51
52agent.register_tool("search", "Search the web", search)
53
54print("Agent Context:")
55for key, value in agent.get_context().items():
56 print(f" {key}: {value}")