🧬 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.
(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.
🔄 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.
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:
- The object's own class
- The parent class
- 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.
🎭 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.
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.
🔍 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:
- Create a
Vehicleclass withmake,model,year, and adescribe()method - Create an
ElectricCarsubclass that addsbattery_kwhand arange_miles()method (estimate: 3.5 miles per kWh) - Create a
Trucksubclass that addspayload_tonsand overridesdescribe()to include payload capacity - 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:
- Create an abstract
Notificationclass with an abstractsend(message)method and a concretelog(message)method that prints a timestamp - Create
EmailNotification(requiresto_address) andSlackNotification(requireschannel) subclasses - Each subclass implements
send()differently (just return a descriptive string — no actual sending) - Write a function
send_all(notifications, message)that loops through a list and callssend()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
Carhas anEngine, not is anEngine - 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 aPenguin(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.