See Typing for more on typing in Python, as in this one we will be discussing duck typing a lot.
In Python, a protocol specifies the methods and attributes that a class must implement to be considered of a given a type.
Before there were protocols, type checking tools could only check for nominal subtyping - i.e. checking whether any value of the subtype can be used wherever a value of the related supertype is expected - this was based on inheritance. There was no way to check for structural subtyping, which relied on the internal structure of classes. This limitation affected Python’s duck typing system, which allows you to use objects without considering their nominal types. Protocols overcome this limitation, making static duck typing possible.
Nominal and Structural subtyping
Nominal subtyping means that compatbility is name-based and is determined strictly by explicit declarations and type names. Ex. type B is a subtype of type A only if B explicitly declares that it inherits from or implements A. Even if two classes have the exact same fields and methods, they are incompatible if they don’t share an explicit inheritance hierarchy. Ex. Rust, C++, etc.
An example of this in Python:
class Animal:
def __init__(self, name):
self.name = name
def eat(self):
print(f"{self.name} is eating.")
def drink(self):
print(f"{self.name} is drinking.")
class Dog(Animal):
def bark(self):
print(f"{self.name} is barking.")
class Cat(Animal):
def meow(self):
print(f"{self.name} is meowing.")Note how this explicitly uses inheritance.
Structural subtyping means that compatbility is determined entirely by the shape, members and structure of the type. Ex. type B is a subtype of type A if B contains at least all members (methods/fields) that A requires, with compatible signatures. Ex. Python (via Protocols), Go (via interfaces), TypeScript. This is also often called static duck typing.
We will see an example of it below under [#Protocols], but a naive example is given here:
class Dog:
def __init__(self, name):
self.name = name
def eat(self):
print(f"{self.name} is eating.")
def drink(self):
print(f"{self.name} is drinking.")
def make_sound(self):
print(f"{self.name} is barking.")
class Cat:
def __init__(self, name):
self.name = name
def eat(self):
print(f"{self.name} is eating.")
def drink(self):
print(f"{self.name} is drinking.")
def make_sound(self):
print(f"{self.name} is meowing.")Note how there is no strict inheritance relationship between the two. They’re completely decoupled and independent classes - but they implement the same “public interface” - i.e. they have the same internal structure. Because of this, they are structural subtypes of something like “Living” protocol, for example.
Duck typing
Duck typing is a type system that helps you write decoupled and flexible code. This is explained more in Typing.
A quick example of duck typing: lits, tuples, dictionaries, sets - all are builtin container data types, and all support iteration.
Protocols
The typing module defines a base class called Protocol that you can use to create custom protocols. This is a formal way to support structural subtyping in the type system.
example:
from typing import Protocol
class Adder(Protocol):
def add(self, x: int | float, y: int | float) -> int | float:
...
class FloatAdder:
def add(self, x: float, y: float) -> float:
return x + y
def add(adder: Adder) -> None:
print(adder.add(2, 3))
add(FloatAdder())If you create an IntAdder here which takes ints and returns an int - it will CRASH because the protocol says that adder.add(2.5, 9.6) is completely fine -but it’s not fine with the int-only adder. Thus the type checker will flag it incompatible with the Adder protocol.
In type theory, method arguments are contravariant, which means that an implementation can accept wider types that the protocol demands, but never narrower. Here, the protocol demands x: int | float which is wide, and the implementation accepts int, which is a narrower type than the protocol demands. So it fails.
Refer to generic protocols on how to “solve” this.
Generic protocols
Up until very recently code like this was common:
from typing import Protocol, TypeVar
T = TypeVar("T")
class GenericProtocol(Protocol[T]):
def method(self, arg: T) -> T:
...but 3.12+ introduced the type parameter syntax in PEP 695, which completely removes the need to import and define TypeVar.
To implement a generic adder here:
from typing import Protocol
class Adder[Type: (int, float)](Protocol):
def add(self, x: Type, y: Type) -> Type:
... # this is an ellipsis object - you cannot use pass here
class IntAdder:
def add(self, x: int, y: int) -> int:
return x + y
class FloatAdder:
def add(self, x: float, y: float) -> float:
return x + y
def print_add[Type: (int, float)](adder: Adder[Type], num1: Type, num2: Type) -> None:
print(adder.add(num1, num2))
print_add(IntAdder(), 2, 3) # 5
print_add(FloatAdder(), 2.4, 6.7) # 9.1