Classes & OOP
Classes are blueprints for creating objects. Think of a class like a cookie cutter โ it defines the shape, and each cookie you stamp out is an object with that shape but its own filling.
Learning Objectives
- Understand what a class and an object are โ in plain English
- Define a class with __init__ (the setup method)
- Create objects (instances) from a class
- Add methods (functions inside a class)
- Use self correctly โ and understand why it exists
- Use inheritance โ one class builds on another
- Override methods in child classes
- Use class attributes vs instance attributes
- Read and use classes from libraries (what you'll actually do most)
Why Classes? โ A Plain-English Analogy
Imagine you're running a pizza restaurant. You have a recipe for a Margherita pizza โ that recipe lists the dough, sauce, cheese, and how long to bake it. The recipe itself is not a pizza. It's the blueprint.
Every time you follow that recipe and put a pizza in the oven, you get a real pizza. Each pizza is a separate thing โ you can have 5 Margherita pizzas, each with slightly different sizes, but they all follow the same recipe.
The key terms, explained simply
- Class = the recipe / the blueprint / the template
- Object (or instance) = the actual thing you made from the recipe
- Attributes = the data stored inside each object (the ingredients)
- Methods = the actions an object can do (the cooking steps)
- __init__ = the setup that happens when you first create an object (gathering the ingredients)
- self = a reference to "this particular object" (so each pizza knows its own toppings)
Your First Class โ A Simple Bank Account
Let's start with something everyone understands: a bank account. A bank account has a balance (data) and you can deposit or withdraw money (actions).
# Step 1: Define the class (the blueprint)
class BankAccount:
# Step 2: __init__ runs automatically when you create an account
# 'self' refers to the specific account being created
def __init__(self, owner_name, starting_balance=0):
self.owner = owner_name # store the owner's name
self.balance = starting_balance # store the balance
# Step 3: Define methods (things the account can do)
def deposit(self, amount):
self.balance += amount
print(f"Deposited ${amount:.2f}. New balance: ${self.balance:.2f}")
def withdraw(self, amount):
if amount > self.balance:
print(f"Insufficient funds! Balance: ${self.balance:.2f}")
return False
self.balance -= amount
print(f"Withdrew ${amount:.2f}. New balance: ${self.balance:.2f}")
return True
def check_balance(self):
print(f"{self.owner}'s balance: ${self.balance:.2f}")
# Step 4: Create objects (real accounts) from the class
alice_account = BankAccount("Alice", 100)
bob_account = BankAccount("Bob", 50)
# Step 5: Use the objects
alice_account.check_balance() # Alice's balance: $100.00
alice_account.deposit(25) # Deposited $25.00. New balance: $125.00
alice_account.withdraw(10) # Withdrew $10.00. New balance: $115.00
bob_account.check_balance() # Bob's balance: $50.00
bob_account.withdraw(100) # Insufficient funds! Balance: $50.00
Understanding self
Think of self as saying "this particular object". When you write self.balance, you're saying "this account's balance". Without self, Python wouldn't know if you mean Alice's balance or Bob's balance โ both are BankAccount objects.
What's Actually Happening โ Step by Step
When you write alice_account = BankAccount("Alice", 100), Python does this:
1. Creates an empty object (a blank BankAccount)
2. Calls __init__(self, "Alice", 100)
- self = that empty object (Python fills this in automatically)
- owner_name = "Alice"
- starting_balance = 100
3. Inside __init__, self.owner = "Alice" stores the name
4. Inside __init__, self.balance = 100 stores the balance
5. Returns the fully set-up object
6. alice_account now points to that object
You never pass self yourself. Python automatically fills it in. That's why BankAccount("Alice", 100) has 2 arguments, but __init__(self, owner_name, starting_balance) has 3 parameters โ the first one is handled for you.
A Real AI Example โ An LLM Client
Here's a class you might actually write in AI engineering โ a client that talks to an LLM API:
class LLMClient:
"""A simple client for calling LLM APIs."""
# This is a class attribute โ shared by ALL LLMClient objects
default_temperature = 0.7
def __init__(self, api_key: str, model: str = "gpt-4o-mini"):
# These are instance attributes โ each object has its own
self.api_key = api_key
self.model = model
self.conversation_history = [] # empty list for each client
self.total_tokens = 0 # track usage per client
def chat(self, user_message: str) -> str:
"""Send a message and get a response."""
# Add user message to history
self.conversation_history.append({
"role": "user",
"content": user_message
})
# Simulate an API call (in real code, use the requests library)
response = f"[Response from {self.model}]: You said: {user_message}"
tokens_used = len(user_message) * 2 # fake token count
# Add assistant response to history
self.conversation_history.append({
"role": "assistant",
"content": response
})
self.total_tokens += tokens_used
return response
def clear_history(self):
"""Start a fresh conversation."""
self.conversation_history = []
print("Conversation history cleared.")
def get_usage(self):
"""Show token usage for this client."""
print(f"Model: {self.model} | Tokens used: {self.total_tokens | Messages: {len(self.conversation_history)")
# Create two different clients
chatbot = LLMClient(api_key="sk-abc123", model="gpt-4o")
coder = LLMClient(api_key="sk-abc123", model="claude-sonnet-4.6-sonnet")
# Each client has its own conversation
chatbot.chat("What's the weather?")
chatbot.chat("How about tomorrow?") # remembers previous message
coder.chat("Write a Python function") # separate conversation
chatbot.get_usage() # Model: gpt-4o | Tokens: X | Messages: 4
coder.get_usage() # Model: claude-sonnet-4.6-sonnet | Tokens: Y | Messages: 2
Class attribute vs Instance attribute
- Class attribute (
default_temperature) โ shared by all objects. Like a company policy that applies to everyone. - Instance attribute (
self.model) โ each object has its own copy. Like an employee's personal name. - Change a class attribute and it changes for all objects. Change an instance attribute and it only affects that one object.
More Examples โ Different Domains, Same Pattern
Example: A Todo Item
This shows how to represent a simple data object with a method that changes its state.
class TodoItem:
def __init__(self, title, priority="medium"):
self.title = title
self.priority = priority
self.completed = False # all new todos start as not done
def mark_done(self):
self.completed = True
print(f"โ
Done: {self.title}")
def reopen(self):
self.completed = False
print(f"๐ Reopened: {self.title}")
def __str__(self):
"""Controls what print(todo) shows."""
status = "โ
" if self.completed else "โฌ"
return f"{status} [{self.priority] {self.title"
# Create and use todo items
task1 = TodoItem("Learn Python classes", "high")
task2 = TodoItem("Call OpenAI API", "medium")
print(task1) # โฌ [high] Learn Python classes
task1.mark_done() # โ
Done: Learn Python classes
print(task1) # โ
[high] Learn Python classes
print(task2) # โฌ [medium] Call OpenAI API
The __str__ method
__str__ is a special method that controls what happens when you print() an object. Without it, you get an ugly <__main__.TodoItem object at 0x...>. With it, you get a clean, readable string. Always add __str__ to your classes.
Example: A Document for RAG
This is a real pattern from AI engineering โ representing a document that will be stored in a vector database.
class Document:
"""A chunk of text that can be embedded and retrieved."""
def __init__(self, content, source="unknown", page=None):
self.content = content
self.source = source
self.page = page
self.embedding = None # will be filled later
self.chunk_id = None # will be assigned later
def word_count(self):
"""How many words in this document chunk."""
return len(self.content.split())
def preview(self, max_chars=100):
"""Short preview of the content."""
if len(self.content) <= max_chars:
return self.content
return self.content[:max_chars] + "..."
def set_embedding(self, vector):
"""Store the embedding vector (list of floats)."""
self.embedding = vector
def to_dict(self):
"""Convert to a dict for JSON serialization."""
return {
"content": self.content,
"source": self.source,
"page": self.page,
"chunk_id": self.chunk_id,
}
def __str__(self):
return f"Document(from={self.source, words={self.word_count())"
# Create document chunks
doc1 = Document(
content="Python is a programming language...",
source="python_docs.pdf",
page=1
)
doc2 = Document(
content="Machine learning models learn from data...",
source="ml_intro.pdf",
page=5
)
print(doc1) # Document(from=python_docs.pdf, words=6)
print(doc1.preview(20)) # "Python is a program..."
doc1.set_embedding([0.1, 0.5, 0.3]) # store vector
print(doc1.to_dict()) # serialize for JSON
Inheritance โ Building on What Exists
Inheritance means one class copies and extends another. Think of it like a template: a "Vehicle" class has wheels and speed. A "Car" is a Vehicle, but it also has doors. A "Motorcycle" is also a Vehicle, but has no doors. Both inherit "wheels and speed" from Vehicle.
# PARENT class โ the base everything builds on
class LLMClient:
def __init__(self, api_key, model):
self.api_key = api_key
self.model = model
self.history = []
def chat(self, message):
print(f"[{self.model] Processing: {message")
return "Generic response"
def clear_history(self):
self.history = []
# CHILD class โ inherits everything from LLMClient, adds its own stuff
class OpenAIClient(LLMClient): # (LLMClient) = inherits from
def __init__(self, api_key, model="gpt-4o-mini"):
super().__init__(api_key, model) # call parent's __init__
self.base_url = "https://api.openai.com/v1" # OpenAI-specific
def chat(self, message): # OVERRIDE the parent's chat
# Use OpenAI-specific API format
print(f"๐ Calling OpenAI {self.model...")
self.history.append({"role": "user", "content": message})
# ... real API call would go here ...
return "OpenAI response"
# ANOTHER child class โ different behavior, same interface
class AnthropicClient(LLMClient):
def __init__(self, api_key, model="claude-sonnet-4.6-sonnet"):
super().__init__(api_key, model)
self.base_url = "https://api.anthropic.com/v1"
def chat(self, message): # OVERRIDE differently
print(f"๐ค Calling Anthropic {self.model...")
# ... Anthropic-specific API call ...
return "Claude response"
# Use them โ same interface, different behavior
openai = OpenAIClient("sk-abc")
claude = AnthropicClient("sk-xyz")
openai.chat("Hello") # ๐ Calling OpenAI gpt-4o-mini...
claude.chat("Hello") # ๐ค Calling Anthropic claude-sonnet-4.6-sonnet...
# Both inherited clear_history() from the parent
openai.clear_history() # works even though we didn't define it
Why inheritance matters for AI engineering
When you work with LangChain, LlamaIndex, FastAPI, or any AI library, you'll subclass their classes (e.g., class MyRetriever(BaseRetriever)). You rarely write parent classes yourself โ you inherit from the library and override the methods you need to customize.
super() โ Calling the Parent's Methods
super() lets a child class call methods from its parent. The most common use: calling the parent's __init__ so you don't have to repeat the setup code.
class BaseRAG:
"""Base class with shared RAG functionality."""
def __init__(self, documents, embedding_model):
self.documents = documents
self.embedding_model = embedding_model
self.index = None
def index_documents(self):
print(f"Indexing {len(self.documents) documents...")
# ... embedding and indexing logic ...
def query(self, question):
print(f"Querying: {question")
return "Answer from documents"
class PineconeRAG(BaseRAG):
"""RAG using Pinecone as the vector store."""
def __init__(self, documents, embedding_model, pinecone_api_key):
# Call parent's __init__ to set up documents and embedding_model
super().__init__(documents, embedding_model)
# Then add Pinecone-specific setup
self.api_key = pinecone_api_key
self.index_name = "my-rag-index"
def index_documents(self):
# First do the parent's indexing logic
super().index_documents()
# Then add Pinecone-specific upload
print(f"Uploading to Pinecone index: {self.index_name")
# PineconeRAG has EVERYTHING from BaseRAG plus its own additions
rag = PineconeRAG(documents=["doc1", "doc2"], embedding_model="text-embedding-3", pinecone_api_key="pk-xxx")
rag.index_documents() # Indexing 2 documents... + Uploading to Pinecone
rag.query("What is RAG?") # Querying: What is RAG? (inherited from parent)
Special Methods (Dunder Methods)
Python has special methods with double underscores (called "dunder" methods). You've seen __init__ and __str__. Here are the ones you'll use most:
class Vector:
"""A simple 2D vector โ shows off dunder methods."""
def __init__(self, x, y):
self.x = x
self.y = y
def __str__(self):
"""What print() shows."""
return f"Vector({self.x, {self.y)"
def __repr__(self):
"""What the REPL/debugger shows."""
return f"Vector({self.x, {self.y)"
def __add__(self, other):
"""What the + operator does."""
return Vector(self.x + other.x, self.y + other.y)
def __len__(self):
"""What len() returns."""
return int((self.x ** 2 + self.y ** 2) ** 0.5)
def __eq__(self, other):
"""What the == operator does."""
return self.x == other.x and self.y == other.y
# Using the special methods
v1 = Vector(3, 4)
v2 = Vector(1, 2)
print(v1) # Vector(3, 4) โ uses __str__
print(v1 + v2) # Vector(4, 6) โ uses __add__
print(len(v1)) # 5 โ uses __len__ (3ยฒ+4ยฒ=25, โ25=5)
print(v1 == v2) # False โ uses __eq__
Most useful dunder methods for AI engineering
__init__โ Setup when creating an object__str__โ Human-readable string (print)__repr__โ Developer-readable string (debugging)__len__โ Supportlen(obj)__eq__โ Supportobj1 == obj2__getitem__โ Supportobj[key](like dicts/lists)__contains__โ Supportx in obj
What You'll Actually Do โ Read and Use Library Classes
In real AI engineering, you'll use classes written by others far more often than you'll write your own. Here's what that looks like:
# Using FastAPI's APIRouter class
from fastapi import FastAPI, HTTPException
app = FastAPI() # create a FastAPI object
# Using Pydantic's BaseModel class (inheritance!)
from pydantic import BaseModel
class ChatRequest(BaseModel): # inherit from BaseModel
message: str
model: str = "gpt-4o-mini"
# Using LangChain's classes
from langchain.llms import OpenAI
llm = OpenAI(model="gpt-4o-mini", temperature=0.7)
result = llm.predict("Explain OOP") # calling a method on the object
What level of OOP do you actually need?
- Must know: Create objects from classes, call methods, understand
self - Should know: Write simple classes with
__init__and a few methods - Helpful to know: Inheritance,
super(), overriding methods - Don't need yet: Multiple inheritance, metaclasses, decorators as classes, abstract base classes
Example: Putting It All Together
A simple prompt template system โ this shows classes, inheritance, and dunder methods working together:
class PromptTemplate:
"""Base class for prompt templates."""
def __init__(self, template: str, variables: list):
self.template = template
self.variables = variables
def format(self, **kwargs) -> str:
"""Fill in the template with provided values."""
# Check that all required variables are provided
missing = [v for v in self.variables if v not in kwargs]
if missing:
raise ValueError(f"Missing variables: {missing")
result = self.template
for key, value in kwargs.items():
result = result.replace("{" + key + "}", str(value))
return result
def __str__(self):
return f"PromptTemplate(vars={self.variables)"
class SystemPrompt(PromptTemplate):
"""A prompt with a system role attached."""
def __init__(self, system_msg, template, variables):
super().__init__(template, variables)
self.system_message = system_msg
def to_messages(self, **kwargs):
"""Return the full message list for an API call."""
return [
{"role": "system", "content": self.system_message},
{"role": "user", "content": self.format(**kwargs)}
]
def __str__(self):
return f"SystemPrompt(sys={self.system_message[:30]...)"
# Use it
qa_prompt = SystemPrompt(
system_msg="You are a helpful assistant. Answer based on the context.",
template="Context: {context}\n\nQuestion: {question}",
variables=["context", "question"]
)
messages = qa_prompt.to_messages(
context="Python was created by Guido van Rossum",
question="Who created Python?"
)
print(messages)
# [
# {"role": "system", "content": "You are a helpful assistant..."},
# {"role": "user", "content": "Context: Python was created..."}
# ]
๐งช Exercises
Exercise 1 โ Student Grade Book
Create a Student class with: name, grades (list of numbers), methods: add_grade(grade), average() (returns float), status() (returns "Pass" if average >= 60, else "Fail"). Add __str__ that returns "Alice: 85.3 (Pass)". Create 3 students and test.
Exercise 2 โ API Response Wrapper
Create a APIResponse class with: status_code, data (dict), error (str or None). Methods: is_success(), get_data(key, default), __str__. Then create CachedAPIResponse that inherits from it and adds cached_at (timestamp) and is_expired(max_age_seconds).
Exercise 3 โ Chat History
Create a ChatHistory class that stores messages as a list of {"role": ..., "content": ...} dicts. Methods: add_user_msg(text), add_assistant_msg(text), get_last_n(n) (returns last n messages), token_estimate() (rough: 4 chars per token), truncate_to_tokens(max_tokens) (removes oldest messages first to fit).
โ ๏ธ Common Mistakes
Forgetting self in method definitions
def chat(message): will crash with TypeError: chat() takes 1 positional argument but 2 were given. Every method must have self as the first parameter: def chat(self, message):.
Using mutable default arguments
Do NOT write def __init__(self, history=[]):. The list is created ONCE and shared between all instances. Instead: def __init__(self, history=None): self.history = history or [].
Over-engineering with deep inheritance
3+ levels of inheritance makes code hard to follow. Prefer composition (having an object as an attribute) over inheritance. E.g., don't make MarkdownDocument(PDFDocument(Document)) โ instead give Document a format attribute.
Confusing class attributes and instance attributes
If you define a list at the class level (class Foo: items = []), ALL instances share that same list. Always define mutable attributes in __init__ using self.items = [].
Forgetting super().__init__()
When you override __init__ in a child class, the parent's __init__ is NOT called automatically. You must explicitly write super().__init__(...) or the parent's setup won't run.