Lesson 3 - Inheritance and Polymorphism

Introduction

So far, we’ve learned how to create classes with attributes, methods, and properties. But what if we want to create similar classes that share some functionality? For example, what if we’re building a bookstore that sells both physical books and ebooks? They share some properties (title, author, price) but also have differences (physical books have weight and shipping, ebooks have file size and download limits).

Instead of duplicating code, we can use inheritance—creating new classes based on existing ones. This lesson covers:

  • How inheritance works in Python
  • The super() function for calling parent methods
  • Method overriding
  • Multiple inheritance
  • Polymorphism and why it matters

Basic Inheritance

Inheritance lets you create a new class based on an existing class. The new class (called the child or subclass) inherits all attributes and methods from the existing class (called the parent or superclass).

Let’s start with a simple example:

class Book:
    def __init__(self, title, author, price):
        self.title = title
        self.author = author
        self.price = price

    def display_info(self):
        print(f"'{self.title}' by {self.author}")
        print(f"Price: ${self.price}")

# PhysicalBook inherits from Book
class PhysicalBook(Book):
    def __init__(self, title, author, price, weight):
        super().__init__(title, author, price)  # Call parent's __init__
        self.weight = weight

    def calculate_shipping(self):
        # $5 base + $0.50 per pound
        return 5 + (self.weight * 0.5)

# Create a physical book
book = PhysicalBook("Python Crash Course", "Eric Matthes", 39.99, 2.5)

# Inherited method from Book
book.display_info()

# Method specific to PhysicalBook
shipping = book.calculate_shipping()
print(f"Shipping cost: ${shipping:.2f}")

Output:

'Python Crash Course' by Eric Matthes
Price: $39.99
Shipping cost: $6.25

Key points:

  • class PhysicalBook(Book): creates a subclass of Book
  • super().__init__(...) calls the parent class’s __init__ method
  • PhysicalBook has access to display_info() from Book
  • PhysicalBook adds its own method calculate_shipping()

The super() Function

The super() function gives you access to the parent class without explicitly naming it. This is especially useful if you later change which class you’re inheriting from.

class Book:
    def __init__(self, title, author, price):
        self.title = title
        self.author = author
        self.price = price
        print(f"Book.__init__ called for '{title}'")

class PhysicalBook(Book):
    def __init__(self, title, author, price, weight):
        print(f"PhysicalBook.__init__ called")
        super().__init__(title, author, price)
        self.weight = weight

book = PhysicalBook("Python Basics", "John Doe", 29.99, 1.8)

Output:

PhysicalBook.__init__ called
Book.__init__ called for 'Python Basics'

Notice how super().__init__() calls the parent’s initialization, letting us extend it rather than completely replace it.

Method Overriding

Method overriding means defining a method in the child class with the same name as a method in the parent class. The child’s version replaces the parent’s version.

class Book:
    def __init__(self, title, author, price):
        self.title = title
        self.author = author
        self.price = price

    def display_info(self):
        print(f"'{self.title}' by {self.author}")
        print(f"Price: ${self.price}")

class Ebook(Book):
    def __init__(self, title, author, price, file_size_mb):
        super().__init__(title, author, price)
        self.file_size_mb = file_size_mb

    # Override the parent's display_info method
    def display_info(self):
        print(f"[EBOOK] '{self.title}' by {self.author}")
        print(f"Price: ${self.price}")
        print(f"File size: {self.file_size_mb} MB")

# Create both types
physical = Book("Python Basics", "John Doe", 29.99)
ebook = Ebook("Python Basics", "John Doe", 19.99, 25.5)

physical.display_info()
print()
ebook.display_info()

Output:

'Python Basics' by John Doe
Price: $29.99

[EBOOK] 'Python Basics' by John Doe
Price: $19.99
File size: 25.5 MB

The Ebook class completely replaces display_info(). But sometimes you want to extend the parent’s method rather than replace it:

class Ebook(Book):
    def __init__(self, title, author, price, file_size_mb):
        super().__init__(title, author, price)
        self.file_size_mb = file_size_mb

    def display_info(self):
        # Call the parent's method first
        super().display_info()
        # Then add ebook-specific info
        print(f"File size: {self.file_size_mb} MB")
        print("[Digital Download]")

ebook = Ebook("Python Basics", "John Doe", 19.99, 25.5)
ebook.display_info()

Output:

'Python Basics' by John Doe
Price: $19.99
File size: 25.5 MB
[Digital Download]

A Practical Example: Product Hierarchy

Let’s build a more realistic example for a bookstore that sells different types of products:

class Product:
    """Base class for all products"""

    def __init__(self, title, price):
        self.title = title
        self.price = price

    def display_info(self):
        print(f"\n{self.title}")
        print(f"Price: ${self.price:.2f}")

    def get_final_price(self):
        """Base method - can be overridden by subclasses"""
        return self.price

class Book(Product):
    """Physical books with shipping"""

    def __init__(self, title, author, price, pages):
        super().__init__(title, price)
        self.author = author
        self.pages = pages

    def display_info(self):
        super().display_info()
        print(f"Author: {self.author}")
        print(f"Pages: {self.pages}")

    def get_final_price(self):
        # Add shipping for physical books
        shipping = 5.99
        return self.price + shipping

class Ebook(Product):
    """Digital books - no shipping, cheaper base price"""

    def __init__(self, title, author, price, file_size_mb):
        super().__init__(title, price)
        self.author = author
        self.file_size_mb = file_size_mb

    def display_info(self):
        super().display_info()
        print(f"Author: {self.author}")
        print(f"File size: {self.file_size_mb} MB")
        print("[Digital Download - No Shipping]")

    def get_final_price(self):
        # No shipping for ebooks
        return self.price

class AudioBook(Product):
    """Audio books with duration"""

    def __init__(self, title, narrator, price, duration_hours):
        super().__init__(title, price)
        self.narrator = narrator
        self.duration_hours = duration_hours

    def display_info(self):
        super().display_info()
        print(f"Narrated by: {self.narrator}")
        print(f"Duration: {self.duration_hours} hours")
        print("[Digital Audio - No Shipping]")

    def get_final_price(self):
        # No shipping for audio books
        return self.price

# Create different products
products = [
    Book("Python Crash Course", "Eric Matthes", 39.99, 544),
    Ebook("Automate the Boring Stuff", "Al Sweigart", 19.99, 25.5),
    AudioBook("Clean Code", "Robert C. Martin", 29.99, 12.5)
]

# Process all products the same way
total = 0
for product in products:
    product.display_info()
    final_price = product.get_final_price()
    print(f"Final price: ${final_price:.2f}")
    total += final_price

print(f"\nTotal cart value: ${total:.2f}")

Output:

Python Crash Course
Price: $39.99
Author: Eric Matthes
Pages: 544
Final price: $45.98

Automate the Boring Stuff
Price: $19.99
Author: Al Sweigart
File size: 25.5 MB
[Digital Download - No Shipping]
Final price: $19.99

Clean Code
Price: $29.99
Narrated by: Robert C. Martin
Duration: 12.5 hours
[Digital Audio - No Shipping]
Final price: $29.99

Total cart value: $95.96

Notice how we can treat all products the same way in the loop, but each type behaves differently. This is polymorphism in action.

Polymorphism

Polymorphism means “many forms.” In OOP, it means that objects of different classes can be used interchangeably if they share a common interface (the same method names).

class Product:
    def __init__(self, title, price):
        self.title = title
        self.price = price

    def get_description(self):
        return f"{self.title} - ${self.price}"

class Book(Product):
    def __init__(self, title, price, author):
        super().__init__(title, price)
        self.author = author

    def get_description(self):
        return f"{self.title} by {self.author} - ${self.price}"

class Ebook(Product):
    def __init__(self, title, price, file_size):
        super().__init__(title, price)
        self.file_size = file_size

    def get_description(self):
        return f"[EBOOK] {self.title} ({self.file_size}MB) - ${self.price}"

def print_catalog(products):
    """This function works with any product type"""
    for product in products:
        # Each product type implements get_description() differently
        print(product.get_description())

# Create different product types
catalog = [
    Product("Gift Card", 50),
    Book("Python Basics", 29.99, "John Doe"),
    Ebook("Advanced Python", 39.99, 30)
]

print_catalog(catalog)

Output:

Gift Card - $50
Python Basics by John Doe - $29.99
[EBOOK] Advanced Python (30MB) - $39.99

The print_catalog() function doesn’t care what type of product it receives—it just calls get_description(). This is polymorphism: different objects respond to the same method call in their own way.

Checking Class Relationships

Python provides built-in functions to check class relationships:

class Product:
    pass

class Book(Product):
    pass

class Ebook(Book):
    pass

ebook = Ebook()

# isinstance() checks if an object is an instance of a class
print(isinstance(ebook, Ebook))     # True
print(isinstance(ebook, Book))      # True (Ebook inherits from Book)
print(isinstance(ebook, Product))   # True (Book inherits from Product)
print(isinstance(ebook, str))       # False

# issubclass() checks if a class is a subclass of another
print(issubclass(Ebook, Book))      # True
print(issubclass(Ebook, Product))   # True
print(issubclass(Book, Ebook))      # False

Multiple Inheritance

Python supports multiple inheritance—a class can inherit from multiple parent classes. Use this carefully, as it can get complex.

class Discountable:
    """Mixin for products that can be discounted"""

    def apply_discount(self, discount_percent):
        self.price = self.price * (1 - discount_percent / 100)
        return self.price

class Reviewable:
    """Mixin for products that can be reviewed"""

    def __init__(self):
        self.reviews = []

    def add_review(self, rating, comment):
        self.reviews.append({'rating': rating, 'comment': comment})

    def get_average_rating(self):
        if not self.reviews:
            return 0
        total = sum(review['rating'] for review in self.reviews)
        return total / len(self.reviews)

class Book(Discountable, Reviewable):
    """Book that can be discounted and reviewed"""

    def __init__(self, title, author, price):
        self.title = title
        self.author = author
        self.price = price
        Reviewable.__init__(self)  # Initialize reviews list

    def display_info(self):
        avg_rating = self.get_average_rating()
        print(f"\n{self.title} by {self.author}")
        print(f"Price: ${self.price:.2f}")
        if avg_rating > 0:
            print(f"Average rating: {avg_rating:.1f}/5.0")

# Create and use a book with multiple capabilities
book = Book("Python Crash Course", "Eric Matthes", 39.99)

# From Reviewable
book.add_review(5, "Excellent for beginners!")
book.add_review(4, "Very comprehensive")

# From Discountable
book.apply_discount(20)

# From Book
book.display_info()

Output:

Python Crash Course by Eric Matthes
Price: $31.99
Average rating: 4.5/5.0

Note: With multiple inheritance, be careful about the Method Resolution Order (MRO)—the order Python looks for methods. You can check it with ClassName.__mro__ or ClassName.mro().

Abstract Base Classes (Preview)

Sometimes you want to create a parent class that should never be instantiated directly—it only exists to be inherited from. Python’s abc module provides this:

from abc import ABC, abstractmethod

class Product(ABC):
    """Abstract base class - cannot be instantiated"""

    def __init__(self, title, price):
        self.title = title
        self.price = price

    @abstractmethod
    def get_final_price(self):
        """All products must implement this method"""
        pass

    @abstractmethod
    def display_info(self):
        """All products must implement this method"""
        pass

class Book(Product):
    def __init__(self, title, price, author):
        super().__init__(title, price)
        self.author = author

    def get_final_price(self):
        return self.price + 5.99  # Add shipping

    def display_info(self):
        print(f"{self.title} by {self.author} - ${self.price}")

# This works - Book implements all abstract methods
book = Book("Python Basics", 29.99, "John Doe")
book.display_info()

# This would raise an error - Product is abstract
# product = Product("Test", 10)  # TypeError!

Abstract base classes enforce that subclasses implement certain methods, making your code more robust.

Summary

In this lesson, we learned about inheritance and polymorphism:

  • Inheritance lets classes build on other classes, reusing code and creating hierarchies
  • The super() function calls parent class methods
  • Method overriding lets child classes replace or extend parent methods
  • Polymorphism lets different objects respond to the same method calls in their own way
  • Multiple inheritance lets a class inherit from multiple parents (use carefully)
  • Abstract base classes define interfaces that subclasses must implement

These concepts help us:

  • Avoid code duplication
  • Create logical class hierarchies
  • Write flexible code that works with many types
  • Enforce consistent interfaces across related classes

In the next lesson, we’ll explore special methods (also called “magic methods” or “dunder methods”) that let us customize how objects behave with Python’s built-in operations.