Skip to main content

ABCs & Protocols

Level 4: Level 4: Engineeringhard20 minabcprotocolsinterfacespolymorphism

Define interfaces with abc and Protocol, then program to the abstraction.

Programming to an interface

Why interfaces matter

When a function says "give me any Shape", it should not care whether the object is a Rectangle, a Circle, or a type a coworker adds next month. You write a consumer like total_area once against the abstraction area(), and every conforming type flows through it unchanged. That is the point of an interface: you add new types without editing the code that consumes them, instead of stacking if isinstance(...) branches. Interviewers use this to check whether you design for extension. Python gives you two tools here, and they differ in how a type "counts" as conforming.

Abstract base classes: conform by inheritance

An abstract base class declares the methods a subclass must provide. Decorate them with @abstractmethod and the base becomes non-instantiable until every abstract method is overridden:

from abc import ABC, abstractmethod

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

class Rectangle(Shape):
    def __init__(self, width, height):
        self.width, self.height = width, height

    def area(self):
        return self.width * self.height
Check yourself
A teammate writes class Circle(Shape) but forgets to define area(). When does Python complain?

Shape() raises TypeError because you cannot instantiate a class that still has unimplemented abstract methods. Rectangle(3, 4) works and .area() returns 12, as the demo below shows. This is nominal typing: Rectangle conforms because it explicitly declares class Rectangle(Shape). Reach for an ABC when you own the hierarchy and want to share concrete helper code on the base or force implementers to fill in the blanks.

Protocols: conform by shape

A Protocol flips the rule. Any object with a matching area() method qualifies, with no inheritance and no import of your type:

from typing import Protocol

class HasArea(Protocol):
    def area(self) -> float: ...

def total_area(shapes: list[HasArea]) -> float:
    return sum(s.area() for s in shapes)

This is structural (duck) typing made checkable. A third-party Circle you cannot subclass still passes total_area as long as it has an area() method. Reach for a Protocol to accept things that already fit the shape, especially across library boundaries you do not control.

Table
One question decides it: do you control the classes that must conform? If yes, an ABC also buys you shared code. If they come from a library you do not own, only a Protocol can describe them.
QuestionABC (abc.ABC)Protocol (typing.Protocol)
How does a class conform?By inheriting: class Rectangle(Shape)By having the right methods, with no inheritance
Typing styleNominal: conformance is declaredStructural: conformance is observed
Can it accept a third-party class?Only if you can edit it to subclassYes, it never needs to know your type exists
Enforced when?At instantiation: TypeError on a missing methodAt type-check time; runtime needs @runtime_checkable
Can it ship shared code?Yes, concrete helpers live on the baseNo, it is a shape description only
Reach for it whenYou own the hierarchy and want shared behaviourYou are accepting things that already fit
One question decides it: do you control the classes that must conform? If yes, an ABC also buys you shared code. If they come from a library you do not own, only a Protocol can describe them.
Check yourself

Sort each situation into the tool that fits it.

You own every implementer and want to ship a concrete helper method they all inherit
You want to accept a class from a library you cannot edit, as long as it has the right method
You want the interpreter itself to refuse a half-finished implementation
You want mypy to verify that two unrelated classes both satisfy one contract

That last row of the table is the one people trip over, because "checked by a type checker" is easy to read as "checked, eventually, somehow":

Check yourself
You want a runtime guard, so you write: if isinstance(circle, HasArea). The Circle instance does define area(). What happens?

Pitfalls

  • A subclass that forgets even one abstract method stays abstract itself. Remove area from Rectangle and Rectangle(3, 4) raises TypeError. The failure comes at construction time, not when the class is defined, so a broken subclass can look fine until someone builds one.
  • A Protocol is not enforced at runtime by default. isinstance(obj, HasArea) raises TypeError unless you decorate the protocol with @runtime_checkable, and even then the check only confirms the method name exists, never its signature or return type.

Interview nuance: ABCs use nominal subtyping (you conform by declaring the parent) while Protocols use structural subtyping (you conform by having the right members). Protocol conformance is verified by a static type checker such as mypy or pyright, not by the interpreter. At runtime total_area runs on anything with an area() method regardless of annotations, because Python is already duck-typed. The Protocol does not add a runtime gate; it makes the contract explicit and machine-checkable before you ship.

Check yourself
Three things go wrong in one afternoon. Which one does the interpreter itself reject, with no type checker involved?
Worked example (Python)
from abc import ABC, abstractmethod


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


class Rectangle(Shape):
    def __init__(self, width, height):
        self.width, self.height = width, height

    def area(self):
        return self.width * self.height


print(Rectangle(3, 4).area())   # 12

Apply

Your turn

The task this lesson builds to.

Warm-up (one file): implement a Rectangle class with __init__(self, width, height) and an area() method returning width * height. The provided run driver builds one and returns its area.

run(3, 4) is 12.

2 hints and 3 automated checks are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, so it survives past today.

Implement Rectangle(Shape) in shapes/rectangle.py: subclass the abstract Shape, store width and height, and return width * height from area(). It must work polymorphically with total_area. Some tests are hidden.

3 hints and 2 automated checks are waiting in the workspace.