PYTHON / OBJECT-ORIENTED PYTHON
Inheritance and overriding methods
Create subclasses that reuse a base class's code, override selected methods, and predict which implementation runs when an instance calls them.
What you will learn
- Write class Sub(Base): to reuse every Base attribute without copying any code
- Override by defining the same method name in the subclass; that version wins
- Inherited methods call self.x(), so one override changes base-class behaviour too
- Use isinstance() and Sub.__dict__ to see where a method actually comes from
Understanding Inheritance and overriding methods
Writing `class Contractor(Employee):` does not copy anything from Employee. It stores Employee in `Contractor.__bases__`, and from then on every attribute access on a Contractor instance searches the instance dictionary, then Contractor's own namespace, then Employee's. That is why a subclass body can be almost empty and still have a working `__init__`, `pay`, and `describe`: the names are simply found one level up.
Overriding is nothing more than putting the same name into the subclass's own namespace so the search stops there before reaching the base. The important consequence is that the search starts from `type(self)` on every single call, including calls made by code that lives in the base class. When `Employee.pay` runs `self.rate()` on a Contractor instance, it finds Contractor's `rate`, not Employee's, so the base class's algorithm produces a different number without the base class knowing Contractor exists. Every Python method behaves this way; there is no non-virtual method to opt out of.
That behaviour is also the discipline: an override replaces the whole method body, so if callers rely on the base method's contract you must keep the parameter list and return type compatible. If you only want to add something, you have to invoke the inherited implementation yourself, either as `Base.method(self, ...)` or with `super()`. Choose inheritance when the subclass really is a specialisation whose overrides refine behaviour, not when you just want to borrow a couple of convenient methods.
class Employee:
def __init__(self, name, hours):
self.name = name
self.hours = hours
def rate(self):
return 20.0
def pay(self):
return self.hours * self.rate()
def describe(self):
return f"{type(self).__name__} {self.name}: {self.pay():.2f}"
class Contractor(Employee):
def rate(self):
return 45.0
class Intern(Employee):
def pay(self):
return 500.0
staff = [Employee("Ada", 40), Contractor("Lin", 10), Intern("Sam", 40)]
for person in staff:
print(person.describe())
print("rate" in Contractor.__dict__, "pay" in Contractor.__dict__)
print(Contractor.describe is Employee.describe)
print(isinstance(staff[1], Employee), issubclass(Intern, Employee))A subclass shares its base class's methods by lookup rather than by copy, so defining the same name in the subclass replaces it for every call made through that subclass, including calls made by inherited code.
Worked examples
A base class that demands an override
Shows the standard hook pattern: the base class defines the workflow and raises NotImplementedError for the part each subclass must supply.
class Report:
def rows(self):
raise NotImplementedError(f"{type(self).__name__} must define rows()")
def render(self):
return "\n".join(self.rows())
class SalesReport(Report):
def rows(self):
return ["north,120", "south,90"]
class EmptyReport(Report):
pass
print(SalesReport().render())
try:
EmptyReport().render()
except NotImplementedError as exc:
print("error:", exc)Example explained
Line 1`render` is written once in Report and never overridden; it works because `self.rows()` is resolved against the real class of the object.
Line 2SalesReport supplies only `rows`, which is the single piece of behaviour that differs.
Line 3EmptyReport inherits Report's `rows`, so the placeholder raises instead of returning nonsense data.
Line 4`type(self).__name__` reports EmptyReport, not Report, because the base method still runs with a subclass instance as self.
Overriding a method of a built-in type
Demonstrates that subclassing list and overriding append changes only that method, not the C-implemented methods that never route through it.
class UniqueList(list):
def append(self, item):
if item in self:
print(f"skipping duplicate {item!r}")
return
list.append(self, item)
tags = UniqueList()
tags.append("python")
tags.append("oop")
tags.append("python")
print(tags)
tags.extend(["oop", "cli"])
print(tags)Example explained
Line 1`class UniqueList(list)` inherits indexing, iteration, `__repr__` and everything else from list.
Line 2`list.append(self, item)` reaches the inherited implementation explicitly, which is required because plain `self.append(item)` would recurse forever.
Line 3`item in self` works because `__contains__` was inherited untouched.
Line 4`extend` is written in C and appends directly to the internal storage, so it never calls the overridden `append` and the duplicate slips in.
Extending instead of replacing
Shows an override that adds behaviour and then hands off to the inherited version by naming the base class explicitly.
class Cache:
def __init__(self):
self.store = {}
def set(self, key, value):
self.store[key] = value
class LoggingCache(Cache):
def set(self, key, value):
print(f"set {key}={value}")
Cache.set(self, key, value)
c = LoggingCache()
c.set("a", 1)
c.set("b", 2)
print(c.store)Example explained
Line 1LoggingCache does not define `__init__`, so `self.store` is created by Cache's inherited initialiser.
Line 2The override runs its own line first, then delegates; without the delegation nothing would ever be stored.
Line 3`Cache.set` looked up on the class is a plain function, so `self` has to be passed by hand as the first argument.
Line 4Hard-coding the base class name works here but ties the subclass to Cache; `super()` is the general form.
Important notes
Nothing about an override is checked when the class is defined; a mismatched name or signature only shows up at call time. `typing.override` (Python 3.12+) helps static checkers but has no runtime effect, and there is no `final` to forbid overriding.
Overriding is name-based, not method-specific: a subclass line like `rate = 45.0` also shadows the base's `rate`, but then `self.rate()` fails with TypeError, so keep the kind of attribute the same.
Common mistakes
Misspelling the name (`def Pay(self)` or `def pay_amount(self)`): Python happily adds a brand-new method, no error is raised, and the base version keeps running unnoticed.
Changing the signature, e.g. `def rate(self, hours)` when the base calls `self.rate()`: the inherited method now raises TypeError: rate() missing 1 required positional argument: 'hours'.
Overriding `__init__` in the subclass and never running the base initialiser: attributes like `self.store` are never created, so an inherited method fails later with AttributeError.
Try it yourself
Change, predict, then run
Write a `Parser` class whose `parse(text)` splits on commas and returns `[self.clean(part) for part in ...]`, with `clean` returning `part.strip()`. Then add a subclass `LoudParser` that overrides only `clean` to also uppercase, and print `parse(" a, b ,c ")` from both classes.
Open the Python workspaceCheck your understanding
A base class method `total()` calls `self.tax()`. A subclass overrides `tax()` but not `total()`. When you call `total()` on a subclass instance, which `tax` runs?
- The subclass's `tax`, because `self.tax` is resolved from the instance's actual class every time it is used
- The base class's `tax`, because `total` was defined in the base class and its names bind to that class
- The base class's `tax`, unless the subclass also overrides `total`
- The subclass's `tax` only if the base method is marked virtual or decorated as a hook
Show answer
Attribute lookup for `self.tax` happens at call time starting at `type(self)`, so the subclass's version is found even though the calling code lives in the base class. Options 1 and 2 assume the name was bound to the defining class, which is how non-virtual methods work in some other languages; Python has no such binding and no virtual keyword, so option 3 is wrong too.