Skip to main content

🧬 Lesson 2: Inheritance & Polymorphism

Build specialized classes on top of existing ones — reuse code, override behavior, and write functions that work with any compatible object.

🎯 Learning Objectives

By the end of this lesson, you will be able to:

  • Create child classes that inherit from a parent class
  • Override methods to customize behavior in subclasses
  • Use super() to extend (not replace) parent behavior
  • Understand polymorphism — one interface, many forms
  • Define abstract base classes that enforce a contract

Estimated Time: 60 minutes

Project: Build a shape hierarchy with Circle, Rectangle, and Triangle that share a common interface

In This Lesson

🤔 Why Inheritance?

In Lesson 1 you learned to bundle data and behavior into classes. But what happens when you have several related classes that share a lot of the same code?

Imagine you're building an employee system. You might have Manager, Engineer, and Intern classes. They all have a name, employee ID, and a get_info() method — but each role also has unique attributes and behaviors. Without inheritance, you'd duplicate the shared code in every class.

Inheritance lets you define a parent (base) class with the shared code, then create child (derived) classes that automatically get everything the parent has — plus whatever they add or change.

📖 Key Terms

Parent class (Base / Superclass): The general class being inherited from.

Child class (Derived / Subclass): The specialized class that inherits from the parent.

Inheritance: The mechanism by which a child class gets all attributes and methods of its parent.

Method Overriding: When a child defines a method with the same name as the parent's, replacing the parent's version.

graph TD A["🏢 Employee
(Parent Class)"] --> B["👔 Manager"] A --> C["💻 Engineer"] A --> D["🎓 Intern"] style A fill:#3b82f6,color:#fff style B fill:#6366f1,color:#fff style C fill:#10b981,color:#fff style D fill:#f59e0b,color:#fff

✏️ Creating a Subclass

To create a child class, put the parent class name in parentheses after the class name:

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

    def get_info(self):
        return f"{self.name} (ID: {self.emp_id})"

    def clock_in(self):
        return f"{self.name} clocked in."


class Manager(Employee):
    """A Manager IS-AN Employee with extra responsibilities."""
    pass  # inherits everything from Employee — nothing new yet

Even with pass, Manager already has __init__, get_info(), and clock_in():

mgr = Manager("Alice", "M-001")
print(mgr.get_info())
print(mgr.clock_in())

Output:

Alice (ID: M-001)
Alice clocked in.

✅ The "Is-A" Test

Use inheritance when the child class truly is a specialized version of the parent. A Manager is an Employee. A Circle is a Shape. If the relationship is "has a" instead (a Car has an Engine), use composition — store the other object as an attribute rather than inheriting from it.

Adding New Attributes and Methods

A child class can add things the parent doesn't have:

class Manager(Employee):
    def __init__(self, name, emp_id, department):
        # Initialize the Employee part first
        super().__init__(name, emp_id)
        # Add Manager-specific attributes
        self.department = department
        self.direct_reports = []

    def add_report(self, employee):
        self.direct_reports.append(employee)
        return f"{employee.name} now reports to {self.name}"

    def team_size(self):
        return len(self.direct_reports)


class Engineer(Employee):
    def __init__(self, name, emp_id, language):
        super().__init__(name, emp_id)
        self.language = language
        self.commits = 0

    def write_code(self, lines):
        self.commits += 1
        return f"{self.name} wrote {lines} lines of {self.language} (commit #{self.commits})"
mgr = Manager("Alice", "M-001", "Engineering")
eng = Engineer("Bob", "E-042", "Python")

print(mgr.add_report(eng))
print(f"Team size: {mgr.team_size()}")
print(eng.write_code(150))

# Both still have parent methods
print(mgr.clock_in())
print(eng.clock_in())

Output:

Bob now reports to Alice
Team size: 1
Bob wrote 150 lines of Python (commit #1)
Alice clocked in.
Bob clocked in.
🏢 Employee name, emp_id get_info() clock_in() 👔 Manager + name, emp_id (inherited) + department, direct_reports + add_report() + team_size() 💻 Engineer + name, emp_id (inherited) + language, commits + write_code()

🔄 Method Overriding

When a child class defines a method with the same name as the parent, the child's version takes over. This is called overriding.

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

    def get_info(self):
        return f"{self.name} (ID: {self.emp_id})"


class Manager(Employee):
    def __init__(self, name, emp_id, department):
        super().__init__(name, emp_id)
        self.department = department

    def get_info(self):
        """Override: include department info."""
        return f"{self.name} (ID: {self.emp_id}) — Dept: {self.department}"


class Intern(Employee):
    def __init__(self, name, emp_id, school):
        super().__init__(name, emp_id)
        self.school = school

    def get_info(self):
        """Override: include school info."""
        return f"{self.name} (ID: {self.emp_id}) — Student at {self.school}"
emp = Employee("Dana", "G-100")
mgr = Manager("Alice", "M-001", "Engineering")
intern = Intern("Carlos", "I-015", "UNLV")

print(emp.get_info())
print(mgr.get_info())
print(intern.get_info())

Output:

Dana (ID: G-100)
Alice (ID: M-001) — Dept: Engineering
Carlos (ID: I-015) — Student at UNLV

Each class has its own version of get_info(). Python looks at the actual type of the object to decide which version to call — not the variable name or the parent class.

flowchart LR A["mgr.get_info()"] --> B{"Is get_info()
defined in Manager?"} B -->|Yes| C["✅ Use Manager's
get_info()"] B -->|No| D["🔍 Check Employee
(parent class)"] D --> E["Use Employee's
get_info()"]

🧠 Method Resolution Order (MRO)

When you call a method on an object, Python searches for it in this order:

  1. The object's own class
  2. The parent class
  3. The parent's parent (and so on up the chain)

You can see the full chain with ClassName.__mro__ or ClassName.mro().

print(Manager.__mro__)
# (<class 'Manager'>, <class 'Employee'>, <class 'object'>)

🔗 The super() Function

Sometimes you don't want to replace the parent's method — you want to extend it. That's what super() is for. It gives you a reference to the parent class so you can call its methods.

Extending __init__

You've already seen this pattern — call the parent's __init__ to set up the shared attributes, then add your own:

class Engineer(Employee):
    def __init__(self, name, emp_id, language):
        super().__init__(name, emp_id)  # Employee handles name & emp_id
        self.language = language         # Engineer adds language

⚠️ What Happens Without super().__init__()?

If you define __init__ in the child without calling super().__init__(), the parent's __init__ never runs. The shared attributes (name, emp_id) won't exist on the object.

# ❌ Forgot super().__init__()
class BrokenEngineer(Employee):
    def __init__(self, name, emp_id, language):
        self.language = language

eng = BrokenEngineer("Bob", "E-042", "Python")
print(eng.language)  # Python
print(eng.name)      # AttributeError! name was never set

Extending Other Methods

super() works with any method, not just __init__. Use it when you want the parent's behavior plus something extra:

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

    def get_info(self):
        return f"{self.name} (ID: {self.emp_id})"


class Manager(Employee):
    def __init__(self, name, emp_id, department):
        super().__init__(name, emp_id)
        self.department = department
        self.direct_reports = []

    def get_info(self):
        # Start with whatever the parent returns
        base = super().get_info()
        # Extend it with Manager-specific info
        return f"{base} — Dept: {self.department}, Team: {len(self.direct_reports)}"


mgr = Manager("Alice", "M-001", "Engineering")
print(mgr.get_info())

Output:

Alice (ID: M-001) — Dept: Engineering, Team: 0

This approach is powerful because if the parent's get_info() changes later (say, to include a hire date), the child's version automatically picks up the change.

sequenceDiagram participant Code as 👤 Your Code participant Mgr as 👔 Manager participant Emp as 🏢 Employee Code->>Mgr: mgr.get_info() Mgr->>Emp: super().get_info() Emp-->>Mgr: "Alice (ID: M-001)" Mgr->>Mgr: Append department & team size Mgr-->>Code: "Alice (ID: M-001) — Dept: Engineering, Team: 0"

🎭 Polymorphism

Polymorphism means "many forms." In practice, it means you can write code that works with objects of different types, as long as they share the same interface (method names).

import math

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

    def area(self):
        raise NotImplementedError("Subclasses must implement area()")

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


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

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


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

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


class Triangle(Shape):
    def __init__(self, base, height):
        super().__init__("Triangle")
        self.base = base
        self.height = height

    def area(self):
        return 0.5 * self.base * self.height

Now you can treat all shapes uniformly:

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

for shape in shapes:
    print(shape.describe())

# Find the largest shape
biggest = max(shapes, key=lambda s: s.area())
print(f"\nLargest: {biggest.name} ({biggest.area():.2f})")

Output:

Circle: area = 78.54
Rectangle: area = 28.00
Triangle: area = 9.00

Largest: Circle (78.54)

✅ Why This Matters

The for loop and max() call don't know or care whether they're dealing with a Circle, Rectangle, or Triangle. They just call .area() and it works. That's polymorphism — you can add a Pentagon class later without touching any of this code.

shape.describe() Circle area = πr² Rectangle area = w × h Triangle area = ½bh

Duck Typing — Python's Approach

Python doesn't actually require inheritance for polymorphism to work. If an object has the right method, Python will call it regardless of its type. This is called duck typing:

"If it walks like a duck and quacks like a duck, it's a duck."
class Carpet:
    """Not a Shape subclass — but has area()."""
    def __init__(self, width, length):
        self.name = "Carpet"
        self.width = width
        self.length = length

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

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

# Works seamlessly alongside real Shape subclasses
shapes = [Circle(5), Rectangle(4, 7), Carpet(3, 4)]

for shape in shapes:
    print(shape.describe())  # Carpet works fine!

🧠 Inheritance vs. Duck Typing

Inheritance gives you polymorphism plus code reuse. Duck typing gives you polymorphism alone. Both are valid — Python supports both styles. Use inheritance when objects truly share an "is-a" relationship and common code. Use duck typing for looser coupling.

📐 Abstract Base Classes

In the Shape example, the parent's area() raised NotImplementedError — but that only catches the mistake at runtime (when someone actually calls it). Python's abc module lets you catch it at instantiation time:

from abc import ABC, abstractmethod
import math


class Shape(ABC):
    """Abstract base class — cannot be instantiated directly."""

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

    @abstractmethod
    def area(self):
        """Subclasses MUST implement this."""
        pass

    @abstractmethod
    def perimeter(self):
        """Subclasses MUST implement this."""
        pass

    def describe(self):
        """Concrete method — available to all subclasses."""
        return f"{self.name}: area={self.area():.2f}, perimeter={self.perimeter():.2f}"


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

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

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


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

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

    def perimeter(self):
        return 2 * (self.width + self.height)
# This works — all abstract methods are implemented
c = Circle(5)
print(c.describe())

r = Rectangle(4, 7)
print(r.describe())

# This FAILS — can't instantiate an abstract class
s = Shape("mystery")
# TypeError: Can't instantiate abstract class Shape
#   with abstract methods area, perimeter

Output:

Circle: area=78.54, perimeter=31.42
Rectangle: area=28.00, perimeter=22.00

TypeError: Can't instantiate abstract class Shape with abstract methods area, perimeter

📖 When to Use ABCs

Use abstract base classes when you want to enforce a contract — guarantee that all subclasses implement certain methods. This is especially useful in larger codebases and team projects where you want compile-time-like safety in a dynamic language.

classDiagram class Shape { <<abstract>> +name: str +area()* float +perimeter()* float +describe() str } class Circle { +radius: float +area() float +perimeter() float } class Rectangle { +width: float +height: float +area() float +perimeter() float } Shape <|-- Circle Shape <|-- Rectangle

🔍 isinstance() and issubclass()

Python provides two built-in functions for checking type relationships:

c = Circle(5)
r = Rectangle(4, 7)

# isinstance — is this object of this type (or a subtype)?
print(isinstance(c, Circle))    # True
print(isinstance(c, Shape))     # True — Circle IS a Shape
print(isinstance(c, Rectangle)) # False

# issubclass — is this class a subclass of another?
print(issubclass(Circle, Shape))     # True
print(issubclass(Rectangle, Shape))  # True
print(issubclass(Circle, Rectangle)) # False

Output:

True
True
False
True
True
False

⚠️ Don't Overuse Type Checking

If you find yourself writing if isinstance(obj, Circle): ... everywhere, you're probably fighting polymorphism instead of using it. The whole point is that you don't need to check — just call the method and let the object figure out what to do.

Use isinstance() for edge cases like input validation, serialization, or debugging — not as a substitute for proper method design.

Function Question It Answers Example
isinstance(obj, cls) Is this object of this type (or a child type)? isinstance(c, Shape)True
issubclass(cls_a, cls_b) Is this class a subclass of another class? issubclass(Circle, Shape)True
type(obj) What is the exact type of this object? type(c)<class 'Circle'>

🏋️ Hands-on Exercises

🏋️ Exercise 1: Vehicle Hierarchy

Objective: Practice inheritance, super(), and method overriding.

Requirements:

  1. Create a Vehicle class with make, model, year, and a describe() method
  2. Create an ElectricCar subclass that adds battery_kwh and a range_miles() method (estimate: 3.5 miles per kWh)
  3. Create a Truck subclass that adds payload_tons and overrides describe() to include payload capacity
  4. Both subclasses should use super().__init__()

Starter Code:

class Vehicle:
    def __init__(self, make, model, year):
        # TODO
        pass

    def describe(self):
        # TODO: return "2024 Toyota Camry"
        pass


class ElectricCar(Vehicle):
    def __init__(self, make, model, year, battery_kwh):
        # TODO: call super, add battery_kwh
        pass

    def range_miles(self):
        # TODO: return estimated range (3.5 miles per kWh)
        pass


class Truck(Vehicle):
    def __init__(self, make, model, year, payload_tons):
        # TODO
        pass

    def describe(self):
        # TODO: extend parent describe with payload info
        pass


# Test
ev = ElectricCar("Tesla", "Model 3", 2024, 75)
print(ev.describe())
print(f"Range: {ev.range_miles():.0f} miles")

truck = Truck("Ford", "F-150", 2023, 1.5)
print(truck.describe())
💡 Hint

In Truck.describe(), call super().describe() to get the base string, then append the payload info with an f-string.

✅ Solution
class Vehicle:
    def __init__(self, make, model, year):
        self.make = make
        self.model = model
        self.year = year

    def describe(self):
        return f"{self.year} {self.make} {self.model}"


class ElectricCar(Vehicle):
    def __init__(self, make, model, year, battery_kwh):
        super().__init__(make, model, year)
        self.battery_kwh = battery_kwh

    def range_miles(self):
        return self.battery_kwh * 3.5


class Truck(Vehicle):
    def __init__(self, make, model, year, payload_tons):
        super().__init__(make, model, year)
        self.payload_tons = payload_tons

    def describe(self):
        base = super().describe()
        return f"{base} (Payload: {self.payload_tons}T)"


ev = ElectricCar("Tesla", "Model 3", 2024, 75)
print(ev.describe())               # 2024 Tesla Model 3
print(f"Range: {ev.range_miles():.0f} miles")  # Range: 262 miles

truck = Truck("Ford", "F-150", 2023, 1.5)
print(truck.describe())            # 2023 Ford F-150 (Payload: 1.5T)

🏋️ Exercise 2: Notification System with ABC

Objective: Practice abstract base classes and polymorphism.

Requirements:

  1. Create an abstract Notification class with an abstract send(message) method and a concrete log(message) method that prints a timestamp
  2. Create EmailNotification (requires to_address) and SlackNotification (requires channel) subclasses
  3. Each subclass implements send() differently (just return a descriptive string — no actual sending)
  4. Write a function send_all(notifications, message) that loops through a list and calls send() on each one
💡 Hint

Import ABC and abstractmethod from abc. The log() method is concrete (not abstract), so it's shared by all subclasses. For timestamps, use from datetime import datetime and datetime.now().strftime("%H:%M:%S").

✅ Solution
from abc import ABC, abstractmethod
from datetime import datetime


class Notification(ABC):
    @abstractmethod
    def send(self, message):
        pass

    def log(self, message):
        ts = datetime.now().strftime("%H:%M:%S")
        print(f"[{ts}] {message}")


class EmailNotification(Notification):
    def __init__(self, to_address):
        self.to_address = to_address

    def send(self, message):
        self.log(f"Email → {self.to_address}")
        return f"📧 Sent to {self.to_address}: {message}"


class SlackNotification(Notification):
    def __init__(self, channel):
        self.channel = channel

    def send(self, message):
        self.log(f"Slack → #{self.channel}")
        return f"💬 Posted to #{self.channel}: {message}"


def send_all(notifications, message):
    for n in notifications:
        print(n.send(message))


channels = [
    EmailNotification("alice@example.com"),
    SlackNotification("engineering"),
    EmailNotification("bob@example.com"),
]

send_all(channels, "Deploy complete!")

🎯 Quick Quiz

Question 1: What does super().__init__(name, emp_id) do inside a child class's __init__?

Question 2: What is polymorphism?

Question 3: What happens if you try to instantiate an abstract class that has unimplemented abstract methods?

📏 Best Practices

✅ Do's

  • Use the "is-a" test — only inherit when the child truly is a specialized version of the parent
  • Always call super().__init__() — ensure parent attributes get initialized properly
  • Keep hierarchies shallow — one or two levels deep is usually enough. Deep inheritance chains become hard to follow.
  • Favor composition over inheritance for "has-a" relationships — a Car has an Engine, not is an Engine
  • Use ABCs when you need to enforce an interface — especially in team settings or library code

❌ Don'ts

  • Don't inherit just to reuse one method — that's a sign you want a utility function or mixin instead
  • Don't override methods and completely change their meaning — a fly() method on a Penguin(Bird) that raises an error violates the principle of least surprise
  • Don't check types when polymorphism would work — let the method dispatch handle it

💡 Pro Tips

  • Use ClassName.__mro__ to debug method resolution when you're confused about which version of a method is being called
  • Abstract methods can have a body — subclasses can call super().method() to use it as a default and extend it
  • In Python, every class implicitly inherits from object — that's where __str__, __repr__, and other built-in methods come from

📝 Summary

🎉 Key Takeaways

  • Inheritance lets a child class reuse attributes and methods from a parent class
  • Method overriding lets a child replace a parent's method with its own version
  • super() calls the parent's version — use it to extend rather than replace behavior
  • Polymorphism means one interface, many implementations — write code that works with any compatible type
  • Abstract base classes (via abc) enforce that subclasses implement required methods
  • Duck typing means Python cares about what an object can do, not what it is
Concept Syntax Purpose
Inherit class Child(Parent): Child gets all of Parent's code
Call parent method super().method(args) Extend instead of replace
Override Define method with same name Customize behavior in child
Abstract class class X(ABC): Can't be instantiated directly
Abstract method @abstractmethod Subclasses must implement
Type check isinstance(obj, cls) Check if obj is of type cls

📚 Additional Resources

🚀 What's Next?

In the next lesson, we'll explore Magic Methods & Operator Overloading — how to make your classes work with print(), len(), ==, <, and other built-in Python operations by implementing special __dunder__ methods.

🎉 Level Up!

You can now build class hierarchies, reuse code through inheritance, and write flexible polymorphic code. These patterns show up everywhere in Python — from web frameworks to game engines.