TechByteByByte

Object-Oriented Python

Learn classes, objects, inheritance, polymorphism, composition, and dunder methods in Python, and how they're used to build model wrappers, tools, and agents in AI applications.

#Python#OOP#Classes#Inheritance#AI#Python for AI

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 self identifies 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.
  • self refers 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 raises NotImplementedError, 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

  1. Write a ChatMessage class with role and content instance variables, and a __str__ method that prints them nicely.
  2. Write a BaseTool class with a run() method that raises NotImplementedError, then create two subclasses that override it.
  3. Add a class variable default_temperature = 0.7 to an LLMClient class, and show that changing it on the class affects new instances that don’t override it.
  4. Build a small Agent class via composition — it should have a retriever and a tool as attributes, each its own simple object.
  5. 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