Python Essentials for AI Engineers · Lesson 24 of 36
Inheritance and Method Overriding
Basic Inheritance
A child class inherits all attributes and methods from its parent:
class Medication:
"""Base class for all medications."""
def __init__(self, name: str, dose_mg: float, route: str = "PO"):
self.name = name
self.dose_mg = dose_mg
self.route = route
self._interactions: list[str] = []
def describe(self) -> str:
return f"{self.name} {self.dose_mg}mg {self.route}"
def add_interaction(self, drug: str) -> None:
self._interactions.append(drug)
class Anticoagulant(Medication):
"""Anticoagulant medications require INR monitoring."""
def __init__(self, name: str, dose_mg: float, inr_target: tuple[float, float] = (2.0, 3.0)):
super().__init__(name=name, dose_mg=dose_mg, route="PO") # Call parent __init__
self.inr_target = inr_target # Additional attribute
def inr_in_range(self, inr: float) -> bool:
return self.inr_target[0] <= inr <= self.inr_target[1]
warfarin = Anticoagulant("warfarin", 5.0)
print(warfarin.describe()) # "warfarin 5.0mg PO" — inherited from Medication
print(warfarin.inr_in_range(2.4)) # True
warfarin.add_interaction("aspirin") # Inherited methodMethod Overriding
A child class can replace a parent method with its own implementation:
class Medication:
def describe(self) -> str:
return f"{self.name} {self.dose_mg}mg"
def administration_notes(self) -> str:
return "Take as directed by your physician."
class Warfarin(Anticoagulant):
"""Warfarin with specific monitoring requirements."""
def __init__(self):
super().__init__("warfarin", 5.0, inr_target=(2.0, 3.0))
def describe(self) -> str:
# Override: add INR target to description
base = super().describe() # Call parent's describe()
return f"{base} [INR target: {self.inr_target[0]}-{self.inr_target[1]}]"
def administration_notes(self) -> str:
# Complete override: don't call super()
return (
"Take at the same time each day. "
"Avoid foods high in vitamin K (leafy greens). "
"Monitor INR as directed. "
"Report any unusual bleeding immediately."
)
w = Warfarin()
print(w.describe())
# "warfarin 5.0mg PO [INR target: 2.0-3.0]"
print(w.administration_notes())
# Warfarin-specific notessuper(): Calling the Parent
super() gives you access to the parent class's methods. It's essential in __init__ and useful whenever you want to extend (not replace) parent behavior:
class BaseEvaluator:
def evaluate(self, response: str, expected: str) -> dict:
return {
"response_len": len(response),
"expected_len": len(expected),
}
class ClinicalEvaluator(BaseEvaluator):
def evaluate(self, response: str, expected: str) -> dict:
# Get the base results
base_scores = super().evaluate(response, expected)
# Add clinical-specific scoring
clinical_terms = {"inr", "dose", "mechanism", "interaction", "contraindication"}
response_terms = set(response.lower().split()) & clinical_terms
# Merge: parent results + new fields
return {
**base_scores,
"clinical_terms_present": len(response_terms),
"has_disclaimer": "verify" in response.lower() or "consult" in response.lower(),
}
evaluator = ClinicalEvaluator()
result = evaluator.evaluate(
"Warfarin dose is 5mg daily. Verify with INR monitoring.",
"Warfarin 5mg daily, INR-guided."
)
print(result)
# {"response_len": 51, "expected_len": 34, "clinical_terms_present": 2, "has_disclaimer": True}Abstract Base Classes
An abstract class defines a contract — it lists methods that every subclass must implement, but doesn't implement them itself:
from abc import ABC, abstractmethod
class BaseRetriever(ABC):
"""Abstract retriever — all subclasses must implement retrieve()."""
def __init__(self, k: int = 4):
self.k = k
self._call_count = 0
@abstractmethod
def _search(self, query: str) -> list:
"""Subclasses implement the actual search logic."""
...
def retrieve(self, query: str) -> list:
"""Template method — calls _search, adds logging."""
self._call_count += 1
results = self._search(query)
return results[:self.k]
# Cannot instantiate abstract class
try:
retriever = BaseRetriever() # TypeError: can't instantiate abstract class
except TypeError as e:
print(e)
class VectorRetriever(BaseRetriever):
def __init__(self, vectorstore, k: int = 4):
super().__init__(k=k)
self.vectorstore = vectorstore
def _search(self, query: str) -> list:
return self.vectorstore.similarity_search(query, k=self.k)
class BM25Retriever(BaseRetriever):
def __init__(self, corpus: list[str], k: int = 4):
super().__init__(k=k)
self.corpus = corpus
def _search(self, query: str) -> list:
# BM25 keyword search implementation
return [] # Simplified
# Both subclasses work polymorphically
retrievers: list[BaseRetriever] = [
VectorRetriever(vectorstore=vs),
BM25Retriever(corpus=docs),
]
for retriever in retrievers:
results = retriever.retrieve("warfarin interactions")
print(f"{type(retriever).__name__}: {len(results)} results")Multiple Inheritance
Python supports inheriting from multiple parents. Use it carefully:
class Loggable:
def log(self, message: str) -> None:
print(f"[{type(self).__name__}] {message}")
class Cacheable:
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._cache: dict = {}
def cached_call(self, key: str, fn):
if key not in self._cache:
self._cache[key] = fn()
return self._cache[key]
class CachedLoggableRetriever(Cacheable, Loggable, BaseRetriever):
def __init__(self, vectorstore, k: int = 4):
super().__init__(k=k) # Follows MRO: Cacheable → Loggable → BaseRetriever
self.vectorstore = vectorstore
def _search(self, query: str) -> list:
self.log(f"Searching: {query[:50]}")
return self.cached_call(
query,
lambda: self.vectorstore.similarity_search(query, k=self.k)
)
# Method Resolution Order (MRO) determines which method is called for super()
print(CachedLoggableRetriever.__mro__)
# (CachedLoggableRetriever, Cacheable, Loggable, BaseRetriever, ABC, object)How LangChain Uses Inheritance
LangChain's tool system is built on this exact pattern:
from langchain_core.tools import BaseTool
from pydantic import BaseModel, Field
from typing import Type
# BaseTool is an abstract class — requires _run() to be implemented
class DrugSearchTool(BaseTool):
name: str = "search_drug"
description: str = "Search for clinical drug information."
def _run(self, query: str) -> str:
"""Required by BaseTool — the actual tool logic."""
return f"Clinical info for: {query}"
async def _arun(self, query: str) -> str:
"""Optional async version."""
return self._run(query) # Fall back to sync version
# LangChain's BaseCallbackHandler uses the same pattern
from langchain_core.callbacks import BaseCallbackHandler
class MyLoggingCallback(BaseCallbackHandler):
"""Override only the events you care about."""
def on_llm_start(self, serialized, prompts, **kwargs) -> None:
print(f"LLM starting with {len(prompts)} prompts")
def on_tool_start(self, serialized, input_str, **kwargs) -> None:
print(f"Tool '{serialized['name']}' called with: {input_str[:80]}")
# All other events have default (no-op) implementations in BaseCallbackHandler
# No need to implement on_llm_end, on_chain_start, etc. unless neededInheritance vs Composition
# Inheritance: "is-a" relationship
class VectorRetriever(BaseRetriever):
pass # VectorRetriever IS A retriever
# Composition: "has-a" relationship — often more flexible
class RAGPipeline:
def __init__(self, retriever: BaseRetriever, model, prompt):
self.retriever = retriever # RAGPipeline HAS A retriever
self.model = model
self.prompt = prompt
def answer(self, question: str) -> str:
docs = self.retriever.retrieve(question)
context = "\n".join(d.page_content for d in docs)
return self.model.invoke(self.prompt.format(context=context, question=question)).contentRule of thumb: Prefer composition over inheritance. Use inheritance when there's a genuine "is-a" relationship and when you want to be forced to implement abstract methods.