In Python, a protocol specifies the methods and attributes that a class must implement to be considered of a given type. Protocols are important in Python’s type hint system, which allows for static type checking through external tools, such as mypy, Pyright, and Pyre.
Before there were protocols, these tools could only check for nominal subtyping based on inheritance. There was no way to check for structural subtyping, which relies 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.
In this tutorial, you’ll:
- Gain clarity around the use of the term protocol in Python
- Learn how type hints facilitate static type checking
- Learn how protocols allow static duck typing
- Create custom protocols with the
Protocolclass - Understand the differences between protocols and abstract base classes
To get the most out of this tutorial, you’ll need to know the basics of object-oriented programming in Python, including concepts such as classes and inheritance. You should also know about type checking and duck typing in Python.
Get Your Code: Click here to download the runnable examples that show you how to leverage structural subtyping with Python protocols.
Take the Quiz: Test your knowledge with our interactive “Python Protocols: Leveraging Structural Subtyping” quiz. You’ll receive a score upon completion to help you track your learning progress:
Interactive Quiz
Python Protocols: Leveraging Structural SubtypingTake this quiz to test your understanding of how to create and use Python protocols while providing type hints for your functions, variables, classes, and methods.
Object-Oriented Python Live Course: Want to practice designing classes that work naturally with Python’s protocols? In Real Python’s Object-Oriented Python live course, you build a complete simulator with an instructor, from Python’s data model to choosing between inheritance and composition. Join the waitlist to hear about the next dates.
The Meaning of “Protocol” in Python
During Python’s evolution, the term protocol became overloaded with two subtly different meanings. The first meaning refers to internal protocols, such as the iterator, context manager, and descriptor protocols.
These protocols are widely understood in the community and consist of special methods that make up a given protocol. For example, the .__iter__() and .__next__() methods define the iterator protocol.
Python 3.8 introduced a second, slightly different type of protocol. These protocols specify the methods and attributes that a class must implement to be considered of a given type. So, these protocols also have to do with a class’s internal structure.
With this kind of protocol, you can define interchangeable classes as long as they share a common internal structure. This feature allows you to enforce a relationship between types or classes without the burden of inheritance. This relationship is known as structural subtyping or static duck typing.
In this tutorial, you’ll focus on this second meaning of the term protocol. First, you’ll have a look at how Python manages types.
Dynamic and Static Typing in Python
Python is a dynamically typed language, which means that the Python interpreter checks an object’s type when the code runs. It also means that while a variable can only reference one object at a time, the type of that object can change during the variable’s lifetime.
For example, you can have a variable that starts as a string and changes into an integer number:
>>> value = "One hundred"
>>> value
'One hundred'
>>> value = 100
>>> value
100
In this example, you have a variable that starts as a string. Later in your code, you change the variable’s value to an integer.
Because of its dynamic nature, Python has embraced a flexible typing system that’s known as duck typing.
Duck Typing
Duck typing is a type system in which an object is considered compatible with a given type if it has all the methods and attributes that the type requires. This typing system supports the ability to use objects of independent and decoupled classes in a specific context as long as they adhere to some common interface.
Note: To dive deeper into duck typing, check out the Duck Typing in Python: Writing Flexible and Decoupled Code tutorial.
As an example of duck typing, you can consider built-in container data types, such as lists, tuples, strings, dictionaries, and sets. All of these data types support iteration:
>>> numbers = [1, 2, 3]
>>> person = ("Jane", 25, "Python Dev")
>>> letters = "abc"
>>> ordinals = {"one": "first", "two": "second", "three": "third"}
>>> even_digits = {2, 4, 6, 8}
>>> containers = [numbers, person, letters, ordinals, even_digits]
>>> for container in containers:
... for element in container:
... print(element, end=" ")
... print()
...
1 2 3
Jane 25 Python Dev
a b c
one two three
8 2 4 6
In this code snippet, you define a few variables using different built-in types. Then, you start a for loop over the collections and iterate over each of them to print their elements to the screen. Even though the built-in types are significantly different from one another, they all support iteration.
The duck typing system allows you to create code that can work with different objects, provided that they share a common interface. This system allows you to set relationships between classes that don’t rely on inheritance, which produces flexible and decoupled code.
Type Hints and Type Checking
Even though Python is a dynamically typed language that widely relies on duck typing, not knowing the type of arguments, return values, and attributes can be a source of errors. This is especially true in large codebases where functions and classes are spread through several modules and packages.
To overcome the potential issues, Python 3.5 introduced type hints or optional static typing. Type hints let you optionally specify types of arguments, return values, and attributes in your functions and classes. Then, you can check these types with a static type checker, such as mypy, and get useful information that can help you debug and improve your code, making it more robust.
Here’s a quick overview of some great benefits of using type hints in your code:
- Improved code readability: You can explicitly state the type of arguments, return values, and attributes, which enhances code readability for developers, making the interface of functions and classes easier to understand.
- Support for static type checking: You can provide type hints in your code and then use tools like mypy, Pyright, and Pyre to perform static type checking. These tools will allow you to find type inconsistencies and mismatches early in the development process so that you can fix them and make your code more robust.
- Better development environments: You can use type hints and take advantage of a modern code editor or IDE’s enhanced code completion, function signature information, and inline documentation, which improve your life quality as a Python developer. These features can also speed up development and reduce bugs caused by incorrect type usage.
- Aids in refactoring: You can make refactors that involve changing the type of arguments, return values, and attributes of a given function or class. In this situation, the type checkers will help you find all the parts of the code that you must update to align with the new definitions.
- Improved documentation: You can take advantage of type hints when writing your code’s documentation with tools like MkDocs. This way, the type information in the documentation will stay in sync with the code.
Python doesn’t do anything with type hints at runtime. They are stored in an .__annotations__ attribute and otherwise ignored. So, apart from the improved code readability, you need external tools like a static type checker or a modern code editor or IDE to enjoy these benefits.
Note: To learn more about type checking in Python, check out the Python Type Checking (Guide) tutorial.
As an example of using type hints, consider the following function that adds two numbers:
calculations.py
1from typing import Union
2
3def add(a: Union[float, int], b: Union[float, int]) -> float:
4 return float(a + b)
5
6print(add(2, 4))
7
8print(add("2", "4"))
The typing module plays a crucial role in defining type hints. It defines classes like Union that you can use to specify multiple possible types of variables, function arguments, and return values. In this example, you add type hints for the function’s arguments, a and b, and for the return value.
The arguments to add() can be floating-point or integer numbers. To set up these type hints, you use the Union class with the float and int types. Alternatively, you can use the pipe operator to express the same type hint. So, for example, you can write something like a: float | int.
Then, you use the arrow syntax (-> float) to state that the function’s return value will be a floating-point number. Finally, you have a couple of calls to add(). Here’s what you get when you run this script:
$ python calculations.py
6.0
24.0
The first result is correct because you used two numbers as arguments. Unfortunately, the second result is incorrect, but your code didn’t break. It ran without apparent problems. How would you avoid this type of issue? You can run some static type checking in your code.
To perform static type checking on the above code, you need an external tool. For example, you can use mypy, but first, you need to install it with the following command:
$ python -m pip install mypy==2.4.0
This command will install the mypy static type checker from PyPI. Now you can run the following command against your calculations.py file:
$ mypy calculations.py
calculations.py:8: error: Argument 1 to "add" has incompatible type "str";
expected "float | int" [arg-type]
calculations.py:8: error: Argument 2 to "add" has incompatible type "str";
expected "float | int" [arg-type]
Found 2 errors in 1 file (checked 1 source file)
With this command, you run a static type analysis on your code. As a result, you learn that your code has two errors in line 8. Those errors warn you about type inconsistencies in your code. With this information, you can go to your code and fix the inconsistencies so that the code works correctly.
Duck Typing vs Type Hints
As you’ve learned, duck typing allows for flexible and dynamic code. With duck typing, you can use different and unrelated objects in a given context if those objects have the expected methods and attributes. You don’t have to ensure that the objects share a common parent type through inheritance.
When you add type hints to a piece of code that relies on duck typing, you can encounter some challenges. Here’s a toy example that illustrates how type hints and duck typing can collide:
birds_v1.py
1class Duck:
2 def quack(self):
3 return "The duck is quacking!"
4
5def make_it_quack(duck: Duck) -> str:
6 return duck.quack()
7
8class Person:
9 def quack(self):
10 return "The person is imitating a duck quacking!"
11
12print(make_it_quack(Duck()))
13
14print(make_it_quack(Person()))
In this code, you define a Duck class with a .quack() method. Then, you define the make_it_quack() function that takes an argument of type Duck. Next, you create a Person class to use in a duck typing context. In this example, the calls to print() will work. However, in the second call, you have a type inconsistency because an instance of Person isn’t of type Duck.
Here’s what you get when you run mypy on this file:
$ mypy birds_v1.py
birds_v1.py:14: error: Argument 1 to "make_it_quack" has incompatible type
"Person"; expected "Duck" [arg-type]
Found 1 error in 1 file (checked 1 source file)
The type checker displays an error telling you that the call to print() in line 14 is using an object with an incompatible type.
One way you can fix this issue is to use inheritance:
birds_v2.py
class QuackingThing:
def quack(self):
raise NotImplementedError(
"Subclasses must implement this method"
)
class Duck(QuackingThing):
def quack(self):
return "The duck is quacking!"
class Person(QuackingThing):
def quack(self):
return "The person is imitating a duck quacking!"
def make_it_quack(duck: QuackingThing) -> str:
return duck.quack()
print(make_it_quack(Duck()))
print(make_it_quack(Person()))
In this new implementation, you create a new class called QuackingThing and use it as the parent class for Duck and Person. Now, the type checker passes:
$ mypy birds_v2.py
Success: no issues found in 1 source file
The type checker’s output is clean. However, you traded duck typing for inheritance. Now, your classes are tightly coupled, even if they don’t have a clear inheritance relationship. How can you fix this collision between duck typing and type hints? This is where protocols come into the scene.
Structural Subtyping and Protocols
In Python’s type system, you’ll find two ways to decide whether two objects are compatible as types:
- Nominal subtyping is strictly based on inheritance. A class that inherits from a parent class is a subtype of its parent.
- Structural subtyping is based on the internal structure of classes. Two classes with the same methods and attributes are structural subtypes of one another.
To understand the first subtyping strategy, say you have a class hierarchy to represent animals:
animals_v1.py
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.")