Learnixo

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:

Python
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 method

Method Overriding

A child class can replace a parent method with its own implementation:

Python
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 notes

super(): 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:

Python
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:

Python
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:

Python
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:

Python
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 needed

Inheritance vs Composition

Python
# 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)).content

Rule 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.