Beginner · Lesson 39 of 94

Polymorphism and duck typing

Watch this lesson

View full playlist ↗

Read the lesson

What you'll learn

Concept

Polymorphism means "many shapes": one piece of code works with objects of different classes because they all answer to the same method name. A loop that calls shape.area() does not care whether each shape is a circle or a square; each class provides its own area, and the right one runs. The caller is written once and never changes when a new shape appears. That is the payoff of the previous lessons: encapsulated objects with a shared vocabulary.

Polymorphism and duck typing: for shape in shapes:; shape.area() → Circle.area → πr² → Square.area → side² → Triangle.area → ½bh
Polymorphism and duck typing · Figure 1 · Open for full size

In many languages polymorphism requires inheritance: the shapes must share a parent that declares area. Python does not require that. If an object has an area() method, shape.area() works, whatever the object's class and whoever wrote it. This is duck typing, from the saying "if it walks like a duck and quacks like a duck, it is a duck". Python asks what an object can do, not what it is. Built-ins work this way: len() accepts anything with __len__, and for accepts anything that can be iterated.

Approach Requires Checks Where you see it
inheritance-based a shared parent class isinstance(x, Shape) many other languages
duck typing the method to exist nothing; call it idiomatic Python
protocols (later) the method to exist, declared static type checker typed Python codebases

The practical consequence is a rule of thumb: when you find yourself writing if isinstance(x, Circle): ... elif isinstance(x, Square): ..., stop. Give each class a method with the same name and call it. The chain of type checks grows every time a class is added and lives far from the classes it knows about; the method lives with its class and new classes bring their own.

Polymorphism and duck typing: if isinstance(x, Circle): ...; elif isinstance(x, Square): ...; elif isinstance(x, Triangle): ... → x.area() → a new class defines area(); no call site changes
Polymorphism and duck typing · Figure 2 · Open for full size

Sometimes you do need to check. When an object lacks the method, the call raises AttributeError at the moment of use, which can be far from the cause. For inputs from outside your code, check early: hasattr(x, "area") asks whether the method exists, and isinstance(x, (int, float)) is right for built-in values where behavior genuinely depends on type. The later lesson on abstract base classes shows how to declare the required methods formally, and the typing lessons show how a checker can verify them before the program runs.

Code

Three classes with no shared parent, one loop. Each provides area and describe; the loop knows only the names.

# shapes.py
import math


class Circle:
    def __init__(self, r):
        self.r = r

    def area(self):
        return math.pi * self.r ** 2

    def describe(self):
        return f"circle r={self.r}"


class Square:
    def __init__(self, side):
        self.side = side

    def area(self):
        return self.side ** 2

    def describe(self):
        return f"square side={self.side}"


class Triangle:
    def __init__(self, base, height):
        self.base, self.height = base, height

    def area(self):
        return 0.5 * self.base * self.height

    def describe(self):
        return f"triangle {self.base}x{self.height}"


shapes = [Circle(1), Square(3), Triangle(4, 5)]
for shape in shapes:
    print(f"{shape.describe():<16} area {shape.area():6.2f}")
print(f"total {sum(s.area() for s in shapes):.2f}")
Output
circle r=1       area   3.14
square side=3    area   9.00
triangle 4x5     area  10.00
total 22.14

Duck typing with the built-ins. len and for work on anything that supports them, including a class you wrote a minute ago.

# ducks.py
class Playlist:
    def __init__(self, songs):
        self._songs = list(songs)

    def __len__(self):
        return len(self._songs)

    def __iter__(self):
        return iter(self._songs)


things = ["abc", [1, 2], {"k": 1}, Playlist(["intro", "verse", "chorus"])]
for thing in things:
    print(f"{type(thing).__name__:<9} len={len(thing)} items={list(thing)}")
Output
str       len=3 items=['a', 'b', 'c']
list      len=2 items=[1, 2]
dict      len=1 items=['k']
Playlist  len=3 items=['intro', 'verse', 'chorus']

The refactor: an isinstance chain and the polymorphic version produce the same result, and only one of them survives adding a new kind of notification.

# refactor.py
class Email:
    def __init__(self, to):
        self.to = to

    def send(self):
        return f"emailing {self.to}"


class Sms:
    def __init__(self, number):
        self.number = number

    def send(self):
        return f"texting {self.number}"


def notify_with_checks(target):
    if isinstance(target, Email):
        return f"emailing {target.to}"
    elif isinstance(target, Sms):
        return f"texting {target.number}"
    raise TypeError("unknown notification")


def notify(target):
    return target.send()


targets = [Email("ada@example.com"), Sms("555-0100")]
print([notify_with_checks(t) for t in targets])
print([notify(t) for t in targets])
Output
['emailing ada@example.com', 'texting 555-0100']
['emailing ada@example.com', 'texting 555-0100']

Checking politely when the input is untrusted. hasattr asks up front, and the failure is a clear message rather than an AttributeError deep inside a loop.

# check_early.py
class Square:
    def __init__(self, side):
        self.side = side

    def area(self):
        return self.side ** 2


def total_area(shapes):
    for shape in shapes:
        if not hasattr(shape, "area"):
            raise TypeError(f"{type(shape).__name__} has no area() method")
    return sum(shape.area() for shape in shapes)


print(total_area([Square(2), Square(3)]))
try:
    total_area([Square(2), "not a shape"])
except TypeError as err:
    print("rejected:", err)
Output
13
rejected: str has no area() method

Try it yourself

Create payments.py with three unrelated classes, CardPayment, BankTransfer, and Cash, each with a process(amount) method that returns a short string and a fee(amount) method with a different rule. Write one function that takes a list of payments and an amount, calls both methods on each, and prints a summary line, without a single isinstance. Then add a fourth class and confirm the function needs no change.

Common mistakes

What's next

You can write code that welcomes new classes. Next you will make the shared vocabulary explicit and enforceable, with abstract base classes.

Course overview