Codebeez

Type hinting in moderne Python: De Protocol-class

Python is een dynamisch getypeerde taal. De types van objecten zijn van secundair belang vergeleken met hun gedrag. Om het gedrag van objecten te achterhalen wordt duck-typing aanbevolen.

In Python wordt het principe van duck-typing samengevat in de uitspraak: “If it walks like a duck and quacks like a duck, then it must be a duck.” Dit wordt vaak gecombineerd met de “EAFP”-aanpak (Easier to Ask for Forgiveness than Permission) in plaats van “LBYL” (Look Before You Leap): in plaats van vooraf te controleren of een operatie waarschijnlijk slaagt (zoals bij LBYL), voeren Python-ontwikkelaars de operatie liever uit en handelen ze de fout af als die optreedt (EAFP).

Deze principes worden geïllustreerd met het onderstaande codefragment:

try:
    potential_duck.quack()  # duck-typing
except AttributeError:
    print("sorry, I guess")  # ask for forgiveness

In de loop van Python 3 is er echter een beweging op gang gekomen om type-annotaties en static type checking aan te bieden. Het resultaat hiervan zie je in de typing module, die de tools bevat om je classes en functies van type hints te voorzien.

Het toevoegen van static typing, waar dat zinvol is, kan van onschatbare waarde zijn voor de onderhoudbaarheid van je codebase en voorkomt veel frustratie en crashes. Maar bij het werken met bestaande Python-code, die vaak principes uit dynamic typing hanteert, kan het achteraf toevoegen van type hints net zo frustrerend zijn.

Gelukkig zijn er typing-tools gemaakt die ons hierbij helpen. Eén daarvan, het onderwerp van dit artikel, is de Protocol

De Protocol-class

De Protocol-class biedt static type checking voor duck-typed gebruik van classes. Bekijk het volgende protocol en een implementatie daarvan:

import time
from typing import Protocol


class DuckLike(Protocol):

    def walk(self, to: str) -> None: ...
    def quack(self) -> str: ...


class Chicken:

    def walk(self, to):
        time.sleep(5)
        self.location = to

    def quack(self): return "tok"


def add_to_zoo(duck: DuckLike):
    duck.walk("zoo")
    duck.quack()

add_to_zoo(Chicken())  # Type checker allows this

Het gebruik van Protocol vertoont een opvallende gelijkenis met bijvoorbeeld een Interface in Java, met één belangrijk verschil: de Protocol-class hoeft niet expliciet door andere classes overgeërfd te worden om door de type checker geaccepteerd te worden. Een Chicken is geen eend, maar gedraagt zich zo voor de doeleinden van add_to_zoo.

We krijgen nog steeds alle voordelen van klassieke type checking alsof het object overgeërfd was. De volgende wijzigingen aan Chicken leiden tot fouten:

# Invalid: method `quack` is missing
class Chicken:
    def walk(self, to):
        time.sleep(5)
        self.location = to


# Invalid: `walk` has the wrong signature
class Chicken:
    def walk(self): time.sleep(2)
    def quack(self): return "tok"

Het grootste voordeel van Protocol voor python komt naar voren bij het werken met externe libraries, met types die we niet rechtstreeks kunnen aanpassen. Wanneer het tijd wordt om polymorfe functies te maken, die zowel classes uit externe libraries als één van onze eigen classes kunnen accepteren, hebben we geen gemeenschappelijke voorouder om ze mee te annoteren. Het specificeren van een Protocol omzeilt dit probleem. Het stelt ons in staat om alleen het gedrag te definiëren dat we van onze geannoteerde parameter verwachten.

De onderstaande code is geldig en stelt ons in staat type checking te bieden op de export-functie zonder die sterk te koppelen aan de pandas-DataFrame. Als we ooit van framework willen wisselen, of dat nu naar een andere externe library zoals polars is of zelfs naar een zelfgebouwd framework, kunnen we deze functie hergebruiken, zolang er maar een to_csv-methode bestaat die een bestand als parameter aanneemt.

from pathlib import Path
from typing import  Protocol

import pandas as pd

class Writable(Protocol):
    def to_csv(self, path_or_buf) -> None: ...


def export(data: Writable, output_path: Path):
    data.to_csv(output_path)

df = pd.DataFrame({'id': [1,2,3], 'date': ['2023-05-13', '2022-01-31', '2028-07-06']})
export(df, Path('~/out.csv'))

Abstracte collections: type hinting op basis van gedrag

Python biedt ons al een aantal kant-en-klare protocols waarmee we onze code kunnen annoteren.

Als de typing module nog onbekend voor je is (en gezien de omvang ervan is je dat niet kwalijk te nemen), ligt het voor de hand om elke variabele te annoteren met exact het objecttype waarmee we verwachten die aan te roepen. Bekijk bijvoorbeeld de volgende code:

def my_function(my_list):
    for item in my_list:
        do_something(item)
        do_something_else(item)


the_list = [1, 2, 3, 4, 5]

my_function(the_list)

Welke type-annotatie zou my_list moeten hebben? list[int] lijkt de voor de hand liggende keuze (n.b: subscripting als list[int] werkt alleen voor Python 3.9 of hoger. Gebruik voor oudere versies typing.List[int]). Maar hoe wordt my_list daadwerkelijk gebruikt in my_function? Er wordt alleen overheen geïtereerd. Andere features van een list, zoals het controleren van de grootte of het benaderen van items via een index, zijn niet nodig.

Wat nu als we in de toekomst my_function willen gebruiken met een tuple, een set of zelfs een numpy-array? Niets in de code verbiedt dat: al die classes kwaken als eenden. De type-annotatie van my_list kan dit dienovereenkomstig weergeven. Dit doe je met de module collections.abc.Iterable.

from collections.abc import Iterable
import numpy as np

def my_function(my_list: Iterable[int]):
    for item in my_list:
        do_something(item)
        do_something_else(item)


my_function([1,2,3])  # valid
my_function((1, 2, 3))  # valid
my_function(np.array([1, 2, 3]))  # also valid

Voor wie afkomstig is uit statisch getypeerde talen, of uit codebases vol overerving en interfaces, is dit niets nieuws. Maar voor wie worstelt met grip krijgen op een dynamisch getypeerde codebase, zijn deze protocols van onschatbare waarde om code los gekoppeld te houden.

Protocols at runtime

De abstracte collection-Protocols hebben een extra voordeel voor wie de voorkeur geeft aan LBYL (Look Before You Leap) boven EAFP voor type checking. Ze vertonen namelijk uniek gedrag wanneer ze gebruikt worden voor de isinstance-check in python:

from collections.abc import Iterable

class MyIterable:

    def __iter__(self):
        yield 1
        yield 2
        yield 3


print(isinstance(MyIterable(), Iterable))  # prints True

Voor strikte aanhangers van OOP is dit ketterij, maar vanuit het oogpunt van een duck-typer is dit volkomen logisch: de MyIterable-class heeft de eigenschappen die nodig zijn om Iterable te zijn, en dus moet het wel een Iterable zijn.

Andere objecten in de collections.abc module volgen dezelfde logica. Alles wat met len(anything) gebruikt kan worden, moet Sized zijn, en alles wat aangeroepen kan worden (dat wil zeggen, de __call__-dunder-methode geïmplementeerd heeft) moet een Callable zijn.

Dit principe geldt ook voor veel andere dunder-methoden die niets met collections te maken hebben. Daarom bestaan er ook types zoals typing.SupportsAbs en typing.SupportsInt, die controleren op het bestaan van hun bijbehorende dunder-methoden (__abs__ en __int__ in dit geval, die ervoor zorgen dat onze eenden voldoen aan de abs()- en int()-builtins).

Als we onze eigen Protocol willen gebruiken met isinstance-checks, moeten we die eerst decoreren met @typing.runtime_checkable. Protocols bestaan standaard niet tijdens runtime, een eigenschap die ze delen met veel leden van de typing module, die in de eerste plaats bedoeld is voor static checking. De decorator voegt bovendien runtime-functionaliteit toe, via een eigen implementatie van __instancecheck__, de dunder-methode die onder de motorkap gecontroleerd wordt door de isinstance-builtin.

from typing import Protocol, runtime_checkable


@runtime_checkable
class DuckLike(Protocol):

    def walk(self, to: str) -> None: ...
    def quack(self) -> str: ...

isinstance(Chicken(), DuckLike)  # returns True. Without the decorator, it would raise a TypeError

Conclusie

De Protocol-class is een uitstekend voorbeeld van hoe praktijken uit static type checking zich kunnen vertalen naar Pythonische manieren om code te schrijven.

Python zal altijd een dynamisch getypeerde taal blijven (dit wordt opnieuw bevestigd door de type hinting PEP: “Python will remain a dynamically typed language, and the authors have no desire to ever make type hints mandatory, even by convention”).

De taal profiteert sterk van de flexibiliteit die dynamic typing biedt. Toch kan static type checking van onschatbare waarde zijn voor grotere codebases, omdat het een groot aantal problemen kan onderscheppen voordat ze optreden. Met de Protocol-class kunnen de voordelen van static type checking worden overgedragen op het dynamische karakter van bestaande Python-code.

Blog