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:
| Method | Enables |
|---|---|
__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.