The previous lesson ended with a seemingly trivial scene: a loop walked through books, magazines and ebooks calling description() and in_stock(), and each object answered in its own way without the loop ever asking "and what exactly are you?". That phenomenon is called polymorphism ("many forms"): the same message produces different responses depending on who receives it. It is the real payoff of building hierarchies, and in Python it goes even further thanks to duck typing. In this lesson you'll learn to write code that works with types that don't even exist yet — like Papyrus's checkout terminal, which will happily charge for city maps and art prints the day Ana decides to sell them, without changing a single line.
Contents
- One message, many answers
- Papyrus's checkout terminal: iterating without asking
- Duck typing: "if it walks like a duck..."
- Polymorphism in the built-in functions
- Overriding vs overloading (and why Python doesn't have the latter)
- The code smell:
isinstancechains - Common mistakes and tips
- Exercises with solutions
One message, many answers
We start from the 05-02 hierarchy: Product as the base, with Book, Magazine and EBook (which inherits from Book). All three derived classes override description(), and EBook additionally overrides in_stock().
odyssey = Book("The Odyssey", "Homer", 12.50, 4)
quimera = Magazine("Quimera", 482, 6.50, 10)
hamlet_epub = EBook("Hamlet", "Shakespeare", 4.95, "EPUB")
for item in (odyssey, quimera, hamlet_epub):
print(item.description())[Book] The Odyssey, by Homer — 13.00 EUR [Magazine issue 482] Quimera — 6.76 EUR [Digital EPUB] Hamlet, by Shakespeare — 5.15 EUR
The call is identical (item.description()), but Python resolves at runtime which version to run by following each object's MRO (05-02): it starts at the object's actual class and climbs up. Same message, three forms.
Papyrus's checkout terminal: iterating without asking
Let's watch polymorphism do useful work. Luis (member LUIS-001) comes up to the counter with a mixed basket, and Ana's terminal charges without distinguishing types:
def checkout(basket, member=False):
"""Prints the receipt and returns the total for a basket of Products."""
total = 0
for item in basket:
if not item.in_stock():
print(f" (out of stock: {item.title} goes to reservations)")
continue
amount = item.final_price(member) # each class computes its own
total += amount
print(f" {item.description()}")
return round(total, 2)
luis_basket = [
Book("The Odyssey", "Homer", 12.50, 4),
Book("Faust", "Goethe", 21.00, 0), # sold out since module 4
Magazine("Quimera", 482, 6.50, 10),
EBook("Hamlet", "Shakespeare", 4.95),
]
print(f"Total (member): {checkout(luis_basket, member=True):.2f} EUR")[Book] The Odyssey, by Homer — 13.00 EUR (out of stock: Faust goes to reservations) [Magazine issue 482] Quimera — 6.76 EUR [Digital EPUB] Hamlet, by Shakespeare — 5.15 EUR Total (member): 23.66 EUR
Details that make this function polymorphic:
checkout()mentions no concrete class: it only requires each element to respond toin_stock(),final_price(),description()and to have atitle.- "Faust" is left out because its own version of
in_stock()(the one inherited fromProduct, with stock 0) returnsFalse; the digital Hamlet gets in because its overridden version always returnsTrue. - The member price of "The Odyssey" (12.35 EUR), Quimera (6.42 EUR) and the EPUB (4.89 EUR) adds up to 23.66 EUR — each object applied the canonical formula to its own base price.
- If next month Ana creates
CityMap(Product)orArtPrint(Product),checkout()will accept them without being modified. That's known as code that is open to extension, closed to modification.
flowchart LR
A["checkout(basket)"] -->|"item.description()"| B{"What class is<br>the actual object?"}
B -->|Book| C["'[Book] The Odyssey, by Homer...'"]
B -->|Magazine| D["'[Magazine issue 482] Quimera...'"]
B -->|EBook| E["'[Digital EPUB] Hamlet...'"]
Duck typing: "if it walks like a duck..."
In many languages, polymorphism requires inheritance: you can only pass objects declared as Product to checkout(). Python is more pragmatic. Its philosophy is duck typing: "if it walks like a duck and quacks like a duck, it's a duck". What matters isn't which class an object descends from; it's which methods it responds to.
Marta suggests selling gift vouchers, and doesn't even make them inherit from Product:
class GiftVoucher:
"""Doesn't inherit from Product, but 'quacks' all the same."""
def __init__(self, amount):
self.title = f"Gift voucher for {amount:.2f} EUR"
self.amount = amount
def in_stock(self):
return True # a voucher can always be issued
def final_price(self, member=False):
return round(self.amount, 2) # no VAT, no discount: worth what it's worth
def description(self):
return f"[Voucher] {self.title}"
basket = [Book("Don Quixote", "Cervantes", 15.90, 8), GiftVoucher(20)]
print(f"Total: {checkout(basket):.2f} EUR") # Total: 36.54 EUR (16.54 + 20.00)checkout() accepts it without blinking: it has the four "quacks" the function uses. This is polymorphism without inheritance.
| Polymorphism via inheritance | Duck typing | |
|---|---|---|
| Requirement | Descend from a common base | Respond to the methods used |
| Verification | isinstance(x, Product) possible |
Only at runtime: if the method is missing, AttributeError |
| Typical style of | Java, C# | Python |
| At Papyrus | Book, Magazine, EBook |
GiftVoucher |
When is each one appropriate? Inheritance provides shared code and documents the relationship; duck typing provides maximum flexibility. In practice Python combines both: a hierarchy for whatever shares implementation, duck typing for occasional guests. (In module 8, type hints with protocols will give these "quacks" a formal name.)
Polymorphism in the built-in functions
You've been using polymorphism since module 1 without knowing it. Python's built-in functions are polymorphic by design:
len("Papyrus") # 7 — characters in a string
len(["The Odyssey", "Hamlet"]) # 2 — elements in a list
len({"LUIS-001", "MARTA-002"}) # 2 — elements in a set
len(catalog) # 4 — keys in a dictionary
sum([12.50, 9.95, 15.90]) # 38.35 — sums lists...
sum((4, 6, 8, 0)) # 18 — ...and tuples, and any iterablelen() doesn't ask about the type: it sends the same message ("how long are you?") and each type answers in its own way. The for loop, sorted(), min()/max() with key= (03-03) work the same way: they accept any object that knows how to behave as an iterable. In 05-05 you'll discover the exact mechanism (magic methods like __len__) and make your own classes respond to len() and friends.
Overriding vs overloading (and why Python doesn't have the latter)
Two terms that are easily confused:
| Concept | What it means | Does it exist in Python? |
|---|---|---|
| Overriding | The derived class redefines a parent method | Yes — EBook.in_stock() |
| Overloading | Several versions of the same method with different signatures, in the same class | Not in the classic sense |
In Java you could declare finalPrice() and finalPrice(boolean member) as two distinct methods. In Python, if you define final_price twice in a class, the second definition silently replaces the first. The Pythonic equivalent of overloading is the toolkit from module 3:
def final_price(self, member=False, units=1):
"""A single signature covers every use case."""
discount = Product.MEMBER_DISCOUNT if member else 0
gross = self.price * units * (1 - discount) * (1 + Product.BOOK_VAT)
return round(gross, 2)
odyssey.final_price() # 13.00 — no arguments
odyssey.final_price(member=True) # 12.35 — "another signature"
odyssey.final_price(member=True, units=2) # 24.70 — "yet another"- Default values (
member=False,units=1) cover the usual variants. - For extreme cases,
*args/**kwargs(03-02) allow fully flexible signatures. - There is
functools.singledispatchfor dispatching by argument type, but it's a niche tool: default values solve 95% of the cases.
The code smell: isinstance chains
isinstance() (05-02) is legitimate for validating inputs or filtering collections. But when it's used to decide behavior, it betrays a design that ignores polymorphism. Compare:
# BAD: the terminal asks each item what type it is
def describe_bad(item):
if isinstance(item, EBook):
return f"[Digital {item.file_format}] {item.title}"
elif isinstance(item, Book):
return f"[Book] {item.title}, by {item.author}"
elif isinstance(item, Magazine):
return f"[Magazine issue {item.issue}] {item.title}"
else:
return item.title# GOOD: each class already knows how to describe itself
def describe_good(item):
return item.description()Problems with the "bad" version:
- Every new class forces you to edit the function (and every similar function scattered around the program). With polymorphism, the new class brings its behavior with it.
- The order matters, and it betrays you: if you check
BookbeforeEBook, digital books fall into the wrong branch (because anEBookis aBook). It's a real, silent bug. - It duplicates knowledge: the description format lives far away from the class it describes.
Practical rule: if you're writing isinstance inside an if/elif to vary behavior, ask yourself whether that behavior shouldn't be an overridden method in each class. The answer is almost always yes.
Common Mistakes and Tips
- Ordering the
isinstancebranches wrong: the derived class must be checked before its base, or its branch will never be reached. Better still: eliminate the chain with a polymorphic method. - Overriding with a different signature: if
Product.final_price(self, member=False)accepts an argument, a derived class's version must accept it too. If the derived class defines a barefinal_price(self), the terminal will fail withTypeErrorprecisely on that type — breaking the promise that "any Product will do". - Defining two methods with the same name in a class hoping for overloading: Python keeps only the last one, with no warning. Use default values.
- Relying on duck typing without documenting the "quacks": if
checkout()requires four methods, say so in its docstring (03-01). Whoever writes aGiftVoucherwill thank you. - Excessive defensiveness: checking
isinstanceon everything, everywhere, "just in case". Python favors the EAFP style (easier to ask forgiveness than permission), which we'll develop with exceptions in module 7. - Tip: when in doubt about whether your design is polymorphic, run the new-item test: can you add
CityMapwithout editingcheckout()? If the answer is no, there's a hiddenisinstanceto remove.
Exercises
Exercise 1: a new duck in the basket
Create the class ArtPrint (an illustrated art print) without inheriting from Product: with title, price, and the methods in_stock() (always True), final_price(member=False) (Papyrus's canonical formula) and description() ("[Print] ..."). Verify that checkout([ArtPrint("The Raven", 5.00)]) works through pure duck typing.
Exercise 2: removing the code smell
Refactor this function inherited from the old terminal so that it uses polymorphism (you'll need to touch the classes too: hint — shipping is behavior that belongs to each class):
def shipping_cost(item):
if isinstance(item, EBook):
return 0.0
elif isinstance(item, Magazine):
return 1.50
elif isinstance(item, Book):
return 2.95Exercise 3: polymorphic inventory
Write inventory_value(items) that returns the sum of final_price() × stock for the physical items (the ones whose stock attribute is greater than 0), traversing the list only once and without using isinstance. Test it with "The Odyssey" (stock 4), "Don Quixote" (stock 8) and an EBook (which must contribute 0).
Solutions
Solution 1:
class ArtPrint:
BOOK_VAT = 0.04
MEMBER_DISCOUNT = 0.05
def __init__(self, title, price):
self.title = title
self.price = price
def in_stock(self):
return True
def final_price(self, member=False):
discount = ArtPrint.MEMBER_DISCOUNT if member else 0
return round(self.price * (1 - discount) * (1 + ArtPrint.BOOK_VAT), 2)
def description(self):
return f"[Print] {self.title} — {self.final_price():.2f} EUR"
print(f"Total: {checkout([ArtPrint('The Raven', 5.00)]):.2f} EUR") # Total: 5.20 EURcheckout() doesn't check inheritance: ArtPrint quacks (it responds to the four methods), therefore it's a duck.
Solution 2:
class Product:
# ... everything as before ...
def shipping_cost(self):
return 2.95 # a sensible default for physical goods
class Magazine(Product):
# ... everything as before ...
def shipping_cost(self):
return 1.50 # overrides: magazines ship in an envelope
class EBook(Book):
# ... everything as before ...
def shipping_cost(self):
return 0.0 # overrides: nothing to ship
def shipping_cost(item):
return item.shipping_cost() # or simply remove the functionKnowledge about shipping now lives in each class. Adding CityMap with tube shipping (3.50 EUR) no longer touches any existing function. Notice how Book doesn't even appear: it inherits the 2.95 from Product.
Solution 3:
def inventory_value(items):
"""Value at sale price (non-member) of the physical stock."""
total = sum(item.final_price() * item.stock for item in items)
return round(total, 2)
inventory = [
Book("The Odyssey", "Homer", 12.50, 4),
Book("Don Quixote", "Cervantes", 15.90, 8),
EBook("Hamlet", "Shakespeare", 4.95),
]
print(f"{inventory_value(inventory):,.2f} EUR") # 184.32 EURsum() with a generator expression traverses the list only once. The EBook needs no special treatment: its inherited stock is 0, so it multiplies by 0 and contributes nothing — the data itself is polymorphic. (13.00 × 4 + 16.54 × 8 = 184.32 EUR.)
Conclusion
Polymorphism is the reason the hierarchy you built in 05-02 exists: checkout() sends the same messages — in_stock(), final_price(), description() — and each object answers according to its actual class, without a single type-checking if. You've seen that Python pushes the idea further with duck typing (the GiftVoucher sneaks in without inheriting from anyone), that built-in functions like len() and sum() are polymorphic out of the box, that classic "overloading" is replaced with default values, and that isinstance chains are the code smell that gives away a rigid design. However: this whole building rests on a delicate trust — nothing stops someone from writing odyssey.price = -5 or faust.stock = -3 and wrecking Ana's accounts. Protecting an object's data so it can only change through legitimate channels is encapsulation, the subject of the next lesson.
Python Programming Course
Module 1: Introduction to Python
- Introduction to Python
- Setting Up the Development Environment
- Python Syntax and Basic Data Types
- Variables and Constants
- Basic Input and Output
- Virtual Environments and Package Management
Module 2: Control Structures
Module 3: Functions and Modules
- Defining Functions
- Function Arguments
- Lambda Functions
- Modules and Packages
- Standard Library Overview
Module 4: Data Structures
Module 5: Object-Oriented Programming
Module 6: File Handling
Module 7: Error and Exception Handling
- Introduction to Exceptions
- Handling Exceptions
- Raising Exceptions
- Custom Exceptions
- Best Practices and Error Logging
Module 8: Advanced Topics
- Type Hints
- Decorators
- Generators
- Context Managers
- Concurrency: Threads and Processes
- Asyncio for Asynchronous Programming
Module 9: Testing and Debugging
- Introduction to Testing
- Unit Testing with unittest
- Testing with pytest
- Test-Driven Development
- Debugging Techniques
- Using pdb for Debugging
Module 10: Web Development with Python
- Introduction to Web Development
- Flask Framework Fundamentals
- Building REST APIs with Flask
- Introduction to Django
- Building Web Applications with Django
Module 11: Data Science with Python
- Introduction to Data Science
- NumPy for Numerical Computing
- Pandas for Data Manipulation
- Matplotlib for Data Visualization
- Introduction to Machine Learning with scikit-learn
