Polymorphism#
Polymorphism — "many forms" — means code can operate on objects of different types through a common interface. Python achieves this primarily through duck typing (behavior-based) and secondarily through inheritance-based subtype polymorphism. No interface keyword required — if it quacks, it works.
This page builds on Introdicion to OOPs, Inheritance and Multiple Inheritance, and Abstract Base Classes.
How to use this page
Understand duck typing first — it's Python's default. Then study ABC/Protocol for explicit contracts. Related: Dunder or Magic Methods, Method Resolution Order.
At a glance
| Track | Python Advanced → Object Oriented Programming |
| Sections | 13 major topics |
| Outline | Use the right-hand TOC to jump |
Topics: Two flavors in Python · Duck typing — the Python default · Inheritance-based polymorphism · Polymorphism via dunder methods · ABC-driven polymorphism — explicit contracts · typing.Protocol — structural polymorphism · Polymorphism in the standard library · Strategy pattern — runtime polymorphism · … (+5 more)
- Two flavors in Python
- Duck typing — the Python default
- Inheritance-based polymorphism
- Polymorphism via dunder methods
- ABC-driven polymorphism — explicit contracts
typing.Protocol— structural polymorphism- Polymorphism in the standard library
- Strategy pattern — runtime polymorphism
- Multiple approaches to the same problem
- Polymorphism in interviews
isinstancevs duck typing — when to check types- Overriding and dynamic dispatch … and 1 more sections
Two flavors in Python#
| Flavor | Mechanism | Example |
|---|---|---|
| Duck typing | Object supports required methods | file_like.read() |
| Subtype polymorphism | Inheritance / ABC / isinstance | Animal.speak() overridden in Dog |
| Ad hoc polymorphism | Operator overloading via dunders | a + b for custom types |
Python favors duck typing; use ABCs when you need explicit contracts.
Duck typing — the Python default#
def process(source) -> str:
return source.read().upper()
# Works with any object that has .read() -> str
process(open("file.txt"))
process(io.StringIO("hello"))
No common base class required. The function declares its expected behavior, not its expected type.
EAFP vs LBYL#
Python prefers Easier to Ask Forgiveness than Permission:
# EAFP — Pythonic
def process(source) -> str:
try:
return source.read().upper()
except AttributeError:
raise TypeError("source must have read()") from None
# LBYL — less Pythonic
def process(source) -> str:
if not hasattr(source, "read"):
raise TypeError("source must have read()")
return source.read().upper()
Use hasattr sparingly — it can lie if __getattr__ is involved.
Inheritance-based polymorphism#
class Animal:
def __init__(self, name: str):
self.name = name
def speak(self) -> str:
return "..."
class Dog(Animal):
def speak(self) -> str:
return "woof"
class Cat(Animal):
def speak(self) -> str:
return "meow"
def announce(pet: Animal) -> None:
print(f"{pet.name} says {pet.speak()}")
announce(Dog("Rex")) # Rex says woof
announce(Cat("Mia")) # Mia says meow
announce works with any Animal subclass — substitutability (Liskov Substitution Principle).
Liskov Substitution Principle (LSP)#
Subtypes must be usable wherever the base type is expected without breaking behavior:
# Violation example — classic rectangle/square debate
class Rectangle:
def __init__(self, w: float, h: float):
self.width = w
self.height = h
def area(self) -> float:
return self.width * self.height
class Square(Rectangle):
def __init__(self, side: float):
super().__init__(side, side)
def set_width(self, w: float):
self.width = self.height = w # breaks Rectangle expectations
If client code sets width independently of height, Square violates LSP. Prefer composition or separate types.
Polymorphism via dunder methods#
Operator and built-in function polymorphism:
class Vector:
def __init__(self, x: float, y: float):
self.x, self.y = x, y
def __add__(self, other: "Vector") -> "Vector":
return Vector(self.x + other.x, self.y + other.y)
def __mul__(self, scalar: float) -> "Vector":
return Vector(self.x * scalar, self.y * scalar)
def __repr__(self) -> str:
return f"Vector({self.x}, {self.y})"
Vector(1, 2) + Vector(3, 4) # Vector(4, 6)
Vector(1, 2) * 3 # Vector(3, 6)
Same + syntax, different behavior per type — operator overloading.
ABC-driven polymorphism — explicit contracts#
When duck typing isn't enough (frameworks, plugins):
from abc import ABC, abstractmethod
class PaymentGateway(ABC):
@abstractmethod
def charge(self, amount: float) -> bool: ...
class StripeGateway(PaymentGateway):
def charge(self, amount: float) -> bool:
print(f"Stripe: ${amount}")
return True
class PayPalGateway(PaymentGateway):
def charge(self, amount: float) -> bool:
print(f"PayPal: ${amount}")
return True
def checkout(gateway: PaymentGateway, amount: float) -> None:
if gateway.charge(amount):
print("Success")
checkout(StripeGateway(), 99.99)
checkout(PayPalGateway(), 49.99)
ABCs add instantiation-time enforcement — see Abstract Base Classes.
typing.Protocol — structural polymorphism#
Static type checking without inheritance:
from typing import Protocol
class SupportsWrite(Protocol):
def write(self, data: str) -> int: ...
def save(output: SupportsWrite, data: str) -> None:
output.write(data)
class FileWriter:
def write(self, data: str) -> int:
print(data)
return len(data)
save(FileWriter(), "hello") # OK — structural match
| Approach | Runtime check | Static check | Inheritance required |
|---|---|---|---|
| Duck typing | Implicit (try/except) | Optional hints | No |
| ABC | isinstance |
Yes | Yes (or register) |
| Protocol | With @runtime_checkable |
Yes | No |
Polymorphism in the standard library#
collections.abc — iterable polymorphism#
from collections.abc import Iterable
def consume(items: Iterable[int]) -> int:
return sum(items)
consume([1, 2, 3])
consume(range(10))
consume({1, 2, 3}) # sets are iterable
File-like objects#
def load_json(source) -> dict:
import json
return json.load(source)
load_json(open("data.json"))
load_json(io.StringIO('{"key": "value"}'))
Sorting polymorphism via __lt__#
@dataclass(order=True)
class Task:
priority: int
name: str
sorted([Task(2, "b"), Task(1, "a")]) # sorts by priority
Strategy pattern — runtime polymorphism#
from abc import ABC, abstractmethod
class CompressionStrategy(ABC):
@abstractmethod
def compress(self, data: bytes) -> bytes: ...
class GzipStrategy(CompressionStrategy):
import gzip
def compress(self, data: bytes) -> bytes:
return gzip.compress(data)
class RawStrategy(CompressionStrategy):
def compress(self, data: bytes) -> bytes:
return data
class Archiver:
def __init__(self, strategy: CompressionStrategy):
self._strategy = strategy
def archive(self, data: bytes) -> bytes:
return self._strategy.compress(data)
Archiver(GzipStrategy()).archive(b"hello")
Swap strategies at runtime without changing Archiver — composition + polymorphism.
Multiple approaches to the same problem#
Problem: format any object as JSON#
Approach 1 — duck typing
def to_json(obj) -> str:
import json
if hasattr(obj, "to_json"):
return obj.to_json()
return json.dumps(obj.__dict__)
Approach 2 — ABC
Approach 3 — singledispatch (functools)
from functools import singledispatch
import json
@singledispatch
def serialize(obj) -> str:
return json.dumps(obj)
@serialize.register
def _(obj: JSONSerializable) -> str:
return obj.to_json()
| Approach | Best when |
|---|---|
| Duck typing | Small codebase, flexible APIs |
| ABC | Plugin systems, team contracts |
| singledispatch | Type-based dispatch on functions |
Polymorphism in interviews#
Design: notification system#
from abc import ABC, abstractmethod
class Notifier(ABC):
@abstractmethod
def send(self, message: str) -> bool: ...
class EmailNotifier(Notifier):
def send(self, message: str) -> bool:
print(f"Email: {message}")
return True
class SMSNotifier(Notifier):
def send(self, message: str) -> bool:
print(f"SMS: {message}")
return True
class NotificationService:
def __init__(self, notifiers: list[Notifier]):
self._notifiers = notifiers
def notify_all(self, message: str) -> None:
for n in self._notifiers:
n.send(message)
DSA: polymorphic compare for priority queue#
class Task:
def __init__(self, priority: int, name: str):
self.priority = priority
self.name = name
def __lt__(self, other: "Task") -> bool:
return self.priority < other.priority
import heapq
heap = []
heapq.heappush(heap, Task(2, "low"))
heapq.heappush(heap, Task(1, "high"))
heapq.heappop(heap) # Task(1, "high")
isinstance vs duck typing — when to check types#
def handle(obj) -> None:
if isinstance(obj, str):
process_string(obj)
elif isinstance(obj, int):
process_int(obj)
else:
process_generic(obj)
Use isinstance |
Use duck typing |
|---|---|
| Specific type branches needed | Generic algorithms |
| Exception hierarchies | File-like, iterable-like |
| Framework dispatch | Most library code |
Avoid type(obj) == SomeClass — breaks subclass polymorphism.
Overriding and dynamic dispatch#
Method lookup is dynamic (runtime) — based on object's actual type:
class Base:
def action(self):
return "base"
class Derived(Base):
def action(self):
return "derived"
def run(obj: Base):
return obj.action()
run(Derived()) # "derived" — not "base"
This is virtual dispatch — no virtual keyword needed.
MRO determines which override wins in multiple inheritance: Method Resolution Order.
Parametric polymorphism (generics)#
Type hints express polymorphism over types:
from typing import TypeVar
T = TypeVar("T")
def first(items: list[T]) -> T:
return items[0]
first([1, 2, 3]) # int
first(["a", "b"]) # str
Generics are static — erased at runtime in standard CPython.
Interview traps (quick reference)#
| Trap | What goes wrong | Safe approach |
|---|---|---|
Over-using isinstance |
Breaks extensibility | Duck typing or ABC at boundaries |
| LSP violation in inheritance | Subclass breaks callers | Favor composition |
| Assuming Java-style interfaces | Python doesn't require them | Duck type or Protocol |
type(x) == list vs isinstance |
Rejects subclasses | Use isinstance(x, list) |
| Checking types before EAFP | Race conditions, verbose | try/except AttributeError |
| Polymorphism without common interface docs | Unclear contract | Docstring expected methods or ABC |
Mental model checklist#
- What is duck typing, and why is it Python's default?
- How does inheritance-based polymorphism differ from duck typing?
- When would you use an ABC instead of duck typing?
- What is the Liskov Substitution Principle?
- How do dunder methods enable operator polymorphism?
What's next#
| Topic | Page |
|---|---|
| ABC contracts | Abstract Base Classes |
| Inheritance design | Inheritance and Multiple Inheritance |
| Dunder methods | Dunder or Magic Methods |
| Intermediate OOP | Classes and OOP Basics |