Skip to content
elephantoo

Inheritance & polymorphism

Lesson 20 of 38 14 min read

Subclasses, overriding, super(), duck typing, abstract base classes, mixins, the MRO and composition.


Inheritance lets you define a new class based on an existing one: the new subclass gets all the attributes and methods of its parent (base) class, and can add or change behaviour. Combined with polymorphism — different objects responding to the same method call in their own way — it lets you write code that works with whole families of types.

Basic inheritance#

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

    def speak(self):
        return "..."

    def introduce(self):
        return f"I am {self.name} and I say {self.speak()}"


class Dog(Animal):            # Dog inherits from Animal
    def speak(self):          # override
        return "Woof"


class Cat(Animal):
    def speak(self):
        return "Meow"


for pet in [Dog("Rex"), Cat("Tom"), Animal("Generic")]:
    print(pet.introduce())

print(isinstance(Dog("Rex"), Animal), issubclass(Cat, Animal))
Output
I am Rex and I say Woof
I am Tom and I say Meow
I am Generic and I say ...
True True

Dog didn't define __init__ or introduce — it inherited them. It overrode speak. Notice that introduce, written in Animal, calls self.speak() — and Python uses the subclass's version. That's polymorphism in action.

Every class ultimately inherits from object, which provides defaults like __repr__ and __eq__.

Extending the parent with super()#

When a subclass needs extra attributes, it overrides __init__ — and should call the parent's initialiser with super() so the inherited setup still happens:

Python
class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary

    def annual_cost(self):
        return self.salary * 12

    def __repr__(self):
        return f"{type(self).__name__}({self.name!r})"


class Manager(Employee):
    def __init__(self, name, salary, reports=None):
        super().__init__(name, salary)          # run Employee.__init__
        self.reports = reports or []

    def annual_cost(self):
        base = super().annual_cost()            # reuse parent logic
        return base + 50_000                    # plus a yearly bonus


ada = Employee("Ada", 90_000)
grace = Manager("Grace", 150_000, reports=[ada])
print(grace, grace.reports)
print(ada.annual_cost(), grace.annual_cost())
Output
Manager('Grace') [Employee('Ada')]
1080000 1850000

Forgetting super().__init__(...) is a classic bug: the subclass object never gets name and salary, and you get an AttributeError later.

Polymorphism and duck typing#

Polymorphism means code can call the same method on different types without knowing which one it has:

Python
class Shape:
    def area(self):
        raise NotImplementedError


class Rectangle(Shape):
    def __init__(self, w, h):
        self.w, self.h = w, h

    def area(self):
        return self.w * self.h


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

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


shapes = [Rectangle(3, 4), Circle(1), Rectangle(2, 2)]
print(round(sum(s.area() for s in shapes), 2))
Output
19.14

In Python, polymorphism doesn't even require a shared parent. Duck typing — "if it walks like a duck and quacks like a duck…" — means any object with the right method works:

Python
# continuing the shapes example above
class Report:
    def area(self):            # not a Shape, but it has .area()
        return 100


print(sum(s.area() for s in [Circle(1), Report()]))
Output
103.14159

This is why Python functions usually check behaviour, not types: json.dump writes to anything with a write method, for loops over anything iterable.

Abstract base classes#

Sometimes a parent class exists only to define an interface that subclasses must implement. The abc module enforces this:

Python
from abc import ABC, abstractmethod


class PaymentMethod(ABC):
    @abstractmethod
    def pay(self, amount: float) -> str: ...

    def receipt(self, amount):                 # concrete shared method
        return f"Receipt: {self.pay(amount)}"


class UPI(PaymentMethod):
    def __init__(self, vpa):
        self.vpa = vpa

    def pay(self, amount):
        return f"₹{amount} paid via UPI ({self.vpa})"


class Card(PaymentMethod):
    pass                                        # forgot to implement pay()


print(UPI("ada@okbank").receipt(499))
try:
    Card()
except TypeError as e:
    print("TypeError:", e)
Output
Receipt: ₹499 paid via UPI (ada@okbank)
TypeError: Can't instantiate abstract class Card without an implementation for abstract method 'pay'

The error appears when you create the broken object, not much later when someone calls pay. (For structural, duck-typed interfaces checked by type checkers, see typing.Protocol in the type hints lesson.)

Multiple inheritance and the MRO#

A class can inherit from several parents. Python decides which method wins using the Method Resolution Order (MRO), which you can inspect:

Python
class JSONMixin:
    def to_json(self):
        import json
        return json.dumps(self.__dict__)


class ReprMixin:
    def __repr__(self):
        fields = ", ".join(f"{k}={v!r}" for k, v in self.__dict__.items())
        return f"{type(self).__name__}({fields})"


class User(JSONMixin, ReprMixin):
    def __init__(self, name, email):
        self.name = name
        self.email = email


u = User("Ada", "ada@example.com")
print(u)
print(u.to_json())
print([cls.__name__ for cls in User.__mro__])
Output
User(name='Ada', email='ada@example.com')
{"name": "Ada", "email": "ada@example.com"}
['User', 'JSONMixin', 'ReprMixin', 'object']

The most common, sane use of multiple inheritance is mixins: small classes that add one capability and aren't meant to stand alone. Deep diamond-shaped hierarchies quickly become confusing — avoid them in application code. Using super() consistently (rather than calling Parent.method(self) directly) is what makes cooperative multiple inheritance work.

Composition over inheritance#

Inheritance models an is-a relationship: a Manager is an Employee. When you just want to reuse behaviour, composition — holding another object (a has-a relationship) — is usually more flexible:

Python
class Engine:
    def __init__(self, hp):
        self.hp = hp

    def start(self):
        return f"{self.hp}hp engine roars"


class ElectricMotor:
    def start(self):
        return "motor hums silently"


class Car:
    def __init__(self, model, engine):
        self.model = model
        self.engine = engine          # has-a: swap engines freely

    def start(self):
        return f"{self.model}: {self.engine.start()}"


print(Car("Hatchback", Engine(90)).start())
print(Car("EV", ElectricMotor()).start())
Output
Hatchback: 90hp engine roars
EV: motor hums silently

With inheritance you'd need PetrolCar, ElectricCar, HybridCar… With composition you plug in a different part.

Inheriting from built-ins#

You can subclass built-in types, and exceptions are the everyday example. For containers, subclassing collections.UserDict/UserList is safer than subclassing dict/list directly, because the built-ins' C methods don't always call your overrides.

Python
from collections import UserDict


class LowerKeyDict(UserDict):
    def __setitem__(self, key, value):
        super().__setitem__(key.lower(), value)


headers = LowerKeyDict()
headers["Content-Type"] = "text/html"
headers.update({"X-Request-ID": "42"})
print(dict(headers))
Output
{'content-type': 'text/html', 'x-request-id': '42'}

Common mistakes#

  • Forgetting super().__init__() in an overridden __init__.
  • Deep hierarchies (five levels of subclasses) — prefer composition and small mixins.
  • Overriding a method with a different signature — callers that work with the parent will break.
  • Using inheritance just to share a helper function — a module-level function or composition is simpler.

What's next#

You've overridden __repr__ and __init__ — special "dunder" methods. Next you'll learn the rest: magic methods that make your objects work with +, ==, len(), for loops and with statements.

Check your understanding

Quick quiz

0/3 answered
  1. 1.What does super().__init__(name) do inside a subclass's __init__?

  2. 2.What happens when you try to instantiate a class that inherits from ABC and has an unimplemented @abstractmethod?

  3. 3.Which statement best describes "composition over inheritance"?

Finished reading?

Mark this lesson complete to track your progress.