PEP 647 – Benutzerdefinierte Typ-Guards
- Autor:
- Eric Traut <erictr at microsoft.com>
- Sponsor:
- Guido van Rossum <guido at python.org>
- Discussions-To:
- Typing-SIG list
- Status:
- Final
- Typ:
- Standards Track
- Thema:
- Typisierung
- Erstellt:
- 07-Okt-2020
- Python-Version:
- 3.10
- Post-History:
- 28-Dez-2020, 09-Apr-2021
- Resolution:
- Python-Dev thread
Inhaltsverzeichnis
Zusammenfassung
Diese PEP spezifiziert eine Methode, mit der Programme die bedingte Typenverengung beeinflussen können, die von einem Typ-Checker basierend auf Laufzeitprüfungen angewendet wird.
Motivation
Statische Typ-Checker verwenden üblicherweise eine Technik namens „Typenverengung“, um einen präziseren Typ eines Ausdrucks im Codefluss eines Programms zu bestimmen. Wenn die Typenverengung innerhalb eines Codeblocks basierend auf einer bedingten Codeflussanweisung (wie if und while Anweisungen) angewendet wird, wird der bedingte Ausdruck manchmal als „Typ-Guard“ bezeichnet. Python-Typ-Checker unterstützen typischerweise verschiedene Formen von Typ-Guard-Ausdrücken.
def func(val: Optional[str]):
# "is None" type guard
if val is not None:
# Type of val is narrowed to str
...
else:
# Type of val is narrowed to None
...
def func(val: Optional[str]):
# Truthy type guard
if val:
# Type of val is narrowed to str
...
else:
# Type of val remains Optional[str]
...
def func(val: Union[str, float]):
# "isinstance" type guard
if isinstance(val, str):
# Type of val is narrowed to str
...
else:
# Type of val is narrowed to float
...
def func(val: Literal[1, 2]):
# Comparison type guard
if val == 1:
# Type of val is narrowed to Literal[1]
...
else:
# Type of val is narrowed to Literal[2]
...
Es gibt Fälle, in denen Typenverengung nur auf Basis statischer Informationen nicht angewendet werden kann. Betrachten Sie das folgende Beispiel
def is_str_list(val: List[object]) -> bool:
"""Determines whether all objects in the list are strings"""
return all(isinstance(x, str) for x in val)
def func1(val: List[object]):
if is_str_list(val):
print(" ".join(val)) # Error: invalid type
Dieser Code ist korrekt, aber ein Typ-Checker wird einen Typfehler melden, da der an die join Methode übergebene Wert val als Typ List[object] verstanden wird. Der Typ-Checker hat an dieser Stelle nicht genügend Informationen, um statisch zu überprüfen, dass der Typ von val List[str] ist.
Diese PEP führt eine Methode ein, mit der eine Funktion wie is_str_list als „benutzerdefinierter Typ-Guard“ definiert werden kann. Dies ermöglicht es Code, die von Typ-Checkern unterstützten Typ-Guards zu erweitern.
Unter Verwendung dieses neuen Mechanismus würde die Funktion is_str_list im obigen Beispiel leicht modifiziert werden. Ihr Rückgabetyp würde von bool zu TypeGuard[List[str]] geändert. Dies verspricht nicht nur, dass der Rückgabewert boolesch ist, sondern dass ein wahrer Wert angibt, dass die Eingabe für die Funktion vom angegebenen Typ war.
from typing import TypeGuard
def is_str_list(val: List[object]) -> TypeGuard[List[str]]:
"""Determines whether all objects in the list are strings"""
return all(isinstance(x, str) for x in val)
Benutzerdefinierte Typ-Guards können auch verwendet werden, um zu bestimmen, ob ein Wörterbuch den Typanforderungen eines TypedDict entspricht.
class Person(TypedDict):
name: str
age: int
def is_person(val: dict) -> "TypeGuard[Person]":
try:
return isinstance(val["name"], str) and isinstance(val["age"], int)
except KeyError:
return False
def print_age(val: dict):
if is_person(val):
print(f"Age: {val['age']}")
else:
print("Not a person!")
Spezifikation
TypeGuard-Typ
Diese PEP führt das Symbol TypeGuard ein, das aus dem Modul typing exportiert wird. TypeGuard ist eine spezielle Form, die ein einzelnes Typargument akzeptiert. Sie wird verwendet, um den Rückgabetyp einer benutzerdefinierten Typ-Guard-Funktion zu annotieren. Rückgaben innerhalb einer Typ-Guard-Funktion sollten boolesche Werte zurückgeben, und Typ-Checker sollten überprüfen, dass alle Rückpfade einen booleschen Wert zurückgeben.
In allen anderen Belangen ist TypeGuard ein vom bool verschiedenen Typ. Er ist keine Unterklasse von bool. Daher ist Callable[..., TypeGuard[int]] nicht zu Callable[..., bool] zuweisbar.
Wenn TypeGuard verwendet wird, um den Rückgabetyp einer Funktion oder Methode zu annotieren, die mindestens einen Parameter akzeptiert, wird diese Funktion oder Methode von Typ-Checkern als benutzerdefinierter Typ-Guard behandelt. Das für TypeGuard bereitgestellte Typargument gibt den vom Funktion getesteten Typ an.
Benutzerdefinierte Typ-Guards können generische Funktionen sein, wie dieses Beispiel zeigt
_T = TypeVar("_T")
def is_two_element_tuple(val: Tuple[_T, ...]) -> TypeGuard[Tuple[_T, _T]]:
return len(val) == 2
def func(names: Tuple[str, ...]):
if is_two_element_tuple(names):
reveal_type(names) # Tuple[str, str]
else:
reveal_type(names) # Tuple[str, ...]
Typ-Checker sollten davon ausgehen, dass die Typenverengung auf den Ausdruck angewendet wird, der als erstes positionales Argument an einen benutzerdefinierten Typ-Guard übergeben wird. Wenn die Typ-Guard-Funktion mehr als ein Argument akzeptiert, wird keine Typenverengung auf diese zusätzlichen Argumentausdrücke angewendet.
Wenn eine Typ-Guard-Funktion als Instanzmethode oder Klassenmethode implementiert ist, wird das erste positionale Argument dem zweiten Parameter (nach „self“ oder „cls“) zugeordnet.
Hier sind einige Beispiele für benutzerdefinierte Typ-Guard-Funktionen, die mehr als ein Argument akzeptieren
def is_str_list(val: List[object], allow_empty: bool) -> TypeGuard[List[str]]:
if len(val) == 0:
return allow_empty
return all(isinstance(x, str) for x in val)
_T = TypeVar("_T")
def is_set_of(val: Set[Any], type: Type[_T]) -> TypeGuard[Set[_T]]:
return all(isinstance(x, type) for x in val)
Der Rückgabetyp einer benutzerdefinierten Typ-Guard-Funktion bezieht sich normalerweise auf einen Typ, der strenger „enger“ als der Typ des ersten Arguments ist (d.h. es ist ein spezifischerer Typ, der dem allgemeineren Typ zugewiesen werden kann). Es ist jedoch nicht erforderlich, dass der Rückgabetyp strenger ist. Dies ermöglicht Fälle wie das obige Beispiel, bei dem List[str] aufgrund von Invarianzregeln nicht zu List[object] zuweisbar ist.
Wenn eine bedingte Anweisung einen Aufruf einer benutzerdefinierten Typ-Guard-Funktion enthält und diese Funktion true zurückgibt, sollte der als erstes positionales Argument an die Typ-Guard-Funktion übergebene Ausdruck von einem statischen Typ-Checker als Typ angenommen werden, der im TypeGuard-Rückgabetyp angegeben ist, es sei denn und bis er innerhalb des bedingten Codeblocks weiter verengt wird.
Einige integrierte Typ-Guards bieten eine Verengung sowohl für positive als auch für negative Tests (sowohl in den if als auch in den else Klauseln). Betrachten Sie zum Beispiel den Typ-Guard für einen Ausdruck der Form x is None. Wenn x einen Typ hat, der eine Union von None und einem anderen Typ ist, wird er im positiven Fall zu None und im negativen Fall zum anderen Typ verengt. Benutzerdefinierte Typ-Guards wenden die Verengung nur im positiven Fall (der if Klausel) an. Der Typ wird im negativen Fall nicht verengt.
OneOrTwoStrs = Union[Tuple[str], Tuple[str, str]]
def func(val: OneOrTwoStrs):
if is_two_element_tuple(val):
reveal_type(val) # Tuple[str, str]
...
else:
reveal_type(val) # OneOrTwoStrs
...
if not is_two_element_tuple(val):
reveal_type(val) # OneOrTwoStrs
...
else:
reveal_type(val) # Tuple[str, str]
...
Abwärtskompatibilität
Bestehender Code, der diese neue Funktionalität nicht verwendet, wird davon nicht betroffen sein.
Insbesondere Code, der Annotationen auf eine Weise verwendet, die mit der stdlib typing library inkompatibel ist, sollte TypeGuard einfach nicht importieren.
Referenzimplementierung
Der Pyright-Typ-Checker unterstützt das in dieser PEP beschriebene Verhalten.
Abgelehnte Ideen
Decorator-Syntax
Die Verwendung eines Decorators wurde zur Definition von Typ-Guards in Betracht gezogen.
@type_guard(List[str])
def is_str_list(val: List[object]) -> bool: ...
Der Decorator-Ansatz ist unterlegen, da er die Laufzeitauswertung des Typs erfordert und Vorwärtsreferenzen ausschließt. Der vorgeschlagene Ansatz wurde auch als einfacher zu verstehen und simpler in der Implementierung angesehen.
Erzwingen strenger Verengung
Eine strenge Erzwingung der Typenverengung (was erfordert, dass der im TypeGuard-Typargument angegebene Typ eine engere Form des für den ersten Parameter angegebenen Typs ist) wurde in Betracht gezogen, aber dies schließt wertvolle Anwendungsfälle für diese Funktionalität aus. Zum Beispiel wäre das is_str_list Beispiel oben als ungültig betrachtet worden, da List[str] aufgrund von Invarianzregeln keine Unterklasse von List[object] ist.
Eine Variante, die in Betracht gezogen wurde, war, standardmäßig eine strenge Verengungsanforderung zu verlangen, aber der Typ-Guard-Funktion zu erlauben, ein Flag anzugeben, das anzeigt, dass sie diese Anforderung nicht befolgt. Dies wurde abgelehnt, da es als umständlich und unnötig angesehen wurde.
Eine weitere Überlegung war die Definition einer weniger strengen Prüfung, die sicherstellt, dass eine Überlappung zwischen dem Werttyp und dem im TypeGuard angegebenen verengten Typ besteht. Das Problem mit diesem Vorschlag ist, dass die Regeln für Typkompatibilität bereits sehr komplex sind, wenn Unions, Protokolle, Typvariablen, Generics usw. berücksichtigt werden. Das Definieren einer Variante dieser Regeln, die einige dieser Einschränkungen nur für den Zweck dieser Funktion lockert, würde erfordern, dass wir all die subtilen Arten formulieren, wie sich die Regeln unterscheiden, und unter welchen spezifischen Umständen die Einschränkungen gelockert werden. Aus diesem Grund wurde beschlossen, alle Prüfungen wegzulassen.
Es wurde festgestellt, dass ohne die Erzwingung strenger Verengung die Typsicherheit verletzt werden könnte. Eine schlecht geschriebene Typ-Guard-Funktion könnte unsichere oder sogar unsinnige Ergebnisse liefern. Zum Beispiel
def f(value: int) -> TypeGuard[str]:
return True
Es gibt jedoch viele Möglichkeiten, wie ein entschlossener oder unerfahrener Entwickler die Typsicherheit untergraben kann – am häufigsten durch die Verwendung von cast oder Any. Wenn ein Python-Entwickler die Zeit aufwendet, sich mit benutzerdefinierten Typ-Guards auseinanderzusetzen und diese in seinem Code zu implementieren, kann man davon ausgehen, dass er an Typsicherheit interessiert ist und seine Typ-Guard-Funktionen nicht so schreibt, dass sie die Typsicherheit untergraben oder unsinnige Ergebnisse liefern.
Bedingte Anwendung des TypeGuard-Typs
Es wurde vorgeschlagen, dass der als erstes Argument an eine Typ-Guard-Funktion übergebene Ausdruck seinen bestehenden Typ behalten sollte, wenn der Typ des Ausdrucks eine richtige Unterklasse des im TypeGuard-Rückgabetyp angegebenen Typs war. Wenn beispielsweise die Typ-Guard-Funktion def f(value: object) -> TypeGuard[float] ist und der an diese Funktion übergebene Ausdruck vom Typ int ist, würde er den Typ int behalten, anstatt den vom TypeGuard-Rückgabetyp angezeigten Typ float anzunehmen. Dieser Vorschlag wurde abgelehnt, da er Komplexität und Inkonsistenz hinzufügte und zusätzliche Fragen bezüglich des richtigen Verhaltens aufwarf, wenn der Typ des Ausdrucks aus zusammengesetzten Typen wie Unions oder Typvariablen mit mehreren Einschränkungen bestand. Es wurde entschieden, dass die zusätzliche Komplexität und Inkonsistenz nicht gerechtfertigt sei, da sie wenig bis keinen Mehrwert bieten würde.
Verengung beliebiger Parameter
Typskripts Formulierung von benutzerdefinierten Typ-Guards erlaubt die Verwendung jedes Eingabeparameters als den Wert, der für die Verengung getestet wird. Die Autoren der Typskript-Sprache konnten sich keine realen Beispiele in Typskript erinnern, bei denen der getestete Parameter nicht der erste Parameter war. Aus diesem Grund wurde es als unnötig erachtet, die Python-Implementierung von benutzerdefinierten Typ-Guards mit zusätzlicher Komplexität zu belasten, um einen konstruierten Anwendungsfall zu unterstützen. Wenn solche Anwendungsfälle in Zukunft identifiziert werden, gibt es Möglichkeiten, wie der TypeGuard-Mechanismus erweitert werden könnte. Dies könnte die Verwendung von Schlüsselwortindizierung beinhalten, wie in PEP 637 vorgeschlagen.
Verengung impliziter „self“- und „cls“-Parameter
Der Vorschlag besagt, dass der erste positionale Parameter als der für die Verengung getestete Wert angenommen wird. Wenn die Typ-Guard-Funktion als Instanz- oder Klassenmethode implementiert ist, wird auch ein impliziter self- oder cls-Parameter an die Funktion übergeben. Es wurde die Bedenken geäußert, dass es Fälle geben könnte, in denen die Verengungslogik auf self und cls angewendet werden soll. Dies ist ein ungewöhnlicher Anwendungsfall, und seine Berücksichtigung würde die Implementierung von benutzerdefinierten Typ-Guards erheblich komplizieren. Daher wurde beschlossen, keine besonderen Vorkehrungen dafür zu treffen. Wenn eine Verengung von self oder cls erforderlich ist, kann der Wert als explizites Argument an eine Typ-Guard-Funktion übergeben werden.
Urheberrecht
Dieses Dokument wird in die Public Domain oder unter die CC0-1.0-Universal-Lizenz gestellt, je nachdem, welche Lizenz permissiver ist.
Quelle: https://github.com/python/peps/blob/main/peps/pep-0647.rst
Zuletzt geändert: 2024-06-11 22:12:09 GMT