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.
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
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
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
speak()
speak()
animal.speak()
dog.speak()
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)
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("Buddy", 3, "Lab")
super().__init__(name, age)
self.name = "Buddy"self.age = 3self.breed = "Lab"
name=Buddy age=3 breed=Lab
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.
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
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
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.
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
class ElectricCar(Car): def __init__(self): super().__init__() # ElectricCar IS a Car
class ElectricCar: def __init__(self): self.engine = ElectricEngine() # ElectricCar HAS an engine
# 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")
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)
dog.speak()speak() → "Generic sound"
speak() → "Woof!" CALLED
dog.speak()
Multi-level Inheritance: Animal → Mammal → Dog
name, ageeat(), sleep()warm_blooded, feed_young()breed, bark(), fetch()Dog
Mammal
Animal
object
super().__init__() Execution Flow
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
Dog("Buddy", 3, "Lab")Dog.__init__()
called
super().__init__("Buddy", 3)
delegates to Animal
Animal.__init__()
sets name, age
self.breed = "Lab"
Dog adds own attr
name="Buddy"
age=3
breed="Lab"
Method Override Comparison
Override vs Extend with super()
class Dog(Animal): def speak(self): return "Woof!"
dog.speak()
- Child behavior totally different
- No need for parent logic
class Dog(Animal): def speak(self): base = super().speak() return f"{base}... Woof!"
dog.speak()
- Add to parent's behavior
- Build on existing logic
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