Module 5 · Object-Oriented Programming ⏱ 18 min

Inheritance & Polymorphism

By the end of this lesson you will be able to:
  • Create a subclass that inherits from a parent with class Child(Parent)
  • Override a parent method to change behaviour in the subclass
  • Reuse parent behaviour from a subclass with super()
  • Explain why polymorphism lets one piece of code work with many types
  • Avoid the broken __init__ trap when a subclass adds new attributes

Inheritance lets a new class borrow the data and behavior of an existing one. Write class Dog(Animal) and Dog is a subclass that automatically gets everything Animal defines — you only add or change what is different.

This matters because copying code is dangerous. When you fix a bug in one copy, you usually forget the others. Inheritance keeps shared behavior in one place — the parent — and lets each child specialise. The result is less code, fewer bugs, and a clear structure that mirrors real-world categories: a Dog is an Animal. That IS-A relationship is the heart of inheritance.

class Animal:
    def __init__(self, name):
        self.name = name
    def speak(self):
        return self.name + ' makes a sound'

class Dog(Animal):
    def speak(self):
        return self.name + ' woofs'

Dog redefines — overridesspeak, but inherits __init__ from Animal unchanged. That is the point: reuse what is the same, replace what is different.

flowchart TD
  A["class Animal\nspeak()"] --> D["class Dog\nspeak() override"]
  A --> C["class Cat\nspeak() override"]
  style A fill:#3776ab,color:#fff
Subclasses inherit from a parent and can override its methods.

Extending with super()

Use super() inside a subclass method to call the parent's version — handy when you want to extend rather than replace it. super().speak() runs Animal.speak, then you add extra behaviour on top:

class Dog(Animal):
    def speak(self):
        return super().speak() + ' ... then WOOF'

This is cleaner than rewriting the parent's logic inside the child. It also survives changes: if Animal.speak is fixed later, Dog automatically gets the fix because it delegates upward instead of copying. The super() call looks up the method on the parent class and invokes it with the same instance, so self.name is still available. Without super(), you would have to duplicate the parent's code, and duplicated code always drifts out of sync.

Subclasses override methods and use super() to extend parents.
class Animal:
    def __init__(self, name):
        self.name = name
    def speak(self):
        return self.name + ' makes a sound'

class Dog(Animal):
    def speak(self):
        return super().speak() + ' ... then WOOF'

class Cat(Animal):
    def speak(self):
        return self.name + ' purrs'

pets = [Dog('Rex'), Cat('Bo'), Animal('Mia')]
for p in pets:
    print(p.speak())

Polymorphism: same call, different behaviour

Because every subclass has its own speak, you can loop over mixed objects and each one responds its own way. That is polymorphism — one piece of code works with many types because they share an interface.

The loop above does not know whether p is a Dog, a Cat, or a plain Animal. It only knows that p has a speak method, and that is enough. This is the real power of inheritance: not just saving typing, but letting you write general code that specialises automatically. You can add a Bird tomorrow and the same loop will work without a single change.

Inheritance means IS-A

isinstance respects the family tree too. A Dog IS-A Animal, so isinstance(Dog('Rex'), Animal) is True. Inheritance means a subclass instance is acceptable anywhere code expects the parent type — that is what makes polymorphism safe. You can pass a Dog to a function that asks for an Animal, and it will work perfectly.

Polymorphism in action: one loop, many behaviours.
class Animal:
    def __init__(self, name):
        self.name = name
    def speak(self):
        return self.name + ' makes a sound'

class Dog(Animal):
    def speak(self):
        return self.name + ' woofs'

class Cat(Animal):
    def speak(self):
        return self.name + ' purrs'

def describe(pet):
    print('A pet says:', pet.speak())

describe(Dog('Rex'))
describe(Cat('Bo'))
print('isinstance check:', isinstance(Dog('Rex'), Animal))
flowchart TD
  L["for p in pets:"] -->|p = Dog| D["Dog.speak()\nRex woofs"]
  L -->|p = Cat| C["Cat.speak()\nBo purrs"]
  L -->|p = Animal| A["Animal.speak()\nMia makes a sound"]
  style L fill:#3776ab,color:#fff
One loop calls speak() on every object; each class answers with its own version.

Making objects print nicely with __str__

Print a bare object and Python shows something like <__main__.Dog object at 0x...> — useful to a debugger, meaningless to anyone else. The fix is a specially named method called __str__ (double underscore, str, double underscore) that returns the string you want people to see. Whenever print() or an f-string needs text from your object, Python calls __str__ behind the scenes and uses what it returns.

Because __str__ is just a method, a subclass can override it the same way it overrides speak. Give Animal one version and Dog another, and printing each object now produces a clean, human-readable line instead of an address. This is polymorphism showing up in a second place: the same print(p) call does the right thing for every kind of object in the list.

flowchart TD
  C["look up speak() on a Dog"] --> Q1{"defined in Dog?"}
  Q1 -- yes --> R1["use Dog.speak"]
  Q1 -- no --> P["look in Animal next"]
  P --> Q2{"defined in Animal?"}
  Q2 -- yes --> R2["use Animal.speak"]
  Q2 -- no --> ERR["AttributeError"]
  style C fill:#3776ab,color:#fff
  style ERR fill:#b91c1c,color:#fff
Python walks from the subclass up to the parent looking for a method; the first match wins.
Override __str__ on each class and print() shows a clean line per object.
class Animal:
    def __init__(self, name):
        self.name = name
    def __str__(self):
        return f"<Animal {self.name}>"

class Dog(Animal):
    def __str__(self):
        return f"<Dog {self.name}>"

pets = [Animal("Mia"), Dog("Rex")]
for p in pets:
    print(p)

is-a versus has-a: when inheritance is the wrong tool

Inheritance answers an is-a question. A Dog is a more specific sort of Animal, so the relationship fits: whatever is true of animals is true of dogs. That test is stricter than it looks. A car has an engine, but a car is not an engine, so making Car inherit from Engine would be a design blunder — the car would suddenly sprout pistons and its own fuel mix.

That has-a relationship is called composition, and it is the safer default for most designs. A class holds another object as an attribute and delegates work to it, so a Car can keep an engine attribute and call self.engine.start() without pretending to be an engine. Reach for inheritance only when a subclass truly is a specialized kind of its parent; reach for composition whenever one object merely uses another.

Duck typing: polymorphism without a parent

Python's polymorphism is looser than the family tree suggests. The loop earlier worked because every pet could speak, and in Python that is all the language asks for. It never inspects an object's family tree before calling a method; it simply tries the call and trusts the object to answer. If it walks like a duck and quacks like a duck, the code treats it as a duck.

This is called duck typing, and it is why a function that calls .speak() will happily accept any class you hand it, related or not. Inheritance gives you a tidy way to share code; duck typing means you rarely have to prove a relationship before your objects work together.

Exercise

Animal is given. Define a Dog subclass that inherits __init__ (do not rewrite it) but overrides speak to return self.name + ' woofs'.

class Animal:
    def __init__(self, name):
        self.name = name
    def speak(self):
        return self.name + ' makes a sound'

# Define Dog below: inherit __init__, override speak.
class Dog(Animal):
    pass
Exercise

What does this print? Square overrides area.

class Shape:
    def area(self):
        return 0

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

s = Square(4)
print(s.area())
Exercise

Write a Bird subclass of Animal that calls super().speak() and appends ' ... then chirps' to the result. Do NOT rewrite __init__.

class Animal:
    def __init__(self, name):
        self.name = name
    def speak(self):
        return self.name + ' makes a sound'

class Bird(Animal):
    pass
Exercise

This Dog subclass adds a breed attribute, but it forgets to call the parent's __init__. Fix it with super().

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

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

Write loudest(pets) that takes a list of Animal objects (or subclasses) and returns the one whose speak() string has the most characters. Use a loop and len(). Return None for an empty list.

def loudest(pets):
    # return the pet with the longest speak() string
    pass

Recap

  • class Dog(Animal): makes Dog a subclass that inherits everything from Animal.
  • Override a method by defining it again in the child; use super() to extend the parent's version instead of replacing it.
  • Polymorphism means one loop or function can work with many types because they share an interface.
  • isinstance respects the family tree: a Dog counts as an Animal.
  • When a subclass adds new attributes, call super().__init__(...) first so the parent state is set up correctly.
  • Inheritance saves duplication; polymorphism lets you write general code that adapts automatically.

Next you'll explore the difference between data that belongs to a whole class and data that belongs to one object — the distinction between class and instance attributes.

Checkpoint quiz

How do you declare that Dog inherits from Animal?

Inside a subclass method, what does super().speak() do?

Go deeper — technical resources