Polymorphism Explained: Interfaces and Abstract Base Classes
8 min readBytePatterns
Polymorphism explained with Python: one method name, many types, interfaces as abstract base classes, duck typing vs protocols, and how method dispatch works.
Polymorphism is the object-oriented word most people can define and fewer can use. The definition is short: one call site, many types, each answering in its own way. The useful part is what that buys you: code that loops over "channels" or "shapes" or "payment methods" without knowing, or caring, which concrete class it holds, so adding a new kind is one new class and zero edits elsewhere. Interfaces are how you make that promise explicit.
The problem it solves
Without polymorphism, code that handles several kinds of thing asks each object what it is:
if kind == "sms": ... elif kind == "webhook": ...appears in every function that touches a channel.- Adding a channel means finding and editing every one of those chains, and missing one is a bug that only the new channel triggers.
- The caller depends on every concrete class, so it cannot be tested or reused without all of them.
Polymorphism moves the decision into the objects. The caller depends on one thing, a method name with a meaning, and each type supplies the behaviour.
The intuition
An interface is a promise about method names, not about data. Anything that keeps the promise is interchangeable. Python gives you three ways to state it:
- Duck typing. No declaration at all: if it has a
sendmethod, the caller can call it. Flexible, and the contract lives only in documentation. - Abstract base classes (
abc.ABCwith@abstractmethod). The contract is a class; subclasses that forget a method cannot be constructed, so the mistake surfaces at startup rather than when the rare code path runs. - Protocols (
typing.Protocol). Structural: any class with the right methods matches, without inheriting. Static type checkers use them, and@runtime_checkableletsisinstancecheck that the methods exist.
Under the hood, obj.send(msg) is dynamic dispatch: Python looks at type(obj), walks its method resolution order (the class, then its bases, in a fixed linearised order), and calls the first send it finds. That lookup is why the same line of caller code runs different methods.
Two more words interviewers use. Subtype polymorphism is the one above: many classes, one method. Ad hoc polymorphism picks behaviour by argument type; languages with overloading do it at compile time, and Python's functools.singledispatch does it at runtime. Whichever you use, a subclass must honour the contract's meaning, not just its name. That is the Liskov substitution principle, the L in SOLID.
Watch it run
The animation dispatches the lesson's notifier. An interface is a promise about method names, not about data. Notifier declares one method and supplies no behaviour at all. Two types honour it; inside, an SMS gateway and an HTTP post share nothing. So the caller writes one line, c.send(msg), for every channel in the list. First channel: Sms answers in its own way. Same call, second type, completely different answer. One line of caller code drove two unrelated implementations, and that is polymorphism. A third channel is one new class, and the loop that fans out is never reopened: it joins by matching the contract, and nothing else changed. Finally, the contract itself refuses to be built, so a missing method becomes an error at construction, not a surprise at three in the morning.
Interfaces and Polymorphism
Step 1 of 11
An interface is a promise about method names, not about data.
The same interactive animation as the lesson — step through it with the controls.
The code
The lesson's contract with a third channel, a subclass that gets the method name wrong, duck typing, a protocol, and ad hoc dispatch:
from abc import ABC, abstractmethod
class Notifier(ABC):
"""The contract: one method, no data, no behaviour."""
@abstractmethod
def send(self, msg): ...
class Sms(Notifier):
def send(self, msg): return "sms:" + msg[:20] # gateways cap the length
class Webhook(Notifier):
def send(self, msg): return "post:" + msg
class Push(Notifier): # the third channel: one new class
def send(self, msg): return "push:" + msg.upper()
def fan_out(channels, msg): # knows the contract, not the classes
return [c.send(msg) for c in channels]
print(fan_out([Sms(), Webhook(), Push()], "disk full"))
# ['sms:disk full', 'post:disk full', 'push:DISK FULL']
class Pager(Notifier):
def page(self, msg): return "page:" + msg # wrong name: contract not met
for cls in (Notifier, Pager):
try:
cls()
except TypeError:
print(cls.__name__, "refused at construction")
# Notifier refused at construction
# Pager refused at construction
class Email: # no base class at all
def send(self, msg): return "mail:" + msg
print(fan_out([Email()], "disk full"), isinstance(Email(), Notifier))
# ['mail:disk full'] False
from typing import Protocol, runtime_checkable
@runtime_checkable
class Sends(Protocol): # structural: matches by shape
def send(self, msg): ...
print(isinstance(Email(), Sends), isinstance(42, Sends)) # True False
from functools import singledispatch
@singledispatch
def describe(x): return "something"
@describe.register
def _(x: int): return "an int"
@describe.register
def _(x: list): return "a list of %d" % len(x)
print(describe(3), "|", describe([1, 2]), "|", describe(2.5))
# an int | a list of 2 | something
Email works in fan_out by duck typing but is not a Notifier; the protocol accepts it by shape. Now dispatch itself, checked against a brute-force walk of the method resolution order on 2,000 seeded random class hierarchies, some with multiple inheritance, where each class either overrides send or inherits it:
import random
def dispatch_by_hand(obj, name, *args):
"""What obj.name(...) does: walk the MRO, call the first class that defines it."""
for cls in type(obj).__mro__:
if name in cls.__dict__:
return cls.__dict__[name](obj, *args)
raise AttributeError(name)
def make_method(tag):
return lambda self, msg: tag + ":" + msg
rng = random.Random(32)
ok, built, rejected = True, 0, 0
for _ in range(2_000):
classes = [type("C0", (), {"send": make_method("C0")})]
for i in range(1, rng.randint(2, 9)):
bases = tuple(rng.sample(classes, rng.randint(1, min(2, len(classes)))))
body = {"send": make_method("C%d" % i)} if rng.random() < 0.5 else {}
try:
classes.append(type("C%d" % i, bases, body))
except TypeError: # no consistent MRO exists
rejected += 1
for cls in classes:
obj = cls()
ok &= obj.send("hi") == dispatch_by_hand(obj, "send", "hi")
built += 1
print(ok, built, rejected) # True 9579 1507
Python refused 1,507 of the random class definitions outright, because their bases could not be put in a consistent order; for the 9,579 it built, the language's dispatch and the hand-written walk agreed every time.
The complexity
- A method call costs one lookup along the method resolution order. CPython caches attribute lookups per type, so in practice it is close to a dictionary read.
- Adding a type: one new class,
O(1)edits to existing code. Theif/elifalternative is one edit per function that switches on the kind. - Construction check: an ABC checks for missing abstract methods when the object is created.
Where it goes wrong
- Type checks in the caller.
isinstance(c, Sms)inside the loop undoes the whole point. - Contracts kept by name only. A
sendthat silently does nothing honours the signature and breaks the meaning. - Fat interfaces. One interface with ten methods forces every implementer to fake the ones it does not need; split it.
- Inheritance for reuse. Sharing code through a base class couples types; composition is usually better.
boolis anint.describe(True)returns "an int", because dispatch follows the class hierarchy.
When it shows up in interviews
As "what is polymorphism?" or "abstract class vs interface?" in object-oriented rounds, and implicitly in every low-level design problem: a parking lot's vehicle types, a file system's files and folders, and patterns like strategy and factory all rest on it.
How to say it in an interview
"Polymorphism means the caller depends on a contract, a method name with agreed behaviour, and each type implements it its own way, so one line like c.send(msg) drives every channel. At runtime Python looks up send on the object's type along its method resolution order and calls the first match. In Python I'd state the contract as an abstract base class, so a class missing the method can't even be constructed, or as a Protocol if I want structural matching. Adding a channel is then one new class, and the fan-out loop never changes."