ABCs & Protocols
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
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.
| Question | ABC (abc.ABC) | Protocol (typing.Protocol) |
|---|---|---|
| How does a class conform? | By inheriting: class Rectangle(Shape) | By having the right methods, with no inheritance |
| Typing style | Nominal: conformance is declared | Structural: conformance is observed |
| Can it accept a third-party class? | Only if you can edit it to subclass | Yes, it never needs to know your type exists |
| Enforced when? | At instantiation: TypeError on a missing method | At type-check time; runtime needs @runtime_checkable |
| Can it ship shared code? | Yes, concrete helpers live on the base | No, it is a shape description only |
| Reach for it when | You own the hierarchy and want shared behaviour | You are accepting things that already fit |
Sort each situation into the tool that fits it.
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":
Pitfalls
- A subclass that forgets even one abstract method stays abstract itself. Remove
areafromRectangleandRectangle(3, 4)raisesTypeError. The failure comes at construction time, not when the class is defined, so a broken subclass can look fine until someone builds one. - A
Protocolis not enforced at runtime by default.isinstance(obj, HasArea)raisesTypeErrorunless 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.
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()) # 12Apply
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.