Inheritance and Polymorphism

SA
StudyAI
AI-generated study notes
· Published Updated Pro Plus model

From the Classes(python) curriculum

TL;DR

You'll learn how Python classes share and override behavior through inheritance, and how polymorphism lets different objects respond to the same method call in their own way. You'll see super(), method overriding, and multiple inheritance with the MRO. By the end, you'll know when to inherit and when to avoid it.

1. The Mental Model

Inheritance lets a new class reuse code from an existing class instead of copying it. Polymorphism means you can call the same method name on different objects and each one does its own version of the thing. Think of a parent class as a template and child classes as specific fillings-in of that template. The core idea: write code against the shared behavior, not against specific types.

2. The Core Material

2.1 Inheritance basics: class Child(Parent)

Children of diverse backgrounds learning and interacting with computers in a modern classroom.
Photo by Gustavo Fring on Pexels

When a class inherits from another, it gets all its attributes and methods for free. You only write what's different.

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

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

    def describe(self):
        return f"I am {self.name}. {self.speak()}"

class Dog(Animal):
    def speak(self):
        return f"{self.name} barks"

class Cat(Animal):
    def speak(self):
        return f"{self.name} meows"

d = Dog("Rex")
c = Cat("Mimi")
print(d.describe())  # I am Rex. Rex barks
print(c.describe())  # I am Mimi. Mimi meows

Dog and Cat don't rewrite __init__ or describe. They just override speak. describe calls self.speak(), and Python looks up speak on the actual object's class first — that's polymorphism happening right there, through a plain method call.

2.2 super() — calling the parent without hardcoding it

A mother multitasking at her desk, holding her baby while on a video call at home, showcasing modern parenthood.
Photo by RDNE Stock project on Pexels

When you override __init__ (or any method) but still want the parent's version to run too, use super() instead of naming the parent class directly.

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

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

class Manager(Employee):
    def __init__(self, name, salary, bonus):
        super().__init__(name, salary)   # reuse parent's init
        self.bonus = bonus

    def annual_pay(self):
        base = super().annual_pay()      # reuse parent's logic
        return base + self.bonus

m = Manager("Ana", 5000, 3000)
print(m.annual_pay())  # 63000

Why super() and not Employee.__init__(self, name, salary)? Two reasons. First, if you ever rename the parent class, super() doesn't break. Second — and this is the real reason — super() respects the Method Resolution Order (MRO), which matters once multiple inheritance is involved. Hardcoding the parent name ignores the MRO and can call the wrong method or call something twice.

2.3 Multiple inheritance and the MRO

Woman using multiple screens for cybersecurity tasks in a cozy home office
Photo by cottonbro studio on Pexels

Python allows a class to inherit from more than one parent. This is powerful but it's also where bugs creep in if you don't understand the MRO — the order Python searches classes to find a method.

Python uses the C3 linearization algorithm to compute this order. You can always check it directly:

class Flyer:
    def move(self):
        return "flying"

class Swimmer:
    def move(self):
        return "swimming"

class Duck(Flyer, Swimmer):
    pass

d = Duck()
print(d.move())          # flying  (Flyer comes first in the MRO)
print(Duck.__mro__)
# (<class 'Duck'>, <class 'Flyer'>, <class 'Swimmer'>, <class 'object'>)

The MRO lists Duck, then Flyer, then Swimmer, then object. Python searches left to right through that list and stops at the first match. Swap the parent order — class Duck(Swimmer, Flyer) — and move() returns "swimming" instead. Order of base classes in the class definition changes behavior.

Where this gets genuinely useful is cooperative multiple inheritance, where every class calls super() and the MRO chains them together like a relay:

class Base:
    def greet(self):
        return "Base"

class A(Base):
    def greet(self):
        return "A -> " + super().greet()

class B(Base):
    def greet(self):
        return "B -> " + super().greet()

class C(A, B):
    def greet(self):
        return "C -> " + super().greet()

print(C().greet())
# C -> A -> B -> Base
print(C.__mro__)
# (C, A, B, Base, object)

Notice A.greet calls super().greet(), but in C's MRO that super() resolves to B, not Base directly — because the MRO is C, A, B, Base, object. This is why super() isn't "my parent class" — it's "the next class in this instance's MRO." That distinction is the single most misunderstood thing about multiple inheritance in Python.

graph TD
    C["class C(A, B)"] --> A["class A(Base)"]
    A --> B["class B(Base)"]
    B --> Base["class Base"]
    Base --> Obj["object"]

2.4 Polymorphism beyond inheritance: duck typing

A vintage typewriter outdoors typing 'EDGE COMPUTING' on paper, blending technology with nostalgia.
Photo by Markus Winkler on Pexels

Python doesn't require a shared parent class for polymorphism to work — it just needs matching method names. This is duck typing: "if it walks like a duck and quacks like a duck, treat it as a duck."

class Circle:
    def __init__(self, r):
        self.r = r
    def area(self):
        return 3.14159 * self.r ** 2

class Square:
    def __init__(self, side):
        self.side = side
    def area(self):
        return self.side ** 2

shapes = [Circle(3), Square(4)]
for s in shapes:
    print(round(s.area(), 2))
# 28.27
# 16

Circle and Square share no base class at all, yet the loop treats them identically because both expose .area(). For formal contracts (e.g., "every shape MUST have .area()"), use an Abstract Base Class:

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self):
        ...

class Triangle(Shape):
    def __init__(self, base, height):
        self.base, self.height = base, height
    def area(self):
        return 0.5 * self.base * self.height

# Shape()            # TypeError: can't instantiate abstract class
t = Triangle(6, 4)
print(t.area())       # 12.0

ABC with @abstractmethod makes Python enforce that subclasses implement area() — forgetting it raises a TypeError at instantiation time, not silently later when you call a missing method.

3. Worked Example

Let's build a small payment system that combines everything: inheritance, super(), overriding, and polymorphic dispatch over a list.

from abc import ABC, abstractmethod

class PaymentMethod(ABC):
    def __init__(self, amount):
        self.amount = amount

    @abstractmethod
    def fee(self):
        """Each payment type defines its own fee."""

    def total(self):
        return self.amount + self.fee()

    def __repr__(self):
        return f"{type(self).__name__}(amount={self.amount}, total={self.total():.2f})"


class CreditCard(PaymentMethod):
    def fee(self):
        return self.amount * 0.029 + 0.30   # percentage + fixed


class BankTransfer(PaymentMethod):
    def fee(self):
        return 0.50                          # flat fee


class CryptoWallet(PaymentMethod):
    FEE_RATE = 0.01

    def __init__(self, amount, network_congestion=1.0):
        super().__init__(amount)             # reuse parent init
        self.network_congestion = network_congestion

    def fee(self):
        return self.amount * self.FEE_RATE * self.network_congestion


payments = [
    CreditCard(100),
    BankTransfer(100),
    CryptoWallet(100, network_congestion=1.5),
]

for p in payments:
    print(p)

grand_total = sum(p.total() for p in payments)
print(f"Grand total: {grand_total:.2f}")

Output:

CreditCard(amount=100, total=103.20)
BankTransfer(amount=100, total=100.50)
CryptoWallet(amount=100, total=101.50)
Grand total: 305.20

Walk through what happened: PaymentMethod is abstract — you can never do PaymentMethod(100) directly because fee() has no body. Each subclass supplies its own fee(), and total() (defined once, in the parent) automatically uses whichever fee() belongs to the actual object — that's polymorphism inside the parent class itself. CryptoWallet needed an extra constructor argument, so it overrode __init__ but called super().__init__(amount) to avoid duplicating the assignment logic. The loop for p in payments: print(p) doesn't know or care which subclass each p is — it just works, because every subclass honors the same interface shape.

4. Production Pitfalls & Best Practices

4.1 Real-World Best Practices

  • Favor composition over inheritance when the relationship isn't truly "is-a." A Car has an Engine; it isn't an Engine. Model that with an attribute, not a base class.
  • Use ABC + @abstractmethod to define contracts when you want Python to catch missing implementations early, especially in plugin-style architectures.
  • Always call super().__init__(...) in overridden __init__, even if you think you don't need parent state — future maintainers adding parent fields will thank you.
  • Keep override signatures compatible — same parameter names/order as the parent — so callers using the parent type can swap in any subclass without surprises (the Liskov Substitution Principle).
  • Use isinstance() sparingly — rely on duck typing / polymorphic dispatch for behavior, and reserve isinstance checks for genuinely structural decisions (e.g., serialization).
  • Document the MRO-sensitive parts of any multi-inheritance class — a comment saying "this mixin expects super().save() to exist" saves hours of debugging.
  • Prefer mixins with narrow, single-purpose behavior (e.g., LoggingMixin, JSONSerializableMixin) over deep inheritance chains.

4.2 Common Bugs & Anti-Patterns

1. Forgetting super().__init__() loses parent state silently.

# BAD
class Admin(User):
    def __init__(self, name, level):
        self.level = level   # self.name never set!
# FIXED
class Admin(User):
    def __init__(self, name, level):
        super().__init__(name)
        self.level = level

2. Mutable default arguments shared across subclasses.

# BAD
class Cart:
    def __init__(self, items=[]):   # one shared list for ALL instances!
        self.items = items
# FIXED
class Cart:
    def __init__(self, items=None):
        self.items = items if items is not None else []

3. Deep inheritance chains that nobody can trace.

# BAD: 5 levels deep, overriding the same method 3 times
class A: ...
class B(A): ...
class C(B): ...
class D(C): ...
class E(D): ...   # good luck finding where behavior actually comes from
# FIXED: flatten with composition or a single mixin
class LoggingMixin:
    def log(self, msg): print(f"[{type(self).__name__}] {msg}")

class E(A, LoggingMixin):
    ...

4. Hardcoding the parent class name instead of super() breaks under multiple inheritance.

# BAD
class C(A, B):
    def greet(self):
        return A.greet(self)   # skips B entirely, ignores MRO
# FIXED
class C(A, B):
    def greet(self):
        return super().greet()   # follows the real MRO chain

4.3 When to Use What (Decision Matrix)

Situation Approach
Subclasses share state + behavior, true "is-a" relationship Single inheritance with super()
Need to enforce "every subclass MUST implement X" ABC + @abstractmethod
Objects from unrelated classes need interchangeable methods Duck typing (no shared base needed)
Want to add one reusable capability (logging, serialization) to many unrelated classes Mixin class
Combining 2+ independent behaviors into one class Multiple inheritance, but check __mro__
Relationship is "has-a" (Car has Engine), not "is-a" Composition, not inheritance

5. Now Try It

Build this right here, in a Python file or REPL:

Create an abstract class Notifier with an abstract method send(message). Create two subclasses: EmailNotifier (prints "Email: {message}") and SMSNotifier (prints "SMS: {message}", but first override __init__ to also store a phone_number, calling super().__init__() if Notifier has one). Then write a function broadcast(notifiers, message) that loops over a list of mixed notifier objects and calls .send(message) on each — polymorphically, without any isinstance checks. Test it with a list containing one of each.

Success looks like: running broadcast([EmailNotifier(), SMSNotifier("555-1234")], "Hello!") prints both "Email: Hello!" and "SMS: Hello!" using the exact same loop body, and attempting Notifier() directly raises a TypeError.

Frequently asked about Inheritance and Polymorphism

You'll learn how Python classes share and override behavior through inheritance, and how polymorphism lets different objects respond to the same method call in their own way. You'll see super(), method overriding, and multiple inheritance with the MRO. Read the full notes above for the details.

Inheritance and Polymorphism is a core topic in Classes(python). Most exam papers test it via a mix of definitions, worked examples, and applied problems. The notes above cover the high-yield sub-topics, common pitfalls, and the kind of questions examiners typically set.

Yes — every note in the StudyAI Campus Hub is free to read in full, right here on this page, with no account needed. If you clone the plan into your own dashboard, the free plan shows a preview of each note there; Basic and above unlock the full notes in your dashboard, along with practice quizzes, flashcards and offline study. You can always come back here to read the complete note for free.

Study this next


Get the full Classes(python) curriculum

Clone the complete plan to your dashboard for unlimited AI-generated notes, practice quizzes, and a personalised revision schedule.

Save this course free