Lesson 19 of 25

OOP: Inheritance & Polymorphism

Inheritance Means "Is a Kind Of"

Writing class Dog(Animal): makes Dog a subclass of Animal. Every attribute and method defined on Animal is available on a Dog without being written again, and a Dog can add its own or replace what it inherited. Every class you write already inherits from object, which is where the default behaviour of ==, repr() and the rest comes from.

The test for whether inheritance is the right tool is a sentence: "a Dog is a kind of Animal" is true, so the relationship holds. "A Car is a kind of Engine" is false — a car has an engine, which is a different relationship, and the last-but-one section of this lesson is about why that distinction matters.

There is a stricter version of the same test that is worth learning early: anywhere the parent type is expected, an instance of the child must work correctly. If code that handles Animal objects breaks when handed one of your subclasses — because the subclass forbids something the parent allowed, or needs extra setup — then it is not really a kind of that thing, whatever the words suggest.

isinstance() and issubclass() ask about these relationships, and isinstance is the one you want in real code. It returns True for the class and every subclass, which is the whole point: code written for Animal should accept a Dog. Comparing type(x) is Animal is a stricter test that rejects subclasses, and it is almost always a bug when used for that purpose.

Example
class Animal:
    def __init__(self, name, species):
        self.name = name
        self.species = species

    def speak(self):
        return f"{self.name} makes a sound"

    def __str__(self):
        return f"{self.name} ({self.species})"


class Dog(Animal):
    def speak(self):                 # replaces the inherited version
        return f"{self.name} says woof"

    def fetch(self):                 # entirely new
        return f"{self.name} brings the ball back"


d = Dog("Buddy", "Dog")
print(d)             # Buddy (Dog)        — inherited __str__
print(d.speak())     # Buddy says woof    — overridden
print(d.fetch())     # only Dogs have this

# Relationships
print(isinstance(d, Dog))       # True
print(isinstance(d, Animal))    # True  — a Dog IS a kind of Animal
print(isinstance(d, object))    # True  — everything is
print(issubclass(Dog, Animal))  # True

# type() is stricter, and usually the wrong question
print(type(d) is Dog)           # True
print(type(d) is Animal)        # False — rejects the subclass

# Which is why functions should use isinstance
def introduce(a):
    if not isinstance(a, Animal):
        raise TypeError("expected an Animal")
    return a.speak()

print(introduce(d))             # works, because a Dog is an Animal
  • class Child(Parent): — inherits every attribute and method
  • A subclass may add new members and override inherited ones
  • Every class inherits from object, even when you do not say so
  • isinstance(obj, Class) — true for the class and all its subclasses
  • issubclass(A, B) — asks about the classes, not an instance
  • Use inheritance only when "is a kind of" is genuinely true
Notes
  • isinstance accepts a tuple of classes: isinstance(x, (int, float)) is the clean way to ask "is this a number". Note that isinstance(True, int) is True, because bool is a subclass of int.

Overriding and super()

Defining a method in a subclass with the same name as one in the parent overrides it: calls on instances of the subclass get the new version. That is how a Dog barks while a generic Animal only makes a sound.

Often you want to add to the parent's behaviour rather than throw it away, and super() is how you reach it. super().speak() runs the parent's version from inside the child's; you can use its result, or run it before or after your own work. In Python 3 you write plain super() with no arguments — the older super(Dog, self) form still works but is only needed in unusual cases.

The most important use is super().__init__(...). When a subclass writes its own __init__, the parent's no longer runs automatically, so any attribute the parent would have set simply never exists. The failure appears later and somewhere else: an AttributeError in an inherited method that expects self.species. Call super().__init__(...) first, then set the subclass's own attributes.

There is a subtler trap in the other direction. If a parent's __init__ calls a method that the subclass overrides, the subclass's version runs before the subclass's __init__ has finished, so it may read attributes that do not exist yet. Keep __init__ to assigning attributes, and leave anything that could be overridden to a separate method the caller invokes afterwards.

A note on style: overriding a method should keep the same meaning and, ideally, the same parameters. If the child's version needs different arguments, callers written against the parent will break — which is the earlier "is a kind of" rule, showing up as a concrete bug.

Example
class Animal:
    def __init__(self, name, species):
        self.name = name
        self.species = species

    def speak(self):
        return f"{self.name} makes a sound"

    def describe(self):
        return f"{self.name} is a {self.species}"


class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name, species="Dog")   # run the parent's setup FIRST
        self.breed = breed                       # then add our own

    def speak(self):
        return f"{self.name} says woof"

    def describe(self):
        # extend rather than replace
        return super().describe() + f", specifically a {self.breed}"


d = Dog("Buddy", "Labrador")
print(d.describe())     # Buddy is a Dog, specifically a Labrador


# Forgetting super().__init__ — the failure is delayed and confusing
class Cat(Animal):
    def __init__(self, name):
        self.name = name        # species never set

c = Cat("Whiskers")
print(c.name)               # fine
# print(c.describe())       # AttributeError: 'Cat' object has no
                            # attribute 'species'


# Calling an overridable method from __init__ — runs too early
class Base:
    def __init__(self):
        self.setup()            # the SUBCLASS version runs here

    def setup(self):
        pass

class Child(Base):
    def __init__(self):
        super().__init__()      # calls setup() before the next line runs
        self.items = []

    def setup(self):
        pass
        # self.items.append(1)  # AttributeError: 'items' does not exist yet
Notes
  • super() works in any method, not only __init__. It is also the right way to extend a dunder method — a subclass __repr__ can call super().__repr__() and add to it.

How Python Finds a Method: the MRO

When you write obj.method(), Python looks in a fixed order: the instance's own attributes first, then its class, then that class's parents. The exact sequence is the method resolution order, and every class carries it as __mro__. Printing it answers "where is this method actually coming from" without guessing.

With single inheritance the order is obvious — child, parent, grandparent, object — and the first match wins. This is also why an attribute set on an instance hides a class attribute of the same name, as you saw in the previous lesson: the instance is searched first.

Python permits multiple inheritance: class C(A, B):. The MRO then puts A before B, so a method defined in both comes from A. The rules that build this order handle the diamond case — where two parents share a grandparent — by visiting the shared class only once, after both of its children. It is well-defined, and it is also where inheritance stops being simple.

The disciplined use of multiple inheritance is the mixin: a small class that provides one capability and is not useful on its own, such as a JSONExportMixin that adds a to_json() method. Mixins are listed before the main base class, they add behaviour rather than data, and they keep the hierarchy shallow.

For everything else, be cautious. Once two real parent classes both define __init__, correct behaviour depends on every class in the chain calling super().__init__() cooperatively, and getting that wrong produces bugs that are genuinely hard to trace. In application code, a shallow hierarchy plus composition will serve you better than a clever one.

Example
class Animal:
    def speak(self): return "generic sound"

class Dog(Animal):
    def speak(self): return "woof"

print(Dog.__mro__)
# (<class 'Dog'>, <class 'Animal'>, <class 'object'>)

# Lookup order in action
d = Dog()
d.speak = lambda: "instance override"    # on the instance itself
print(d.speak())        # instance override — the instance is searched first
del d.speak
print(d.speak())        # woof — now the class wins


# Multiple inheritance: left to right
class Swimmer:
    def move(self): return "swims"

class Runner:
    def move(self): return "runs"

class Duck(Swimmer, Runner):
    pass

print(Duck().move())    # swims — Swimmer comes first in the MRO
print([c.__name__ for c in Duck.__mro__])
# ['Duck', 'Swimmer', 'Runner', 'object']


# The disciplined use: a mixin adds one capability
import json

class JSONExportMixin:
    def to_json(self):
        return json.dumps(self.__dict__, ensure_ascii=False)

class Student(JSONExportMixin):          # mixin listed first
    def __init__(self, name, marks):
        self.name = name
        self.marks = marks

print(Student("Asha", [78, 84]).to_json())
# {"name": "Asha", "marks": [78, 84]}
  • Lookup order: instance, then class, then parents in MRO order; first match wins
  • Class.__mro__ — the exact search order, printed
  • class C(A, B): — A is searched before B
  • A mixin adds one capability, holds no data, and is listed before the main base
  • Deep or diamond-shaped hierarchies get hard to reason about — keep them shallow
Notes
  • If Python cannot build a consistent order — for example class C(A, B) where B is already a parent of A — it refuses at class-definition time with TypeError: Cannot create a consistent method resolution order. That error is telling you the hierarchy is contradictory, not that you mistyped.

Polymorphism and Duck Typing

Polymorphism means the same call does the right thing for different types. shape.area() in a loop runs a circle's formula for a circle and a rectangle's for a rectangle, and the loop does not need to know or ask which it has. The decision happens at call time, based on the object's actual class.

The strength of this is that you write the loop once, and adding a new shape later requires no change to it at all. That is the practical payoff of the whole chapter: code that is open to new types without being edited.

Python takes it further than most languages, because it does not require a shared base class. If an object has an area() method, it works in that loop, whatever it inherits from. This is duck typing — if it walks like a duck and quacks like a duck, treat it as a duck. It is why len() works on a string, a list, a dictionary and your own class, all unrelated: each one provides __len__.

So a common base class becomes a design choice rather than a requirement. It is worth having when it holds shared code — a describe() that every subclass can use — or when you want the base to state what subclasses must provide. Raising NotImplementedError in the base method is the plain way to do that: a subclass that forgets to override it fails immediately with a message naming the problem, instead of silently inheriting nonsense. Lesson 20 shows the stricter version with abstract base classes.

The practical habit that follows is to write functions against behaviour, not types. A function that needs something it can iterate over should just iterate; a check of isinstance(x, list) in front of it needlessly rejects tuples, sets and generators that would have worked perfectly.

Example
class Shape:
    def area(self):
        raise NotImplementedError(f"{type(self).__name__} must define area()")

    def describe(self):
        return f"{type(self).__name__}: area = {self.area():.2f}"


class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius

    def area(self):
        return 3.14159 * self.radius ** 2


class Rectangle(Shape):
    def __init__(self, width, height):
        self.width, self.height = width, height

    def area(self):
        return self.width * self.height


# One loop, correct for every type — and for types added later
for s in [Circle(5), Rectangle(4, 6)]:
    print(s.describe())
# Circle: area = 78.54
# Rectangle: area = 24.00

# A subclass that forgets fails loudly
class Hexagon(Shape):
    pass

# Hexagon().area()   # NotImplementedError: Hexagon must define area()


# Duck typing: no shared base class needed
class Plot:                 # inherits from nothing in particular
    def area(self):
        return 120.0

for s in [Circle(2), Plot()]:
    print(round(s.area(), 2))    # both work

# The same idea powers the built-ins
class Basket:
    def __init__(self, items):
        self.items = items
    def __len__(self):
        return len(self.items)

print(len(Basket(["a", "b"])))   # 2 — len() only needs __len__

# Write against behaviour, not type
def total(values):
    return sum(values)           # works for list, tuple, set, generator

print(total([1, 2]), total((3, 4)), total(x for x in range(3)))
Notes
  • hasattr(obj, "area") checks for a capability without demanding a particular class. Use it sparingly — usually it is cleaner to attempt the call and catch AttributeError, which is the EAFP style from the previous lesson.

Composition Over Inheritance

The most common mistake with inheritance is using it to reuse code rather than to express a relationship. It is tempting: the parent already has the method you want, so you inherit and get it free. The trouble is that you also inherit everything else, and you have declared to every reader that your class is a kind of that thing.

The cost shows up later. A change to the parent silently changes every subclass, which is why the phenomenon has a name — the fragile base class problem. Hierarchies grow deep, and understanding one method means opening four files. And multi-way combinations explode: with electric and petrol vehicles crossed against cars and trucks, inheritance needs a class for every pairing.

Composition is the alternative, and it is usually better. Instead of inheriting from a class, hold an instance of it as an attribute and call it. A Car that has an Engine can swap that engine for an electric one without a new class, and nothing about Car claims to be an engine.

The rule of thumb is the sentence test again. "Is a kind of" means inheritance; "has a" or "uses a" means composition. A SavingsAccount is a kind of Account — inherit. A TaskManager has a storage backend — compose. When you are unsure, compose: it is easier to change your mind later, because a held object can be replaced and a base class cannot.

Composition also makes testing simpler, which matters more than it sounds. If your class holds a storage object, a test can pass in a fake one that keeps everything in memory. If your class inherits from a database class, the database comes along with it.

Example
# Inheritance used for code reuse — the seams show quickly
class Engine:
    def start(self): return "engine started"

class CarBad(Engine):        # a Car IS NOT a kind of Engine
    pass

print(CarBad().start())      # works, and says something false about Car


# Composition: a Car HAS an Engine
class PetrolEngine:
    def start(self): return "petrol engine started"

class ElectricEngine:
    def start(self): return "electric motor engaged"

class Car:
    def __init__(self, engine):
        self.engine = engine          # held, not inherited

    def drive(self):
        return f"{self.engine.start()}; moving off"

print(Car(PetrolEngine()).drive())
print(Car(ElectricEngine()).drive())   # no new Car subclass needed


# Inheritance where it genuinely fits: "is a kind of"
class Account:
    def __init__(self, holder, balance=0):
        self.holder = holder
        self.balance = balance

    def deposit(self, amount):
        self.balance += amount
        return self.balance

class SavingsAccount(Account):        # a SavingsAccount IS an Account
    def __init__(self, holder, balance=0, rate=0.04):
        super().__init__(holder, balance)
        self.rate = rate

    def add_interest(self):
        return self.deposit(self.balance * self.rate)

s = SavingsAccount("Asha", 10000)
print(s.add_interest())     # 10400.0


# Composition makes testing easy
class InMemoryStore:
    def __init__(self): self.rows = []
    def save(self, row): self.rows.append(row)

class TaskManager:
    def __init__(self, store):
        self.store = store
    def add(self, title):
        self.store.save({"title": title, "done": False})

fake = InMemoryStore()
TaskManager(fake).add("revise DSA")
print(fake.rows)            # no database needed to test this
Notes
  • A useful signal: if a subclass overrides most of what it inherited, or has to disable inherited behaviour to be correct, the relationship was never "is a kind of". Convert it to composition before the hierarchy grows.

Traps Worth Recognising

The first is one you have already met, in a new place: a mutable class attribute in a base class is shared by every subclass and every instance of all of them. One append from anywhere shows up everywhere. Class attributes should be immutable; anything mutable belongs in __init__.

The second is changing a method's signature when overriding. If the parent's save(self, path) becomes save(self, path, compress) with no default in the child, then any code holding what it believes is a parent object breaks the moment it is handed the child. Add new parameters with defaults, or the substitution promise is broken.

The third is silent shadowing. Define a method or attribute in a subclass with the same name as something in the parent and the parent's version disappears for that class, with no warning. Since Python has no @Override marker, a typo in a method name creates a new method instead of overriding the intended one — and the parent's version keeps being called while you wonder why your change has no effect.

The fourth is depth. A hierarchy more than two or three levels deep means reading a single call requires opening several files, and any change near the top ripples down invisibly. If you are adding a fifth level, the design is telling you to compose instead.

Finally, remember that inheritance is not required for polymorphism in Python. Duck typing already gives you interchangeable objects. Use a base class when it carries shared code or states a contract — not because you feel a hierarchy is expected.

Example
# 1. A mutable class attribute is shared by the whole family
class Base:
    registry = []            # ONE list for Base and every subclass

class A(Base): pass
class B(Base): pass

A.registry.append("from A")
print(B.registry)            # ['from A'] — B never touched it
print(A.registry is Base.registry)   # True

# Fix: create it per instance
class Better:
    def __init__(self):
        self.registry = []


# 2. Changing the signature breaks substitution
class Exporter:
    def save(self, path):
        return f"saved to {path}"

class ZipExporter(Exporter):
    def save(self, path, compress):        # extra required parameter
        return f"saved {path}, compress={compress}"

def backup(exporter):
    return exporter.save("out.txt")        # written against Exporter

print(backup(Exporter()))
# print(backup(ZipExporter()))   # TypeError: save() missing 1 required
                                 # positional argument: 'compress'

class ZipExporterOK(Exporter):
    def save(self, path, compress=True):   # a default keeps the promise
        return f"saved {path}, compress={compress}"

print(backup(ZipExporterOK()))


# 3. A typo creates a new method instead of overriding one
class Animal:
    def speak(self): return "sound"

class Cow(Animal):
    def speek(self): return "moo"     # typo — nothing is overridden

print(Cow().speak())                  # sound — and no warning anywhere
  • Mutable class attributes are shared across the whole hierarchy — keep them in __init__
  • Overrides should keep the parent's parameters; add new ones with defaults
  • Python has no override marker, so a misspelled method name fails silently
  • Keep hierarchies two or three levels deep at most
  • Polymorphism does not need inheritance — duck typing already provides it
Notes
  • Type checkers such as mypy catch the signature and typo problems above before the code ever runs. If a project is large enough for deep inheritance, it is large enough to justify running one.
Ask AI