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

  1. One message, many answers
  2. Papyrus's checkout terminal: iterating without asking
  3. Duck typing: "if it walks like a duck..."
  4. Polymorphism in the built-in functions
  5. Overriding vs overloading (and why Python doesn't have the latter)
  6. The code smell: isinstance chains
  7. Common mistakes and tips
  8. 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 to in_stock(), final_price(), description() and to have a title.
  • "Faust" is left out because its own version of in_stock() (the one inherited from Product, with stock 0) returns False; the digital Hamlet gets in because its overridden version always returns True.
  • 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) or ArtPrint(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 iterable

len() 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.singledispatch for 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 Book before EBook, digital books fall into the wrong branch (because an EBook is a Book). 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 isinstance branches 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 bare final_price(self), the terminal will fail with TypeError precisely 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 a GiftVoucher will thank you.
  • Excessive defensiveness: checking isinstance on 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 CityMap without editing checkout()? If the answer is no, there's a hidden isinstance to 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.95

Exercise 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 EUR

checkout() 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 function

Knowledge 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 EUR

sum() 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

Module 2: Control Structures

Module 3: Functions and Modules

Module 4: Data Structures

Module 5: Object-Oriented Programming

Module 6: File Handling

Module 7: Error and Exception Handling

Module 8: Advanced Topics

Module 9: Testing and Debugging

Module 10: Web Development with Python

Module 11: Data Science with Python

Module 12: Final Project

© Copyright 2026. All rights reserved