Module 6.2

Inheritance

Inheritance lets child classes reuse and extend parent class code. Instead of writing the same code repeatedly, you define common behavior in a parent class and let child classes inherit it. Children can add new features or customize inherited behavior to meet their specific needs.

45 min
Intermediate
Hands-on
What You'll Learn
  • Basic inheritance syntax
  • Parent and child class relationships
  • Method overriding
  • Using super() to call parent methods
  • Multi-level inheritance
Contents
01

What Is Inheritance?

Inheritance is an OOP mechanism that allows a new class (child) to inherit attributes and methods from an existing class (parent). This promotes code reuse and creates logical hierarchies. The child class automatically has access to everything the parent defines.

Key Concept

The Is-A Relationship

Inheritance models an "is-a" relationship. A Dog is an Animal. A Car is a Vehicle. A Manager is an Employee. When you see this relationship, inheritance is appropriate. The child class "is a" specific type of the parent class.

Why it matters: Instead of duplicating code for Dog, Cat, and Bird, define common behavior in Animal once. Each child inherits it automatically.

Inheritance Hierarchy
Animal
name, age
eat(), sleep()
Parent Class
inherits
Dog
breed
bark(), fetch()
Cat
indoor
meow(), purr()
Bird
wingspan
fly()
All children inherit: name, age, eat(), sleep()

Basic Inheritance Syntax

Define a child class by putting the parent class name in parentheses after the child class name. The child automatically inherits all parent attributes and methods.

# Parent class
class Animal:
    def __init__(self, name, age):
        self.name = name
        self.age = age
    
    def eat(self):
        print(f"{self.name} is eating")

# Child class inherits from Animal
class Dog(Animal):
    pass  # Empty but inherits everything

# Dog has Animal's __init__ and eat()
dog = Dog("Buddy", 3)
print(dog.name)  # Output: Buddy
dog.eat()        # Output: Buddy is eating

The Dog class is completely empty using just the pass statement, yet it is fully functional because it inherited both __init__ and eat() from the Animal class. This demonstrates the simplest form of inheritance where the child class adds nothing new. When we create a Dog object and call its methods, Python automatically looks up the inheritance chain and finds these methods in the Animal parent class.

Adding Child-Specific Features

Child classes can add their own attributes and methods in addition to what they inherit. This extends the parent's functionality.

class Animal:
    def __init__(self, name, age):
        self.name = name
        self.age = age
    
    def eat(self):
        print(f"{self.name} is eating")

class Dog(Animal):
    def bark(self):
        print(f"{self.name} says: Woof!")
    
    def fetch(self):
        print(f"{self.name} is fetching the ball")

dog = Dog("Buddy", 3)
dog.eat()    # Inherited from Animal
dog.bark()   # Dog's own method
dog.fetch()  # Dog's own method

The Dog class now has four methods available to it: eat() which is inherited from Animal, bark() and fetch() which are its own unique methods, and __init__ which is also inherited. This pattern demonstrates how child classes extend the functionality of their parents by adding new behaviors while still retaining all inherited capabilities. The dog object can seamlessly use both inherited and its own methods without any special syntax or calls.

Practice: Inheritance Basics

Task: Create a Vehicle class with brand and model attributes. Create a Car class that inherits from Vehicle. Create a car and print its brand.

Show Solution
class Vehicle:
    def __init__(self, brand, model):
        self.brand = brand
        self.model = model

class Car(Vehicle):
    pass

car = Car("Toyota", "Camry")
print(f"{car.brand} {car.model}")  # Toyota Camry

Task: Using the Vehicle/Car from above, add a honk() method to Car that prints "Beep beep!". Test it.

Show Solution
class Vehicle:
    def __init__(self, brand, model):
        self.brand = brand
        self.model = model

class Car(Vehicle):
    def honk(self):
        print("Beep beep!")

car = Car("Toyota", "Camry")
car.honk()  # Output: Beep beep!

Task: Create Employee with name, salary, and a work() method. Create Manager that inherits from Employee and adds a conduct_meeting() method. Test both methods on a manager.

Show Solution
class Employee:
    def __init__(self, name, salary):
        self.name = name
        self.salary = salary
    
    def work(self):
        print(f"{self.name} is working")

class Manager(Employee):
    def conduct_meeting(self):
        print(f"{self.name} is conducting a meeting")

mgr = Manager("Alice", 75000)
mgr.work()            # Alice is working
mgr.conduct_meeting() # Alice is conducting a meeting
02

Method Overriding

Method overriding allows a child class to provide its own implementation of a method that exists in the parent. When the method is called on a child object, the child's version runs instead of the parent's.

Method Override Flow
PARENT
Animal
speak()
"Some sound"
OVERRIDES
CHILD
Dog
speak()
"Woof!"
animal.speak()
"Some sound"
dog.speak()
"Woof!"
When a child defines a method with the same name, it overrides the parent's version for child objects.

Basic Method Overriding

To override a method, simply define a method in the child class with the same name as the parent's method. The child's implementation replaces the parent's for child objects.

class Animal:
    def __init__(self, name):
        self.name = name
    
    def speak(self):
        print(f"{self.name} makes a sound")

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

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

dog = Dog("Buddy")
cat = Cat("Whiskers")
dog.speak()  # Output: Buddy says: Woof!
cat.speak()  # Output: Whiskers says: Meow!

Each child class customizes the speak() method to provide its own unique implementation while sharing the same method name. Python uses a lookup mechanism called the Method Resolution Order (MRO) that checks the child class first before looking in parent classes. This means when you call dog.speak(), Python finds speak() in Dog and uses that version, completely bypassing the parent's implementation.

Overriding __init__

You can override __init__ to add child-specific attributes. When you override __init__, the parent's __init__ is NOT called automatically. You must call it explicitly using super().

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

class Dog(Animal):
    def __init__(self, name, age, breed):
        # THIS REPLACES parent __init__ completely!
        self.name = name
        self.age = age
        self.breed = breed  # Child-specific attribute

dog = Dog("Buddy", 3, "Labrador")
print(f"{dog.name}, {dog.age}, {dog.breed}")
# Output: Buddy, 3, Labrador

This approach works correctly and the Dog object has all three attributes, but notice we had to duplicate the assignment of name and age that the parent already handles. This code duplication violates the DRY (Don't Repeat Yourself) principle and creates maintenance problems if the parent's initialization logic changes. The super() function in the next section provides an elegant solution by allowing us to reuse the parent's initialization code.

Practice: Method Overriding

Task: Create Shape with a describe() method that prints "I am a shape". Create Circle that overrides describe() to print "I am a circle". Test both.

Show Solution
class Shape:
    def describe(self):
        print("I am a shape")

class Circle(Shape):
    def describe(self):
        print("I am a circle")

shape = Shape()
circle = Circle()
shape.describe()   # I am a shape
circle.describe()  # I am a circle

Task: Create Shape with area() returning 0. Create Rectangle(width, height) that overrides area() to return width * height. Test with a 5x3 rectangle.

Show Solution
class Shape:
    def area(self):
        return 0

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

rect = Rectangle(5, 3)
print(f"Area: {rect.area()}")  # Area: 15

Task: Create Person with name and __str__ returning "Person: name". Create Student(Person) that overrides __str__ to return "Student: name (grade)". Test printing both.

Show Solution
class Person:
    def __init__(self, name):
        self.name = name
    
    def __str__(self):
        return f"Person: {self.name}"

class Student(Person):
    def __init__(self, name, grade):
        self.name = name
        self.grade = grade
    
    def __str__(self):
        return f"Student: {self.name} ({self.grade})"

p = Person("Alice")
s = Student("Bob", "A")
print(p)  # Person: Alice
print(s)  # Student: Bob (A)
03

The super() Function

The super() function returns a proxy object that lets you call parent class methods. It is most commonly used in __init__ to reuse parent initialization code. This avoids duplicating code and ensures proper initialization.

How super() Works
Dog.__init__
Dog("Buddy", 3, "Lab")
Called with all args
super().__init__
super().__init__(name, age)
Delegates to parent
Animal.__init__
self.name = "Buddy"
self.age = 3
Dog adds own
self.breed = "Lab"
Result
name=Buddy age=3 breed=Lab
super() delegates to parent class, reusing its initialization and then adding child-specific setup.

Using super() in __init__

Call super().__init__() to run the parent's __init__, then add any child-specific attributes. This reuses parent code instead of duplicating it.

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

class Dog(Animal):
    def __init__(self, name, age, breed):
        super().__init__(name, age)  # Call parent's __init__
        self.breed = breed           # Add child-specific attribute

dog = Dog("Buddy", 3, "Labrador")
print(f"{dog.name}, {dog.age}, {dog.breed}")
# Output: Buddy, 3, Labrador

The super().__init__(name, age) call delegates the initialization of name and age to the Animal parent class, which already knows how to handle these attributes. After the parent's initialization completes, the Dog class adds its own breed attribute. This eliminates all code duplication and ensures that if Animal's initialization logic ever changes, Dog will automatically benefit from those updates without any modifications.

Using super() in Other Methods

You can use super() to call any parent method, not just __init__. This is useful when you want to extend rather than completely replace parent behavior.

class Animal:
    def speak(self):
        return "Some generic sound"

class Dog(Animal):
    def speak(self):
        parent_sound = super().speak()  # Get parent's result
        return f"{parent_sound}... actually, Woof!"

dog = Dog()
print(dog.speak())
# Output: Some generic sound... actually, Woof!

The super().speak() call retrieves the return value from the parent class's speak() method, which we then incorporate into the child's response. This pattern is powerful because it allows you to extend rather than completely replace parent behavior. You get the benefit of the parent's logic while adding your own customizations on top, creating a layered approach to method implementation.

When to use super(): Use super() whenever you override a parent method but still want to use the parent's functionality. This is especially important for __init__ to ensure proper initialization.

Practice: Using super()

Task: Create Person(name). Create Student(Person) with name and school, using super() to initialize name. Print both attributes.

Show Solution
class Person:
    def __init__(self, name):
        self.name = name

class Student(Person):
    def __init__(self, name, school):
        super().__init__(name)
        self.school = school

student = Student("Alice", "MIT")
print(f"{student.name} at {student.school}")
# Output: Alice at MIT

Task: Create Logger with log(msg) that prints the message. Create TimestampLogger that calls super().log() and adds "[12:00]" prefix. Test it.

Show Solution
class Logger:
    def log(self, msg):
        print(msg)

class TimestampLogger(Logger):
    def log(self, msg):
        super().log(f"[12:00] {msg}")

logger = TimestampLogger()
logger.log("Hello!")  # Output: [12:00] Hello!

Task: Create Car(brand, model, year). Create ElectricCar that adds battery_size using super(). Add a range() method that returns battery_size * 3. Test with a Tesla.

Show Solution
class Car:
    def __init__(self, brand, model, year):
        self.brand = brand
        self.model = model
        self.year = year

class ElectricCar(Car):
    def __init__(self, brand, model, year, battery_size):
        super().__init__(brand, model, year)
        self.battery_size = battery_size
    
    def range(self):
        return self.battery_size * 3

tesla = ElectricCar("Tesla", "Model 3", 2024, 75)
print(f"Range: {tesla.range()} miles")  # 225 miles

Task: Create Product(name, price) with describe() that prints name and price. Create DiscountProduct that adds discount and overrides describe() to also show discount. Use super().

Show Solution
class Product:
    def __init__(self, name, price):
        self.name = name
        self.price = price
    
    def describe(self):
        print(f"{self.name}: ${self.price}")

class DiscountProduct(Product):
    def __init__(self, name, price, discount):
        super().__init__(name, price)
        self.discount = discount
    
    def describe(self):
        super().describe()
        print(f"Discount: {self.discount}%")

item = DiscountProduct("Laptop", 999, 10)
item.describe()
# Laptop: $999
# Discount: 10%

Task: Create BankAccount(owner, balance) with deposit() and withdraw() methods. Create SavingsAccount that adds interest_rate and an add_interest() method. Use super() for __init__.

Show Solution
class BankAccount:
    def __init__(self, owner, balance=0):
        self.owner = owner
        self.balance = balance
    
    def deposit(self, amount):
        self.balance += amount
    
    def withdraw(self, amount):
        if amount <= self.balance:
            self.balance -= amount

class SavingsAccount(BankAccount):
    def __init__(self, owner, balance, interest_rate):
        super().__init__(owner, balance)
        self.interest_rate = interest_rate
    
    def add_interest(self):
        interest = self.balance * self.interest_rate
        self.deposit(interest)

acc = SavingsAccount("Alice", 1000, 0.05)
acc.add_interest()
print(f"Balance: ${acc.balance}")  # Balance: $1050.0
04

Multi-level Inheritance

Multi-level inheritance creates a chain of inheritance. A child can be the parent of another class, forming hierarchies like Animal -> Mammal -> Dog. Each level inherits from the level above it.

Multi-level Inheritance Chain
Grandparent
Animal
eat()
Parent
Mammal
feed_young()
Child
Dog
bark()
Dog has all:
eat() + feed_young() + bark()
class Animal:
    def eat(self):
        print("Eating...")

class Mammal(Animal):
    def feed_young(self):
        print("Feeding young with milk")

class Dog(Mammal):
    def bark(self):
        print("Woof!")

dog = Dog()
dog.eat()        # From Animal (grandparent)
dog.feed_young() # From Mammal (parent)
dog.bark()       # Own method

The Dog class sits at the bottom of a three-level inheritance chain: it directly inherits from Mammal, which in turn inherits from Animal. This means Dog has access to bark() (its own), feed_young() (from Mammal), and eat() (from Animal). Python traverses up this hierarchy automatically when looking for methods, making the inheritance chain completely transparent to the code using the Dog class.

Keep hierarchies shallow: Deep inheritance chains (more than 3 levels) become hard to understand and maintain. Prefer composition over deep inheritance when possible.
05

Best Practices

Knowing when to use inheritance—and when not to—is crucial for writing maintainable code. Follow these guidelines to make the right design decisions and avoid common pitfalls that can lead to fragile, hard-to-understand class hierarchies.

When to Use Inheritance

Use Inheritance When...
  • Clear "is-a" relationship: A Dog is an Animal, a Manager is an Employee
  • Shared behavior: Multiple classes need the same methods and attributes
  • Polymorphism needed: You want to treat different objects uniformly through a common interface
  • Extending frameworks: Building on existing library classes that expect inheritance
  • Specialization: Creating more specific versions of general concepts
Avoid Inheritance When...
  • "Has-a" relationship: A Car has an Engine, not is an Engine
  • Just for code reuse: Composition might be cleaner if there is no logical hierarchy
  • Deep hierarchies: More than 3 levels becomes hard to maintain
  • Tight coupling: Child depends heavily on parent's internal implementation
  • Changing requirements: The relationship between classes may change over time

Composition vs Inheritance

Composition means building classes by including instances of other classes as attributes, rather than inheriting from them. It often provides more flexibility than inheritance.

Composition vs Inheritance Comparison
Inheritance (is-a)
class ElectricCar(Car):
  def __init__(self):
    super().__init__()
    # ElectricCar IS a Car
Natural hierarchy
Polymorphism built-in
Tight coupling
Fragile base class problem
Composition (has-a)
class ElectricCar:
  def __init__(self):
    self.engine = ElectricEngine()
    # ElectricCar HAS an engine
More flexible
Easy to swap components
Loose coupling
Easier to test
Rule of thumb: "Favor composition over inheritance" — but use inheritance when you need polymorphism or have a true is-a relationship.
# COMPOSITION EXAMPLE: Car has an Engine
class Engine:
    def __init__(self, horsepower):
        self.horsepower = horsepower
    
    def start(self):
        return "Engine started"

class Car:
    def __init__(self, brand, engine):
        self.brand = brand
        self.engine = engine  # Composition: Car HAS an Engine
    
    def start(self):
        return f"{self.brand}: {self.engine.start()}"

# Easy to swap different engines
v8 = Engine(400)
electric = Engine(300)

car1 = Car("Ford", v8)
car2 = Car("Tesla", electric)

In this composition example, the Car class contains an Engine instance as an attribute rather than inheriting from Engine. This design allows us to easily swap different engine types, test the Car and Engine classes independently, and modify one without affecting the other. The relationship correctly models that a car "has an" engine rather than "is an" engine.

Common Pitfalls to Avoid

# WRONG: Parent __init__ never runs!
class Dog(Animal):
    def __init__(self, name, breed):
        self.breed = breed
        # Forgot super().__init__(name)!
        # self.name is never set!

# CORRECT:
class Dog(Animal):
    def __init__(self, name, breed):
        super().__init__(name)  # Always call parent first
        self.breed = breed

# WRONG: Override breaks expected behavior
class BankAccount:
    def withdraw(self, amount):
        if amount <= self.balance:
            self.balance -= amount
            return True
        return False

class BadSavingsAccount(BankAccount):
    def withdraw(self, amount):
        # Forgot to check balance!
        self.balance -= amount  # Can go negative!

# CORRECT: Maintain parent's contract
class GoodSavingsAccount(BankAccount):
    def withdraw(self, amount):
        if amount > 1000:
            return False  # Additional restriction
        return super().withdraw(amount)  # Use parent logic

# BAD: Too deep, hard to understand
class Entity: pass
class LivingEntity(Entity): pass
class Animal(LivingEntity): pass
class Mammal(Animal): pass
class Canine(Mammal): pass
class Dog(Canine): pass
class Labrador(Dog): pass  # 7 levels deep!

# BETTER: Flatten the hierarchy
class Animal: pass
class Dog(Animal): pass  # 2 levels, much clearer

# Or use composition for behaviors
class Dog:
    def __init__(self):
        self.movement = QuadrupedMovement()
        self.diet = CarnivoreDiet()

# WRONG: No logical is-a relationship
class Logger:
    def log(self, msg):
        print(f"[LOG] {msg}")

class User(Logger):  # User is NOT a Logger!
    def __init__(self, name):
        self.name = name
    
    def greet(self):
        self.log(f"User {self.name} said hello")  # Works but wrong

# CORRECT: Use composition
class User:
    def __init__(self, name, logger):
        self.name = name
        self.logger = logger  # User HAS a logger
    
    def greet(self):
        self.logger.log(f"User {self.name} said hello")
Golden Rule: Ask yourself "Is B really a type of A?" before writing class B(A). If the answer is no, consider composition instead.

Interactive Demo

Visualize how inheritance chains work and see how Python resolves method calls through the Method Resolution Order (MRO). These diagrams illustrate the concepts covered in this lesson.

Inheritance Chain Visualization

Single Inheritance: Method Resolution Order (MRO)
When you call: dog.speak()
1 Dog class Found speak()? YES
2 Animal class (not checked)
3 object class Base of all
Animal
speak() → "Generic sound"
inherits
Dog
speak() → "Woof!" CALLED
dog.speak()
"Woof!"
Multi-level Inheritance: Animal → Mammal → Dog
Animal
Attrs: name, age
Methods: eat(), sleep()
Mammal
Inherited: name, age, eat(), sleep()
Own: warm_blooded, feed_young()
Dog
Inherited: all from Animal + Mammal
Own: breed, bark(), fetch()
Dog's MRO
Dog Mammal Animal object
super().__init__() Execution Flow
Code Structure
class Dog(Animal):
def __init__(self, name, age, breed):
super().__init__(name, age)
self.breed = breed
class Animal:
def __init__(self, name, age):
self.name = name
self.age = age
Execution: Dog("Buddy", 3, "Lab")
1 Dog.__init__() called
2 super().__init__("Buddy", 3) delegates to Animal
3 Animal.__init__() sets name, age
4 self.breed = "Lab" Dog adds own attr
Result: dog object has
name="Buddy" age=3 breed="Lab"

Method Override Comparison

Override vs Extend with super()
Complete Override
class Dog(Animal):
  def speak(self):
    return "Woof!"
dog.speak()
"Woof!"
Parent method: REPLACED
  • Child behavior totally different
  • No need for parent logic
Extend with super()
class Dog(Animal):
  def speak(self):
    base = super().speak()
    return f"{base}... Woof!"
dog.speak()
"Sound... Woof!"
Parent method: EXTENDED
  • Add to parent's behavior
  • Build on existing logic
Pro Tip: You can check any class's MRO in Python using ClassName.__mro__ or ClassName.mro(). This shows you exactly how Python will search for methods!

Key Takeaways

Inheritance Promotes Reuse

Child classes automatically get all parent attributes and methods. Define common behavior once in the parent.

Override to Customize

Define a method with the same name in the child to replace the parent's version. Python uses the child's method.

super() Calls Parent

Use super() to call parent methods, especially in __init__. This reuses parent code and avoids duplication.

Extend, Do Not Just Replace

Use super() to extend parent behavior rather than completely replacing it. Add to what the parent provides.

Keep Hierarchies Shallow

Limit inheritance depth to 2-3 levels. Deep hierarchies become hard to understand and maintain.

Is-A Relationship

Use inheritance when the child "is a" type of the parent. Dog is an Animal. Manager is an Employee.

Knowledge Check

Quick Quiz

Test your understanding of Python inheritance concepts

1 What does inheritance allow a child class to do?
2 What is method overriding?
3 What does super().__init__() do?
4 In class Dog(Animal), which is the parent?
5 When should you prefer composition over inheritance?
6 What is the Method Resolution Order (MRO)?
Answer all questions to check your score