Skip to Content
Course content

132: Protocols and Structural Subtyping

Click on the "Edit" button in the top corner of the screen to edit your slide content.

A few years ago, I was building a data pipeline that needed to support multiple different storage backends—S3, local disk, and a proprietary database. I did what any "proper" engineer would do: I created an Abstract Base Class (ABC) called StorageBackend with a save() method, and I made every backend inherit from it. It worked great until we had to integrate a third-party library for Azure Blob Storage. The library's client had a save() method that matched my signature perfectly, but because it didn't explicitly inherit from my StorageBackend class, my type checker (mypy) started screaming at me. I was stuck choosing between writing a tedious wrapper class for a library I didn't control or just ignoring the type errors.

That's where Protocols come in. They allow us to move from "nominal subtyping" (where a type is defined by its name and inheritance tree) to "structural subtyping" (where a type is defined by what it can actually do). In simpler terms, it's static duck typing.

The Nominal vs. Structural Divide

Up until now, you've likely used nominal typing. If you want a function to accept any Shape, you make sure the objects passed into it inherit from the Shape class. The type checker looks at the "name" of the class in the inheritance chain to decide if it's valid. This is rigid. It requires the author of the class to have known about your base class when they wrote their code.

Structural subtyping, implemented via typing.Protocol, flips this. Instead of saying "This object must be a Shape," you say "This object must have a draw() method that returns None." If the object has that method, it's a match. It doesn't matter where it came from or what its parents are. This is incredibly powerful when you're working with external libraries or want to keep your components decoupled.

Defining the Shape of Your Objects

To use a Protocol, you inherit from typing.Protocol. You define the methods and attributes you expect, but you don't actually implement them. Here is how I would have handled that storage problem differently:

from typing import Protocol

class StorageBackend(Protocol):
    def save(self, data: str, filename: str) -> bool:
        ...

class S3Backend:
    # Notice: No inheritance from StorageBackend!
    def save(self, data: str, filename: str) -> bool:
        print(f"Uploading {filename} to S3")
        return True

class LocalBackend:
    # This also works, even without explicit inheritance
    def save(self, data: str, filename: str) -> bool:
        print(f"Writing {filename} to disk")
        return True

def upload_report(backend: StorageBackend, content: str):
    backend.save(content, "report.txt")

# Both of these are now perfectly valid to the type checker
upload_report(S3Backend(), "Hello S3")
upload_report(LocalBackend(), "Hello Disk")

The ... (ellipsis) is the standard way to indicate that the method has no implementation. My favorite part about this approach is that S3Backend and LocalBackend remain "pure." They don't need to know that StorageBackend exists. They just happen to satisfy the requirements.

When to Prefer Protocols Over ABCs

You might be wondering if ABCs are now obsolete. Not quite. ABCs are still great when you want to provide a base implementation that subclasses can share to avoid code duplication. If you have five classes that all share 80% of the same logic, use an ABC.

However, use a Protocol when:

  • You are defining an interface for classes you don't own (third-party libraries).
  • You want to keep your packages loosely coupled.
  • You care more about the behavior (the methods) than the identity (the class hierarchy).

I've found that as projects grow, leaning toward Protocols usually leads to less friction. It prevents that "inheritance hell" where you find yourself creating deep, fragile hierarchies just to satisfy a type checker.




📋 Practical Task

Implementing a Pluggable Notification System

You are building a system that sends notifications. You want to support different channels (Email, SMS, Slack), but you want the main NotificationManager to be agnostic about who actually sends the message. Some of these senders might come from external SDKs that you cannot modify.

Your task:

  1. Define a Sender Protocol that requires a method send_message(message: str, recipient: str) -> None.
  2. Create a class EmailSender and a class SmsSender. Crucially: Do not make them inherit from the Sender protocol.
  3. Implement the send_message method in both classes so they satisfy the protocol.
  4. Create a function broadcast_notification(sender: Sender, message: str, recipients: list[str]) that loops through the recipients and uses the sender to deliver the message.
  5. Instantiate both senders and use the broadcast_notification function with each of them to verify it works.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.