Understanding Protocols in Python
Python is a dynamically type general-purpose programming language. Python uses dynamic typing, means it checks variable type during runtime while running the program. Protocols as introduced in Python 3.8 (PEP 544) offers a way for Python to becomes more dynamic in nature. Protocol is a class that has a set of methods and attributes. Any class that has the required methods implemented and attributes defined is considered a subtype of that protocol class. They provides a way to have structural subtyping or static duck typing in Python.
Python provides more a flexible approach for interface definition using protocol. We can create a protocol class by sub classing from typing.Protocol
and specifying its methods and attributes. Any class that will have the same methods and attributes will be considered as subtype of that protocol class.
from typing import Protocol
class Writable(Protocol):
def write(self):
...
Here, “Writable” is a protocol which has a “write” method. Any class that has a “write” method will be considered an “Writable” type while checking the type.
class Logger:
def write(self):
print("Write to the logger.")
class File:
def write(self):
print("Write to the file.")
“Logger” and “File” both classes implemented the “write” method which is the requirement for “Writable” protocol. So we can treat them as “Writable” type.
def write_all(writter: Writable):
writter.write()
write_all(Logger())
write_all(File())
# Write to the logger.
# Write to the file.
In “write_all” function we are taking a “Writable” parameter. As “Logger” and “File” both have the write method they works as a “Writable”.
Protocol is also inheritable and we can create new protocol by inhariting an existing one. In the example below, “ReadableWritable” class inherits from “Writable” protocol. Now any class that has “read” and “write” method will be considered as “ReadableWritable” type.
from typing import Protocol
class ReadableWritable(Writable, Protocol):
def read(self):
...
class File:
def write(self):
print("Write to the file.")
def read(self):
print("Read from the file.")
We can also type hint the method parameters of a protocol to leverage more strict type checking process.
from typing import Protocol
class Adder(Protocol):
def add(self, a: int, b: int) -> int:
...
class IntAdder:
def add(self, a: int, b: int) -> int:
return a + b
class FloatAdder:
def add(self, a: float, b: float) -> float:
return a + b
Here, “Adder” uses explicit type hint thus the class “IntAdder” will be considered as “Adder” type by the typing system. But “FloatAdder: will not be considered a “Adder” type although it implements the “add” method. It is because “FloatAdder.add” method parameter has different type.
To fix the problem, we need to create a generic protocol. The systax for creating a generic protocol is,
from typing import Protocol, TypeVar
T = TypeVar("T")
class Generic(Protocol[T]):
def method(self, arg: T) -> T:
...
So, to make the “FloatAdder” a subtype of “Adder” protocol we need to rewrite it using generic protocol.
from typing import Protocol, TypeVar
T = TypeVar("T")
class Adder(Protocol[T]):
def add(self, a: T, b: T) -> T:
...
Now that we have generic “Adder” protocol we can use both “IntAdder” and “FloatAdder” as “Adder” type.
def add(adder: Adder) -> None:
print(adder.add(2, 3))
add(IntAdder())
add(FloatAdder())
From Python 3.12 and onwards, we can have a more simplified version of generic protocol.
from typing import Protocol
class Adder(Protocol):
def add[T: int | float](self, x: T, y: T) -> T:
...
A protocol class can have different types of members including, class attributes, class methods, instance attributes, instance methods, class methods, static methods, properties and abstruct methods. We should use ClassVar
class to distinguish between class attributes and the instance attributes. We need to define the instance attributes at the class level. If we define them inside the instance method type checker will through an error.
from abc import abstractmethod
from typing import ClassVar, Protocol
class ProtocolMembersDemo(Protocol):
class_attribute: ClassVar[int]
instance_attribute: str = ""
def instance_method(self, arg: int) -> int:
...
@classmethod
def class_method(cls) -> str:
...
@staticmethod
def static_method(arg: int) -> int:
...
@property
def property_name(self) -> str:
...
@property_name.setter
def property_name(self, value: str) -> str:
...
@abstractmethod
def abstract_method(self) -> float:
...
Till now we have discussed Protocols from typing perspective. But the word “Protocol” in not new in Python. Python has lots internal protocols like Iterator, contex Manager, Descriptor protocol and many more.
These built-in protocols consists of special methods that make up the given protocol. Like, __iter__()
and __next__()
methods defines iterator protocol, __hash__()
method defines Hashable protocol, __call__()
method defines Callable protocol and many more.
Those protocols live mostly in the collections.abc
module because they are implemented as abstruct base class. ABC protocols offers nominal subtyping through inheritance. A class that inherits from parent classs is considered a subtype of the parent class
There is a difference between abstruct base class protocols and typing protocols. ABC protocols works through inheritance relationship while typing protocols does not require inheritance they works by defining a set of characteristics.
Note that this differences doesn’t make ABCs better than typing protocols and vice versa. ABCs are suitable when there is hierarchy in the classes and protocols are suitable to handle totally different kinds of class that has common behavior.
Following is an example of inheritance based nominal subtyping in Python,
from abc import ABC, abstractmethod
class Animal(ABC):
@abstractmethod
def eat(self) -> None:
...
class Dog(Animal):
def __init__(self, name: str):
self.name = name
def eat(self) -> None:
print(f"Dog {self.name} is eating.")
class Cat(ABC):
def __init__(self, name: str):
self.name = name
def eat(self) -> None:
print(f"Cat {self.name} is eating.")
def time_to_eat(animal: Animal):
animal.eat()
time_to_eat(Dog("Milo"))
time_to_eat(Cat("Tobo"))
# Dog Milo is eating.
# Cat Tobo is eating.
Conclusion
Python has two kinds of protocols, ABCs and typing.Protocol. We can use both of them for type checking. ABCs allows us to create an inheritance based interface while typing.Protocol allows us to create relationship based interface. With both of the Protocols, we can perform static duck typing in Python’s type hint system. We can also use external tools like mypy, Pyright and Pyre to check proper typing before runtime.