> "Programs must be written for people to read, and only incidentally for machines to execute."
Learning Objectives
- Create subclasses that inherit attributes and methods from a parent class
- Override methods to customize behavior for specific subclasses
- Use super() to extend (not just replace) parent behavior
- Write polymorphic code that operates on any object sharing a common interface
- Define abstract base classes that enforce method contracts on subclasses
- Compare inheritance ('is-a') with composition ('has-a') and choose appropriately
- Explain the Method Resolution Order (MRO) and why deep hierarchies are risky
In This Chapter
- Chapter Overview
- 15.1 Inheritance: Don't Repeat Yourself
- 15.2 Creating Subclasses
- 15.3 Method Overriding
- 15.4 super(): Extending Parent Behavior
- 15.5 Polymorphism
- 15.6 Abstract Base Classes
- 15.7 Composition vs. Inheritance
- 15.8 Multiple Inheritance
- 15.9 Practical OOP Design Guidelines
- 15.10 Project Checkpoint: TaskFlow v1.4
- 15.11 Chapter Summary
- What's Next
- Chapter 15 Exercises → exercises.md
- Chapter 15 Quiz → quiz.md
- Case Study: The Shape Hierarchy → case-study-01.md
- Case Study: How GUI Frameworks Use Inheritance → case-study-02.md
Chapter 15: Inheritance and Polymorphism: Building on What Exists
"Programs must be written for people to read, and only incidentally for machines to execute." — Harold Abelson, Structure and Interpretation of Computer Programs
Chapter Overview
In Chapter 14, you learned to bundle state and behavior into classes. You built Task objects, BankAccount objects, objects that knew what they were and what they could do. That was a major leap. But here's the problem that comes next: what happens when you need similar but not identical objects?
Imagine you're building Crypts of Pythonia and you have items the player can pick up — weapons, potions, keys. They all have a name and a description. They all go in the inventory. They can all be used. But how they're used is completely different: a weapon deals damage, a potion heals the player, a key unlocks a door. Do you write three separate classes with duplicate code for everything they share? Or is there a better way?
There is. It's called inheritance, and it's one of the most powerful organizing principles in object-oriented programming. Alongside it comes polymorphism — the ability to write code that says "use this item" without knowing (or caring) what kind of item it is. The weapon knows how to use itself. The potion knows how to use itself. Your code just says item.use(player) and the right thing happens.
This chapter will change how you think about organizing code. By the end, you won't just know how to create class hierarchies — you'll know when you should and when you shouldn't.
In this chapter, you will learn to:
- Create subclasses that inherit behavior from parent classes
- Override methods to customize subclass behavior
- Use super() to extend parent behavior instead of replacing it
- Write polymorphic code that works with any subclass
- Define abstract base classes that enforce contracts
- Choose between inheritance and composition
🏃 Fast Track: If you're comfortable with basic class creation from Chapter 14, you can skim 15.1 and jump straight to 15.2. The critical sections are 15.3 through 15.5, with 15.7 being essential for real-world design.
🔬 Deep Dive: Section 15.8 on multiple inheritance and Section 15.9 on design guidelines will pay dividends if you're planning to work on larger projects or continue to CS2.
15.1 Inheritance: Don't Repeat Yourself
Let's start with a problem. Here are two classes for Crypts of Pythonia:
class Weapon:
def __init__(self, name: str, description: str, damage: int):
self.name = name
self.description = description
self.damage = damage
def __str__(self) -> str:
return f"{self.name}: {self.description}"
def use(self, player) -> str:
return f"You swing the {self.name} for {self.damage} damage!"
class Potion:
def __init__(self, name: str, description: str, heal_amount: int):
self.name = name
self.description = description
self.heal_amount = heal_amount
def __str__(self) -> str:
return f"{self.name}: {self.description}"
def use(self, player) -> str:
return f"You drink the {self.name} and restore {self.heal_amount} HP!"
Look at how much is duplicated. Both classes have name, description, and __str__. If you wanted to add a weight attribute to all items, you'd have to add it in both places. If you had ten item types, you'd have to change ten classes. This is the exact kind of repetition that leads to bugs — you update nine of them and forget the tenth.
Inheritance solves this by letting you define shared behavior in one place and reuse it. You create a general parent class (also called a superclass or base class) and then create specialized child classes (also called subclasses or derived classes) that inherit everything the parent has.
The relationship is described with the phrase "is-a": a Weapon is an Item. A Potion is an Item. If that sentence sounds natural, inheritance is probably appropriate.
🔗 Connection: This is the DRY principle — Don't Repeat Yourself — applied to class design. You saw DRY with functions in Chapter 6 (extracting repeated code into a function). Inheritance does the same thing at the class level.
15.2 Creating Subclasses
Here's the refactored version with inheritance:
class Item:
"""Base class for all game items."""
def __init__(self, name: str, description: str, weight: float = 0.0):
self.name = name
self.description = description
self.weight = weight
def __str__(self) -> str:
return f"{self.name}: {self.description}"
def use(self, player) -> str:
return f"You can't figure out how to use the {self.name}."
class Weapon(Item):
"""A weapon that deals damage."""
def __init__(self, name: str, description: str, damage: int,
weight: float = 0.0):
super().__init__(name, description, weight)
self.damage = damage
def use(self, player) -> str:
return f"You swing the {self.name} for {self.damage} damage!"
class Potion(Item):
"""A consumable that heals the player."""
def __init__(self, name: str, description: str, heal_amount: int,
weight: float = 0.0):
super().__init__(name, description, weight)
self.heal_amount = heal_amount
def use(self, player) -> str:
return f"You drink the {self.name} and restore {self.heal_amount} HP!"
class Key(Item):
"""A key that unlocks a specific door."""
def __init__(self, name: str, description: str, door_id: str,
weight: float = 0.0):
super().__init__(name, description, weight)
self.door_id = door_id
def use(self, player) -> str:
return f"You use the {self.name} to unlock the door."
The syntax class Weapon(Item): means "Weapon inherits from Item." Let's verify what this gives us:
sword = Weapon("Iron Sword", "A sturdy blade", damage=15, weight=3.5)
potion = Potion("Health Elixir", "Glows faintly red", heal_amount=50)
key = Key("Brass Key", "Tarnished but functional", door_id="dungeon_01")
# __str__ is inherited from Item
print(sword) # Iron Sword: A sturdy blade
print(potion) # Health Elixir: Glows faintly red
print(key) # Brass Key: Tarnished but functional
# Each subclass has its own use() behavior
print(sword.use(None)) # You swing the Iron Sword for 15 damage!
print(potion.use(None)) # You drink the Health Elixir and restore 50 HP!
print(key.use(None)) # You use the Brass Key to unlock the door.
# isinstance() checks work with the hierarchy
print(isinstance(sword, Weapon)) # True
print(isinstance(sword, Item)) # True -- a Weapon IS an Item
print(isinstance(sword, Potion)) # False -- a Weapon is NOT a Potion
Three things are happening here:
- Inherited attributes:
Weapondoesn't definename,description, orweightitself — it gets them fromItemviasuper().__init__(). - Inherited methods:
__str__is defined only inItem, but all three subclasses can use it. - Overridden methods: Each subclass defines its own
use(), which replaces the parent's default.
💡 Intuition: Think of inheritance like a family tree. Children inherit traits from their parents — eye color, hair texture, height tendencies. But children can also develop their own unique traits. A
WeaponinheritsnameanddescriptionfromItembut adds its owndamageattribute. It is anItem, and it's also more than anItem.🔄 Check Your Understanding (try to answer without scrolling up)
- What does the syntax
class Dog(Animal):mean?- If
Animalhas aspeak()method andDogdoesn't define one, what happens when you callmy_dog.speak()?- True or false:
isinstance(my_dog, Animal)returnsTrueifDoginherits fromAnimal.
Verify
Dogis a subclass that inherits fromAnimal.Doggets all ofAnimal's methods and attributes.- Python looks for
speak()inDog, doesn't find it, then looks inAnimaland calls that version.- True. A
Doginstance is also an instance ofAnimalbecause of the "is-a" relationship.
15.3 Method Overriding
Method overriding means defining a method in a subclass that has the same name as a method in the parent class. The subclass version replaces the parent version for instances of that subclass.
You already saw this with use() above. Let's look at a more nuanced example — Elena's report formatters:
class ReportFormatter:
"""Base formatter for nonprofit reports."""
def __init__(self, title: str, data: list[dict]):
self.title = title
self.data = data
def format_header(self) -> str:
return f"Report: {self.title}"
def format_row(self, row: dict) -> str:
return str(row)
def format(self) -> str:
lines = [self.format_header()]
for row in self.data:
lines.append(self.format_row(row))
return "\n".join(lines)
class CSVFormatter(ReportFormatter):
"""Formats reports as CSV."""
def format_header(self) -> str:
if not self.data:
return ""
return ",".join(self.data[0].keys())
def format_row(self, row: dict) -> str:
return ",".join(str(v) for v in row.values())
class HTMLFormatter(ReportFormatter):
"""Formats reports as an HTML table."""
def format_header(self) -> str:
if not self.data:
return "<table>"
headers = "".join(f"<th>{k}</th>" for k in self.data[0].keys())
return f"<table>\n<tr>{headers}</tr>"
def format_row(self, row: dict) -> str:
cells = "".join(f"<td>{v}</td>" for v in row.values())
return f"<tr>{cells}</tr>"
def format(self) -> str:
result = super().format()
return result + "\n</table>"
class PlainTextFormatter(ReportFormatter):
"""Formats reports as aligned plain text."""
def format_header(self) -> str:
header = f"=== {self.title} ==="
return header + "\n" + "=" * len(header)
def format_row(self, row: dict) -> str:
return " | ".join(f"{k}: {v}" for k, v in row.items())
data = [
{"name": "Shelter Program", "served": 142, "budget": 45000},
{"name": "Food Bank", "served": 890, "budget": 23000},
]
for FormatterClass in [CSVFormatter, HTMLFormatter, PlainTextFormatter]:
fmt = FormatterClass("Q3 Services", data)
print(fmt.format())
print()
Expected output:
name,served,budget
Shelter Program,142,45000
Food Bank,890,23000
<table>
<tr><th>name</th><th>served</th><th>budget</th></tr>
<tr><td>Shelter Program</td><td>142</td><td>45000</td></tr>
<tr><td>Food Bank</td><td>890</td><td>23000</td></tr>
</table>
=== Q3 Services ===
====================
name: Shelter Program | served: 142 | budget: 45000
name: Food Bank | served: 890 | budget: 23000
Notice how CSVFormatter and PlainTextFormatter override format_header() and format_row() but inherit format() unchanged. The parent's format() method calls self.format_header() and self.format_row() — and because of method overriding, it calls the subclass versions. This pattern is called the Template Method pattern, and it's extremely common in real-world code.
HTMLFormatter goes one step further: it overrides format() itself, calling super().format() to get the base result and then adding the closing </table> tag. That's the topic of the next section.
15.4 super(): Extending Parent Behavior
super() gives you access to the parent class's methods. You've already seen it in __init__, but it works with any method. The key distinction:
- Overriding = replacing the parent's behavior entirely
- Extending = doing what the parent does plus something extra
class Animal:
def __init__(self, name: str, sound: str):
self.name = name
self.sound = sound
def describe(self) -> str:
return f"{self.name} says {self.sound}"
class Dog(Animal):
def __init__(self, name: str, breed: str):
super().__init__(name, sound="Woof") # Call parent's __init__
self.breed = breed # Add new attribute
def describe(self) -> str:
base = super().describe() # Get parent's description
return f"{base} (breed: {self.breed})" # Extend it
rex = Dog("Rex", breed="German Shepherd")
print(rex.describe())
# Rex says Woof (breed: German Shepherd)
Without super().__init__(), the Dog would never set name and sound. This is one of the most common bugs in OOP code.
🐛 Debugging Walkthrough: Forgetting
super().__init__()Consider this broken code:
```python class Vehicle: def init(self, make: str, model: str, year: int): self.make = make self.model = model self.year = year
class ElectricCar(Vehicle): def init(self, make: str, model: str, year: int, battery_kwh: float): # BUG: forgot to call super().init()! self.battery_kwh = battery_kwh
my_car = ElectricCar("Tesla", "Model 3", 2024, 75.0) print(my_car.battery_kwh) # 75.0 -- works fine print(my_car.make) # AttributeError: 'ElectricCar' object # has no attribute 'make' ```
What happened:
ElectricCar.__init__never calledsuper().__init__(), somake,model, andyearwere never set. The constructor replaced the parent's entirely instead of extending it.The fix:
python class ElectricCar(Vehicle): def __init__(self, make: str, model: str, year: int, battery_kwh: float): super().__init__(make, model, year) # Initialize parent attrs self.battery_kwh = battery_kwh # Then add child attrsRule of thumb: If your subclass defines
__init__, the first line should almost always besuper().__init__(...). The rare exceptions involve classes that intentionally break compatibility with their parent — and if you're doing that, you probably shouldn't be using inheritance.🔗 Spaced Review — Chapter 11 (Error Handling): Remember custom exceptions? They use inheritance too:
```python class TaskFlowError(Exception): """Base exception for all TaskFlow errors.""" pass
class TaskNotFoundError(TaskFlowError): """Raised when a task ID doesn't exist.""" pass
class InvalidPriorityError(TaskFlowError): """Raised when an invalid priority is given.""" pass ```
TaskNotFoundErroris aTaskFlowError, which is anException. You can catchTaskFlowErrorto handle any TaskFlow-specific error, or catch a specific subclass for finer control. This is inheritance in action — the exact same mechanism you're learning now.
15.5 Polymorphism
🚪 Threshold Concept
Polymorphism is one of those ideas that fundamentally changes how you design programs. Once it clicks, you'll stop writing long
if/elifchains to handle different types and start writing code that "just works" with any compatible object.Before this clicks: "I need
if isinstance(item, Weapon): ... elif isinstance(item, Potion): ...to handle different types."After this clicks: "I call
item.use(player)and each item type knows how to use itself."
Polymorphism means "many forms." In Python, it means that different objects can respond to the same method call in different ways. You've already seen it — you just didn't have the name for it.
Here's the critical example. Compare these two approaches for processing items in a player's inventory:
Without polymorphism (fragile, verbose):
def use_item_bad(item, player):
"""The painful way — checking types manually."""
if isinstance(item, Weapon):
print(f"You swing the {item.name} for {item.damage} damage!")
elif isinstance(item, Potion):
print(f"You drink the {item.name} and restore {item.heal_amount} HP!")
elif isinstance(item, Key):
print(f"You use the {item.name} to unlock the door.")
else:
print(f"You can't figure out how to use the {item.name}.")
With polymorphism (clean, extensible):
def use_item_good(item, player):
"""The polymorphic way — let the object handle itself."""
print(item.use(player))
The second version is three lines shorter, but that's not the real win. The real win is: when you add a new item type, the first version needs to be modified. The second version doesn't change at all.
# Add a new item type six months later
class Scroll(Item):
def __init__(self, name: str, description: str, spell: str,
weight: float = 0.0):
super().__init__(name, description, weight)
self.spell = spell
def use(self, player) -> str:
return f"You read the {self.name} and cast {self.spell}!"
The polymorphic use_item_good works with Scroll without any changes. The if/elif version would crash with a generic "can't figure out how to use" message — or worse, silently do the wrong thing — until someone remembered to add another elif.
This is the power of polymorphism: you write code against an interface (what methods an object has), not against a specific type (what class an object is).
Let's see it in a complete, runnable example:
class Item:
def __init__(self, name: str, description: str, weight: float = 0.0):
self.name = name
self.description = description
self.weight = weight
def use(self, player) -> str:
return f"You can't figure out how to use the {self.name}."
class Weapon(Item):
def __init__(self, name: str, description: str, damage: int,
weight: float = 0.0):
super().__init__(name, description, weight)
self.damage = damage
def use(self, player) -> str:
return f"You swing the {self.name} for {self.damage} damage!"
class Potion(Item):
def __init__(self, name: str, description: str, heal_amount: int,
weight: float = 0.0):
super().__init__(name, description, weight)
self.heal_amount = heal_amount
def use(self, player) -> str:
return f"You drink the {self.name} and restore {self.heal_amount} HP!"
class Key(Item):
def __init__(self, name: str, description: str, door_id: str,
weight: float = 0.0):
super().__init__(name, description, weight)
self.door_id = door_id
def use(self, player) -> str:
return f"You use the {self.name} to unlock the door."
# Polymorphism: same loop, different behavior
inventory = [
Weapon("Iron Sword", "A sturdy blade", damage=15),
Potion("Health Elixir", "Glows faintly red", heal_amount=50),
Key("Brass Key", "Tarnished but functional", door_id="dungeon_01"),
Potion("Mana Potion", "Shimmers blue", heal_amount=30),
]
print("=== Using all items ===")
for item in inventory:
print(item.use(None))
Expected output:
=== Using all items ===
You swing the Iron Sword for 15 damage!
You drink the Health Elixir and restore 50 HP!
You use the Brass Key to unlock the door.
You drink the Mana Potion and restore 30 HP!
The for loop doesn't know — or care — what type each item is. It calls use() on each one, and each object responds in its own way. That's polymorphism.
📊 You've Already Used Polymorphism: Python's built-in
len()function is polymorphic. It works on strings, lists, dictionaries, sets, tuples — any object that defines__len__(). The+operator is polymorphic too: it adds numbers, concatenates strings, and joins lists. Python is full of polymorphism; this chapter just teaches you to create your own.🔄 Check Your Understanding (try to answer without scrolling up)
- Why is
isinstance()checking in a function usually a sign of a design problem?- What makes polymorphic code easier to extend than
if/eliftype-checking?- If you add a new subclass of
Itemthat definesuse(), do you need to change existing code that callsitem.use()?
Verify
- Because it tightly couples the function to specific types. Every time you add a new type, you need to modify the function. Polymorphism lets each type handle itself.
- With polymorphism, you add a new subclass with its own method implementation — no existing code needs to change. With
if/elif, every function that checks types needs a new branch.- No. As long as the new subclass defines
use(), existing code works unchanged. That's the core benefit of polymorphism.
15.6 Abstract Base Classes
Sometimes you want to define a parent class that says "every subclass MUST implement these methods" — a contract. In Crypts of Pythonia, every item must be usable. But the base Item.use() method returns a vague "can't figure out how to use" message, which is a silent bug waiting to happen. What if someone creates a Shield subclass and forgets to implement use()?
Python's abc module lets you create abstract base classes (ABCs) that require subclasses to implement specific methods. If a subclass doesn't implement them, Python raises an error when you try to create an instance — not when you try to call the missing method.
from abc import ABC, abstractmethod
class Item(ABC):
"""Abstract base class for all game items.
Subclasses MUST implement use().
"""
def __init__(self, name: str, description: str, weight: float = 0.0):
self.name = name
self.description = description
self.weight = weight
def __str__(self) -> str:
return f"{self.name}: {self.description}"
@abstractmethod
def use(self, player) -> str:
"""Use this item. Must be implemented by subclasses."""
...
class Weapon(Item):
def __init__(self, name: str, description: str, damage: int,
weight: float = 0.0):
super().__init__(name, description, weight)
self.damage = damage
def use(self, player) -> str:
return f"You swing the {self.name} for {self.damage} damage!"
# This would crash at instantiation:
class BrokenShield(Item):
"""Forgot to implement use()!"""
def __init__(self, name: str, description: str, weight: float = 0.0):
super().__init__(name, description, weight)
# Uncomment to see the error:
# shield = BrokenShield("Wooden Shield", "Splintered")
# TypeError: Can't instantiate abstract class BrokenShield
# with abstract method use
The @abstractmethod decorator on use() tells Python: "You cannot create an instance of any class that inherits from Item unless it provides its own use() implementation." This catches bugs at object creation time, not when you try to call the method during gameplay.
You also cannot instantiate Item directly — it's abstract:
# This also fails:
# generic = Item("Thing", "Just a thing")
# TypeError: Can't instantiate abstract class Item
# with abstract method use
That's intentional. Item exists to define the contract — the "shape" that all items must follow. It's a blueprint for blueprints.
💡 Intuition: Think of an abstract class like a form template. The template says "fill in your name here" and "fill in your address here." You can't submit the blank template — you have to fill in the blanks. Similarly, you can't create an instance of an abstract class — you have to fill in (implement) the abstract methods.
🔗 Spaced Review — Chapter 14 (OOP Intro): In Chapter 14, you defined classes as "bundles of state and behavior." Abstract base classes add a third dimension: contracts. An ABC doesn't just define what an object can do — it defines what an object must do. This becomes critical when multiple developers work on the same codebase, because it turns "please implement use()" from a comment that might be ignored into an enforced rule that Python checks automatically.
15.7 Composition vs. Inheritance
Inheritance isn't always the right tool. Consider this scenario: you're modeling a Car. A car has an engine, wheels, and a GPS. Is a car "a kind of engine"? No. A car has an engine. This is the "has-a" relationship, and it calls for composition — building objects that contain other objects — rather than inheritance.
class Engine:
def __init__(self, horsepower: int, fuel_type: str):
self.horsepower = horsepower
self.fuel_type = fuel_type
self.running = False
def start(self) -> str:
self.running = True
return f"{self.fuel_type} engine ({self.horsepower}hp) started."
def stop(self) -> str:
self.running = False
return "Engine stopped."
class GPS:
def __init__(self):
self.destination: str | None = None
def set_destination(self, dest: str) -> str:
self.destination = dest
return f"Navigation set to {dest}."
def get_directions(self) -> str:
if self.destination:
return f"Proceed to {self.destination}."
return "No destination set."
class Car:
"""A car HAS an engine and HAS a GPS. It is NOT an engine."""
def __init__(self, make: str, model: str, horsepower: int,
fuel_type: str):
self.make = make
self.model = model
self.engine = Engine(horsepower, fuel_type) # Composition
self.gps = GPS() # Composition
def start(self) -> str:
return f"{self.make} {self.model}: {self.engine.start()}"
def navigate_to(self, destination: str) -> str:
return self.gps.set_destination(destination)
my_car = Car("Toyota", "Camry", 203, "gasoline")
print(my_car.start())
# Toyota Camry: gasoline engine (203hp) started.
print(my_car.navigate_to("Portland"))
# Navigation set to Portland.
print(my_car.gps.get_directions())
# Proceed to Portland.
Car doesn't inherit from Engine — it contains an Engine. This is more flexible because:
- You can swap the engine without changing the car class.
- The car can have multiple components (engine, GPS, stereo) without multiple inheritance.
- The car's interface is independent of any single component's interface.
When to Use Each
| Criterion | Inheritance | Composition |
|---|---|---|
| Relationship | "is-a" (a Dog is an Animal) | "has-a" (a Car has an Engine) |
| Code reuse | Subclass gets parent's methods for free | Must explicitly delegate to components |
| Flexibility | Locked into hierarchy at design time | Components can be swapped at runtime |
| Coupling | Tight — subclass depends on parent's internals | Loose — only depends on component's interface |
| When to favor | True type hierarchies, few levels deep | Most other cases |
⚠️ Common Mistake: New programmers often use inheritance because "it saves typing," even when the relationship isn't really "is-a." A
Stackis not alist— it uses a list internally. ADatabaseConnectionis not aSocket— it has a socket. If the "is-a" test fails, use composition.🧩 Productive Struggle
Before reading on, try this design exercise:
You have a
Shapeclass. You needCircle,Rectangle, andTrianglesubclasses, each witharea()andperimeter()methods. Sketch the class hierarchy — what attributes does each subclass need? What methods can be shared in the parent, and what must be overridden?Spend 5 minutes on paper before looking at the case study in
case-study-01.md, which walks through the full solution.
15.8 Multiple Inheritance
Python supports multiple inheritance — a class can inherit from more than one parent:
class Flyable:
def fly(self) -> str:
return f"{self.name} takes to the sky!"
class Swimmable:
def swim(self) -> str:
return f"{self.name} dives into the water!"
class Duck(Animal, Flyable, Swimmable):
def __init__(self, name: str):
super().__init__(name, sound="Quack")
donald = Duck("Donald")
print(donald.describe()) # Donald says Quack
print(donald.fly()) # Donald takes to the sky!
print(donald.swim()) # Donald dives into the water!
This works, but multiple inheritance introduces complexity. The biggest issue is the diamond problem: when two parent classes share a common ancestor, which version of a method gets called?
Python resolves this using the Method Resolution Order (MRO) — a specific algorithm (called C3 linearization) that determines the order in which Python searches for methods. You can inspect it:
print(Duck.__mro__)
# (<class 'Duck'>, <class 'Animal'>, <class 'Flyable'>,
# <class 'Swimmable'>, <class 'object'>)
Python searches for methods in this order: Duck first, then Animal, then Flyable, then Swimmable, then object. The first class that defines the method wins.
Practical advice: Multiple inheritance is powerful but dangerous. Most professional Python code uses it sparingly, primarily through mixins — small, focused classes that add a single capability (like Flyable and Swimmable above). Keep these guidelines in mind:
- Prefer composition over multiple inheritance in most cases.
- If you do use multiple inheritance, keep the hierarchy shallow (1-2 levels).
- Use mixins (small, single-purpose parent classes) rather than inheriting from multiple "full" classes.
- Always check the MRO if you're uncertain about method resolution.
15.9 Practical OOP Design Guidelines
After two chapters of OOP, let's step back and talk about design. Here are the guidelines that professional developers follow:
Keep Hierarchies Shallow
Deep inheritance trees (5+ levels) are hard to understand and debug. If you find yourself creating SpecializedThingTypeB that extends SpecializedThing that extends SpecificThing that extends BaseThing that extends AbstractThing, something has gone wrong. Two or three levels is usually the sweet spot.
Favor Composition Over Inheritance
This is the most commonly cited OOP design principle, and it's good advice. Use inheritance for genuine "is-a" relationships in a type hierarchy. Use composition for everything else. When in doubt, try composition first.
The Liskov Substitution Principle (Intuitive Version)
Named after computer scientist Barbara Liskov, this principle states: if you have code that works with a parent class, it should work with any subclass without surprises.
More practically: a subclass should be usable anywhere its parent is used. If your Penguin subclass of Bird throws an error when someone calls fly(), something is wrong with your hierarchy — penguins aren't really the same "kind" of bird that your code expects.
🐛 Debugging Walkthrough: Breaking the Liskov Substitution Principle
```python class Rectangle: def init(self, width: float, height: float): self.width = width self.height = height
def area(self) -> float: return self.width * self.heightclass Square(Rectangle): def init(self, side: float): super().init(side, side)
# BUG: Square overrides width setter to also change height @property def width(self): return self._width @width.setter def width(self, value): self._width = value self._height = value # Keep it square!```
This looks reasonable — a square is a rectangle where width equals height. But consider code that works with rectangles:
python def double_width(rect: Rectangle): """Double the width, keep height the same.""" rect.width = rect.width * 2 return rect.area()For a 5x10 rectangle,
double_widthreturns 100 (10x10). For a 5x5 square, it returns 100 too (10x10) — but the caller expected 50 (10x5). The square changed the height when the width was set, violating the expectation that width and height are independent.The lesson: A
Squaredoesn't behave like aRectanglein all contexts, so makingSquarea subclass ofRectangleviolates Liskov. Better designs: make both inherit from an abstractShape, or don't use inheritance at all.
Design for the Interface, Not the Implementation
When writing functions that accept objects, think about what methods you need rather than what class you expect. This is called programming to an interface and it's what makes polymorphism powerful. Python's duck typing ("if it walks like a duck and quacks like a duck...") naturally encourages this.
🔄 Check Your Understanding (try to answer without scrolling up)
- Why is "favor composition over inheritance" good advice?
- In your own words, what does the Liskov Substitution Principle say?
- A
Penguinclass inherits fromBird, which has afly()method. Why is this a design problem?
Verify
- Composition gives you looser coupling and more flexibility. Components can be swapped independently, and you avoid the complexity of deep inheritance hierarchies. Use inheritance only for true "is-a" relationships.
- Any code that works with a parent class should work with any subclass without surprises. Subclasses should be drop-in replacements for their parents.
- If code that processes
Birdobjects callsfly(), it will break or produce unexpected behavior forPenguin. This violates Liskov —Penguinisn't substitutable forBirdin all contexts. The hierarchy is wrong.
15.10 Project Checkpoint: TaskFlow v1.4
📐 Project Checkpoint
In Chapter 14, TaskFlow v1.3 introduced the
TaskandTaskListclasses. Now we'll add specialized task types using inheritance and polymorphism.
New features in v1.4:
DeadlineTask— a task with a due date that can be overdueRecurringTask— a task with a frequency (daily, weekly, monthly) that resets when completedChecklistTask— a task with sub-items that tracks partial progress
All three inherit from Task and override display() and is_overdue() polymorphically.
from datetime import datetime, timedelta
from abc import ABC, abstractmethod
class Task:
"""Base task class from v1.3."""
def __init__(self, title: str, priority: str = "medium",
category: str = "general"):
self.title = title
self.priority = priority
self.category = category
self.completed = False
self.created = datetime.now()
def complete(self) -> None:
self.completed = True
def is_overdue(self) -> bool:
return False # Base tasks are never overdue
def display(self) -> str:
status = "x" if self.completed else " "
return f"[{status}] {self.title} ({self.priority}, {self.category})"
class DeadlineTask(Task):
"""A task with a due date."""
def __init__(self, title: str, due_date: datetime,
priority: str = "medium", category: str = "general"):
super().__init__(title, priority, category)
self.due_date = due_date
def is_overdue(self) -> bool:
return not self.completed and datetime.now() > self.due_date
def display(self) -> str:
base = super().display()
due_str = self.due_date.strftime("%Y-%m-%d %H:%M")
overdue = " *** OVERDUE ***" if self.is_overdue() else ""
return f"{base} | due: {due_str}{overdue}"
class RecurringTask(Task):
"""A task that resets on a schedule."""
VALID_FREQUENCIES = ("daily", "weekly", "monthly")
def __init__(self, title: str, frequency: str,
priority: str = "medium", category: str = "general"):
if frequency not in self.VALID_FREQUENCIES:
raise ValueError(
f"Frequency must be one of {self.VALID_FREQUENCIES}"
)
super().__init__(title, priority, category)
self.frequency = frequency
self.completions = 0
def complete(self) -> None:
self.completions += 1
self.completed = False # Recurring tasks reset!
def display(self) -> str:
base = super().display()
return f"{base} | repeats: {self.frequency} (done {self.completions}x)"
class ChecklistTask(Task):
"""A task with sub-items."""
def __init__(self, title: str, sub_items: list[str],
priority: str = "medium", category: str = "general"):
super().__init__(title, priority, category)
self.sub_items = {item: False for item in sub_items}
def check_item(self, item: str) -> None:
if item not in self.sub_items:
raise KeyError(f"Sub-item '{item}' not found")
self.sub_items[item] = True
# Auto-complete when all sub-items are done
if all(self.sub_items.values()):
self.completed = True
def progress(self) -> str:
done = sum(1 for v in self.sub_items.values() if v)
total = len(self.sub_items)
return f"{done}/{total}"
def display(self) -> str:
base = super().display()
lines = [f"{base} | progress: {self.progress()}"]
for item, checked in self.sub_items.items():
mark = "x" if checked else " "
lines.append(f" [{mark}] {item}")
return "\n".join(lines)
Now the power of polymorphism — we can process any mix of task types uniformly:
tasks = [
Task("Read chapter 15", priority="high", category="study"),
DeadlineTask(
"Submit lab report",
due_date=datetime(2025, 4, 1, 23, 59),
priority="high",
category="school"
),
RecurringTask("Review flashcards", frequency="daily",
category="study"),
ChecklistTask(
"Prepare presentation",
sub_items=["Write outline", "Create slides", "Practice"],
priority="medium",
category="school"
),
]
# Polymorphic display — each task type formats itself
print("=== All Tasks ===")
for task in tasks:
print(task.display())
print()
# Check off some sub-items
checklist = tasks[3]
checklist.check_item("Write outline")
checklist.check_item("Create slides")
# Polymorphic overdue check
print("=== Overdue Check ===")
for task in tasks:
if task.is_overdue():
print(f"OVERDUE: {task.title}")
See code/project-checkpoint.py for the complete, runnable version with sample output.
🔍 What Changed from v1.3: -
Taskis now the base class in a hierarchy instead of the only class -DeadlineTask,RecurringTask, andChecklistTaskadd specialized behavior via inheritance -display()andis_overdue()are polymorphic — theforloop processes all task types uniformly -RecurringTask.complete()overrides the parent to reset instead of staying done -ChecklistTaskuses composition (a dictionary of sub-items) alongside inheritance
15.11 Chapter Summary
Key Concepts
- Inheritance lets subclasses reuse code from a parent class ("is-a" relationships).
- Method overriding lets subclasses customize inherited behavior.
super()calls the parent's version of a method, enabling extension rather than replacement.- Polymorphism lets different objects respond to the same method call in their own way, eliminating
isinstancechains. - Abstract base classes enforce method contracts: subclasses must implement abstract methods.
- Composition ("has-a") is often preferable to inheritance for building flexible systems.
Key Terms Summary
| Term | Definition |
|---|---|
| Inheritance | A mechanism where a subclass gets methods and attributes from a parent class |
| Parent class (superclass) | The class being inherited from |
| Child class (subclass) | The class that inherits from another class |
super() |
A function that returns a proxy to the parent class, allowing you to call parent methods |
| Method overriding | Defining a method in a subclass with the same name as one in the parent |
| Polymorphism | Different objects responding to the same method call with different behavior |
| Abstract class | A class that cannot be instantiated and defines abstract methods for subclasses |
| Abstract method | A method declared in an abstract class that subclasses must implement |
ABC |
Python's abc.ABC — the base class for creating abstract base classes |
@abstractmethod |
Decorator marking a method as abstract (must be overridden) |
| Composition | Building objects that contain other objects ("has-a" relationship) |
| "is-a" | The relationship that justifies inheritance (a Dog "is an" Animal) |
| "has-a" | The relationship that justifies composition (a Car "has an" Engine) |
| Method Resolution Order (MRO) | The order Python searches classes for a method in a hierarchy |
What You Should Be Able to Do
- [ ] Create subclasses that inherit from a parent class
- [ ] Override methods to customize behavior
- [ ] Use
super()to extend parent methods without replacing them - [ ] Write polymorphic code that works with any object sharing an interface
- [ ] Define abstract base classes with
@abstractmethod - [ ] Choose between inheritance and composition based on the relationship type
- [ ] Explain the MRO and why deep hierarchies are problematic
What's Next
In Chapter 16: OOP Design Patterns and Best Practices, you'll learn design patterns like Observer, common uses of dataclasses, and how to keep classes loosely coupled. You'll apply these patterns to build TaskFlow v1.5 with an event notification system.
Before moving on, complete the exercises and quiz to solidify your understanding.