Python: Zero to Hero
Home/Object-Oriented Programming
Share

Chapter 18: Inheritance and Polymorphism

In Chapter 17 you built classes from scratch. Each class was independent — a Dog, a BankAccount, a Student. That works, but real programs have families of related things.

A SavingsAccount is a BankAccount with an interest rate. A GuideDog is a Dog with extra training. A Manager is an Employee with extra responsibilities. They share most of their behavior, but differ in specific ways.

Inheritance lets you build a new class on top of an existing one. The new class gets everything the parent has, and you add or change only what's different. This is one of the most powerful ideas in OOP — it eliminates duplication and lets you build complex systems from simple, layered pieces.

Polymorphism lets different classes respond to the same method call in their own way. A Dog and a Cat both have a speak() method, but one barks and one meows. Your code can call speak() without caring which animal it has.

What is Inheritance?

Inheritance creates an is-a relationship. A SavingsAccount is a BankAccount. A GuideDog is a Dog. The new class (child, subclass) inherits all the attributes and methods of the existing class (parent, superclass).

Without inheritance — duplicating code:

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

    def breathe(self):
        print(f"{self.name} breathes.")

    def sleep(self):
        print(f"{self.name} sleeps.")


# Without inheritance, you copy these methods into Dog and Cat
class Dog:
    def __init__(self, name, age):
        self.name = name   # duplicated
        self.age  = age    # duplicated

    def breathe(self):     # duplicated
        print(f"{self.name} breathes.")

    def sleep(self):       # duplicated
        print(f"{self.name} sleeps.")

    def bark(self):
        print(f"{self.name} says: Woof!")

With inheritance — define once, reuse everywhere:

class Dog(Animal):   # Dog inherits from Animal
    def bark(self):
        print(f"{self.name} says: Woof!")

Dog inherits __init__, breathe, and sleep from Animal. You only write what's new — bark. If Animal.sleep() changes, Dog gets the update automatically.

Creating a Child Class

Put the parent class name in parentheses:

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

    def breathe(self):
        print(f"{self.name} breathes.")

    def sleep(self):
        print(f"{self.name} is sleeping.")

    def describe(self):
        print(f"{self.name} is a {self.age}-year-old {self.species}.")

    def __str__(self):
        return f"{self.species}({self.name!r}, age={self.age})"


class Dog(Animal):
    def bark(self):
        print(f"{self.name} says: Woof!")

    def fetch(self, item):
        print(f"{self.name} fetches the {item}!")


class Cat(Animal):
    def meow(self):
        print(f"{self.name} says: Meow!")

    def purr(self):
        print(f"{self.name} purrs contentedly.")

Dog and Cat inherit everything from Animal and add their own methods:

rex   = Dog("Rex",   "German Shepherd", 3)
bella = Cat("Bella", "Persian Cat",     5)

rex.describe()    # Rex is a 3-year-old German Shepherd.
rex.breathe()     # Rex breathes.
rex.bark()        # Rex says: Woof!
rex.fetch("ball") # Rex fetches the ball!

bella.describe()  # Bella is a 5-year-old Persian Cat.
bella.meow()      # Bella says: Meow!
bella.purr()      # Bella purrs contentedly.

print(rex)        # German Shepherd('Rex', age=3)
print(bella)      # Persian Cat('Bella', age=5)

rex and bella can use both their own methods and everything from Animal.

super() — Calling the Parent's Method

When you define __init__ in a child class, you need to call the parent's __init__ too — otherwise the parent's setup code never runs and the object won't have the parent's attributes.

super() gives you access to the parent class:

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


class Dog(Animal):
    def __init__(self, name, age, breed):
        super().__init__(name, "Dog", age)   # call Animal.__init__
        self.breed = breed                   # add Dog-specific attribute

    def describe(self):
        print(f"{self.name} is a {self.age}-year-old {self.breed}.")

    def __str__(self):
        return f"Dog({self.name!r}, {self.breed!r}, age={self.age})"
rex = Dog("Rex", 3, "German Shepherd")
print(rex.name)    # Rex      (set by Animal.__init__ via super())
print(rex.age)     # 3        (set by Animal.__init__ via super())
print(rex.breed)   # German Shepherd  (set by Dog.__init__)
rex.describe()     # Rex is a 3-year-old German Shepherd.

The rule: When a child class has its own __init__, call super().__init__(...) first to initialize the parent's part of the object. Then add the child's own attributes.

super() also works for other methods — call super().method() to run the parent version and then extend it:

class Animal:
    def describe(self):
        print(f"Name: {self.name}, Age: {self.age}")


class Dog(Animal):
    def describe(self):
        super().describe()            # run Animal's version first
        print(f"Breed: {self.breed}") # then add Dog's extra info

Overriding Methods

A child class can override any parent method — define a method with the same name. The child's version replaces the parent's for that class.

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

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

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


class Dog(Animal):
    def speak(self):                         # overrides Animal.speak
        print(f"{self.name} says: Woof!")


class Cat(Animal):
    def speak(self):                         # overrides Animal.speak
        print(f"{self.name} says: Meow!")


class Duck(Animal):
    def speak(self):                         # overrides Animal.speak
        print(f"{self.name} says: Quack!")


class Fish(Animal):
    pass                                     # no override — uses Animal.speak
animals = [
    Dog("Rex",   3),
    Cat("Bella", 5),
    Duck("Donald", 7),
    Fish("Nemo", 1),
]

for animal in animals:
    animal.speak()

Output:

Rex says: Woof!
Bella says: Meow!
Donald says: Quack!
Nemo makes a sound.

This is polymorphism — the same speak() call on the same loop produces different behavior depending on which class the object belongs to. The loop doesn't know or care whether it has a dog, cat, or duck. It just calls speak() and each object knows what to do.

type(self).__name__ in __str__ above automatically uses the actual class name — "Dog", "Cat", etc. — without hardcoding it. This works correctly in child classes without overriding __str__.

isinstance() and issubclass()

isinstance(obj, Class) — is obj an instance of Class (or a subclass of it)?

rex = Dog("Rex", 3)

print(isinstance(rex, Dog))     # True  — rex IS a Dog
print(isinstance(rex, Animal))  # True  — Dog is a subclass of Animal
print(isinstance(rex, Cat))     # False — rex is not a Cat

issubclass(Child, Parent) — is Child a subclass of Parent?

print(issubclass(Dog, Animal))    # True
print(issubclass(Cat, Animal))    # True
print(issubclass(Dog, Cat))       # False
print(issubclass(Animal, Animal)) # True — a class is a subclass of itself

These are useful for writing code that handles multiple types gracefully:

def process_animal(animal):
    if isinstance(animal, Dog):
        animal.bark()
    elif isinstance(animal, Cat):
        animal.meow()
    else:
        animal.speak()

But here's the thing: if you're checking isinstance in a lot of places, that's often a sign you should use polymorphism instead — let each class define its own behavior so you don't have to check.

Inheritance Chains — Multiple Levels

Inheritance can go multiple levels deep. A GuideDog is a Dog which is an Animal.

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

    def breathe(self):
        print(f"{self.name} breathes.")


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

    def bark(self):
        print(f"{self.name} says: Woof!")


class GuideDog(Dog):
    def __init__(self, name, age, breed, handler):
        super().__init__(name, age, breed)
        self.handler = handler

    def guide(self):
        print(f"{self.name} guides {self.handler} safely.")

    def __str__(self):
        return (f"GuideDog({self.name!r}, {self.breed!r}, "
                f"handler={self.handler!r})")
buddy = GuideDog("Buddy", 4, "Labrador", "John")

buddy.breathe()    # inherited from Animal
buddy.bark()       # inherited from Dog
buddy.guide()      # defined in GuideDog
print(buddy)       # GuideDog('Buddy', 'Labrador', handler='John')

print(isinstance(buddy, GuideDog))  # True
print(isinstance(buddy, Dog))       # True
print(isinstance(buddy, Animal))    # True

GuideDog inherits from Dog, which inherits from Animal. buddy has access to everything from all three levels.

Multiple Inheritance

Python allows a class to inherit from more than one parent class.

class Flyable:
    def fly(self):
        print(f"{self.name} is flying!")

    def land(self):
        print(f"{self.name} has landed.")


class Swimmable:
    def swim(self):
        print(f"{self.name} is swimming!")


class Duck(Animal, Flyable, Swimmable):
    def __init__(self, name, age):
        super().__init__(name, age)

    def speak(self):
        print(f"{self.name} says: Quack!")
donald = Duck("Donald", 3)
donald.speak()    # Donald says: Quack!
donald.fly()      # Donald is flying!
donald.swim()     # Donald is swimming!
donald.breathe()  # Donald breathes.  (from Animal)

Duck inherits from Animal, Flyable, and Swimmable simultaneously.

Be careful with multiple inheritance. When two parent classes define the same method name, Python's Method Resolution Order (MRO) determines which one runs. Check it with:

print(Duck.__mro__)
# (<class 'Duck'>, <class 'Animal'>, <class 'Flyable'>, <class 'Swimmable'>, <class 'object'>)

Python resolves methods left to right through the MRO chain. Keep multiple inheritance simple — preferably use it only for mixins (small classes that add one focused capability, like Flyable and Swimmable above). When inheritance gets complex, prefer composition (holding another object as an attribute) over deep inheritance trees.

Abstract Base Classes — Enforcing a Contract

Sometimes you want to define a class that requires child classes to implement certain methods. You can't use the parent class directly — it's just a template.

Python's abc module provides this:

from abc import ABC, abstractmethod


class Shape(ABC):
    """Abstract base class for all shapes."""

    @abstractmethod
    def area(self):
        """Return the area of the shape."""

    @abstractmethod
    def perimeter(self):
        """Return the perimeter of the shape."""

    def describe(self):
        """Non-abstract — available to all shapes."""
        print(f"{type(self).__name__}: area={self.area():.2f}, "
              f"perimeter={self.perimeter():.2f}")

Any class that inherits from Shape must implement area() and perimeter(). If it doesn't, Python raises a TypeError when you try to create an instance.

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

    def area(self):
        import math
        return math.pi * self.radius ** 2

    def perimeter(self):
        import math
        return 2 * math.pi * self.radius

    def __str__(self):
        return f"Circle(radius={self.radius})"


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

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

    def perimeter(self):
        return 2 * (self.width + self.height)

    def __str__(self):
        return f"Rectangle({self.width}x{self.height})"


class Triangle(Shape):
    def __init__(self, a, b, c):
        self.a, self.b, self.c = a, b, c

    def area(self):
        # Heron's formula
        s = (self.a + self.b + self.c) / 2
        return (s * (s-self.a) * (s-self.b) * (s-self.c)) ** 0.5

    def perimeter(self):
        return self.a + self.b + self.c

    def __str__(self):
        return f"Triangle({self.a}, {self.b}, {self.c})"
# This would raise TypeError — Shape is abstract
# s = Shape()

shapes = [
    Circle(5),
    Rectangle(4, 6),
    Triangle(3, 4, 5),
    Circle(2.5),
    Rectangle(10, 3),
]

for shape in shapes:
    shape.describe()

print(f"\nLargest area: {max(shapes, key=lambda s: s.area())}")

Output:

Circle: area=78.54, perimeter=31.42
Rectangle: area=24.00, perimeter=20.00
Triangle: area=6.00, perimeter=12.00
Circle: area=19.63, perimeter=15.71
Rectangle: area=30.00, perimeter=26.00

Largest area: Circle(radius=5)

The for loop calls .describe() on every shape. Each shape calculates its own area() and perimeter() — polymorphism again. The loop doesn't know or care about the specific type.

Abstract base classes enforce a contract: "any class that inherits from me must provide these methods." This is how libraries and frameworks define interfaces that your code must implement.

Your turn: Add a Square class that inherits from Rectangle. Its __init__ takes only a side argument and sets width = height = side. Override __str__ to return "Square(side=4)". Add it to the shapes list and verify it works with describe().

A Real-World Inheritance Example: Payment System

from abc import ABC, abstractmethod
from datetime import datetime


class PaymentMethod(ABC):
    """Abstract base class for all payment methods."""

    def __init__(self, owner):
        self.owner = owner
        self._transactions = []

    @abstractmethod
    def process(self, amount, description):
        """Process a payment. Must be implemented by subclasses."""

    def _record(self, amount, description, status):
        self._transactions.append({
            "timestamp":   datetime.now().strftime("%Y-%m-%d %H:%M"),
            "amount":      amount,
            "description": description,
            "status":      status,
        })

    def history(self):
        print(f"\nTransaction history for {self.owner}:")
        for t in self._transactions:
            print(f"  [{t['timestamp']}] {t['description']}: "
                  f"${t['amount']:.2f}{t['status']}")

    def __str__(self):
        return f"{type(self).__name__}(owner={self.owner!r})"


class CreditCard(PaymentMethod):
    def __init__(self, owner, card_number, credit_limit):
        super().__init__(owner)
        self._card_number  = card_number
        self._credit_limit = credit_limit
        self._balance      = 0.0

    def process(self, amount, description):
        if self._balance + amount > self._credit_limit:
            self._record(amount, description, "DECLINED — over limit")
            print(f"DECLINED: {description} ${amount:.2f} — credit limit reached.")
            return False
        self._balance += amount
        self._record(amount, description, "APPROVED")
        print(f"APPROVED: {description} ${amount:.2f} via credit card.")
        return True

    def pay_bill(self, amount):
        self._balance = max(0, self._balance - amount)
        print(f"Bill payment: ${amount:.2f}. Remaining balance: ${self._balance:.2f}")

    def __str__(self):
        masked = "*" * 12 + self._card_number[-4:]
        return f"CreditCard({self.owner!r}, {masked}, limit=${self._credit_limit:.2f})"


class BankTransfer(PaymentMethod):
    def __init__(self, owner, account_number, balance):
        super().__init__(owner)
        self._account = account_number
        self._balance = float(balance)

    def process(self, amount, description):
        if amount > self._balance:
            self._record(amount, description, "DECLINED — insufficient funds")
            print(f"DECLINED: {description} ${amount:.2f} — insufficient funds.")
            return False
        self._balance -= amount
        self._record(amount, description, "APPROVED")
        print(f"APPROVED: {description} ${amount:.2f} via bank transfer.")
        return True

    def __str__(self):
        return f"BankTransfer({self.owner!r}, balance=${self._balance:.2f})"


class DigitalWallet(PaymentMethod):
    def __init__(self, owner, initial_balance=0.0):
        super().__init__(owner)
        self._balance = float(initial_balance)

    def top_up(self, amount):
        self._balance += amount
        print(f"Wallet topped up: ${amount:.2f}. New balance: ${self._balance:.2f}")

    def process(self, amount, description):
        if amount > self._balance:
            self._record(amount, description, "DECLINED — low balance")
            print(f"DECLINED: {description} ${amount:.2f} — wallet balance too low.")
            return False
        self._balance -= amount
        self._record(amount, description, "APPROVED")
        print(f"APPROVED: {description} ${amount:.2f} via digital wallet.")
        return True

    def __str__(self):
        return f"DigitalWallet({self.owner!r}, balance=${self._balance:.2f})"


def checkout(payment_method, cart):
    """Process a list of (description, amount) purchases."""
    print(f"\nChecking out with: {payment_method}")
    total = 0
    for description, amount in cart:
        if payment_method.process(amount, description):
            total += amount
    print(f"Total charged: ${total:.2f}")
    payment_method.history()


# — Demonstrate polymorphism ---
card   = CreditCard("Alice",   "1234567890123456", credit_limit=500.0)
bank   = BankTransfer("Bob",   "ACC-9876",         balance=300.0)
wallet = DigitalWallet("Carlos", initial_balance=150.0)

cart = [
    ("Coffee",       4.50),
    ("Lunch",       12.75),
    ("Books",       45.00),
    ("Electronics", 600.00),   # this will be declined for credit card
]

# Same checkout() function — different payment method — different behavior
checkout(card,   cart)
checkout(wallet, cart)

Three completely different payment methods, one checkout() function. Polymorphism in action.

What You Learned in This Chapter

  • Inheritance creates an is-a relationship: child class gets all parent attributes and methods.
  • Define a child class: class Child(Parent):.
  • super().__init__(...) calls the parent's constructor — always do this in a child's __init__.
  • super().method() calls the parent's version of any method.
  • Overriding replaces a parent method with a new version in the child class.
  • Polymorphism — different classes respond to the same method call differently.
  • isinstance(obj, Class) checks if an object is an instance of a class or its subclasses.
  • issubclass(Child, Parent) checks inheritance relationships.
  • Inheritance chains can go multiple levels deep — each super() call walks up the chain.
  • Multiple inheritance lets a class inherit from several parents. Use mixins for focused capabilities.
  • Method Resolution Order (MRO) — Python's left-to-right rule for resolving method names in multiple inheritance.
  • Abstract base classes (from abc) enforce contracts — any subclass must implement @abstractmethod methods.
  • If you can't instantiate an abstract class directly, that's intentional — it's just a blueprint.

What's Next?

You know how to build class hierarchies. Chapter 19 goes deeper into OOP — encapsulation and special methods. You'll learn about properties, private attributes, and the full range of dunder methods that let your objects work with Python's built-in operators: +, [], len(), in, comparisons, iteration, and more. This is where your classes start feeling like native Python types.

Your turn: Build a Vehicle base class with make, model, year, and fuel_level attributes. Add fuel() (add fuel) and drive(distance) (consumes fuel) methods. Create three subclasses: Car (regular fuel consumption), Truck (double fuel consumption), and ElectricCar (uses charge instead of fuel, with a different display). Override drive() in each. Make them all work with a road_trip(vehicle, stops) function that drives between each stop and prints a status update. Use isinstance() to give a special message for ElectricCar about charging.

© 2026 Abhilash Sahoo. Python: Zero to Hero.