Python – Static Duck-Typing

Python erfreut sich schon seit längerem einer enormen Beliebtheit und hat durch den vermehrten Einsatz im Bereich des Data Science und Engineering einen weiteren Sprung in der Verbreitung geschafft.
Durch die vielfältigen Einsatzmöglichkeiten und die weitere Verbreitung sind auch immer neue Konzepte in die Sprache eingeflossen. So wurde etwa mit dem PEP 484 zur Version 3.5 die Möglichkeit geschaffen, optional Typ-Annotationen in der dynamisch typisierten Interpretersprache zu verwenden. Diese Annotationen werden dabei nicht zur Laufzeit geprüft, können aber bei der Entwicklung in größeren Teams, bei der Qualitätssicherung, der Dokumentation und der Nachvollziehbarkeit eine enorme Unterstützung bieten. Dieser Artikel soll beschreiben, welches spezielle Konzept dabei hilft, diese Typ-Annotationen zu verwenden, ohne dabei den "pythonic way" des Duck-Typings verlassen zu müssen.
Python wurde als dynamisch typisierte Sprache konzipiert, woran sich auch bis heute nichts geändert hat. Für Neulinge vereinfacht dies den Einstieg in die Sprache enorm. So ist es möglich, sich in den ersten Schritten auf das Wesentliche zu fokussieren und die gewünschte Fachlichkeit gut les- und nachvollziehbar in Code umzusetzen. Es ist dabei nicht zwingend erforderlich, sich im Vorfeld mit dem Design von Interfaces, der Auswahl der passenden Datentypen für die Abbildung bestimmter Werte oder der maximalen Länge der verwendeten Textwerte auseinanderzusetzen.
Dieser Vorteil ist vor allem in der Entwicklung kleiner bis mittelgroßer Skripte und Anwendungen zu spüren, da sich diese im Gegensatz zu umfangreichen Lösungen noch gut genug überblicken lassen. Gleiches gilt für die explorative Entwicklung sowie der Entwicklung von Prototypen oder eines Proof of Concept (PoC). Vor allem dann, wenn in einem sehr kleinen Team oder sogar alleine gearbeitet wird.
Dokumentation
Bei der Entwicklung größerer Anwendungen ergeben sich zwangsläufig erweiterte Anforderungen an die Dokumentation des Codes. Sei es um die Motivationen und Hintergründe des Codes besser zu beschreiben, aber auch um den genauen Zweck einer Funktion für Entwickler:innen separater Teams oder anderer Stakeholder nachvollziehbar zu erläutern.
Dafür bieten sich Docstrings an. Sie werden vorwiegend dazu verwendet, eine fachliche Beschreibung der Konstrukte zu hinterlegen, können aber unter Verwendung bestimmter Formate auch dazu genutzt werden, die erwarteten Typen für Argumente oder Rückgabetypen zu dokumentieren. Daher existieren mittlerweile unterschiedliche Standards, die darauf abzielen, diese Notationen zu vereinheitlichen. Bekannte Formate sind etwa Epytext, Numpydoc oder das auf restructured Text (reST) basierende Format Sphinx. Google verwendet in seinen Programmen wiederum ein eigenes Format.
Leider ist bisher keines der verbreiteten Formate durch ein entsprechendes PEP standardisiert worden. Während durch PEP 287 definiert wurde, dass die Inhalte der Docstrings zwar in reST gehalten werden sollen, hat sich bisher noch kein einheitliches Format/Aufbau durchgesetzt und etabliert.
Diese Vielfalt hat zur Folge, dass Docstrings in unterschiedlichen Projekten anders verwendet werden und damit unterschiedlich ausgeprägt sind. Dies birgt mindestens einen höheren Abstimmungsaufwand im Team und einen entsprechenden mentalen Overhead beim Wechsel zwischen Projekten, in denen jeweils unterschiedliche Standards zum Einsatz kommen.
Ein weiterer Kritikpunkt an der Dokumentation der Typen in Docstrings besteht darüber hinaus darin, dass sie nicht direkt mit dem Code in Verbindung stehen, den sie beschreiben sollen. Um beides synchron zu halten, ist ein entsprechender Mehraufwand erforderlich, aus dem sich die Gefahr ergibt, dass Code und Dokumentation sukzessive auseinander driften und nicht aktuell sind.
Statische Codeanalysen
Eine einheitliche Qualitätsprüfung und Syntax-Checks stoßen bei dem dynamischen Aufbau und den bunten Möglichkeiten zu Dokumentation an ihre Grenzen. Daher gestaltet es sich schwierig, ein einheitliches Tooling für statische Codeanalye, Autocompletions usw. bereitzustellen, das dann flächendeckend in allen Python-Projekten Anwendung finden kann.
So entgehen Python-Entwicker:innen einige Vorteile aus anderen, oft statisch typisierten Sprachen, die bereits während der Compilezeit auf dem Entwickler:innen-Rechner entsprechende Prüfungen durchführen und damit etwa die Anzahl der Laufzeitfehler reduzieren oder mehr Sicherheit beim Refactoring bieten können.
Tools wie pylint, flake8 usw. federn dies bereits ab, allerdings bringt die Typenprüfung durch beispielsweise mypy dies noch auf eine weitere Ebene.
Typisierung (Statisch, Dynamisch, Duck-Typing)
In statisch typisierten Sprachen findet die Prüfung der zu verwendenden Typen schon zum Zeitpunkt der Kompilierung durch den Compiler statt. Dies schafft auf der einen Seite wesentlich mehr Verlässlichkeit bei der Verwendung, erfordert aber zu jeder Zeit die Kenntnis eines jeden Typs.
Für die/den Entwickler:in ist dies mit der genauen Definition der Typen verbunden und bedeutet damit u. a. wesentlich mehr Schreibarbeit und eine höhere Komplexität, etwa bei der Definition von teils aufwändigen Klassenstrukturen und Interfaces. Folglich steigt vermeintlich auf den ersten Blick der Aufwand in der Implementierung.
Funktionalitäten wie Type Interference, also das Herleiten der korrekten Typen auf Basis anderer Definitionen, durch den Compiler wirken dem entgegen. Darüber hinaus tragen moderne Autovervollständigung und automatische Generierung von Code zu mehr Komfort bei und verringern damit den Schreibaufwand.
Ein erheblicher Vorteil des expliziten Typings liegt in der Möglichkeit, das Programm vor der Ausführung auf die Nutzung korrekter Typen prüfen zu lassen. Die Anzahl der daraus entstehenden Laufzeitfehler lässt sich somit reduzieren oder das Risiko bewusster eingehen.
Zusätzlich vereinfacht eine explizite Definition die Unterstützungsmöglichkeit durch IDEs, etwa mittels Refactoring oder statischer Code-Analyse usw. Viele Inkonsistenzen können und müssen so schon frühzeitig behoben werden. Dies erhöht die Fehlerfreiheit und verringert die Gefahr von vermeidbaren Laufzeitfehlern.
Da es sich bei Python um eine interpretierte Sprache handelt, entfällt der Schritt der Kompilierung. Typenprüfungen finden erst zur Laufzeit statt. Nämlich genau dann, wenn der Interpreter Operationen mit definierten Variablen oder Funktionsaufrufen auszuführen versucht. Inkompatible Definitionen führen damit zu Laufzeitfehlern, zu denen es in dieser Form bei statisch typisierten Sprachen nicht gekommen wäre.
Was im ersten Moment wie ein essentieller Nachteil erscheint, bietet in der Realität den mächtigen Vorteil, notwendige Typen zur Laufzeit zu erstellen oder wie benötigt zu erweitern. Auch das Implementieren von Prototypen und Experimenten lässt sich so enorm vereinfachen und beschleunigen. Allerdings erschwert dieses Modell aus Sicht der Qualitätssicherung die vorzeitige statische Analyse und Prüfung des Codes.
Python ist jedoch nicht nur dynamisch typisiert, sondern unterstützt zudem das Konzept des Duck-Typings. Beim Duck-Typing wird nicht auf die Implementierung eines konkreten Interfaces oder einer Klassenhierarchie geprüft (nominal typing). Es reicht hingegen vollkommen aus, dass das übergebene Objekt die erforderliche Struktur aufweist, um kompatibel zu sein (structural typing).
"When I see a bird that walks like a duck and swims like a duck and quacks like a duck, I call that bird a duck," (J. W. Riley) beschreibt das Verhalten dabei sehr treffend.
Konkret bedeutet das, dass ein Objekt, über das iteriert werden kann, ausschließlich die Methode __iter__() ausprägen muss, um kompatibel zu sein. Anders als man es etwa von Java kennt, muss hier kein Interface á la Iterable implementiert werden, um diese Funktion kenntlich zu machen und die Funktionalität zuzusichern.
Auch dies ist ein mächtiges Feature, das auf die Entwicklungsflexibilität einzahlt, allerdings erneut auf Kosten der statischen Analyse und Nachvollziehbarkeit geht.
Die optionalen Typ-Annotationen können dabei unterstützen, die Vorteile der dynamischen Natur von Python zu nutzen und gleichzeitig einen großen Schritt in Richtung statischer Analyse und Typsicherheit zu machen.
Typisierung in Klassen verwenden
Das folgende Beispiel soll anhand einer einfachen, nominellen Klassenhierarchie verdeutlichen, wie die optionalen Typ-Annotationen dazu verwendet werden können. Es soll dabei klar machen, wie sich Typdefinitionen für Attribute, Parameter und Rückgabetypen definieren lassen, um damit von den Prüfungen durch den statischen Typechecker mypy profitieren zu können.
Das Ziel ist die Abbildung einer einfachen Klasse Human, die mit Hilfe der Methode greet ein Grußwort zurückliefern kann.
Listing 1:
# – models.py
class Human:
def __init__(self, fn: str, ln: str):
self.first_name: str = fn
self.last_name: str = ln
def greet(self) -> str:
return f"hello i'am {self.first_name} {self.last_name}!"
# - actions.py
from models import Human
def say_hello(instance: Human):
message = instance.greet()
print(message)
# - main.py
from models import Human
from actions import say_hello
say_hello(Human("john", "doe"))
In Listing 1 wird eine Klasse Human implementiert, über die mit Hilfe von Typ-Annotationen beschrieben wird, dass der Vorname first_name und der Nachname last_name vom Typ String (str) im Konstruktor zu übergeben sind. Darüberhinaus liefert die Methode greet das entsprechende Grußwort, ebenfalls in Form eines Strings (str).
Die Funktion say_hello erwartet eine Instanz dieses Typs Human, um damit über den Aufruf der Methode greet das Grußwort zu ermitteln und auf der Konsole auszugeben. Um die Annotation des Funktionsparameters zu gewährleisten, wird die Klasse Human aus dem entsprechenden Modul models importiert. Letzteres könnte bei nicht verwendeter Typisierung entfallen.
Im main-Modul wird ein Human-Objekt instanziiert und der Funktion say_hello übergeben.
Der Vorteil, der durch dieses Beispiel verdeutlicht wird, liegt auf der Hand: Für den/die Entwickler:in wird auf den ersten Blick erkennbar, welcher Typ bei der Funktion say_hello erwartet wird. So wird direkt klar, welche Klasse erwartet wird, ohne die Verwendung im Code zu prüfen, die Aufrufhierarchie zu betrachten oder auf eine korrekte Dokumentation der Docstrings zu hoffen.
Moderne IDEs können dies ebenfalls nutzen und innerhalb der Methode entsprechende Autovervollständigung anbieten. Linter können Inkonsistenzen erkennen und darauf hinweisen.
Besteht folgend die Anforderung, dass die Funktion say_hello auch andere Objekte entgegennehmen soll, die eine Methode greet implementieren, liegt es nahe, eine entsprechende Basisklasse oder ein Mixin zu Implementieren, welches diese Struktur zusichert. Diese Zusicherung wird im Beispiel (Listing 2) durch das Mixin Greetable bereitgestellt, welches die neue Basis der Klasse Human darstellt. Zur Verwendung ist damit ein entsprechender Import aus dem mixins-Module erforderlich.
Listing 2:
# - mixins.py
class Greetable:
def greet(self) -> str:
raise NotImplementedError()
# - models.py
from interfaces import Greetable
class Human(Greetable):
…
# - actions.py
from interfaces import Greetable
def say_hello(instance: Greetable):
Dieser Art der Typenstrukturierung wird nominelle Strukturierung genannt und orientiert sich stark an der Klassenhierarchie.
Statische Analyse mit MyPy
Ein so aufgebauter, sehr expliziter Programmcode bietet einen guten Ansatzpunkt für die Verwendung statischer Code-Analysen, etwa durch den optionalen Typchecker mypy.
Dieser lässt sich als CLI-Tool installieren und wahlweise im Terminal verwenden oder in IDEs oder CI/CD-Pipelines integrieren. Die Anwendung auf das oben beschrieben Beispiel ist dabei denkbar einfach und liefert in der Standardkonfiguration keine Fehler:
$ mypy src/
Success: no issues found in 4 soruce files
Nehmen wir an, die Klasse Human unseres Beispiels bekäme fälschlicherweise als zweiten Parameter nicht ihren Nachnamen, sondern das Alter übergeben. Dieser Fehler würde ohne entsprechenden Linter und Typenprüfung erst zur Laufzeit auffallen. Die Typenkonvertierung bei der Formatierung des Grußworts würde ebenfalls funktionieren, die Ausgabe im Terminal aber entsprechend fehlerhaft sein:
>>> say_hello(Human("john", 26))
“hello i’am john 26”
Verwendet man an dieser Stelle das Tool mypy, wird auf die Inkompatibilität der Typen hingewiesen, so dass sich dieses Problem rechtzeitig beheben lässt. Die Prüfung erfolgt dann im besten Fall direkt durch die IDE, im Rahmen von Pre-Commit Hooks oder des CI/CD-Prozesses.
src/main.py:5: error: Argument 2 to "Human" has incompatible type "int"; expected "str"
Found 1 error in 1 file (checked 3 source files)
Die Möglichkeit dieser Prüfungen schafft einen enormen Vorteil in der Qualitätssicherung bezogen auf den Programmcode. Dabei lässt sich mypy auf viele unterschiedliche Weisen konfigurieren und so an die eigenen Erfordernisse und Ansprüche anpassen. Dadurch ist es etwa bei einer bestehenden Codebase möglich, die Regeln sukzessive restriktiver zu gestalten oder bestimmte Module von der Prüfung auszuschließen.
Zurück zum Beispielcode (Listing 3): Das Programm wird jetzt um eine neue Klasse Robot erweitert. Da ein Roboter und ein Mensch keine gemeinsame Basis bilden, dieser aber dennoch grüßen können soll, leitet diese neue Klasse nicht von Greetable ab, implementiert aber dennoch eine Methode greet.
Listing 3:
# - models.py
class Robot:
serial: str = ""
def __init__(self, sn: str):
self.serial = sn
def greet(self) -> str:
return f"hello i'am {self.serial}!"
# - main.py
say_hello(Robot("SN-4711"))
Verwendet man eine Instanz des Roboters nun als Argument der Methode say_hello und startet den mypy-Linter erneut, weist dieser folglich darauf hin, dass eine Instanz an say_hello übergeben wird, die nicht vom erwarteten Typen Greetable ableitet.
main.py:5: error: Argument 1 to "say_hello" has incompatible type "Robot"; expected "Greetable"
Found 1 error in 1 file (checked 4 source files)
Dies ist aus Sicht des mypy-Typecheckers zwar grundsätzlich richtig, allerdings handelt es sich hier um lauffähigen Python-Code. Er ist nicht nur lauffähig, sondern folgt dabei dem Pattern des Duck-Typings und ist damit sogar "pythonic", also der Python-Weg zur Implementierung.
"Das ist nicht mehr mein Python!"
Bei all den Sicherheiten, die bisher vorgestellte Funktionen mit sich bringen mögen, scheint es, als wenn die Vorteile der dynamisch typisierten Entwicklung und vor allem das Duck-Typing erheblich erschwert werden. Denn das Ziel beim Duck-Typing ist die Implementation einer gemeinsamen Struktur, ohne dabei eine Hierarchie aus geteilten Basisklassen oder Mixins verwenden zu müssen.
Desweiteren hat die Verwendung einer gemeinsamen Basis weitere Auswirkungen auf die Programmstruktur. Allein für die Verwendung von Typen in Methodensignaturen oder Variablen-Zuweisungen wird es erforderlich, diese aus entsprechenden Modulen zu importieren. Für die statischen Analysen hilfreich, kann dies aber zur Laufzeit schaden. So können sich für die Programmausführung unnötige Importe etwa negativ auf die Performance auswirken oder im schlimmsten Fall zu zirkulären Abhängigkeiten führen.
Unter Verwendung der Konstante typing.TYPE_CHECKING lassen sich Importe, die nur für das Checking und Linting benötigt werden, zwar zur Laufzeit ausschließen, sorgen aber für einen entsprechenden mentalen Overhead bei der Entwicklung und werden daher schnell mal vergessen. Darüber hinaus ist es nicht immer direkt möglich, die benötigten Typen zu importieren. Etwa wenn diese durch Drittbibliotheken dynamisch erstellt oder tief in deren Implementation versteckt werden.
Beabsichtigt man also, die Typ-Annotationen in dieser dogmatischen Form zu verwenden, ist man gezwungen, eine aufgeblähte Klassenhierarchie einzuführen, durch welche dann die Typen- Konsistenz sichergestellt werden kann. Diese schadet in der Folge allerdings zur Laufzeit und eliminiert einige essentielle Designvorteile von Python. Reißerisch könnte man sagen, man schafft so eine langsame, interpretierte Version von Java ohne Klammern.
PEP 544 - Protocols: Structural subtyping (static duck typing)
Um die Vorteile der Typdefinitionen nutzen zu können, dabei aber alle Nachteile des oben genannten Vorgehens zu vermeiden, wurden mit dem PEP 544 in der Version 3.8 die Protokolle eingeführt. Beginnend mit den PEPs 483 und 484 wurde erfolgreich versucht, standardisierte Typ-Annotationen in die Python-Syntax zu integrieren. Diese PEPs konzentrierten sich dabei zunächst auf das nominale Subtyping, wie es in den bisherigen Absätzen verwendet wurde. Mit PEP 544 - Protocols: Structural subtyping (static duck typing) wird nun aber auch die dynamische Typisierung für das in Python übliche Duck-Typing unterstützt.
Das "Rational Goals" der PEPs beschreibt dies recht gut (frei übersetzt): "Durch das PEP 484 wurde nur die Semantik für das nominale Subtyping implementiert, was dazu führt, dass Typen immer auch gleichzeitig Subtypen der bereitzustellenden Funktion sein müssen. So muss eine Klasse etwa “Sized” und “Iterable” als Basis haben, um kenntlich zu machen, dass sie ihre Größe liefern kann oder über sie iterierbar ist." [1]
Dies ist "unpythonic and unlike what one would normally do in idiomatic dynamically typed Python code", also nicht der Python-Weg und auch nicht das, was man üblicherweise in Python erwarten und implementieren würde.
Konkret bedeutet dies, dass neben dem nominellen Subtyping noch eine weitere Methode – nämlich die des strukturellen Subtyping – implementiert wurde. Während beim nominellen Subtyping strikt die Klassenhierarchie verfolgt wird und damit auch Typ-Prüfungen etwa mit isinstance oder issubclass implementiert werden können, definiert das strukturelle Subtyping ausschließlich die Struktur eines Objekts. Ungeachtet der Klassenhierarchie.
Hierfür wurde u. a. typing.Protocol geschaffen, das als Basis aller Protokoll-Definitionen zu verwenden ist. Durch die Verwendung der Protokolle anstelle der expliziten Typen, ist es dem Type Checker damit möglich, Strukturen zu prüfen und zu validieren.
Refaktorisierung des Beispiels mit Protokollen
Mit diesem Werkzeug lässt sich das oben aufgebaute Beispiel entsprechend optimieren. Die für den Anwendungsfall unnötige Klassenhierarchie kann jetzt durch Protokolle ersetzt werden. Zu Beginn ist es erforderlich, die gewünschte Struktur in Form eines Protokolls zu beschreiben. Daher wird das folgende Protokoll definiert, welches damit die zuvor verwendete Basisklasse Greetable ersetzt. Ähnlich wie bei der Basisklasse wird die Methodensignatur beschrieben, der Methodenrumpf kann allerdings durch drei Punkte oder pass ersetzt werden, da dieser Teil zur Laufzeit nie verwendet wird.
Listing 4:
from typing import Protocol
class Greetable(Protocol):
def greet(self) -> str: …
Die Methode say_hello kann damit auf den Import des Mixins verzichten und erwartet eine Instanz, die der Struktur des Protokolls Greetable folgt.
Listing 5:
def say_hello(instance: Greetable):
message = instance.greet()
print(message)
Damit wird die nur für die Typenprüfung eingeführte Basis obsolet und kann aus der Klasse Human entfernt werden. Die Klasse Robot erfüllt damit schon alle Erfordernisse. Es entstehen dadurch zwei vollkommen unabhängige Klassen ohne künstliche Basis.
Listing 6:
class Human:
first_name: str = ""
last_name: str = ""
def greet(self) -> str:
# …
…
class Robot:
serial: str = ""
def greet(self) -> str:
# …
…
Der Inhalt des main-Modules kann unverändert belassen werden und ist weiterhin funktionsfähig.
say_hello(Human("john", "doe"))
say_hello(Robot("SN-4711"))
Durch diese Änderungen wird es mypy ermöglicht, die Struktur von Objekten zu prüfen und sicherzustellen, dass Instanzen beliebiger Typen der erwarteten Struktur entsprechen und schafft damit die Unterstützung des statischen Duck-Typings. Die Prüfung mit mypy bestätigt, dass es sich hierbei um ein komplett valides Konstrukt handelt.
$ mypy .
Success: no issues found in 4 source files
Das Refactoring unseres Beispiels hat mit dem Blick auf größere Projekte oder bei der Verwendung von Bibliotheken drei erhebliche Vorteile:
- Durch die Verwendung von Protokollen lässt es sich vermeiden, eine aufgeblähte Klassenhierarchie oder viele verteilte Mixins zu schaffen, die ausschließlich für die Typenprüfung herangezogen werden und auf der anderen Seite der Laufzeit schaden. Damit lässt sich die eigene Codebase überschaubar aber trotzdem explizit halten.
- Bei der Implementierung der konkreten Ausprägungen ist keine Referenz und damit kein Import einer Basisklasse oder eines Mixins erforderlich. Dies vermeidet unnötige und möglicherweise zirkuläre Importe zur Laufzeit.
- Durch Protokolle lassen sich Strukturen für Objekte erstellen, die aus Bibliotheken von Drittanbietern stammen, über die man keine Hoheit hat. Durch statische Prüfungen lässt sich so die Kompatibilitätsprüfung etwa bei Updates implementieren.
Protokolle bringen damit eine optionale Erweiterung zur statischen Typisierung, die es weiterhin zulässt, in den flexiblen und eleganten Mustern des Duck-Typings zu entwickeln.
Erweiterte Verwendung
Die Protokolle erweitern die bestehende Möglichkeit der optionalen Typ-Aannotationen und fügen sich entsprechend gut in das Python-Typsystem ein. So lassen sie sich beispielsweise auch durch Mehrfachvererbung zu neuen, zusammengesetzten Protokollen kombinieren.
Listing 7:
class A(Protocol):
def get_a(self) -> int: …
class B(Protocol):
def get_b(self) -> int: …
class AB(A, B, Protocol): …
Ist bei der Protokolldefinition eine Selbstreferenzierung erforderlich, ist es analog zur Verwendung bei Klassen möglich, den Protokollnamen in Form eines Strings zu verwenden. Eine Definition eines klonbaren Objektes könnte so aussehen.
Listing 8:
class Clonable(Protocol):
def clone(self) -> "Clonable": …
Wer in seinen Typdefinitionen die Generics verwendet, kann auch diese bei der Strukturdefinition mit Protokollen unverändert einsetzten. So lässt sich beispielsweise ein generischer Baum mit zu erntenden Früchten definieren, ohne dass dabei eine gemeinsame Basis verwendet werden muss.
Listing 9:
class Fruit(Protocol):
...
class FruitTree(Protocol[Fruit]):
def harvest(self) -> Fruit: …
Runtime Checkable
Protokolle sind zunächst nur für das Structural Typing vorgesehen gewesen. Sie sollten nur eine Typenstruktur vorgeben und damit eine bessere Unterstützung für statische Typenchecker wie mypy und eine bessere Unterstützung durch Entwicklungsumgebungen ermöglichen. Zu diesem Zweck sollen sie möglichst leichtgewichtig sein und sich nicht auf die Laufzeit auswirken. Daher lassen sie sich auch nicht – wie übliche Klassen zur Laufzeit – mit isinstance oder issubclass verwenden. Tut man dies allerdings doch, wird zum Zeitpunkt des Aufrufs ein entsprechender Fehler geworfen:
TypeError: Instance and class checks can only be used with @runtime_checkable protocols
Es kann allerdings Gründe geben, Protokolle zur Laufzeit verwenden zu wollen. Möchte man beispielsweise bestehende Basistypen umstellen und die Funktionsweise der Codebase sukzessive migrieren. Um diese Doppelverwendung zu ermöglichen und ein Protokoll in ein Runtime-Protokoll zu konvertieren, kann man dieses mit dem Decorator runtime_checkable versehen.
Sowohl die Mypy-Dokumentation [2] als auch eine ausführliche Beschreibung im PEP 544 [3] weisen explizit darauf hin, dass eine Wandlung eines Protokolls zu einem Runtime-Protokoll nicht dazu führt, dass bei der Verwendung von isinstance oder issubclass die vollständigen Methodensignaturen geprüft werden. Es wird ausschließlich sichergestellt, dass entsprechende Attribute und Methoden existieren.
Listing 10:
from typing import runtime_checkable
@runtime_checkable
class HasArgs(Protocol):
def method(self, arg1): ...
class HasNoArgs:
def method(self):
pass
print(isinstance(HasNoArgs(), HasArgs)) # == True
Viele der bis Python 3.5 implementierten ABC-Typen im Standardmodul typing wurden im Rahmen von PEP 544 auf Protokolle mit entsprechender Flag für die Verwendung zur Laufzeit ("runtime_checkable") angepasst und lassen sich so weiterhin als Basisklasse und als Protokoll verwenden.
Fazit
Immer ausgefeiltere IDEs, umfangreiche Linter und ausgereiftere CI/CD-Pipelines ermöglichen es, zum einen den Komfort einer dynamischen Sprache zu nutzen, aber gleichzeitig auch eine entsprechende Code-Qualität sicherzustellen, die in der Folge zu einer stabileren Anwendung führt. Die Einführung der optionalen Typisierung in Python spielt ihre Stärken dabei gerade bei wachsenden und großen Projekten aus, ohne dabei praktische und etablierte Konzepte wie das Duck-Typing aufgeben zu müssen. So gelingt es Python immer mehr, eine Sprache für viele unterschiedliche Anwendungsfälle zu sein und damit eine gute Wahl sowohl für kleine Skripte als auch komplexe Anwendungen zu sein.
Protokolle sind in diesem Zuge ein elegantes und leichtgewichtiges Werkzeug, um die Vorteile der Typisierung – ohne die große Komplexität von aufgeblähten Klassenhierarchien – nutzen zu können. Sie ermöglichen so, die Wartbarkeit und Transparenz des Codes zu erhöhen und sind gleichzeitig pythonic.