Advanced22 min

Inheritance, polymorphism, and dunder methods

Subclassing, method resolution order, abstract base classes, properties, and the special methods that make objects feel built-in.

Why this matters in AI / ML / GenAI

Every custom PyTorch model subclasses nn.Module, every LangChain tool subclasses a base class, and scikit-learn estimators all expose the same fit/predict interface. Polymorphism is why you can swap one retriever for another without changing the pipeline.

1. Read

Understand the idea in plain English first.

2. Run

Load any example into the compiler and press Run.

3. Change

Edit one value, rerun, and learn from the output.

Inheritance and super()

class Child(Parent): inherits every attribute and method. Override by redefining; extend by calling super().method() inside the override.

Always call super().__init__(...) in a subclass constructor. Forgetting it in a PyTorch nn.Module is a classic error — the module's internal registries never get set up and parameters go missing.

Method resolution order (MRO) decides which implementation wins with multiple inheritance. Inspect it with Class.__mro__. Python uses C3 linearisation: left to right, depth first, and no class appears before its subclasses.

Keep hierarchies shallow. Two levels is usually plenty; beyond that, composition is easier to follow and test.

Polymorphism and duck typing

Polymorphism means different classes respond to the same call in their own way. A loop over mixed retrievers calling .search(query) does not care which class each one is.

Python uses duck typing: if it has the method, it works. No shared base class is required. This is why scikit-learn estimators interoperate — they all implement fit and predict.

When you want the contract enforced, use abc.ABC with @abstractmethod. Python then refuses to instantiate a subclass that has not implemented every abstract method, turning a runtime AttributeError into an immediate, clear failure.

typing.Protocol offers the same guarantee for static checkers without requiring inheritance.

Dunder methods and properties

Special methods let your objects work with Python's own syntax:

MethodEnables
__init__construction
__repr__debugging output
__str__print() / str()
__len__len(obj)
__getitem__obj[i], and iteration
__iter__for x in obj
__eq__==
__call__obj(...)
__contains__x in obj
__enter__ / __exit__with obj:

Define __repr__ on every class you debug. Without it you get <Chunk object at 0x7f...>, which tells you nothing.

@property turns a method into a read-only attribute, so a computed value like remaining is accessed as budget.remaining. It lets you add validation later without changing every call site.

Hands-on practice

Compiler on the left. Examples on the right.

On desktop, keep the compiler beside the examples. On mobile, the same blocks stack cleanly. Pick an example, try it in the compiler, then change one small thing.

Implement a scikit-learn style estimator

Live Python compiler

Try it — in-browser Python

Add a third estimator class with its own fit/predict and append it to the list.

Code editor

Output

Python runs in your browser. First run downloads the runtime.

Press Run (or Ctrl+Enter) to execute.

Runs CPython in your browser. NumPy, pandas, scikit-learn and Matplotlib load on demand. Charts appear below the output. No input(), no GPU, no network installs.

Clear code examples

Every example is copy-ready. Use Try in compiler when you want to experiment without scrolling around.

Example

An abstract base class with two implementations

Python refuses to instantiate a subclass that skips an abstract method.

from abc import ABC, abstractmethod

class BaseRetriever(ABC):
    def __init__(self, name):
        self.name = name

    @abstractmethod
    def search(self, query, top_k=2):
        """Return a list of matching documents."""

    def describe(self):
        return f"{self.__class__.__name__}(name={self.name!r})"

class KeywordRetriever(BaseRetriever):
    def __init__(self, docs):
        super().__init__("keyword")
        self.docs = docs

    def search(self, query, top_k=2):
        terms = set(query.lower().split())
        hits = [d for d in self.docs if terms & set(d.lower().split())]
        return hits[:top_k]

class EchoRetriever(BaseRetriever):
    def __init__(self):
        super().__init__("echo")

    def search(self, query, top_k=2):
        return [f"echo: {query}"]

class Incomplete(BaseRetriever):
    pass

docs = ["python for ml", "kubernetes scaling", "python rag apps"]
for retriever in [KeywordRetriever(docs), EchoRetriever()]:
    print(retriever.describe(), "->", retriever.search("python rag"))

try:
    Incomplete("broken")
except TypeError as err:
    print("blocked:", err)

Example

Duck typing: no shared base needed

The pipeline only cares that each object has .search().

class VectorStore:
    def search(self, query):
        return [f"vector-hit for {query}"]

class SqlStore:
    def search(self, query):
        return [f"sql-row for {query}"]

def run_pipeline(stores, query):
    results = []
    for store in stores:
        results.extend(store.search(query))
    return results

print(run_pipeline([VectorStore(), SqlStore()], "mlops"))

Example

Dunder methods make a class feel built-in

len(), indexing, iteration, printing, and == all come from special methods.

class ChunkSet:
    def __init__(self, chunks):
        self.chunks = list(chunks)

    def __len__(self):
        return len(self.chunks)

    def __getitem__(self, index):
        return self.chunks[index]

    def __contains__(self, text):
        return any(text in c for c in self.chunks)

    def __eq__(self, other):
        return isinstance(other, ChunkSet) and self.chunks == other.chunks

    def __repr__(self):
        return f"ChunkSet({len(self.chunks)} chunks)"

    def __str__(self):
        return " | ".join(self.chunks)

cs = ChunkSet(["python basics", "rag pipeline", "vector search"])
print(repr(cs))
print(str(cs))
print("len       :", len(cs))
print("index     :", cs[1])
print("slice     :", cs[:2])
print("membership:", "rag" in cs)
print("iteration :", [c.split()[0] for c in cs])
print("equality  :", cs == ChunkSet(["python basics", "rag pipeline", "vector search"]))

Example

Properties with validation

Computed and guarded attributes without changing the call site.

class TokenBudget:
    def __init__(self, limit):
        self._limit = limit
        self._used = 0

    @property
    def used(self):
        return self._used

    @property
    def remaining(self):
        return self._limit - self._used

    @property
    def limit(self):
        return self._limit

    @limit.setter
    def limit(self, value):
        if value < self._used:
            raise ValueError("limit cannot be below tokens already used")
        self._limit = value

    def spend(self, tokens):
        if tokens > self.remaining:
            raise RuntimeError("budget exceeded")
        self._used += tokens

budget = TokenBudget(1000)
budget.spend(300)
print("used:", budget.used, "remaining:", budget.remaining)

budget.limit = 2000
print("raised limit, remaining:", budget.remaining)

try:
    budget.limit = 100
except ValueError as err:
    print("setter guard:", err)

Example

Method resolution order

MRO decides which parent method wins with multiple inheritance.

class Timed:
    def run(self):
        return "timed"

class Cached:
    def run(self):
        return "cached"

class Service(Timed, Cached):
    pass

print("result:", Service().run())
print("MRO:", [c.__name__ for c in Service.__mro__])

Takeaways

  • Always call super().__init__() in a subclass constructor.
  • Duck typing means any object with the right method works; ABCs enforce the contract.
  • Define __repr__ everywhere, and use @property for computed or validated attributes.