Following system colour scheme Selected dark colour scheme Selected light colour scheme

Python Enhancement Proposals

PEP 724 – Strengere Typ-Guards

Autor:
Rich Chiodo <rchiodo at microsoft.com>, Eric Traut <erictr at microsoft.com>, Erik De Bonte <erikd at microsoft.com>
Sponsor:
Jelle Zijlstra <jelle.zijlstra at gmail.com>
Discussions-To:
Discourse thread
Status:
Zurückgezogen
Typ:
Standards Track
Thema:
Typisierung
Erstellt:
28-Jul-2023
Python-Version:
3.13
Post-History:
30-Dez-2021, 19-Sep-2023

Inhaltsverzeichnis

Status

Diese PEP wurde zurückgezogen. Der Typing Council konnte keinen Konsens zu diesem Vorschlag erreichen, und die Autoren beschlossen, ihn zurückzuziehen.

Zusammenfassung

PEP 647 führte das Konzept einer benutzerdefinierten Typ-Guard-Funktion ein, die True zurückgibt, wenn der Typ des Ausdrucks, der an ihren ersten Parameter übergeben wird, mit ihrem Rückgabe-Typ TypeGuard übereinstimmt. Eine Funktion, deren Rückgabetyp TypeGuard[str] ist, wird beispielsweise angenommen, True zurückzugeben, wenn und nur wenn der Typ des Ausdrucks, der an ihren ersten Eingabeparameter übergeben wird, ein str ist. Dies ermöglicht es Typ-Prüfern, Typen zu verfeinern, wenn eine benutzerdefinierte Typ-Guard-Funktion True zurückgibt.

Diese PEP verfeinert den in PEP 647 eingeführten Mechanismus TypeGuard. Sie ermöglicht es Typ-Prüfern, Typen zu verfeinern, wenn eine benutzerdefinierte Typ-Guard-Funktion False zurückgibt. Sie ermöglicht es Typ-Prüfern auch, unter bestimmten Umständen zusätzliche (präzisere) Typverfeinerungen anzuwenden, wenn die Typ-Guard-Funktion True zurückgibt.

Motivation

Benutzerdefinierte Typ-Guard-Funktionen ermöglichen es einem Typ-Prüfer, den Typ eines Ausdrucks zu verfeinern, wenn dieser als Argument an die Typ-Guard-Funktion übergeben wird. Der in PEP 647 eingeführte Mechanismus TypeGuard ist flexibel, aber diese Flexibilität bringt einige Einschränkungen mit sich, die von Entwicklern für einige Anwendungen als umständlich empfunden wurden.

Einschränkung 1: Typ-Prüfern ist es nicht gestattet, einen Typ zu verfeinern, wenn die Typ-Guard-Funktion False zurückgibt. Das bedeutet, dass der Typ im negativen („else“) Zweig nicht verfeinert wird.

Einschränkung 2: Typ-Prüfer müssen den Rückgabetyp TypeGuard verwenden, wenn die Typ-Guard-Funktion True zurückgibt, unabhängig davon, ob zusätzliche Verfeinerungen basierend auf dem Wissen über den vorab verfeinerten Typ angewendet werden können.

Das folgende Codebeispiel demonstriert beide Einschränkungen.

def is_iterable(val: object) -> TypeGuard[Iterable[Any]]:
    return isinstance(val, Iterable)

def func(val: int | list[int]):
    if is_iterable(val):
        # The type is narrowed to 'Iterable[Any]' as dictated by
        # the TypeGuard return type
        reveal_type(val)  # Iterable[Any]
    else:
        # The type is not narrowed in the "False" case
        reveal_type(val)  # int | list[int]

    # If "isinstance" is used in place of the user-defined type guard
    # function, the results differ because type checkers apply additional
    # logic for "isinstance"

    if isinstance(val, Iterable):
        # Type is narrowed to "list[int]" because this is
        # a narrower (more precise) type than "Iterable[Any]"
        reveal_type(val)  # list[int]
    else:
        # Type is narrowed to "int" because the logic eliminates
        # "list[int]" from the original union
        reveal_type(val)  # int

PEP 647 hat diese Einschränkungen auferlegt, um Anwendungsfälle zu unterstützen, bei denen der Rückgabe-Typ TypeGuard keine Unterklasse des Eingabetyp war. Siehe PEP 647 für Beispiele.

Begründung

Es gibt eine Reihe von Problemen, bei denen ein strengeres TypeGuard eine Lösung gewesen wäre

Spezifikation

Die Verwendung einer benutzerdefinierten Typ-Guard-Funktion beinhaltet fünf Typen

  • I = TypeGuard Eingabetyp
  • R = TypeGuard Rückgabetyp
  • A = Typ des Arguments, das an die Typ-Guard-Funktion übergeben wird (vor-verfeinert)
  • NP = Verfeinerter Typ (positiv)
  • NN = Verfeinerter Typ (negativ)
def guard(x: I) -> TypeGuard[R]: ...

def func1(val: A):
    if guard(val):
        reveal_type(val)  # NP
    else:
        reveal_type(val)  # NN

Diese PEP schlägt einige Änderungen an PEP 647 vor, um die oben diskutierten Einschränkungen zu beheben. Diese Einschränkungen können nur sicher beseitigt werden, wenn eine bestimmte Bedingung erfüllt ist. Insbesondere, wenn der Ausgabetyp R einer benutzerdefinierten Typ-Guard-Funktion konsistent [1] mit dem Typ ihres ersten Eingabeparameters (I) ist, sollten Typ-Prüfer strengere Typ-Guard-Semantiken anwenden.

# Stricter type guard semantics are used in this case because
# "Kangaroo | Koala" is consistent with "Animal"
def is_marsupial(val: Animal) -> TypeGuard[Kangaroo | Koala]:
    return isinstance(val, Kangaroo | Koala)

# Stricter type guard semantics are not used in this case because
# "list[T]"" is not consistent with "list[T | None]"
def has_no_nones(val: list[T | None]) -> TypeGuard[list[T]]:
    return None not in val

Wenn strengere Typ-Guard-Semantiken angewendet werden, ändert sich die Anwendung einer benutzerdefinierten Typ-Guard-Funktion auf zwei Arten.

  • Typverfeinerung wird im negativen („else“) Fall angewendet.
def is_str(val: str | int) -> TypeGuard[str]:
    return isinstance(val, str)

def func(val: str | int):
    if not is_str(val):
        reveal_type(val)  # int
  • Zusätzliche Typverfeinerung wird im positiven „if“-Fall angewendet, falls zutreffend.
def is_cardinal_direction(val: str) -> TypeGuard[Literal["N", "S", "E", "W"]]:
    return val in ("N", "S", "E", "W")

def func(direction: Literal["NW", "E"]):
    if is_cardinal_direction(direction):
        reveal_type(direction)  # "Literal[E]"
    else:
        reveal_type(direction)  # "Literal[NW]"

Die typ-theoretischen Regeln für Typverfeinerung sind in der folgenden Tabelle spezifiziert.

Nicht-strikter Typ-Guard Strikter Typ-Guard
Anwendbar, wenn R nicht konsistent mit I R konsistent mit I
NP ist .. R AR
NN ist .. A A∧¬R

In der Praxis können die theoretischen Typen für strenge Typ-Guards im Python-Typensystem nicht präzise ausgedrückt werden. Typ-Prüfer sollten auf praktische Annäherungen dieser Typen zurückgreifen. Als Faustregel sollte ein Typ-Prüfer die gleiche Typverfeinerungslogik verwenden – und Ergebnisse erzielen, die konsistent sind mit – seiner Handhabung von „isinstance“. Diese Richtlinie ermöglicht Änderungen und Verbesserungen, falls das Typensystem in Zukunft erweitert wird.

Zusätzliche Beispiele

Any ist konsistent [1] mit jedem anderen Typ, was bedeutet, dass strengere Semantiken angewendet werden können.

 # Stricter type guard semantics are used in this case because
 # "str" is consistent with "Any"
def is_str(x: Any) -> TypeGuard[str]:
    return isinstance(x, str)

def test(x: float | str):
    if is_str(x):
        reveal_type(x)  # str
    else:
        reveal_type(x)  # float

Abwärtskompatibilität

Diese PEP schlägt eine Änderung des bestehenden Verhaltens von TypeGuard vor. Dies hat keine Auswirkungen zur Laufzeit, ändert aber die von einem Typ-Prüfer ausgewerteten Typen.

def is_int(val: int | str) -> TypeGuard[int]:
    return isinstance(val, int)

def func(val: int | str):
    if is_int(val):
        reveal_type(val)  # "int"
    else:
        reveal_type(val)  # Previously "int | str", now "str"

Diese Verhaltensänderung führt zu unterschiedlichen Typen, die von einem Typ-Prüfer ausgewertet werden. Sie könnte daher neue (oder bestehende) Typfehler erzeugen.

Typ-Prüfer verbessern oft die Verfeinerungslogik oder beheben bestehende Fehler in dieser Logik, sodass Benutzer statischer Typisierung an diese Art von Verhaltensänderung gewöhnt sind.

Wir vermuten auch, dass bestehender typisierter Python-Code unwahrscheinlich ist, der sich auf das aktuelle Verhalten von TypeGuard verlässt. Um unsere Hypothese zu validieren, haben wir die vorgeschlagene Änderung in Pyright implementiert und diese modifizierte Version auf etwa 25 typisierten Codebasen mit mypy primer ausgeführt, um zu sehen, ob es Unterschiede in der Ausgabe gab. Wie vorhergesagt, hatte die Verhaltensänderung nur minimale Auswirkungen. Die einzige nennenswerte Änderung war, dass einige # type: ignore Kommentare nicht mehr notwendig waren, was darauf hindeutet, dass diese Codebasen bereits bestehende Einschränkungen von TypeGuard umgangen haben.

Wichtige Änderung

Es ist möglich, dass eine benutzerdefinierte Typ-Guard-Funktion sich auf das alte Verhalten verlässt. Solche Typ-Guard-Funktionen könnten mit dem neuen Verhalten fehlschlagen.

def is_positive_int(val: int | str) -> TypeGuard[int]:
    return isinstance(val, int) and val > 0

def func(val: int | str):
    if is_positive_int(val):
        reveal_type(val)  # "int"
    else:
        # With the older behavior, the type of "val" is evaluated as
        # "int | str"; with the new behavior, the type is narrowed to
        # "str", which is perhaps not what was intended.
        reveal_type(val)

Wir denken, dass solche benutzerdefinierten Typ-Guards in realem Code unwahrscheinlich sind. Die Mypy-Primer-Ergebnisse haben keine solchen Fälle aufgedeckt.

Wie man das lehrt

Benutzer, die mit TypeGuard nicht vertraut sind, werden wahrscheinlich das in dieser PEP beschriebene Verhalten erwarten, was TypeGuard einfacher zu lehren und zu erklären macht.

Referenzimplementierung

Eine Referenz-Implementierung dieser Idee existiert in Pyright.

Um das modifizierte Verhalten zu aktivieren, muss das Konfigurationsflag enableExperimentalFeatures auf true gesetzt werden. Dies kann pro Datei erfolgen, indem ein Kommentar hinzugefügt wird

# pyright: enableExperimentalFeatures=true

Abgelehnte Ideen

StrictTypeGuard

Es wurde ein neues Konstrukt StrictTypeGuard vorgeschlagen. Diese alternative Form wäre ähnlich einem TypeGuard, würde aber strengere Typ-Guard-Semantiken anwenden. Sie würde auch erzwingen, dass der Rückgabetyp konsistent [1] mit dem Eingabetyp ist. Siehe diesen Thread für Details: StrictTypeGuard-Vorschlag

Diese Idee wurde abgelehnt, da sie in den meisten Fällen unnötig ist und unnötige Komplexität hinzufügt. Sie würde die Einführung einer neuen Sonderform erfordern, und Entwickler müssten über den subtilen Unterschied zwischen den beiden Formen aufgeklärt werden.

TypeGuard mit einem zweiten Ausgabetyp

Eine andere Idee wurde vorgeschlagen, bei der TypeGuard ein zweites optionales Typargument unterstützen könnte, das den Typ angibt, der für die Verfeinerung im negativen („else“) Fall verwendet werden soll.

def is_int(val: int | str) -> TypeGuard[int, str]:
    return isinstance(val, int)

Diese Idee wurde hier vorgeschlagen.

Sie wurde abgelehnt, da sie als zu kompliziert angesehen wurde und nur eine der beiden Haupteinschränkungen von TypeGuard behandelte. Siehe diesen Thread für die vollständige Diskussion.

Fußnoten


Quelle: https://github.com/python/peps/blob/main/peps/pep-0724.rst

Zuletzt geändert: 2025-02-01 08:55:40 GMT