Skip to Content
Course content

310: The Protocol Class for Structural Typing

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

You've likely spent a lot of time using Abstract Base Classes (ABCs) to define interfaces in Python. It feels like the "correct" way—you define a base class, you mark some methods as @abstractmethod, and you force every subclass to implement them. It gives you a sense of security knowing that if a class inherits from PaymentProcessor, it definitely has a process_payment method. But there's a hidden cost to this approach that usually bites you once your project grows or you start integrating third-party libraries.

The Friction of Nominal Inheritance

The problem with ABCs is that they rely on nominal typing. In plain English: Python checks the name of the class (and its parents) to see if it matches. If it doesn't explicitly inherit from your base class, Python's type checker doesn't care if the class has all the right methods—it still considers it the wrong type.

Imagine you've built a system to handle payments. You might start with something like this:

from abc import ABC, abstractmethod

class PaymentProcessor(ABC):
    @abstractmethod
    def process_payment(self, amount: float) -> bool:
        pass

def execute_transaction(processor: PaymentProcessor, amount: float):
    return processor.process_payment(amount)

class InternalProcessor(PaymentProcessor):
    def process_payment(self, amount: float) -> bool:
        print(f"Processing ${amount} internally...")
        return True

# This works great
execute_transaction(InternalProcessor(), 100.0)

This works perfectly until you decide to use a third-party library, like a hypothetical StripeSDK. The SDK provides a StripeClient class that has a process_payment method, but since the SDK authors didn't know about your PaymentProcessor ABC, StripeClient doesn't inherit from it. Even though the StripeClient provides exactly what execute_transaction needs, Mypy or Pyright will scream at you, telling you that StripeClient is not a PaymentProcessor.

I've seen developers solve this by writing "wrapper" or "adapter" classes—essentially creating a new class just to wrap the SDK client and inherit from the ABC. It's a lot of boilerplate code that exists solely to satisfy the type checker, not to add actual value to the logic.

Static Duck Typing with Protocols

This is where typing.Protocol comes in. Protocols implement structural typing. Instead of asking "Who is your parent?", the type checker asks "What can you do?". If a class has the methods defined in the Protocol, it is considered a subtype of that Protocol, regardless of its inheritance tree. I call this "static duck typing" because it gives you the flexibility of Python's dynamic nature but catches the errors before you ever run the code.

Here is how we refactor the payment example to be decoupled:

from typing import Protocol

class PaymentProcessor(Protocol):
    def process_payment(self, amount: float) -> bool:
        ...

def execute_transaction(processor: PaymentProcessor, amount: float):
    return processor.process_payment(amount)

# This class knows NOTHING about PaymentProcessor
class StripeClient:
    def process_payment(self, amount: float) -> bool:
        print(f"Stripe is processing ${amount}...")
        return True

# This now passes type checking!
execute_transaction(StripeClient(), 250.0)

Notice a few things here. First, we use ... (the ellipsis) instead of pass in the Protocol methods. Second, StripeClient doesn't inherit from PaymentProcessor. It just happens to have the right signature. The coupling is gone.

When to stick with ABCs

You might be wondering if you should just delete all your ABCs. Not necessarily. Protocols are fantastic for defining interfaces for things you don't control (like libraries) or for creating highly flexible plugins. However, ABCs are still useful when you want to provide shared logic. Since a Protocol cannot contain actual implementation code (it only defines the shape), an ABC is still the right tool if you have a bunch of subclasses that should all share a common helper method or a default behavior.

The trade-off is simple: use ABCs when you want to provide a template and shared state; use Protocols when you want to define a contract that any class, anywhere, can satisfy simply by having the right methods.




📋 Practical Task

Implementing a Decoupled File Export System

You are building a system that exports data to various formats. Some exporters are written internally, but others come from an external LegacyExportLib that you cannot modify. Your goal is to create a system where any object with an export(data: str) -> bool method can be used, without forcing them to inherit from a specific base class.

Requirements:

  • Define a Exporter Protocol that requires an export method taking a string and returning a boolean.
  • Create an internal JsonExporter class that implements this method.
  • Assume a class LegacyCSVExporter exists (you can just define it normally without inheriting from anything) that also has an export method.
  • Write a function run_export_pipeline(exporter: Exporter, data: str) that calls the export method.
  • Demonstrate that run_export_pipeline accepts both the JsonExporter and the LegacyCSVExporter.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.