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?
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
Optional
s, 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 nurTrue
oderFalse
.
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
undSanitizedString
, um Bugs wie SQL-Injection-Schwachstellen abzufangen. Indem ichSanitizedString
zuNewType
machte, stellte ich sicher, dass nur ordnungsgemäß bereinigte Zeichenketten verarbeitet wurden, wodurch die Möglichkeit einer SQL-Injection ausgeschlossen wurde. -
Verfolge ein
User
Objekt undLoggedInUser
separat. Indem ichUsers
mitNewType
vonLoggedInUser
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 dieif
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
(
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.