The problem: Functions reuse logic, but some things must also carry their own data. Two model clients can use different settings, and two agents must not accidentally share one conversation history.
What you will learn: Object-oriented Python bundles related state and behaviour. You will distinguish a class from an object, see how
selfidentifies one instance, and learn when composition is clearer than inheritance—or when a plain function is clearer than any class.
1. Classes and Objects
Functions answer, “What work should happen?” Classes also help answer, “Which thing owns this data?”
LLMClient class
defines: model_name, temperature, describe()
↓ creates
client object
stores: "small-model", 0.2
can run: client.describe()
The class is code; it does not itself represent one connected client. Calling the class creates an object with its own state. Two objects from the same class can hold different settings without interfering with each other.
Blueprint and Instance
A class is a blueprint. An object (or instance) is a real thing built from that blueprint.
Why Data and Behaviour Belong Together
Functions alone can’t easily “remember” data between calls. A class lets you bundle related data (attributes) and behavior (methods) into a single reusable unit that keeps its own state.
Picture One Blueprint Making Many Objects
A class is a cookie cutter; objects are the cookies it makes. Same shape, but each cookie is a separate, independent thing in memory:
graph TD
blueprint["[ Class Blueprint: LLMClient ]<br/>Attributes: model_name, temperature"]
blueprint -->|instantiates| obj1["[ Object instance 1 ]<br/>model_name = 'gpt-4o-mini'<br/>temperature = 0.2"]
blueprint -->|instantiates| obj2["[ Object instance 2 ]<br/>model_name = 'claude-3-5-sonnet'<br/>temperature = 0.7"]
A Familiar Example
“Car” is a blueprint (class) — it defines that cars have a color, a speed, and can accelerate. Your specific car in the driveway is an object — an actual instance with its own color and current speed.
Syntax
class ClassName:
def __init__(self, param1):
self.attribute1 = param1
def method_name(self):
# do something with self.attribute1
pass
Example
class LLMClient:
model_name: str
temperature: float
def __init__(self, model_name: str, temperature: float = 0.7) -> None:
self.model_name = model_name
self.temperature = temperature
def describe(self) -> str:
return f"LLMClient(model={self.model_name}, temperature={self.temperature})"
client: LLMClient = LLMClient("gpt-4o-mini", temperature=0.2)
print(client.describe())
print(client.model_name)
Expected Output:
LLMClient(model=gpt-4o-mini, temperature=0.2)
gpt-4o-mini
How It Works
class LLMClient:defines the blueprint.__init__is the constructor — it runs automatically when you create a new object, setting up its initial data.selfrefers to “this specific object” — it’s how a method accesses the object’s own data.client = LLMClient(...)creates an actual object (an instance) from the blueprint.
🤖 How Is This Used in AI?
This is exactly the shape of every real AI SDK client:
client = Anthropic(api_key="your_api_key_here")
response = client.messages.create(model="claude-...", messages=[...])
Anthropic(...) builds an object that remembers your API key so you don’t
have to pass it into every single call — that convenience is __init__ and
self at work.
✅ Key Takeaway: Use a class whenever you need something that carries its own settings/state and offers behavior around that state.
2. Constructors (__init__)
__init__ runs once, automatically, the moment an object is created — it’s
where you set up the object’s starting data.
class RAGPipeline:
def __init__(self, vector_db_name, top_k=3):
self.vector_db_name = vector_db_name
self.top_k = top_k
self.history = [] # starts empty for every new pipeline
pipeline = RAGPipeline("my_docs_index")
print(pipeline.top_k)
print(pipeline.history)
Expected Output:
3
[]
🤖 How Is This Used in AI? Every model wrapper, agent, or pipeline
class you write will use __init__ to set up configuration (model name,
API key, thresholds) and starting state (empty history, empty cache) once,
at creation time.
3. Instance Variables vs Class Variables
Instance variables
Belong to one specific object — set via self.x = ... inside methods.
Class variables
Shared by all objects of that class — defined directly in the class body.
class Agent:
default_max_retries = 3 # class variable — shared by every Agent
def __init__(self, name):
self.name = name # instance variable — unique per Agent
agent1 = Agent("Researcher")
agent2 = Agent("Summarizer")
print(agent1.name, agent2.name) # different
print(agent1.default_max_retries) # shared
print(Agent.default_max_retries) # accessible on the class itself
Expected Output:
Researcher Summarizer
3
3
🤖 How Is This Used in AI? A class variable is a great place for a
setting that should default the same way for every instance — like a
default retry count or a default system prompt — while each agent still
keeps its own name and history as instance variables.
⚠️ Common Beginner Mistake: Accidentally modifying a mutable class variable (like a list) causes it to be shared — and changed — across every instance, which is rarely what you want. Mutable “shared defaults” should almost always be created fresh inside
__init__instead.
4. Instance Methods, Class Methods, Static Methods
class PromptBuilder:
default_tone = "neutral"
def __init__(self, topic):
self.topic = topic
# Instance method — operates on this specific object's data
def build(self, tone=None):
tone = tone or self.default_tone
return f"Write about {self.topic} in a {tone} tone."
# Class method — operates on the class itself, not one instance
@classmethod
def with_default_tone(cls, topic, tone):
cls.default_tone = tone
return cls(topic)
# Static method — doesn't need self or cls, just lives here logically
@staticmethod
def word_count(text):
return len(text.split())
builder = PromptBuilder("retrieval-augmented generation")
print(builder.build())
print(PromptBuilder.word_count("this prompt has five words"))
Expected Output:
Write about retrieval-augmented generation in a neutral tone.
5
🧠 Intuition:
- Instance method — “do something using this object’s data.”
- Class method — “do something related to the whole class, not one object” (often used for alternate constructors).
- Static method — “a utility function that’s related to this class but doesn’t need any object or class data at all.”
🤖 How Is This Used in AI? Static methods are common for small helper
utilities (word counting, text cleaning) attached to a relevant class for
organization. Class methods often appear as alternate constructors, e.g.
Agent.from_config(config_dict).
5. Encapsulation
What Is It?
Encapsulation means keeping an object’s internal details hidden, and exposing only what other code needs to interact with.
class APIKeyManager:
_api_key: str
def __init__(self, api_key: str) -> None:
self._api_key = api_key # leading underscore = "internal, don't touch directly"
def get_masked_key(self) -> str:
return self._api_key[:4] + "..." + self._api_key[-4:]
manager: APIKeyManager = APIKeyManager("sk-1234567890abcdef")
print(manager.get_masked_key())
💡 Encapsulating Properties (@property)
Sometimes you want to validate variables before they are set, but still access them like normal variables. Python’s @property decorator allows you to define getter and setter methods that behave like regular class attributes:
class Generator:
_temperature: float
def __init__(self, temperature: float) -> None:
self.temperature = temperature # runs the setter!
@property
def temperature(self) -> float:
return self._temperature
@temperature.setter
def temperature(self, value: float) -> None:
if not (0.0 <= value <= 2.0):
raise ValueError("temperature must be between 0.0 and 2.0")
self._temperature = value
gen: Generator = Generator(0.7)
print(gen.temperature) # 0.7 (reads via getter)
# gen.temperature = 3.5 # would raise ValueError: temperature must be between 0.0 and 2.0
Expected Output:
sk-1...cdef
0.7
🧠 Intuition: Think of a TV remote — you press buttons (the public
interface), but you never touch the internal circuitry directly. Python
uses a leading underscore (_api_key) as a convention signaling “this is
internal — use the methods provided instead of reaching in directly.”
🤖 How Is This Used in AI? API clients hide connection details, retry
logic, and authentication behind clean methods like .create(...) — you
never need to touch the raw HTTP request yourself. Encapsulation is why
calling an LLM API feels simple even though a lot happens underneath.
6. Inheritance
What Is It?
Inheritance lets a class reuse and extend another class’s behavior.
class BaseTool:
def __init__(self, name):
self.name = name
def run(self, input_text):
raise NotImplementedError("Subclasses must implement run()")
class WeatherTool(BaseTool):
def run(self, input_text):
return f"Pretending to fetch weather for: {input_text}"
class CalculatorTool(BaseTool):
def run(self, input_text):
return f"Result: {eval(input_text)}" # simplified for teaching purposes
tools = [WeatherTool("weather"), CalculatorTool("calculator")]
for tool in tools:
print(tool.name, "->", tool.run("2 + 2" if tool.name == "calculator" else "Paris"))
Expected Output:
weather -> Pretending to fetch weather for: Paris
calculator -> Result: 4
🧠 Intuition
Inheritance is a parent-to-child relationship: WeatherTool and
CalculatorTool both are BaseTools, and automatically get anything
BaseTool defines, while adding or overriding their own behavior.
🤖 How Is This Used in AI?
This is precisely how agent-tool frameworks are structured: a BaseTool
class defines the shared interface (name, run()), and every specific
tool (search, calculator, database lookup) is a subclass that implements
run() its own way. Any code that loops through tools doesn’t need to
know which specific tool it’s calling — it just calls .run() on each.
⚠️ Common Beginner Mistake: Forgetting to override a required method (like
run()) in a subclass — calling the base class’s version directly raisesNotImplementedError, which is intentional: it forces every subclass to actually implement its own behavior.
7. Polymorphism
What Is It?
Different classes responding to the same method call in their own way
— exactly what you just saw with tool.run(...) above: one line of code,
different behavior depending on which object it’s called on.
def run_all_tools(tools, input_text):
for tool in tools:
print(tool.run(input_text))
🧠 Intuition: “Many shapes, one interface.” You don’t need an if
statement checking which tool type you have — you just call .run() and
each object knows how to handle it.
🤖 How Is This Used in AI? Agent frameworks rely on polymorphism
heavily: an agent’s planning loop calls tool.run(...) without caring
whether it’s a web search tool, a calculator, or a database query — each
tool object handles the details of “how” internally.
8. Composition
What Is It?
Building a class out of other objects, rather than inheriting from them — “has a,” not “is a.”
class Retriever:
def search(self, query):
return [f"doc about {query}"]
class Generator:
def generate(self, context, question):
return f"Answer based on: {context}"
class RAGSystem:
def __init__(self):
self.retriever = Retriever() # RAGSystem "has a" Retriever
self.generator = Generator() # RAGSystem "has a" Generator
def answer(self, question):
context = self.retriever.search(question)
return self.generator.generate(context, question)
rag = RAGSystem()
print(rag.answer("What is Python?"))
Expected Output:
Answer based on: ['doc about What is Python?']
🧠 Intuition: Inheritance is “a WeatherTool is a BaseTool.”
Composition is “a RAGSystem has a Retriever and has a
Generator.” Composition is usually preferred when the relationship is
“made of parts” rather than “a specialized type of.”
Here is a comparison of these two structures:
graph TD
subgraph Inheritance ["is-a relationship"]
BaseTool[BaseTool] -->|inherited by| WeatherTool[WeatherTool]
BaseTool -->|inherited by| SearchTool[SearchTool]
end
subgraph Composition ["has-a relationship"]
RAGSystem[RAGSystem] -->|has a| Retriever[Retriever]
RAGSystem -->|has a| Generator[Generator]
end
🤖 How Is This Used in AI? This is exactly how real RAG systems and agents are built — a top-level class composed of a retriever, a generator, maybe a memory store and a set of tools, each as its own object.
9. Dunder Methods (__str__, __repr__, __len__, __eq__)
“Dunder” = double underscore. These let your objects work naturally with Python’s built-in functions and operators.
class Document:
def __init__(self, text, score):
self.text = text
self.score = score
def __str__(self):
return f"Document(score={self.score}): {self.text[:20]}..."
def __eq__(self, other):
return self.text == other.text
doc1 = Document("Python is great for AI applications.", 0.9)
doc2 = Document("Python is great for AI applications.", 0.5)
print(doc1) # uses __str__ automatically
print(doc1 == doc2) # uses __eq__ automatically
Expected Output:
Document(score=0.9): Python is great for AI ...
True
🤖 How Is This Used in AI? Custom Document or Message classes in
AI pipelines often define __str__ (readable debug printing) and __eq__
(deduplicating identical chunks before storing them in a vector database).
When a Class Helps—and When It Does Not
A class is useful when something has state that changes over time and
behaviour that belongs with that state. An Agent can hold its model name,
conversation history, and tools, while methods decide how it acts.
class Agent (blueprint)
├── agent_a (its own history and tools)
└── agent_b (a different history and tools)
Do not create a class merely because classes exist. A small calculation with no lasting state is often clearer as a function. A record that mostly carries data may be clearer as a dataclass. Prefer composition—an agent has a retriever and has a model client—when inheritance would create a deep, rigid family tree.
Module Summary
You can now define classes with __init__, distinguish instance vs. class
variables, choose the right kind of method, hide internal details with
encapsulation, reuse and extend behavior with inheritance, let different
objects respond to the same call through polymorphism, build systems out
of smaller objects via composition, and make your own objects print and
compare naturally with dunder methods.
AI Connection
Object-oriented Python is how real AI systems are structured at scale: an
LLMClient class wraps API details, a BaseTool class defines a shared
interface every tool subclass implements, and a top-level Agent or
RAGSystem class is composed of a retriever, a generator, and a memory
store. When you later use LangChain or LangGraph, you’re mostly using
someone else’s well-designed classes built on these exact same ideas.
Mini Practice
- Write a
ChatMessageclass withroleandcontentinstance variables, and a__str__method that prints them nicely. - Write a
BaseToolclass with arun()method that raisesNotImplementedError, then create two subclasses that override it. - Add a class variable
default_temperature = 0.7to anLLMClientclass, and show that changing it on the class affects new instances that don’t override it. - Build a small
Agentclass via composition — it should have aretrieverand atoolas attributes, each its own simple object. - Explain, in your own words, the difference between inheritance (“is a”) and composition (“has a”), using an AI example for each.
Next: Module 6 — Errors and Exception Handling — gracefully handling API failures, invalid model output, and timeouts.
- Author
- TechByteByByte Editorial Team
- Reviewed by
- TechByteByByte Admin
- Published
- Last reviewed