Polymorphism

Polymorphism lets the same operation behave differently depending on the object it's applied to -- in Python this is achieved primarily through duck typing and method overriding, not compile-time type checks.

Polymorphism ('many forms') means code written against a general interface -- calling .speak(), iterating with for, using + -- works correctly across many different types, each supplying its own specific behavior for that operation. Python leans heavily on duck typing: what matters is whether an object supports the operation you perform on it, not what class it was declared as or what it inherits from.

What Is Polymorphism

Polymorphic code doesn't ask 'what exact type is this?' before deciding what to do -- it simply performs the operation, and each object's own type determines what actually happens. The same line of code, len(x) or x.speak() or x + y, produces different, type-appropriate results depending on x (and y).

def describe(shape):
    print(f"{type(shape).__name__} area: {shape.area()}")


class Circle:
    def __init__(self, r): self.r = r
    def area(self): return 3.14159 * self.r ** 2


class Square:
    def __init__(self, s): self.s = s
    def area(self): return self.s ** 2


for shape in [Circle(2), Square(3)]:
    describe(shape)  # same call, different behavior per type

Overriding: Polymorphism Through Inheritance

The most direct route to polymorphism is inheritance combined with method overriding: a family of subclasses shares a common base-class interface, and each provides its own implementation of one or more methods (see the Inheritance topic for override mechanics).

class Animal:
    def speak(self) -> str:
        raise NotImplementedError


class Dog(Animal):
    def speak(self) -> str:
        return "Woof"


class Cat(Animal):
    def speak(self) -> str:
        return "Meow"


for animal in [Dog(), Cat()]:
    print(animal.speak())  # 'Woof' then 'Meow' -- one call site, per-type behavior

Duck Typing: Polymorphism Without a Common Base Class

Duck typing gets its name from 'if it walks like a duck and quacks like a duck, it's a duck': Python code generally doesn't check an object's declared type or ancestry before using it -- it just calls the method or uses the operator, and if the object supports it, the code works, regardless of inheritance. Two completely unrelated classes can both be used polymorphically as long as they implement the same method name.

Duck and Robot share no common ancestor besides object, yet the same loop body works for both because Python only cares that make_sound() exists and is callable, not what class defines it.

This is often phrased as 'structural thinking': design and reason about objects in terms of what operations/attributes they support (their structure/interface) rather than their position in a class hierarchy (their nominal type).

class Duck:
    def make_sound(self) -> str:
        return "Quack"


class Robot:  # unrelated to Duck -- no shared base class beyond object
    def make_sound(self) -> str:
        return "Beep"


for thing in [Duck(), Robot()]:
    print(thing.make_sound())  # works for both -- no inheritance relationship needed

Polymorphism Through Operator Overloading

Operators like +, ==, and len() are themselves polymorphic: they dispatch to whatever special method the object defines (__add__, __eq__, __len__), so the same operator syntax means something different for ints, strings, lists, and any user-defined class that implements those methods. (Full coverage of the individual dunder methods is in the Special / Magic Methods topic.)

print(1 + 2)         # 3 -- int.__add__
print("a" + "b")      # 'ab' -- str.__add__
print([1] + [2])      # [1, 2] -- list.__add__

class Vector:
    def __init__(self, x, y): self.x, self.y = x, y
    def __add__(self, other):
        return Vector(self.x + other.x, self.y + other.y)
    def __repr__(self):
        return f"Vector({self.x}, {self.y})"

print(Vector(1, 2) + Vector(3, 4))  # Vector(4, 6) -- + means something new here

Built-in Polymorphism: len() and Iteration

Many built-in functions are polymorphic by design: len() works on strings, lists, dicts, and sets, and on any user-defined object implementing __len__; the for loop works on any object implementing the iterator protocol (__iter__/__next__), whether it's a list, a file, or a custom generator.

print(len("hello"))     # 5
print(len([1, 2, 3]))   # 3
print(len({"a": 1}))    # 1

class Bag:
    def __init__(self, items): self._items = items
    def __len__(self): return len(self._items)

print(len(Bag([1, 2, 3, 4])))  # 4 -- len() delegates to Bag.__len__

Protocols: Informal Interfaces

A protocol is an informal contract: a set of methods (like __len__, __iter__, or __enter__/__exit__) that, if implemented, makes an object usable in a particular context, without any inheritance requirement. Python's iterator protocol, context-manager protocol, and sequence protocol are all examples -- 'protocol' names an expected shape, not a base class you must inherit from.

typing.Protocol (standard library, 3.8+) lets you write a static, checkable description of a protocol's expected methods for tools like mypy, without requiring runtime inheritance -- useful when you want duck typing's flexibility with static-analysis support.

class Countdown:
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self

    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        self.current -= 1
        return self.current + 1


for n in Countdown(3):  # works anywhere iteration is expected, e.g. for loops, list()
    print(n)  # 3, 2, 1

Polymorphism With Abstract Base Classes

Duck typing works at runtime with no upfront contract, which is flexible but offers no guarantee a given object actually supports what you need until you try it. Abstract base classes (covered fully in the Abstract Base Classes topic) let you formalize a shared interface -- a common base class that declares required methods and prevents instantiation until subclasses implement them -- combining polymorphism with an explicit, enforced contract.

from abc import ABC, abstractmethod

class Shape(ABC):
    @abstractmethod
    def area(self) -> float: ...


class Circle(Shape):
    def __init__(self, r): self.r = r
    def area(self) -> float:
        return 3.14159 * self.r ** 2


# Circle() can be instantiated because it implements area(); Shape() itself cannot

Practical Usage: Writing Polymorphic Functions

The practical payoff of polymorphism is writing one function that works correctly across a whole family of types without an if/elif chain checking type(x). This keeps code open to new types: adding a new Triangle class that implements area() requires no change to describe() at all.

def total_area(shapes) -> float:
    return sum(s.area() for s in shapes)  # works for any object with .area()

Edge Cases

Duck typing means AttributeError (or TypeError) happens at the point of use, not in advance -- if an object lacks the expected method, you find out only when the code tries to call it, not when it's constructed or passed around. This is usually acceptable in Python's dynamic style but is a real tradeoff compared to statically-checked interfaces.

class NoSound:
    pass

try:
    NoSound().make_sound()
except AttributeError as e:
    print("caught:", e)  # fails only when actually called, not before

Key Takeaways

Polymorphism lets the same code (a call, a loop, an operator) work correctly across many types, each behaving in its own type-appropriate way.

Method overriding via inheritance is one route to polymorphism; duck typing -- relying on an object simply supporting an operation -- is Python's more idiomatic default.

Duck typing needs no shared base class: unrelated classes implementing the same method name are equally usable wherever that method is called.

Operators and built-ins like +, len(), and for are themselves polymorphic, dispatching to whatever dunder method the operand defines.

A protocol names an expected shape (a set of methods) without requiring inheritance; abstract base classes add an enforced, explicit version of that same idea.

def describe(shape): if isinstance(shape, Circle): print(shape.area()) elif isinstance(shape, Square): print(shape.area()) # breaks for every new shape type added later def describe(shape): print(shape.area()) # works for any object with.area(), current or future

Branching on isinstance() for every known type defeats the purpose of polymorphism and must be edited every time a new type is added; calling the shared method directly lets new types work automatically.

class Animal: def speak(self): pass # silently does nothing if a subclass forgets to override it class Animal: def speak(self): raise NotImplementedError("subclasses must implement speak()")

A base-class method that silently does nothing hides the mistake of a subclass forgetting to override it; raising NotImplementedError (or using an abstract base class) surfaces the omission immediately.

Duck typing defers the type check to the moment of use; code that assumes every object will support an operation without any guard can crash unpredictably at runtime, especially with unvalidated input objects.

What is duck typing, and how does it differ from polymorphism achieved purely through inheritance?

Duck typing means Python only checks whether an object supports the operation being performed (e.g. it has a callable method with the right name), not its declared type or ancestry; unlike inheritance-based polymorphism, it requires no common base class at all.

Why does for x in obj: work identically whether obj is a list, a string, a file, or a custom class?

Because the for loop is polymorphic over anything implementing the iterator protocol (__iter__/__next__); it doesn't care what the object's declared type is, only that the protocol's methods are present.

What does it mean to say Python operators like + are 'polymorphic'?

It means the same + syntax dispatches to different behavior depending on the operands' types, by calling the operand's own __add__ method -- int addition, string concatenation, list concatenation, and a user-defined Vector.__add__ are all reached through the identical + expression.

What is a 'protocol' in the Python sense, and how is it different from an abstract base class?

A protocol is an informal expected set of methods (like __len__ or __iter__) that makes an object usable in a context, with no inheritance requirement; an abstract base class formalizes and enforces a similar idea by refusing to instantiate a subclass that hasn't implemented the required methods.

What is the main risk of relying on duck typing instead of an enforced shared interface?

An object missing an expected method only fails when that method is actually called (AttributeError at the point of use), not earlier when the object is constructed or passed around, so mismatches can surface later and less predictably than with a statically checked interface.

Why is writing if isinstance(x, TypeA): ... elif isinstance(x, TypeB): ... generally considered non-polymorphic or anti-polymorphic style?

Because it hardcodes every known type into the branching logic, requiring the function to be edited whenever a new type is added, defeating the benefit of polymorphism -- one call site that automatically works for any type supporting the required operation.

What does 'structural thinking' mean in the context of Python polymorphism?

Reasoning about what operations and attributes an object supports (its structure/interface) rather than about its position in a class hierarchy or its declared/nominal type -- the basis of duck typing.

Write two unrelated classes, TextFile and Tweet, that each implement a word_count() method with no shared base class, then write a single function summarize(items) that calls word_count() polymorphically on a mixed list of both.

class TextFile: def __init__(self, text: str) -> None: self.text = text def word_count(self) -> int: return len(self.text.split()) class Tweet: def __init__(self, message: str) -> None: self.message = message def word_count(self) -> int: return len(self.message.split()) def summarize(items) -> int: return sum(item.word_count() for item in items) print(summarize([TextFile("hello world"), Tweet("just one tweet here")])) # 6

TextFile and Tweet share no inheritance relationship, yet summarize() works on both because it only calls word_count(), which both classes happen to implement -- pure duck typing.

What does this print, and what polymorphic mechanism makes len() work on both A and B?

15 -- len() is polymorphic: it delegates to whatever __len__ method the argument defines, so it works identically on A and B despite them being unrelated classes.

Write a base class Shape with a method describe() that calls self.area(), and two subclasses Circle and Rectangle overriding area(); demonstrate calling describe() on instances of both through a loop.

class Shape: def describe(self) -> str: return f"{type(self).__name__} has area {self.area():.2f}" def area(self) -> float: raise NotImplementedError class Circle(Shape): def __init__(self, r: float) -> None: self.r = r def area(self) -> float: return 3.14159 * self.r ** 2 class Rectangle(Shape): def __init__(self, w: float, h: float) -> None: self.w, self.h = w, h def area(self) -> float: return self.w * self.h for shape in [Circle(2), Rectangle(3, 4)]: print(shape.describe())

Prints 'Circle has area 12.57' then 'Rectangle has area 12.00' -- describe() is defined once in Shape and calls self.area(), which resolves polymorphically to each subclass's own override.

class A:
    def __len__(self):
        return 5

class B:
    def __len__(self):
        return 10

print(len(A()) + len(B()))