Python Protocol Classes (and Type Hints)

Table of Contents

TL;DR

  • Type hints (I know you’re here for protocol classes, but please bear with me), a concept that protocol-based type checking builds on, enhance code readability and enable static type checking, but don’t enforce type checks at runtime .
  • Python 3.8 introduced protocol classes, allowing developers to focus on expected behaviors rather than inheritance.
  • Using protocols, objects like Rectangle and Circle can fit the same expectations if they implement all required behaviors.

Type hints in Python

In Python, the following piece of code is a perfectly valid function definition:

def multiply(a, b):
    return a * b

But what if someone tried to call the function with two strings (e.g. "10" and "5") instead of actual integers? πŸ€”

TypeError: can't multiply sequence by non-int of type 'str'

BAM!!! That gave us a TypeError 🫒

How could we make it more clear that this function expects integers as input? This is where type hints come into play:

def multiply(a: int, b: int) -> int:
    return a * b

In a nutshell, type hints allow us to specify the types of a function’s input & output variables. Their purpose is to enhance code readability and enable the use of static type checking tools.

The semantics of type hints were initially laid out in PEP 484, expanding on the syntax for function annotations that was introduced earlier in PEP 3107.

It’s important to note that type hints by themselves don’t perform any type checking. This means that we could still pass strings to the annotated multiply function and get the same TypeError.

Hinting structural subtypes

Though very useful, type hints as introduced by PEP 484 had one important limitation that the Python community couldn’t simply ignore:

Type hints introduced in PEP 484 can be used to specify type metadata for static type checkers and other third party tools. However, PEP 484 only specifies the semantics of nominal subtyping. In this PEP we specify static and runtime semantics of protocol classes that will provide a support for structural subtyping (static duck typing).

– PEP 544

In other words, PEP 544 extends the capabilities of Python’s type hinting system by allowing for the declaration of structural subtypes in addition to nominal ones (originally introduced in PEP 484).

In contrast to nominal subtyping, structural subtyping means that a type is considered a subtype of another type if it has the same properties or behaviors, regardless of whether it was explicitly declared to be a subtype.[1][2]

So in essence, what structural subtyping allows you to do is specify the expected behaviors of a function’s input and output objects, even if their explicit (nominal) types don’t possess those behaviors.

But how exactly does such a specification look in Python? Enter protocol classes. Introduced in Python 3.8[3], the ability to define protocols via special classes gives a fresh spin to how the language handles both duck typing and structural subtyping.

To illustrate, the next section will present an example use case for how to implement a protocol class. But first, a quote:

If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck.

– The Duck test

An example protocol class

To recap, the idea behind protocols is all about letting you focus on what an object can do, not what it is. With that being said, let’s consider the following code sample:

from typing import Protocol

class HasArea(Protocol):
    def area(self) -> float:
        ...

def compute_area(shape: HasArea) -> float:
    return shape.area()

class Rectangle:
    def __init__(self, width: float, height: float):
        self.width = width
        self.height = height
    
    def area(self) -> float:
        return self.width * self.height

class Circle:
    def __init__(self, radius: float):
        self.radius = radius
    def area(self) -> float:
        return 3.14159 * self.radius * self.radius

In the realm of nominal object orientation, the compute_area function would define expected object types based purely on their parent classes. This means that input objects would have to directly or indirectly inherit from a known parent class such as Shape:

def compute_area(shape: Shape) -> float:
    return shape

Granted, the differences between the above two definitions of the compute_area function might seem cosmetic. But what’s more important is that in an “ecosystem” as vast as Python’s, with countless libraries evolving independently, it seems smart to not expect compliance with complex third-party inheritance structures.

By focusing on the behavior and capabilities of objects (rather than their inheritance lineage) we pave the way for more robust and resilient type structures.

FAQ

What is nominal subtyping?

Nominal subtyping means that a type is considered a subtype of another type if and only if it is explicitly stated so in the code. This explicit statement is usually done through inheritance.[1][4]

What is static type checking?

Static type checking is the verification of correct type usage of a program’s variables, functions, and other constructs based on the static analysis of its source code (i.e. before it’s executed).[5]

For example, if you were trying to calculate the sum of a number and a text string in your code, your static type checker would flag this as an error before the program gets executed.

If it was not for static type checking, protocol classes as defined by PEP 544 wouldn’t really add anything new to Python, right?

Yes, that’s right. Python’s dynamic nature has always allowed for structural subtyping. Thanks to duck typing, objects can be used interchangeably if they have the same methods and properties, regardless of their actual class hierarchy.

Is it true that Python had something called protocols even before the introduction of protocol classes?

Yes, that’s correct! The folks behind the introduction of protocol classes have the following to say on the subject:

We propose to use the term protocols for types supporting structural subtyping. The reason is that the term iterator protocol, for example, is widely understood in the community, and coming up with a new term for this concept in a statically typed context would just create confusion.

This has the drawback that the term protocol becomes overloaded with two subtly different meanings: the first is the traditional, well-known but slightly fuzzy concept of protocols such as iterator; the second is the more explicitly defined concept of protocols in statically typed code. The distinction is not important most of the time, and in other cases we propose to just add a qualifier such as protocol classes when referring to the static type concept.

– PEP 544

What is object-oriented programming?

Object-oriented programming (OOP) is a programming paradigm focused on “objects” that encapsulate both data (attributes) and behavior (methods).[6] This stands in contrast to procedural programming, where data and the flow of execution aren’t logically grouped together, as seen with structs in the C programming language.[7]

What is an interface?

In computing, an interface acts as a bridge between two distinct software and/or hardware components to communicate and share data.[8]

Moreover, in OOP (and especially in Java), the term “interface” can also refer to a blueprint or contract of behaviors. A type that adheres to this blueprint is said to implement the interface.

  1. https://peps.python.org/pep-0544/#nominal-vs-structural-subtyping [][]
  2. https://en.wikipedia.org/wiki/Structural_type_system []
  3. https://docs.python.org/3/whatsnew/3.8.html#typing []
  4. https://en.wikipedia.org/wiki/Nominal_type_system []
  5. https://en.wikipedia.org/wiki/Type_system#Static_type_checking []
  6. https://en.wikipedia.org/wiki/Object-oriented_programming []
  7. https://en.wikipedia.org/wiki/Struct_(C_programming_language) []
  8. https://en.wikipedia.org/wiki/Interface_(computing) []
Published