65.5 Protocol: Structural Subtyping
Right, let’s talk about Protocol. This is where we stop politely asking our classes to inherit from a common ancestor and start telling them, “I don’t care who your parents are; if you can do this job, you’re hired.”
This is called structural subtyping, or “duck typing” for type-checkers. The classic line is, “If it walks like a duck and it quacks like a duck, then it must be a duck.” With Protocol, we define what “walking” and “quacking” mean. If an object has those methods with the right signatures, it is the duck we’re looking for, regardless of its class hierarchy. This is the antithesis of nominal typing, where you must explicitly inherit from a specific class or abstract base class (ABC) to be considered a subtype.
Why Bother? The Limits of Nominal Typing
Imagine you have a function that needs to call .render() on an object. With nominal typing, you’d do this:
from abc import ABC, abstractmethod
class Renderable(ABC):
@abstractmethod
def render(self) -> str:
...
def draw(obj: Renderable) -> None:
print(obj.render())
This works, but it forces every single object you want to pass to draw to inherit from Renderable. What if it’s a class from a third-party library you can’t modify? Tough luck. You’re now stuck writing a cumbersome wrapper class. This is a failure of imagination. Protocol fixes this by focusing on the structure—the interface—rather than the name.
Defining a Protocol
You define a protocol by creating a class that inherits from typing.Protocol. The methods and attributes you define in the body are what matter.
from typing import Protocol
class Renderable(Protocol):
def render(self) -> str:
...
# This class has *no* inheritance relationship with Renderable.
# It doesn't know the protocol exists. It just happens to implement it.
class HTMLTag:
def render(self) -> str:
return "<div>Hello</div>"
# This also works. The protocol only cares about the method signature.
class CustomComponent:
def render(self, pretty: bool = True) -> str:
return "<div>Hello</div>" if pretty else "<div>Hello</div>"
# Our function now accepts any object that structurally matches the Protocol.
def draw(obj: Renderable) -> None:
print(obj.render())
# Type checker says: "Yep, HTMLTag has a .render() method that returns a str. Good enough for me!"
draw(HTMLTag()) # OK
draw(CustomComponent()) # Also OK, because the parameter has a default value.
See what happened? HTMLTag is completely unaware of the Renderable protocol, but the type checker sees that it has the required render method and lets it through. This is incredibly powerful for creating flexible, decoupled APIs.
The Devil’s in the Details: Method Signatures
Notice that CustomComponent worked because its render method had a default parameter. The type checker is smart enough to know you can call obj.render() without arguments, which satisfies the protocol’s requirement. If your protocol method has a parameter without a default, the implementing class’s method must match exactly. Get this wrong, and it’s the kind of subtle bug that will have you questioning your life choices at 2 AM.
class BadRenderable(Protocol):
def render(self, pretty: bool) -> str: # No default value!
...
class HTMLTag:
def render(self) -> str: # TypeError! This doesn't accept a `pretty` argument.
return "<div>Hello</div>"
Attributes and @runtime_checkable
Protocols aren’t just about methods; they can require attributes too.
class Named(Protocol):
name: str
def greet(self) -> str:
return f"Hello, {self.name}"
class Person:
def __init__(self, name: str):
self.name = name # This satisfies the Named protocol.
def introduce(obj: Named) -> None:
print(f"This is {obj.name}")
introduce(Person("Alice")) # OK
Now, here’s the kicker: by default, this is all just for static type checkers. The Python interpreter itself doesn’t enforce it at runtime. If you want to do a runtime check (which I generally advise against, as it defeats the purpose of static typing, but sometimes you need it), you can use the @runtime_checkable decorator. It’s a clever hack that uses hasattr() checks.
from typing import runtime_checkable
@runtime_checkable
class Named(Protocol):
name: str
class Person:
def __init__(self, name: str):
self.name = name
class Impostor:
pass
# Static type checker will flag this, but at runtime...
p = Person("Alice")
i = Impostor()
print(isinstance(p, Named)) # True! It has a 'name' attribute.
print(isinstance(i, Named)) # False! It does not.
Use this sparingly. It’s a blunt instrument that only checks for the existence of attributes and methods, not their types or signatures.
Best Practices and Pitfalls
Be Specific, Not Greedy: The power of protocols is also their danger. Don’t define a massive protocol with 20 methods if your function only needs to call one. This forces implementers to add a bunch of useless methods just to satisfy the protocol, which is the very problem protocols were designed to solve. Define the minimal interface required.
Protocols vs ABCs: Use an Abstract Base Class when you want to provide some common implementation or when you genuinely want to enforce a strict “is-a” relationship. Use a Protocol when you care only about the interface for the purposes of type safety.
Readability: It can sometimes be harder to figure out what protocol an object is supposed to fulfill compared to seeing an explicit inheritance. Good documentation is key.
In essence, Protocol is your tool for describing roles, not lineages. It lets you write code that says, “I need something that can do X,” instead of, “I need a specific thing from a specific family.” It’s the difference between hiring based on a resume of skills versus hiring based on which school someone attended. The former is almost always a better strategy.