Skillversitas

Inheritance Basics

Inheritance lets a class build on another: the child automatically has the parent's attributes and methods, and can override any of them. In Python the syntax is a parenthesized base list in the class statement, the lookup for a name walks the child first and then the bases (the MRO, in detail next chapter), and the idiomatic payoff is is-a modeling plus code sharing - a SavingsAccount that is a BankAccount inherits deposit and balance handling and adds interest. This chapter covers the mechanics and the two most common design questions: when inheritance is the right tool, and what a child should and should not assume about a parent.

Single Inheritance

class BankAccount:
    def __init__(self, owner, balance=0.0):
        self.owner = owner
        self._balance = balance

    def deposit(self, amount):
        if amount <= 0:
            raise ValueError("amount must be positive")
        self._balance += amount

    def balance(self):
        return self._balance

    def __repr__(self):
        return f"{type(self).__name__}({self.owner!r}, {self._balance:.2f})"


class SavingsAccount(BankAccount):
    def __init__(self, owner, balance=0.0, rate=0.03):
        super().__init__(owner, balance)
        self.rate = rate

    def apply_interest(self):
        self.deposit(self._balance * self.rate)

s = SavingsAccount("Ada", 1000)
s.deposit(500)
s.apply_interest()
print(s.balance())        # 1545.0
print(s)                  # SavingsAccount('Ada', 1545.00)
print(isinstance(s, BankAccount))   # True - the is-a relation

class SavingsAccount(BankAccount): makes BankAccount a base: everything defined on it is available on the child unless the child defines its own version. The child's __init__ calls super().__init__ so the parent's setup runs (chapter 9 covers super in depth), then adds its own state. isinstance(s, BankAccount) is true - that is the is-a relation made checkable, and it is what lets a list of mixed accounts be processed by code that only knows the base's interface. The __repr__ inherited from the parent even prints the child's class name, because type(self).__name__ resolves dynamically - a nice reminder that inherited code sees the actual instance, not the class it was written in.

Overriding: The Child's Version Wins

class CheckingAccount(BankAccount):
    def __init__(self, owner, balance=0.0, overdraft=100.0):
        super().__init__(owner, balance)
        self.overdraft = overdraft

    def withdraw(self, amount):
        # the parent has no withdraw; the child adds one
        if self._balance - amount < -self.overdraft:
            raise ValueError("overdraft limit exceeded")
        self._balance -= amount

    def __repr__(self):
        # override the inherited repr with a subclass-specific one
        return (f"Checking({self.owner!r}, {self._balance:.2f}, "
                f"od={self.overdraft:.2f})")

c = CheckingAccount("Grace", 200, overdraft=50)
c.withdraw(240)
print(c.balance())   # -40 - within the overdraft
print(c)             # Checking('Grace', -40.00, od=50.00)

When the child defines a name the parent has, the child's version is found first - that is overriding, and it is the core of the polymorphism chapter. The override can use the parent's version (via super()) or replace it entirely; the good pattern for __init__ overrides is to call super().__init__ first (or last, deliberately) so the parent invariants hold, and for behavior overrides to extend the parent's work rather than silently ignoring it. A child that overrides and never calls the parent is a review red flag - it has assumed the parent's setup is unimportant.

The Is-a Test

The design question before any inheritance: is the child really a kind of the parent? If a SavingsAccount can be used anywhere a BankAccount is expected - passed to a function that deposits, balanced, and repr'd - the inheritance is honest. If the "child" needs special-casing to work in the parent's place (if isinstance(x, Child) scattered through the base's consumers), the relation is not is-a, and the tools are composition (chapter 18) or a shared interface (an ABC, chapter 16). Two heuristics: the Liskov test (substituting the child must not break the parent's promises - a Circle that is a Rectangle fails it the moment someone sets its width) and the usage test (does the base code call methods that the child redefines in surprising ways?). When in doubt, compose: "has-a" relationships (a Car has an Engine) are far more common than is-a, and they avoid the inheritance trap entirely.

What a Child May Assume About a Parent

  • The public interface. Methods and documented attributes are stable; a child can rely on them and override them.
  • The _internal state: not at all. A child reading self._balance couples to the parent's implementation; if the parent renames it, the child breaks. Prefer calling the parent's methods.
  • The __mangled state: the parent's. A child cannot reach the parent's double-underscore attributes at all (they are mangled to the parent's name) - by design.
  • The __init__ contract. Call super().__init__ with the arguments the parent documents; a child that skips it is on its own for the parent's invariants.

Summary

Write class Child(Parent): to inherit; the child gets the parent's namespace as a fallback, overrides by defining its own versions, and calls super().__init__ so the parent's setup runs. The relation must be a genuine is-a - checkable by isinstance and by the Liskov/usage tests - or reach for composition instead. A child may rely on the parent's public interface, not its internals: treat _name as the parent's private business and call methods. Inheritance buys code sharing and a type hierarchy that polymorphism (the next-but-one chapter) exploits; used for is-a and not for "reuses some code," it is one of Python's cleanest features.