Once inheritance is in place, the main design question becomes how to customize: when to keep the base implementation as-is, when to extend it, and when to replace it entirely. The three tools that answer those questions are override, extend via super(), and template methods (where the base class defines the skeleton and the subclass fills in specific steps).
An override is a method with the same name as one in the base class. Inside an instance, the most-derived method wins. If the override doesn't call super(), it replaces the base behavior entirely; if it does, it runs both. Whether to call super() is a conscious choice: replace when the base logic no longer applies, extend when it does.
Template methods are a classic inheritance pattern where the base class orchestrates a workflow and leaves specific steps to subclasses. The steps are declared on the base (often with @abstractmethod) but implemented only in subclasses. It is a great way to share a workflow while letting each subclass plug in domain-specific behavior.
Always keep the Liskov substitution principle in mind: a subclass should be usable anywhere the parent class is expected, without surprising the caller. Narrowing return types, widening accepted inputs, and not weakening invariants are the practical consequences. Surprising overrides make inheritance hostile.
Override, extend, or leave alone
Override when the parent logic is wrong for the subclass (replace entirely). Extend via super() when the parent logic should still run but the subclass adds pre- or post-processing. Leave alone when the parent already does the right thing.
Inside an override, super().method(...) dispatches through the MRO. That matters mainly with multiple inheritance; with simple single inheritance, it is just “call the parent's implementation”.
Abstract methods and required overrides
To require a subclass to provide an implementation, inherit from abc.ABC and decorate the method with @abstractmethod. Instances of a class with remaining abstract methods raise TypeError on construction. This turns “you must override this” from documentation into a runtime check.
Pair @abstractmethod with a template method: the base defines run() which calls step_a(), step_b(); subclasses only implement the abstract steps.
The override toolkit.
| Tool | Purpose |
|---|---|
super().m()built-in | Call the parent implementation. |
Cls.__mro__attribute | Resolution order used by super(). |
abc.ABCclass | Base for abstract classes. |
@abstractmethoddecorator | Marks a method that subclasses must override. |
@typing.overridedecorator (3.12+) | Documents/asserts that a method overrides a parent. |
__init_subclass__hook | Validate subclass shape at class-creation time. |
_protectedconvention | Single underscore: not-quite-private helpers. |
self.attraccess | Resolves to the most-derived attribute. |
Customizing Behavior in Subclasses code example
The script builds an abstract Worker with a template-method workflow and two subclasses that fill in the specific steps.
# Lesson: Customizing Behavior in Subclasses
from abc import ABC, abstractmethod
class Worker(ABC):
def run(self) -> str:
rows = self.fetch()
out = [self.transform(r) for r in rows]
return self.finalize(out)
@abstractmethod
def fetch(self) -> list[int]:
"""Subclass must return the raw rows."""
def transform(self, row: int) -> int:
return row * 2 # default; subclasses may override
def finalize(self, rows: list[int]) -> str:
return ",".join(str(r) for r in rows)
class Doubler(Worker):
def fetch(self) -> list[int]:
return [1, 2, 3]
class Squarer(Worker):
def fetch(self) -> list[int]:
return [1, 2, 3]
def transform(self, row: int) -> int:
base = super().transform(row) # extend: reuse + add
return row * row + 0 * base # squared
print("Doubler:", Doubler().run())
print("Squarer:", Squarer().run())
# Abstract without concrete implementations -> cannot instantiate
try:
Worker() # type: ignore[abstract]
except TypeError as err:
print("blocked:", err)
class LoudDoubler(Doubler):
def finalize(self, rows: list[int]) -> str:
result = super().finalize(rows)
return f"!! {result} !!"
print("LoudDoubler:", LoudDoubler().run())
Track each override decision:
1) `fetch` is abstract: every concrete subclass must implement it.
2) `transform` has a default; Squarer replaces it entirely.
3) `finalize` in LoudDoubler extends via `super()` (wrap the parent result).
4) Attempting to instantiate an incomplete abstract class raises TypeError.
Extend a base class with an override that uses super().
class Serializer:
def to_json(self, obj) -> str:
return "{}"
class UserSerializer(Serializer):
def to_json(self, user) -> str:
base = super().to_json(user)
# replace with a real payload
return f'{{"name": "{user}", "base": {base}}}'
print(UserSerializer().to_json("ana"))
Enforce the contract and ordering.
class A:
def f(self): return ["A"]
class B(A):
def f(self): return super().f() + ["B"]
assert B().f() == ["A", "B"]
from abc import ABC, abstractmethod
class Need(ABC):
@abstractmethod
def do(self): ...
try:
Need()
except TypeError:
pass
else:
raise AssertionError("abstract class should not instantiate")
Running prints:
Doubler: 2,4,6
Squarer: 1,4,9
blocked: Can't instantiate abstract class Worker without an implementation for abstract method 'fetch'
LoudDoubler: !! 2,4,6 !!