Learnixo
Back to blog
AI Systemsintermediate

Inheritance and Method Overriding

Understand Python inheritance: single and multiple inheritance, super(), method overriding, abstract base classes, and how LangChain uses inheritance for its Runnable and Tool hierarchies.

Asma Hafeez KhanMay 16, 20265 min read
PythonInheritanceOOPsuper()Abstract ClassesMethod Overriding
Share:š•

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.

Enjoyed this article?

Explore the AI Systems learning path for more.

Found this helpful?

Share:š•

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.