Protocols: Structural vs Dynamic types in Python
Since version 3.8, Python has had access to Protocols, offering a mechanism to implement what the proposal (PEP544) terms Static Duck Typing. Before delving into an example, it’s essential to clarify that Static Duck typing is a fusion of Static and Duck typing principles.
Static typing involves understanding a type by its structure. For instance, in a scenario where a class named Color possesses a method that prints the RGB value, for the color Red to be deemed a valid color, it must inherit from the Color class. The compiler resolves this during compile time, although various static analysis tools exist to alert users about any potential issues.
Conversely, Duck typing involves interpreting a type by its behavior. Using the Color example, imagine a new color, Blue, with a method that also prints the RGB value, featuring the same name and arguments but not extending the Color class. Under a Duck typing strategy, Blue qualifies as a valid Color since it behaves in line with the expectations set by our Color class. This evaluation occurs at runtime, making it impossible to prevent potential errors by invoking code that might not exist.
By merging these concepts, Static Duck typing emerges as a method to apply types during compile time while verifying behavior at runtime. To comprehend how this works, let’s explore an example
Code example
Consider an application responsible for managing parking sessions within a car park. A segment of this application concentrates on handling user payments. To enhance its flexibility, the design employs the Strategy pattern, complemented by the integration of Protocols.
def process_booking(bookable: Bookable):
bookable.handle()
process_booking
accepts a Bookable
instance as an argument and invokes its handle
method. Let’s take a look at the Bookable
protocol.
class Bookable(Protocol):
def handle(self) -> None:
raise NotImplementedError
def _take_payment(self, amount: int) -> None:
print(f"taking payment of ${amount}")
print("payment taken successfully")
Bookable
consists of an abstract method and a default method. The abstract method must be implemented in whatever uses this Protocol; otherwise, an exception is raised. The default method is a part of our payment processing that should remain unchanged. We always want to take payments in our strategy.
Let’s review some examples that extend theBookable
protocol.
class PreBook(Bookable):
def __init__(self, amount) -> None:
self._amount = amount
def handle(self) -> None:
print("This is a prebook booking")
self._take_payment(self._amount)
print("Booking placed successfully")
class PayOnArrival(Bookable):
def __init__(self, amount) -> None:
self._amount = amount
def handle(self) -> None:
print("This is a pay on arrival booking")
self._take_payment(self._amount)
print("Booking successful. Enjoy your stay.")
Each of the classes extends the protocol, meaning thehandle
method needs to be implemented for the class to be considered a valid Bookable
When used in combination with our processing code, we can see that the code works as expected.
strategy = PreBook(amount=19)
process_booking(strategy)
"""
The above prints the below:
This is a prebook booking
taking payment of $19
payment taken successfully
Booking placed successfully
"""
The Strategy pattern allows for differences in the approach to routine processing. Our application needs to introduce a new method, Pay on Exit, in addition to Pre-booking and Pay on Arrival, to process payments after the driver has parked. How do we adjust our code to accommodate this?
class PayOnExit(Bookable):
def __init__(self, amount: int, time: int) -> None:
self._amount = amount
self._time = time
def handle(self) -> None:
print("This is a pay on exit booking")
self._take_payment_on_exit(amount=self._amount, time=self._time)
print("Thanks for parking with us!")
def _take_payment_on_exit(self, amount: int, time: int) -> None:
print(f"Taking payment for {time} hour(s).")
self._take_payment(amount)
Fortunately, we don’t have to change anything; we simply create our class. We require a new method to take payment, as our context has changed. As long as the class has the handle
method, everything works fine.
strategy = PayOnExit(amount=19, time=2)
process_booking(strategy)
"""
The above prints the below:
This is a pay on exit booking
Taking payment for 2 hour(s).
taking payment of $19
payment taken successfully
Thanks for parking with us!
"""
This mirrors the functionality of an ABC (Abstract Base Class) if you are familiar. However, methods specifying protocols in type hints possess a unique ability to handle objects that do not extend the said protocol.
We have our strategies in place, but we want to test if our code is working. Instead of creating fake accounts and other circumventions for our payment processing method, we can create a new class that matches the structure of our other strategies but does not extend Bookable
class TestBooking:
def handle(self):
print("Hello, world. This is a test")
def _take_payment(self, amount: int) -> None:
...
The handle
method needs to be present, as does the _take_payment
method. However, the protocol does not need to be implemented
strategy = TestBooking()
process_booking(strategy)
"""
The above prints the below:
Hello, world. This is a test
"""
Despite our new test class not extending Bookable
, it can still be used as if it had done so. This showcases duck typing in action. But where does the static part come into play? By installing a static analysis tool like MyPy, we can trigger a warning in our process_booking
function.
class TestBooking:
def handle(self):
print("Hello, world. This is a test")
# def _take_payment(self, amount: int) -> None:
# ...
When we comment out the _take_payment
method, our TestBooking
class is no longer a valid Bookable
.
The warning here is explicit — the class is missing the method, failing to meet the criteria as a valid argument for our method. However, the code will still run.
strategy = TestBooking()
process_booking(strategy)
"""
The above prints the below:
Hello, world. This is a test
"""
This is as expected. Protocols are not interfaces; they describe how a class behaves, enabling static analysis for developers. They prove an immensely useful mechanism, maintaining Python’s dynamism while offering structural analysis of our code before execution.