Kapitel 4. Einschränkende Typen

Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com

Viele Entwickler lernen die grundlegenden Typ-Annotationen und machen Schluss damit. Aber wir sind noch lange nicht fertig. Es gibt eine Fülle von erweiterten Typ-Annotationen, die von unschätzbarem Wert sind. Mit diesen erweiterten Typannotationen kannst du Typen einschränken, indem du weiter einschränkst, was sie darstellen können. Dein Ziel ist es, illegale Zustände nicht darstellbar zu machen. Entwickler sollten eigentlich nicht in der Lage sein, Typen zu erstellen, die widersprüchlich oder anderweitig ungültig in deinem System sind. Du kannst keine Fehler in deinem Code haben, wenn es unmöglich ist, den Fehler überhaupt erst zu erzeugen. Mit Typ-Annotationen kannst du genau dieses Ziel erreichen und so Zeit und Geld sparen. In diesem Kapitel zeige ich dir sechs verschiedene Techniken:

Optional

Ersetze None Referenzen in deiner Codebasis.

Union

Damit kannst du eine Auswahl von Typen präsentieren.

Literal

Verwenden Sie diese Option, um Entwickler auf ganz bestimmte Werte zu beschränken.

Annotated

Hier kannst du eine zusätzliche Beschreibung deiner Typen angeben.

NewType

Damit kannst du einen Typ auf einen bestimmten Kontext beschränken.

Final

Verhindert, dass Variablen an einen neuen Wert gebunden werden.

Beginnen wir mit der Handhabung von None Referenzen mit Optional Typen.

Optionale Art

Nullreferenzen werden oft als "Milliarden-Dollar-Fehler" bezeichnet, der von C.A.R. Hoare geprägt wurde:

Ich nenne ihn meinen Milliarden-Dollar-Fehler. Es war die Erfindung der Null-Referenz im Jahr 1965. Damals entwarf ich das erste umfassende Typsystem für Referenzen in einer objektorientierten Sprache. Mein Ziel war es, sicherzustellen, dass die Verwendung von Referenzen absolut sicher ist und vom Compiler automatisch überprüft wird. Aber ich konnte der Versuchung nicht widerstehen, eine Null-Referenz einzufügen, einfach weil sie so einfach zu implementieren war. Das hat zu unzähligen Fehlern, Schwachstellen und Systemabstürzen geführt, die in den letzten vierzig Jahren wahrscheinlich eine Milliarde Dollar an Schmerz und Schaden verursacht haben.1

Null-Referenzen wurden in Algol eingeführt und haben sich in unzähligen anderen Sprachen durchgesetzt. C und C++ werden oft wegen der Null-Zeiger-Dereferenz verspottet (die einen Segmentierungsfehler oder einen anderen Programmabsturz verursacht). Java war dafür bekannt, dass der Benutzer NullPointerException in seinem Code abfangen musste. Es ist nicht übertrieben zu sagen, dass diese Art von Fehlern einen Preis in Milliardenhöhe hat. Denk an die Zeit, die Entwickler aufwenden müssen, an den Verlust von Kunden und an die Systemausfälle, die durch versehentliche Null-Zeiger oder Referenzen verursacht werden.

Warum ist das in Python so wichtig? Das Zitat von Hoare bezieht sich auf objektorientierte kompilierte Sprachen aus den 60er Jahren; Python muss doch inzwischen besser sein, oder? Ich muss dir leider mitteilen, dass dieser Milliardenfehler auch in Python steckt. Er tritt bei uns unter einem anderen Namen auf: None. Ich werde dir einen Weg zeigen, wie du den teuren None Fehler vermeiden kannst, aber lass uns zuerst darüber sprechen, warum None so schlecht ist.

Hinweis

Es ist besonders aufschlussreich, dass Hoare zugibt, dass Null-Referenzen aus Bequemlichkeit entstanden sind. Das zeigt dir, dass der schnellere Weg zu allen möglichen Problemen führen kann, die du später in deinem Entwicklungszyklus hast. Überlege dir, wie sich deine kurzfristigen Entscheidungen von heute auf deine Wartung von morgen auswirken werden.

Betrachten wir einen Code, der einen automatisierten Hotdog-Stand betreibt. Ich möchte, dass mein System ein Brötchen nimmt, eine Wurst in das Brötchen legt und dann Ketchup und Senf durch automatische Spender verspritzt, wie in Abbildung 4-1 beschrieben. Was könnte schiefgehen?

Worfklow for the automated hotdog stand
Abbildung 4-1. Arbeitsablauf für den automatisierten Hotdog-Stand
def create_hot_dog():
    bun = dispense_bun()
    frank = dispense_frank()
    hot_dog = bun.add_frank(frank)
    ketchup = dispense_ketchup()
    mustard = dispense_mustard()
    hot_dog.add_condiments(ketchup, mustard)
    dispense_hot_dog_to_customer(hot_dog)

Ziemlich simpel, oder? Leider gibt es keine Möglichkeit, das wirklich festzustellen. Es ist einfach, sich den glücklichen Weg oder den Kontrollfluss des Programms vorzustellen, wenn alles gut läuft, aber wenn es um robusten Code geht, musst du auch die Fehlerbedingungen berücksichtigen. Wenn dies ein automatisierter Stand ohne manuelle Eingriffe wäre, welche Fehler fallen dir ein?

Hier ist eine unvollständige Liste von Fehlern, die mir einfallen:

  • Keine Zutaten mehr vorhanden (Brötchen, Hot Dogs oder Ketchup/Senf).

  • Bestellung mitten im Prozess storniert.

  • Gewürze werden eingeklemmt.

  • Der Strom ist unterbrochen.

  • Der Kunde möchte keinen Ketchup oder Senf und versucht, das Brötchen mitten im Prozess zu verschieben.

  • Ein konkurrierender Verkäufer tauscht den Ketchup mit Catsup aus und es entsteht ein Chaos.

Dein System ist auf dem neuesten Stand der Technik und erkennt alle diese Bedingungen, aber es gibt None zurück, wenn einer der Schritte fehlschlägt. Was bedeutet das für diesen Code? Du wirst Fehler wie die folgenden sehen:

Traceback (most recent call last):
 File "<stdin>", line 4, in <module>
AttributeError: 'NoneType' object has no attribute 'add_frank'

Traceback (most recent call last):
 File "<stdin>", line 7, in <module>
AttributeError: 'NoneType' object has no attribute 'add_condiments'

Es wäre katastrophal, wenn diese Fehler bei deinen Kunden auftauchen würden. Du bist stolz auf eine saubere Benutzeroberfläche und willst nicht, dass hässliche Rückverfolgungen deine Oberfläche verunreinigen. Um dem entgegenzuwirken, fängst du an, defensiv zu programmieren, d.h. du versuchst, jeden möglichen Fehlerfall vorherzusehen und zu berücksichtigen. Defensives Programmieren ist eine gute Sache, aber es führt zu Code wie diesem:

def create_hot_dog():
    bun = dispense_bun()
    if bun is None:
        print_error_code("Bun unavailable. Check for bun")
        return

    frank = dispense_frank()
    if frank is None:
        print_error_code("Frank was not properly dispensed")
        return

    hot_dog = bun.add_frank(frank)
    if hot_dog is None:
        print_error_code("Hot Dog unavailable. Check for Hot Dog")
        return

    ketchup = dispense_ketchup()
    mustard = dispense_mustard()
    if ketchup is None or mustard is None:
        print_error_code("Check for invalid catsup")
        return

    hot_dog.add_condiments(ketchup, mustard)
    dispense_hot_dog_to_customer(hot_dog)

Das fühlt sich, nun ja, mühsam an. Da in Python jeder Wert None sein kann, scheint es, als müsstest du defensiv programmieren und vor jeder Dereferenzierung eine is None Prüfung durchführen. Das ist übertrieben; die meisten Entwickler werden den Aufrufstapel verfolgen und sicherstellen, dass keine None Werte an den Aufrufer zurückgegeben werden. Dann bleiben nur noch Aufrufe an externe Systeme und vielleicht ein paar wenige Aufrufe in deiner Codebasis übrig, die du immer mit einer None Prüfung umhüllen musst. Das ist fehleranfällig; du kannst nicht erwarten, dass jeder Entwickler, der jemals mit deiner Codebasis zu tun hatte, instinktiv weiß, wo er auf None prüfen muss. Außerdem können die ursprünglichen Annahmen, die du beim Schreiben getroffen hast (z. B. dass diese Funktion niemals None zurückgibt), in der Zukunft gebrochen werden, und jetzt hat dein Code einen Fehler. Und genau hier liegt das Problem: Sich auf manuelle Eingriffe zu verlassen, um Fehler zu finden, ist unzuverlässig.

Der Grund dafür, dass dies so kompliziert (und kostspielig) ist, liegt darin, dass None als Sonderfall behandelt wird. Er existiert außerhalb der normalen Typenhierarchie. Jede Variable kann None zugewiesen werden. Um dem entgegenzuwirken, musst du einen Weg finden, None innerhalb deiner Typenhierarchie darzustellen. Du brauchst Optional Typen.

Optional Typen bieten dir zwei Möglichkeiten: entweder du hast einen Wert oder du hast keinen. Mit anderen Worten: Es ist optional, die Variable auf einen Wert zu setzen.

from typing import Optional
maybe_a_string: Optional[str] = "abcdef" # This has a value
maybe_a_string: Optional[str] = None     # This is the absence of a value

Dieser Code zeigt an, dass die Variable maybe_a_string optional eine Zeichenkette enthalten kann. Dieser Code prüft genau, ob maybe_a_string "abcdef" oder None enthält.

Auf den ersten Blick ist nicht ersichtlich, was dir das bringt. Du musst immer noch None verwenden, um das Fehlen eines Wertes darzustellen. Ich habe aber gute Nachrichten für dich. Es gibt drei Vorteile, die ich mit den Optional Typen verbinde.

Erstens kommunizierst du deine Absicht deutlicher. Wenn ein Entwickler einen Optional Typ in einer Typsignatur sieht, ist das für ihn ein deutliches Zeichen, dass er mit None rechnen sollte.

def dispense_bun() -> Optional[Bun]:
# ...

Wenn du feststellst, dass eine Funktion einen Wert von Optional zurückgibt, solltest du darauf achten und None überprüfen.

Zweitens kannst du die Abwesenheit eines Wertes von einem leeren Wert weiter unterscheiden. Betrachte die harmlose Liste. Was passiert, wenn du eine Funktion aufrufst und eine leere Liste erhältst? Wurde dir einfach kein Ergebnis zurückgeliefert? Oder ist ein Fehler aufgetreten und du musst explizit etwas unternehmen? Wenn du eine unbearbeitete Liste erhältst, kannst du das nicht wissen, ohne den Quellcode zu durchforsten. Wenn du jedoch eine Optional verwendest, übermittelst du eine von drei Möglichkeiten:

Eine Liste mit Elementen

Gültige Daten, mit denen gearbeitet werden soll

Eine Liste ohne Elemente

Es ist kein Fehler aufgetreten, aber es waren keine Daten verfügbar (vorausgesetzt, dass keine Daten kein Fehlerzustand sind)

None

Es ist ein Fehler aufgetreten, den du behandeln musst

Schließlich können Typprüfer Optional Typen erkennen und sicherstellen, dass du keine None Werte durchgehen lässt.

Bedenke:

def dispense_bun() -> Bun:
    return Bun('Wheat')

Fügen wir diesem Code einige Fehlerfälle hinzu:

def dispense_bun() -> Bun:
    if not are_buns_available():
        return None
    return Bun('Wheat')

Wenn du es mit einem Typechecker ausführst, erhältst du folgende Fehlermeldung:

code_examples/chapter4/invalid/dispense_bun.py:12:
    error: Incompatible return value type (got "None", expected "Bun")

Ausgezeichnet! Der Typechecker lässt standardmäßig nicht zu, dass du einen None Wert zurückgibst. Wenn du den Rückgabetyp von Bun in Optional[Bun] änderst, wird der Code erfolgreich typgeprüft. Dies gibt Entwicklern den Hinweis, dass sie nicht None zurückgeben sollten, ohne die Informationen im Rückgabetyp zu kodieren. Du kannst so einen häufigen Fehler abfangen und den Code robuster machen. Aber was ist mit dem aufrufenden Code?

Es stellt sich heraus, dass auch der aufrufende Code davon profitiert. Bedenke:

def create_hot_dog():
    bun = dispense_bun()
    frank = dispense_frank()
    hot_dog = bun.add_frank(frank)
    ketchup = dispense_ketchup()
    mustard = dispense_mustard()
    hot_dog.add_condiments(ketchup, mustard)
    dispense_hot_dog_to_customer(hot_dog)

Wenn dispense_bun ein Optional zurückgibt, führt dieser Code keine Typüberprüfung durch. Er meldet sich mit dem folgenden Fehler:

code_examples/chapter4/invalid/hotdog_invalid.py:27:
    error: Item "None" of "Optional[Bun]" has no attribute "add_frank"
Warnung

Abhängig von deiner Rechtschreibprüfung musst du eventuell eine spezielle Option aktivieren, um diese Art von Fehlern zu erkennen. Sieh immer in der Dokumentation deines Typechecker nach, welche Optionen verfügbar sind. Wenn es einen Fehler gibt, den du unbedingt abfangen willst, solltest du testen, ob dein Typprüfer den Fehler auch wirklich abfängt. Ich empfehle, speziell das Verhalten von Optional zu testen. Bei der Version von mypy, die ich verwende (0.800), muss ich --strict-optional als Befehlszeilenflag verwenden, um diesen Fehler abzufangen.

Wenn du den Typechecker zum Schweigen bringen willst, musst du explizit auf None prüfen und den Wert None behandeln, oder behaupten, dass der Wert nicht None sein kann. Der folgende Code führt eine erfolgreiche Typprüfung durch:

def create_hot_dog():
    bun = dispense_bun()
    if bun is None:
        print_error_code("Bun could not be dispensed")
        return

    frank = dispense_frank()
    hot_dog = bun.add_frank(frank)
    ketchup = dispense_ketchup()
    mustard = dispense_mustard()
    hot_dog.add_condiments(ketchup, mustard)
    dispense_hot_dog_to_customer(hot_dog)

None Werte sind wirklich ein milliardenschwerer Fehler. Wenn sie durchrutschen, können Programme abstürzen, die Benutzer sind frustriert und es geht Geld verloren. Verwende die Optional Typen, um anderen Entwicklern mitzuteilen, dass sie sich vor None hüten sollen, und profitiere von der automatischen Überprüfung deiner Tools.

Diskussionsthema

Wie oft hast du mit None in deiner Codebasis zu tun? Wie sicher bist du, dass jeder mögliche None Wert korrekt behandelt wird? Sieh dir Bugs und fehlgeschlagene Tests an, um herauszufinden, wie oft du von der falschen Handhabung von None betroffen bist. Diskutiere, wie die Optional Typen deiner Codebasis helfen werden.

Gewerkschaftstypen

Ein Union Typ ist ein Typ, der angibt, dass mehrere unterschiedliche Typen für dieselbe Variable verwendet werden können. Ein Union[int,str] bedeutet, dass entweder ein int oder ein str für eine Variable verwendet werden kann. Betrachte zum Beispiel den folgenden Code:

def dispense_snack() -> HotDog:
    if not are_ingredients_available():
        raise RuntimeError("Not all ingredients available")
    if order_interrupted():
        raise RuntimeError("Order interrupted")
    return create_hot_dog()

Ich möchte, dass mein Hotdog-Stand in das lukrative Brezelgeschäft einsteigt. Anstatt dich mit einer seltsamen Klassenvererbung zu befassen (wir werden in Teil II mehr über Vererbung erfahren), die nicht zwischen Hot Dogs und Brezeln gehört, kannst du einfach eine Union der beiden zurückgeben.

from typing import Union
def dispense_snack(user_input: str) -> Union[HotDog, Pretzel]:
    if user_input == "Hot Dog":
        return dispense_hot_dog()
    elif user_input == "Pretzel":
        return dispense_pretzel()
    raise RuntimeError("Should never reach this code,"
                       "as an invalid input has been entered")
Hinweis

Optional ist nur eine spezialisierte Version von Union. Optional[int] ist genau das Gleiche wie Union[int, None].

Die Verwendung eines Union bietet die gleichen Vorteile wie ein Optional. Erstens profitierst du von den gleichen Kommunikationsvorteilen. Ein Entwickler, der auf Union stößt, weiß, dass er in der Lage sein muss, mehr als einen Typ in seinem aufrufenden Code zu behandeln. Außerdem kennt ein Typechecker Union genauso gut wie Optional.

Du wirst Unions in einer Vielzahl von Anwendungen nützlich finden:

  • Behandlung unterschiedlicher Typen, die aufgrund von Benutzereingaben zurückgegeben werden (wie oben)

  • Handhabung von Fehlerrückgabetypen a la Optionals, aber mit mehr Informationen, wie z.B. einer Zeichenkette oder einem Fehlercode

  • Umgang mit unterschiedlichen Benutzereingaben (z.B. wenn ein Benutzer eine Liste oder einen String eingeben kann)

  • Rückgabe unterschiedlicher Typen, z.B. aus Gründen der Abwärtskompatibilität (Rückgabe einer alten Version eines Objekts oder einer neuen Version eines Objekts je nach angeforderter Operation)

  • Und jeder andere Fall, in dem du legitimerweise mehr als einen Wert repräsentiert haben kannst

Angenommen, du hast einen Code, der die Funktion dispense_snack aufruft, aber nur die Rückgabe von HotDog (oder None) erwartet:

from typing import Optional
def place_order() -> Optional[HotDog]:
    order = get_order()
    result = dispense_snack(order.name)
    if result is None
        print_error_code("An error occurred" + result)
        return None
    # Return our HotDog
    return result

Sobald dispense_snack beginnt, Pretzels zurückzugeben, schlägt dieser Code bei der Typenprüfung fehl.

code_examples/chapter4/invalid/union_hotdog.py:22:
    error: Incompatible return value type (got "Union[HotDog, Pretzel]",
                                           expected "Optional[HotDog]")

Die Tatsache, dass der Typechecker in diesem Fall einen Fehler macht, ist fantastisch. Wenn eine Funktion, von der du abhängig bist, einen neuen Typ zurückgibt, muss ihre Rückgabesignatur auf Union aktualisiert werden, was dich zwingt, deinen Code zu aktualisieren, um den neuen Typ zu verarbeiten. Das bedeutet, dass dein Code markiert wird, wenn sich deine Abhängigkeiten auf eine Weise ändern, die deinen Annahmen widerspricht. Mit den Entscheidungen, die du heute triffst, kannst du in der Zukunft Fehler abfangen. Das ist das Markenzeichen von robustem Code: Du machst es den Entwicklern immer schwerer, Fehler zu machen, wodurch ihre Fehlerquote sinkt und damit auch die Anzahl der Bugs, die die Nutzer erleben.

Es gibt noch einen weiteren grundlegenden Vorteil von Union, aber um ihn zu erklären, muss ich dir ein wenig über die Typentheorie erzählen, einen Zweig der Mathematik, der sich mit Typensystemen beschäftigt.

Produkt- und Summenarten

Unions sind von Vorteil, weil sie helfen, den repräsentierbaren Zustandsraum einzuschränken. Der repräsentierbare Zustandsraum ist die Menge aller möglichen Kombinationen, die ein Objekt annehmen kann.

Nimm dies dataclass:

from dataclasses import dataclass
# If you aren't familiar with data classes, you'll learn more in chapter 10
# but for now, treat this as four fields grouped together and what types they are
@dataclass
class Snack:
    name: str
    condiments: set[str]
    error_code: int
    disposed_of: bool


Snack("Hotdog", {"Mustard", "Ketchup"}, 5, False)

Ich habe einen Namen, die Gewürze, die oben drauf kommen können, einen Fehlercode für den Fall, dass etwas schief geht, und einen booleschen Wert, um festzustellen, ob ich den Gegenstand richtig entsorgt habe oder nicht. Wie viele verschiedene Kombinationen von Werten können in dieses Wörterbuch eingegeben werden? Eine potenziell unendliche Anzahl, oder? Allein die name könnte alles sein, von gültigen Werten ("Hotdog" oder "Brezel") über ungültige Werte ("Samosa", "Kimchi" oder "Poutine") bis hin zu absurden ("12345", "" oder "(╯°□°)╯︵ ┻━┻"). condiments hat ein ähnliches Problem. So wie es aussieht, gibt es keine Möglichkeit, die möglichen Optionen zu berechnen.

Der Einfachheit halber werde ich diesen Typ künstlich einschränken:

  • Der Name kann einer von drei Werten sein: Hotdog, Brezel oder Veggie-Burger

  • Die Gewürze können leer sein, Senf, Ketchup oder beides.

  • Es gibt sechs Fehlercodes (0-5); 0 bedeutet Erfolg).

  • disposed_of ist nur True oder False.

Wie viele verschiedene Werte können nun in dieser Kombination von Feldern dargestellt werden? Die Antwort lautet 144, was eine sehr große Zahl ist. Ich erreiche das wie folgt:

3 mögliche Typen für Namen × 4 mögliche Typen für Gewürze × 6 Fehlercodes × 2 boolesche Werte für die Frage, ob der Eintrag entsorgt wurde = 3×4×6×2 = 144.

Wenn du annimmst, dass jeder dieser Werte None sein könnte, erhöht sich die Gesamtzahl auf 420. Obwohl du beim Kodieren immer an None denken solltest (siehe weiter oben in diesem Kapitel über Optional), werde ich für diese Gedankenübung None values ignorieren.

Diese Art von Operation ist als Produkttyp bekannt; die Anzahl der darstellbaren Zustände wird durch das Produkt der möglichen Werte bestimmt. Das Problem ist, dass nicht alle diese Zustände gültig sind. Die Variable disposed_of sollte nur dann auf True gesetzt werden, wenn ein Fehlercode ungleich Null gesetzt ist. Die Entwickler gehen von dieser Annahme aus und vertrauen darauf, dass der ungültige Zustand nie auftaucht. Ein einziger unschuldiger Fehler kann jedoch dein ganzes System zum Absturz bringen. Betrachte den folgenden Code:

def serve(snack):
    # if something went wrong, return early
    if snack.disposed_of:
        return
    # ...

In diesem Fall prüft ein Entwickler disposed_of, ohne vorher zu prüfen, ob der Fehlercode ungleich Null ist. Das ist eine logische Bombe, die nur darauf wartet, zu passieren. Dieser Code funktioniert einwandfrei, solange disposed_of True ist und der Fehlercode ungleich Null ist. Wenn ein gültiger Snack das Flag disposed_of fälschlicherweise auf True setzt, wird dieser Code ungültige Ergebnisse liefern. Das kann schwer zu finden sein, da es für einen Entwickler, der den Snack erstellt, keinen Grund gibt, diesen Code zu überprüfen. So wie es aussieht, gibt es keine andere Möglichkeit, diese Art von Fehler zu finden, als jeden Anwendungsfall manuell zu überprüfen, was bei großen Codebasen schwierig ist. Indem du zulässt, dass ein illegaler Zustand dargestellt werden kann, öffnest du die Tür zu anfälligem Code.

Um das zu ändern, muss ich diesen illegalen Zustand nicht darstellen können. Dazu überarbeite ich mein Beispiel und verwende eine Union:

from dataclasses import dataclass
from typing import Union
@dataclass
class Error:
    error_code: int
    disposed_of: bool

@dataclass
class Snack:
    name: str
    condiments: set[str]

snack: Union[Snack, Error] = Snack("Hotdog", {"Mustard", "Ketchup"})

snack = Error(5, True)

In diesem Fall kann snack entweder ein Snack (was nur ein name und condiments ist) oder ein Error (was nur eine Zahl und ein Boolescher Wert ist) sein. Wie viele darstellbare Zustände gibt es jetzt, wenn man Union verwendet?

Für Snack gibt es 3 Namen und 4 mögliche Listenwerte, was insgesamt 12 darstellbare Zustände ergibt. Bei ErrorCode kann ich den Fehlercode 0 entfernen (da er nur für den Erfolg gilt), so dass ich 5 Werte für den Fehlercode und 2 Werte für den Booleschen Wert habe, was insgesamt 10 darstellbare Zustände ergibt. Da Union ein Entweder-Oder-Konstrukt ist, kann ich entweder 12 darstellbare Zustände in einem Fall oder 10 im anderen Fall haben, also insgesamt 22. Dies ist ein Beispiel für einen Summentyp, da ich die Anzahl der darstellbaren Zustände zusammenzähle, anstatt sie zu multiplizieren.

Das sind insgesamt 22 repräsentierbare Staaten. Vergleiche das mit den 144 Zuständen, als alle Felder in einer einzigen Einheit zusammengefasst waren. Ich habe den Raum der darstellbaren Zustände um fast 85 % reduziert. Ich habe es unmöglich gemacht, Felder zu mischen, die nicht miteinander kompatibel sind. Es ist viel schwieriger, einen Fehler zu machen, und es gibt viel weniger Kombinationen zu testen. Jedes Mal, wenn du einen Summentyp wie Union verwendest, reduzierst du die Anzahl der möglichen darstellbaren Zustände drastisch.

Wörtliche Typen

Bei der Berechnung der Anzahl der darstellbaren Zustände habe ich im letzten Abschnitt einige Annahmen getroffen. Ich habe die Anzahl der möglichen Werte begrenzt, aber das ist doch ein bisschen geschummelt, oder? Wie ich schon sagte, gibt es eine fast unendliche Anzahl von möglichen Werten. Zum Glück gibt es eine Möglichkeit, die Werte mit Python einzuschränken: Literals. Mit den Literal Typen kannst du die Variable auf eine ganz bestimmte Anzahl von Werten beschränken.

Ich ändere meine frühere Snack Klasse, um Literal Werte zu verwenden:

from typing import Literal
@dataclass
class Error:
    error_code: Literal[1,2,3,4,5]
    disposed_of: bool

@dataclass
class Snack:
    name: Literal["Pretzel", "Hot Dog", "Veggie Burger"]
    condiments: set[Literal["Mustard", "Ketchup"]]

Wenn ich nun versuche, diese Datenklassen mit falschen Werten zu instanziieren:

Error(0, False)
Snack("Invalid", set())
Snack("Pretzel", {"Mustard", "Relish"})

Ich erhalte die folgenden Fehler in der Rechtschreibprüfung:

code_examples/chapter4/invalid/literals.py:14: error: Argument 1 to "Error" has
    incompatible type "Literal[0]";
                      expected "Union[Literal[1], Literal[2], Literal[3],
                                      Literal[4], Literal[5]]"

code_examples/chapter4/invalid/literals.py:15: error: Argument 1 to "Snack" has
    incompatible type "Literal['Invalid']";
                       expected "Union[Literal['Pretzel'], Literal['Hotdog'],
                                       Literal['Veggie Burger']]"

code_examples/chapter4/invalid/literals.py:16: error: Argument 2 to <set> has
    incompatible type "Literal['Relish']";
                       expected "Union[Literal['Mustard'], Literal['Ketchup']]"

Literals wurden in Python 3.8 eingeführt und sind eine unschätzbare Möglichkeit, die möglichen Werte einer Variablen einzuschränken. Sie sind ein wenig leichter als Python-Aufzählungen (die ich in Kapitel 8 behandeln werde).

Kommentierte Typen

Was ist, wenn ich noch tiefer gehen und komplexere Bedingungen angeben möchte? Es wäre mühsam, Hunderte von Literalen zu schreiben, und einige Beschränkungen können nicht durch Literal Typen modelliert werden. Mit Literal ist es nicht möglich, eine Zeichenkette auf eine bestimmte Größe zu beschränken oder einem bestimmten regulären Ausdruck zu entsprechen. An dieser Stelle kommt Annotated ins Spiel. Mit Annotated kannst du beliebige Metadaten zusammen mit deiner Typannotation angeben.

x: Annotated[int, ValueRange(3,5)]
y: Annotated[str, MatchesRegex('[0-9]{4}')]

Leider kann der obige Code nicht ausgeführt werden, da ValueRange und MatchesRegex keine eingebauten Typen sind; es handelt sich um beliebige Ausdrücke. Du musst deine eigenen Metadaten als Teil einer Annotated Variablen schreiben. Zweitens gibt es keine Tools, die diese Typen für dich überprüfen können. Das Beste, was du tun kannst, bis es ein solches Werkzeug gibt, ist, Dummy-Annotationen zu schreiben oder Strings zu verwenden, um deine Einschränkungen zu beschreiben. Zu diesem Zeitpunkt ist Annotated am besten als Kommunikationsmethode geeignet.

NewType

Während du darauf wartest, dass die Werkzeuge Annotated unterstützen, gibt es eine weitere Möglichkeit, kompliziertere Einschränkungen darzustellen: NewType. Mit NewType kannst du, nun ja, einen neuen Typ erstellen.

Angenommen, ich möchte den Code meines Hotdog-Standes so aufteilen, dass er zwei verschiedene Fälle behandelt: einen Hotdog, der nicht serviert werden kann (kein Teller, keine Servietten) und einen Hotdog, der servierbereit ist (mit Teller und Servietten). In meinem Code gibt es einige Funktionen, die nur in dem einen oder dem anderen Fall auf den Hotdog wirken sollten. Ein nicht servierbarer Hotdog sollte zum Beispiel nie an den Kunden ausgegeben werden.

class HotDog:
    # ... snip hot dog class implementation ...

def dispense_to_customer(hot_dog: HotDog):
    # note, this should only accept ready-to-serve hot dogs.
    # ...

Nichts hindert jedoch jemanden daran, einen unbrauchbaren Hotdog einzugeben. Wenn ein Entwickler einen Fehler macht und einen untauglichen Hotdog an diese Funktion übergibt, werden die Kunden ziemlich überrascht sein, wenn nur ihre Bestellung ohne Teller oder Servietten aus dem Automaten kommt.

Anstatt dich darauf zu verlassen, dass die Entwickler diese Fehler erkennen, wenn sie auftreten, musst du eine Möglichkeit finden, dass dein Typechecker diese Fehler erkennt. Dazu kannst du NewType verwenden:

from typing import NewType

class HotDog:
    ''' Used to represent an unservable hot dog'''
    # ... snip hot dog class implementation ...

ReadyToServeHotDog = NewType("ReadyToServeHotDog", HotDog)

def dispense_to_customer(hot_dog: ReadyToServeHotDog):
    # ...

Ein NewType nimmt einen bestehenden Typ und erstellt einen ganz neuen Typ, der alle Felder und Methoden des bestehenden Typs hat. In diesem Fall erzeuge ich einen Typ ReadyToServeHotDog, der sich von HotDog unterscheidet; sie sind nicht austauschbar. Das Schöne daran ist, dass dieser Typ die impliziten Typumwandlungen einschränkt. Du kannst HotDog nicht dort verwenden, wo du ReadyToServeHotDog erwartest (du kannst jedoch ReadyToServeHotDog anstelle von HotDog verwenden). Im vorigen Beispiel schränke ich dispense_to_customer so ein, dass nur ReadyToServeHotDog Werte als Argument akzeptiert werden. Dadurch wird verhindert, dass Entwickler ihre Annahmen ungültig machen. Wenn ein Entwickler einen HotDog an diese Methode übergibt, wird der Typechecker ihn anschreien:

code_examples/chapter4/invalid/newtype.py:10: error:
	Argument 1 to "dispense_to_customer"
	has incompatible type "HotDog";
	expected "ReadyToServeHotDog"

Es ist wichtig zu betonen, dass diese Typumwandlung in eine Richtung erfolgt. Als Entwickler kannst du kontrollieren, wann dein alter Typ zu deinem neuen Typ wird.

Ich werde zum Beispiel eine Funktion erstellen, die eine nicht bedienbare HotDog nimmt und sie zum Bedienen bereit macht:

def prepare_for_serving(hot_dog: HotDog) -> ReadyToServeHotDog:
    assert not hot_dog.is_plated(), "Hot dog should not already be plated"
    hot_dog.put_on_plate()
    hot_dog.add_napkins()
    return ReadyToServeHotDog(hot_dog)

Beachte, dass ich ausdrücklich eine ReadyToServeHotDog anstelle einer normalen HotDog zurückliefere. Dies fungiert als "gesegnete" Funktion; es ist die einzige erlaubte Art und Weise, in der ich möchte, dass Entwickler eine ReadyToServeHotDog erstellen. Jeder Benutzer, der versucht, eine Methode zu verwenden, die eine ReadyToServeHotDog benötigt, muss diese zuerst mit prepare_for_serving erstellen.

Es ist wichtig, den Nutzern mitzuteilen, dass die einzige Möglichkeit, einen neuen Typ zu erstellen, eine Reihe von "gesegneten" Funktionen ist. Du möchtest nicht, dass die Benutzer deinen neuen Typ unter anderen Umständen als einer vorgegebenen Methode erstellen, da dies den Zweck verfehlt.

def make_snack():
    serve_to_customer(ReadyToServeHotDog(HotDog()))

Leider gibt es in Python keine gute Möglichkeit, den Benutzern dies mitzuteilen, außer einem Kommentar.

from typing import NewType
# NOTE: Only create ReadyToServeHotDog using prepare_for_serving method.
ReadyToServeHotDog = NewType("ReadyToServeHotDog", HotDog)

Dennoch ist NewType auf viele reale Szenarien anwendbar. Das sind zum Beispiel alles Szenarien, die ich schon erlebt habe und für die NewType eine Lösung wäre:

  • Die Trennung von str und SanitizedString, um Bugs wie SQL-Injection-Schwachstellen abzufangen. Indem ich SanitizedString zu NewType machte, stellte ich sicher, dass nur ordnungsgemäß bereinigte Zeichenketten verarbeitet wurden, wodurch die Möglichkeit einer SQL-Injection ausgeschlossen wurde.

  • Verfolge ein User Objekt und LoggedInUser separat. Indem ich Users mit NewType von LoggedInUser einschränkte, schrieb ich Funktionen, die nur auf eingeloggte Benutzer anwendbar waren.

  • Verfolgung einer Ganzzahl, die eine gültige Benutzer-ID darstellen sollte. Indem ich die Benutzer-ID auf NewType einschränkte, konnte ich sicherstellen, dass einige Funktionen nur mit gültigen IDs arbeiten, ohne die if Anweisungen überprüfen zu müssen.

In Kapitel 10 wirst du sehen, wie du Klassen und Invarianten verwenden kannst, um etwas sehr Ähnliches zu tun, mit einer viel stärkeren Garantie, illegale Zustände zu vermeiden. Dennoch ist NewType ein nützliches Muster, das du kennen solltest, und es ist viel leichter als eine vollständige Klasse.

Letzte Typen

Schließlich (Wortspiel beabsichtigt) möchtest du vielleicht verhindern, dass ein Typ seinen Wert ändert. Hier kommt Final ins Spiel. Final Die in Python 3.8 eingeführte Variable "Typ" zeigt einem Typprüfer an, dass eine Variable nicht an einen anderen Wert gebunden werden kann. Ich möchte zum Beispiel meinen Hotdog-Stand in ein Franchise-System überführen, aber ich möchte nicht, dass der Name aus Versehen geändert wird.

VENDOR_NAME: Final = "Viafore's Auto-Dog"

Wenn ein Entwickler den Namen später versehentlich ändert, würde er einen Fehler sehen.

def display_vendor_information():
    vendor_info = "Auto-Dog v1.0"
    # whoops, copy-paste error, this code should be vendor_info += VENDOR_NAME
    VENDOR_NAME += VENDOR_NAME
    print(vendor_info)
code_examples/chapter4/invalid/final.py:3: error:
	Cannot assign to final name "VENDOR_NAME"
Found 1 error in 1 file (checked 1 source file)

Im Allgemeinen wird Final am besten verwendet, wenn sich der Geltungsbereich einer Variablen über einen großen Teil des Codes erstreckt, z. B. ein Modul. Für Entwickler ist es schwierig, den Überblick über alle Verwendungen einer Variablen in solch großen Bereichen zu behalten; in diesen Fällen ist es ein Segen, wenn der Typprüfer Unveränderbarkeitsgarantien abfangen kann.

Warnung

Final führt nicht zu einem Fehler, wenn ein Objekt durch eine Funktion verändert wird. Sie verhindert nur, dass die Variable neu gebunden (auf einen neuen Wert gesetzt) wird.

Schlussgedanken

In diesem Kapitel hast du viele verschiedene Möglichkeiten kennengelernt, deine Typen einzuschränken. Sie dienen alle einem bestimmten Zweck, von der Handhabung von None mit Optional über die Beschränkung auf bestimmte Werte mit Literal bis hin zur Verhinderung des Rückpralls einer Variablen mit Final. Mit diesen Techniken kannst du Annahmen und Einschränkungen direkt in deinem Code verschlüsseln und verhindern, dass zukünftige Leser deine Logik erraten müssen. Typprüfer nutzen diese fortschrittlichen Typ-Annotationen, um dir strengere Garantien für deinen Code zu geben, die den Betreuern Vertrauen geben, wenn sie in deiner Codebasis arbeiten. Mit diesem Vertrauen werden sie weniger Fehler machen, und deine Codebasis wird dadurch robuster.

Im nächsten Kapitel lernst du, wie du einzelne Werte und Auflistungen richtig beschriftest. Auflistungstypen sind in Python allgegenwärtig; du musst darauf achten, dass du deine Absichten auch für sie ausdrückst. Du musst alle Möglichkeiten kennen, wie du eine Sammlung darstellen kannst, auch für den Fall, dass du eine eigene erstellen musst.

1 C.A.R. Hoare. "Null-Referenzen: The Billion Dollar Mistake". Historically Bad Ideas. Präsentiert auf der Qcon London 2009, n.d.

Get Robustes Python now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.