Python Lecture 13: Mastering Inheritance and Polymorphism
Welcome to one of the most powerful concepts in Object-Oriented Programming! In the previous lecture, you learned how to create classes and objects. Today, we're exploring inheritance - the ability for classes to inherit attributes and methods from other classes - and polymorphism - the ability for different classes to be used interchangeably. These concepts are fundamental to building flexible, maintainable, and scalable software systems.
Inheritance and polymorphism are not just advanced features - they're essential patterns used throughout professional software development. Every major framework, library, and application uses these concepts extensively. Understanding inheritance lets you avoid code duplication, create hierarchies of related classes, and build extensible systems. Polymorphism enables you to write code that works with many different types of objects, making your programs more flexible and powerful.
By the end of this comprehensive lecture, you'll understand how inheritance creates relationships between classes, how to override methods to customize behavior, how to use the super() function to extend parent functionality, and how polymorphism enables flexible design. We'll cover everything from basic inheritance to complex hierarchies with detailed explanations and practical examples. Let's dive into these transformative OOP concepts!
Understanding Inheritance - The IS-A Relationship
Inheritance is a mechanism where a new class (child/derived class) inherits attributes and methods from an existing class (parent/base class). The child class gets everything from the parent class "for free" and can add its own additional features or modify inherited features.
The IS-A Relationship: Inheritance models an "is-a" relationship. A Dog IS-A Animal. A SavingsAccount IS-A BankAccount. A Manager IS-A Employee. When you can say "X is a Y," inheritance is probably appropriate. This relationship is fundamental - don't use inheritance just to reuse code; use it when there's a genuine is-a relationship.
Why Inheritance Matters: Without inheritance, if you have Dog, Cat, and Bird classes, you'd duplicate all common animal behavior (eat, sleep, move) in each class. With inheritance, you create one Animal class with common behavior, then Dog, Cat, and Bird inherit from Animal and only define what makes them unique. This eliminates duplication and makes changes easier - update Animal once and all derived classes benefit.
Terminology Clarity: Parent/base/superclass all mean the same thing - the class being inherited from. Child/derived/subclass all mean the class doing the inheriting. Python uses these terms interchangeably, so understanding all the names helps you read documentation and other people's code.
# Parent class (Base class)
class Animal:
"""Base class representing any animal"""
def __init__(self, name, age):
self.name = name
self.age = age
def eat(self):
return f"{self.name} is eating"
def sleep(self):
return f"{self.name} is sleeping"
def make_sound(self):
return "Some generic animal sound"
# Child class inheriting from Animal
class Dog(Animal):
"""Dog class inherits from Animal"""
def make_sound(self):
"""Override parent's make_sound method"""
return f"{self.name} says Woof!"
def fetch(self):
"""New method specific to dogs"""
return f"{self.name} is fetching the ball"
# Another child class
class Cat(Animal):
"""Cat class also inherits from Animal"""
def make_sound(self):
"""Cats have their own sound"""
return f"{self.name} says Meow!"
def scratch(self):
"""Cats can scratch"""
return f"{self.name} is scratching the furniture"
# Using inherited classes
dog = Dog("Buddy", 3)
cat = Cat("Whiskers", 2)
# Inherited methods work
print(dog.eat()) # From Animal class
print(cat.sleep()) # From Animal class
# Overridden methods
print(dog.make_sound()) # Dog's version
print(cat.make_sound()) # Cat's version
# Class-specific methods
print(dog.fetch()) # Only dogs can fetch
print(cat.scratch()) # Only cats scratch
Design Principle: Put common behavior in the parent class, specialized behavior in child classes. If you find yourself copying methods between classes, that's a sign they should be inherited from a common parent. Good inheritance hierarchies have general classes at the top and increasingly specific classes as you go down.
Method Overriding - Customizing Inherited Behavior
Method overriding is when a child class provides its own implementation of a method that exists in the parent class. The child's version replaces (overrides) the parent's version for objects of the child class. This is how you customize inherited behavior to fit specific needs.
How Overriding Works: When you call a method on an object, Python first looks for that method in the object's class. If found, it uses that version. If not found, it looks in the parent class, then the parent's parent, and so on up the inheritance chain. This is called the Method Resolution Order (MRO). Understanding MRO explains which version of a method gets called.
When to Override: Override methods when the parent's implementation doesn't fit the child's needs. A Bird class might override move() to "flies" instead of "walks." A SavingsAccount might override withdraw() to check minimum balance requirements. Override when you need different behavior, not just additional behavior (for that, use super()).
# Demonstrating method overriding
class Vehicle:
"""Base vehicle class"""
def __init__(self, brand, model):
self.brand = brand
self.model = model
self.speed = 0
def accelerate(self):
self.speed += 10
return f"{self.brand} {self.model} accelerating to {self.speed} km/h"
def get_info(self):
return f"{self.brand} {self.model}"
class ElectricCar(Vehicle):
"""Electric car with battery"""
def __init__(self, brand, model, battery_capacity):
super().__init__(brand, model) # Call parent constructor
self.battery_capacity = battery_capacity
self.battery_level = 100
def accelerate(self):
"""Override - electric cars accelerate faster"""
self.speed += 20 # Faster than regular vehicles
self.battery_level -= 2
return f"{self.brand} {self.model} accelerating to {self.speed} km/h (Battery: {self.battery_level}%)"
def get_info(self):
"""Override to include battery info"""
base_info = super().get_info() # Get parent's info
return f"{base_info} - Electric ({self.battery_capacity} kWh battery)"
# Using overridden methods
regular_car = Vehicle("Toyota", "Camry")
electric_car = ElectricCar("Tesla", "Model 3", 75)
# Same method name, different behavior
print(regular_car.accelerate()) # +10 km/h
print(electric_car.accelerate()) # +20 km/h and uses battery
# Overridden get_info includes extra details
print(regular_car.get_info())
print(electric_car.get_info())
The super() Function - Extending Parent Behavior
The super() function gives you access to the parent class's methods. This is crucial when you want to extend (not replace) parent functionality. Instead of completely rewriting a method, you call the parent's version with super() and add extra behavior before or after.
Why super() Is Important: Without super(), if you override __init__, you'd have to manually copy all the parent's initialization code. That's duplication and error-prone. With super(), you call the parent's __init__ to handle parent attributes, then add your own. This keeps code DRY (Don't Repeat Yourself) and maintainable - if the parent changes, your child class automatically gets those changes.
Common super() Patterns: In __init__, call super().__init__() first to initialize parent attributes, then initialize child attributes. In other methods, you can call super().method_name() before, after, or in the middle of your custom code, depending on whether you want to extend, wrap, or modify parent behavior.
# Demonstrating super() function
class Employee:
"""Base employee class"""
def __init__(self, name, employee_id, salary):
self.name = name
self.employee_id = employee_id
self.salary = salary
def get_details(self):
return f"Employee: {self.name} (ID: {self.employee_id})"
def calculate_bonus(self):
return self.salary * 0.10 # 10% bonus
class Manager(Employee):
"""Manager with team management"""
def __init__(self, name, employee_id, salary, department):
# Call parent __init__ to handle common attributes
super().__init__(name, employee_id, salary)
# Add manager-specific attributes
self.department = department
self.team_members = []
def get_details(self):
"""Extend parent's get_details"""
base_details = super().get_details()
return f"{base_details} - Manager of {self.department}"
def calculate_bonus(self):
"""Managers get higher bonus"""
base_bonus = super().calculate_bonus()
# Managers get 50% more than base bonus
return base_bonus * 1.5
def add_team_member(self, employee):
self.team_members.append(employee)
return f"{employee.name} added to {self.name}'s team"
# Using super() in action
manager = Manager("Sarah Johnson", "M001", 80000, "Engineering")
# Extended methods include parent info plus child info
print(manager.get_details())
# Modified bonus calculation uses parent calculation
print(f"Bonus: ${manager.calculate_bonus():.2f}")
# Add team members
employee1 = Employee("John Doe", "E001", 50000)
employee2 = Employee("Jane Smith", "E002", 55000)
print(manager.add_team_member(employee1))
print(manager.add_team_member(employee2))
Real-World Application - E-commerce System: You might have a Product base class with price, name, description. DigitalProduct inherits and adds download_link. PhysicalProduct inherits and adds weight, dimensions. Both use super().__init__() to initialize common Product attributes, then add their specific attributes. The calculate_shipping() method behaves completely differently for each, but both products can be stored in the same cart and processed uniformly.
Polymorphism - One Interface, Many Forms
Polymorphism means "many forms" - the ability for different classes to be used through a common interface. If multiple classes implement the same methods, you can write code that works with any of them without knowing specifically which class you're using. This is incredibly powerful for flexible design.
Duck Typing Philosophy: Python uses "duck typing" - if it walks like a duck and quacks like a duck, treat it as a duck. You don't check if an object is specifically a Duck class; you just try to call walk() and quack(). If it works, great! This makes Python polymorphism very flexible - any object with the right methods can be used, regardless of inheritance.
Benefits of Polymorphism: Write functions that work with multiple types. A process_payment() function can handle CreditCard, PayPal, or BankTransfer objects - anything with a charge() method. Add new payment types later without changing process_payment(). This extensibility is crucial for large systems that need to grow over time.
# Polymorphism demonstration
class Shape:
"""Base shape class"""
def area(self):
pass
def perimeter(self):
pass
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)
class Circle(Shape):
def __init__(self, radius):
self.radius = radius
def area(self):
return 3.14159 * self.radius ** 2
def perimeter(self):
return 2 * 3.14159 * self.radius
class Triangle(Shape):
def __init__(self, side1, side2, side3):
self.side1 = side1
self.side2 = side2
self.side3 = side3
def area(self):
# Using Heron's formula
s = (self.side1 + self.side2 + self.side3) / 2
return (s * (s - self.side1) * (s - self.side2) * (s - self.side3)) ** 0.5
def perimeter(self):
return self.side1 + self.side2 + self.side3
# Polymorphic function - works with any Shape
def print_shape_info(shape):
"""Works with Rectangle, Circle, Triangle - any Shape!"""
print(f"Area: {shape.area():.2f}")
print(f"Perimeter: {shape.perimeter():.2f}")
print("-" * 30)
# Create different shapes
shapes = [
Rectangle(5, 10),
Circle(7),
Triangle(3, 4, 5)
]
# Process all shapes uniformly
for shape in shapes:
print_shape_info(shape) # Same function, different behaviors!
📚 Related Python Tutorials:
Multiple Inheritance - Advanced Topic
Python supports multiple inheritance - a class can inherit from multiple parent classes. While powerful, this should be used carefully as it can create complexity. Understanding when and how to use it helps you leverage its power while avoiding pitfalls.
# Multiple inheritance demonstration
class Flyer:
"""Mixin for flying ability"""
def fly(self):
return f"{self.name} is flying!"
class Swimmer:
"""Mixin for swimming ability"""
def swim(self):
return f"{self.name} is swimming!"
class Duck(Animal, Flyer, Swimmer):
"""Duck can fly and swim"""
def __init__(self, name, age):
super().__init__(name, age)
def make_sound(self):
return f"{self.name} says Quack!"
# Duck inherits from multiple classes
duck = Duck("Donald", 2)
print(duck.eat()) # From Animal
print(duck.fly()) # From Flyer
print(duck.swim()) # From Swimmer
print(duck.make_sound()) # From Duck
Summary and Inheritance Mastery
Inheritance and polymorphism are cornerstone concepts of OOP. You've learned:
✓ Understanding inheritance and IS-A relationships
✓ Creating child classes that inherit from parents
✓ Method overriding to customize behavior
✓ Using super() to extend parent functionality
✓ Polymorphism for flexible code design
✓ Multiple inheritance possibilities
✓ Real-world applications and patterns
Design with Inheritance: When designing class hierarchies, think carefully about relationships. Start with general concepts at the top (Animal, Vehicle, Account) and get more specific as you go down (Dog, ElectricCar, SavingsAccount). Don't force inheritance where it doesn't fit - use composition instead when you have a HAS-A relationship rather than IS-A.
Practice Challenge: Build a university system with Person as base class. Create Student and Professor subclasses. Students have GPA and enrolled courses; Professors have department and taught courses. Add TeachingAssistant that inherits from both Student and Professor. Implement methods for enrolling, grading, and displaying information. This combines everything about inheritance!

