Skip to content
BytePatterns

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 send method, the caller can call it. Flexible, and the contract lives only in documentation.
  • Abstract base classes (abc.ABC with @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_checkable lets isinstance check 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. The if/elif alternative 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 send that 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.
  • bool is an int. 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."