PEP 742 – Typen mit TypeIs einschränken
- Autor:
- Jelle Zijlstra <jelle.zijlstra at gmail.com>
- Discussions-To:
- Discourse thread
- Status:
- Final
- Typ:
- Standards Track
- Thema:
- Typisierung
- Erstellt:
- 07-Feb-2024
- Python-Version:
- 3.13
- Post-History:
- 11-Feb-2024
- Ersetzt:
- 724
- Resolution:
- 03-Apr-2024
Zusammenfassung
Dieses PEP schlägt eine neue spezielle Form vor, TypeIs, um Funktionen zu annotieren, die verwendet werden können, um den Typ eines Wertes einzuschränken, ähnlich der eingebauten Funktion isinstance(). Im Gegensatz zur bestehenden speziellen Form typing.TypeGuard kann TypeIs den Typ sowohl im if- als auch im else-Zweig einer Bedingung einschränken.
Motivation
Typisierte Python-Code erfordert oft, dass Benutzer den Typ einer Variablen basierend auf einer Bedingung einschränken. Zum Beispiel, wenn eine Funktion eine Vereinigung von zwei Typen akzeptiert, kann sie eine isinstance()-Prüfung verwenden, um zwischen den beiden Typen zu unterscheiden. Typ-Checker unterstützen üblicherweise Typ-Einschränkungen basierend auf verschiedenen eingebauten Funktionen und Operationen, aber gelegentlich ist es nützlich, eine benutzerdefinierte Funktion zur Typ-Einschränkung zu verwenden.
Um solche Anwendungsfälle zu unterstützen, führte PEP 647 die spezielle Form typing.TypeGuard ein, die es Benutzern erlaubt, Type Guards zu definieren.
from typing import assert_type, TypeGuard
def is_str(x: object) -> TypeGuard[str]:
return isinstance(x, str)
def f(x: object) -> None:
if is_str(x):
assert_type(x, str)
else:
assert_type(x, object)
Leider hat das Verhalten von typing.TypeGuard einige Einschränkungen, die es für viele gängige Anwendungsfälle weniger nützlich machen, wie auch im Abschnitt „Motivation“ von PEP 724 erläutert wird. Insbesondere
- Typ-Checker müssen genau den
TypeGuard-Rückgabetyp als eingeschränkten Typ verwenden, wenn der Type GuardTruezurückgibt. Sie können kein Vorwissen über den Typ der Variablen nutzen. - In dem Fall, dass der Type Guard
Falsezurückgibt, kann der Typ-Checker keine zusätzliche Einschränkung anwenden.
Die Standardbibliotheksfunktion inspect.isawaitable() kann als Beispiel dienen. Sie gibt zurück, ob das Argument ein awaitable Objekt ist, und typeshed annotiert sie derzeit als
def isawaitable(object: object) -> TypeGuard[Awaitable[Any]]: ...
Ein Benutzer berichtete ein Problem bei mypy bezüglich des Verhaltens dieser Funktion. Sie beobachteten folgendes Verhalten:
import inspect
from collections.abc import Awaitable
from typing import reveal_type
async def f(t: Awaitable[int] | int) -> None:
if inspect.isawaitable(t):
reveal_type(t) # Awaitable[Any]
else:
reveal_type(t) # Awaitable[int] | int
Dieses Verhalten ist konsistent mit PEP 647, entsprach aber nicht den Erwartungen des Benutzers. Stattdessen erwartete er, dass der Typ von t im if-Zweig auf Awaitable[int] und im else-Zweig auf int eingeschränkt wird. Dieses PEP schlägt ein neues Konstrukt vor, das genau das tut.
Andere Beispiele für Probleme, die sich aus dem aktuellen Verhalten von TypeGuard ergaben, sind:
- Python Typing Issue (
numpy.isscalar) - Python Typing Issue (
dataclasses.is_dataclass()) - Pyright-Problem (Erwartung, dass
typing.TypeGuardwieisinstance()funktioniert) - Pyright-Problem (Erwartung von Einschränkungen im
else-Zweig) - Mypy-Problem (Erwartung von Einschränkungen im
else-Zweig) - Mypy-Problem (Kombination mehrerer TypeGuards)
- Mypy-Problem (Erwartung von Einschränkungen im
else-Zweig) - Mypy-Problem (Benutzerdefinierte Funktion ähnlich
inspect.isawaitable()) - Typeshed-Problem (
asyncio.iscoroutinefunction)
Begründung
Die Probleme mit dem aktuellen Verhalten von typing.TypeGuard zwingen uns, das Typsystem zu verbessern, um ein anderes Typ-Einschränkungsverhalten zu ermöglichen. PEP 724 schlug vor, das Verhalten des bestehenden typing.TypeGuard-Konstrukts zu ändern, aber wir glauben, dass die Abwärtskompatibilitätsimplikationen dieser Änderung zu schwerwiegend sind. Stattdessen schlagen wir die Hinzufügung einer neuen speziellen Form mit den gewünschten Semantiken vor.
Wir erkennen an, dass dies zu einer unglücklichen Situation führt, in der es zwei Konstrukte mit einem ähnlichen Zweck und ähnlichen Semantiken gibt. Wir glauben, dass Benutzer eher das Verhalten von TypeIs, der in diesem PEP vorgeschlagenen neuen Form, wünschen und empfehlen daher, dass die Dokumentation TypeIs gegenüber TypeGuard als ein häufiger anwendbares Werkzeug hervorhebt. Die Semantiken von TypeGuard sind jedoch gelegentlich nützlich, und wir schlagen nicht vor, es zu verwerfen oder zu entfernen. Langfristig sollten die meisten Benutzer TypeIs verwenden, und TypeGuard sollte für seltene Fälle reserviert bleiben, in denen sein Verhalten speziell gewünscht ist.
Spezifikation
Eine neue spezielle Form, TypeIs, wird dem Modul typing hinzugefügt. Ihre Verwendung, ihr Verhalten und ihre Laufzeitimplementierung sind denen von typing.TypeGuard ähnlich.
Sie akzeptiert ein einzelnes Argument und kann als Rückgabetyp einer Funktion verwendet werden. Eine als TypeIs zurückgebende annotierte Funktion wird als Typ-Einschränkungsfunktion bezeichnet. Typ-Einschränkungsfunktionen müssen bool-Werte zurückgeben, und der Typ-Checker sollte überprüfen, ob alle Rückgabepfade bool zurückgeben.
Typ-Einschränkungsfunktionen müssen mindestens ein positionsbezogenes Argument akzeptieren. Das Typ-Einschränkungsverhalten wird auf das erste an die Funktion übergebene positionsbezogene Argument angewendet. Die Funktion kann zusätzliche Argumente akzeptieren, aber diese werden nicht durch Typ-Einschränkung beeinflusst. Wenn eine Typ-Einschränkungsfunktion als Instanzmethode oder Klassenmethode implementiert ist, entspricht das erste positionsbezogene Argument dem zweiten Parameter (nach self oder cls).
Typ-Einschränkungsverhalten
Um das Verhalten von TypeIs zu spezifizieren, verwenden wir die folgende Terminologie:
- I =
TypeIsEingabetyp - R =
TypeIsRückgabetyp - A = Typ des an die Typ-Einschränkungsfunktion übergebenen Arguments (vor Einschränkung)
- NP = Eingeschränkter Typ (positiv; verwendet, wenn
TypeIsTruezurückgegeben hat) - NN = Eingeschränkter Typ (negativ; verwendet, wenn
TypeIsFalsezurückgegeben hat)
def narrower(x: I) -> TypeIs[R]: ...
def func1(val: A):
if narrower(val):
assert_type(val, NP)
else:
assert_type(val, NN)
Der Rückgabetyp R muss konsistent mit I sein. Der Typ-Checker sollte einen Fehler ausgeben, wenn diese Bedingung nicht erfüllt ist.
Formell sollte Typ *NP* zu A∧R eingeschränkt werden, der Schnittmenge von *A* und *R*, und Typ *NN* sollte zu A∧¬R eingeschränkt werden, der Schnittmenge von *A* und dem Komplement von *R*. In der Praxis können die theoretischen Typen für strenge Type Guards im Python-Typsystem nicht präzise ausgedrückt werden. Typ-Checker sollten auf praktische Annäherungen dieser Typen zurückgreifen. Als Faustregel sollte ein Typ-Checker die gleiche Typ-Einschränkungslogik verwenden – und konsistente Ergebnisse erzielen – wie bei der Behandlung von isinstance(). Diese Anleitung ermöglicht Änderungen und Verbesserungen, falls das Typsystem in Zukunft erweitert wird.
Beispiele
Typ-Einschränkung wird sowohl im positiven als auch im negativen Fall angewendet.
from typing import TypeIs, assert_type
def is_str(x: object) -> TypeIs[str]:
return isinstance(x, str)
def f(x: str | int) -> None:
if is_str(x):
assert_type(x, str)
else:
assert_type(x, int)
Der endgültige eingeschränkte Typ kann aufgrund der Einschränkungen des zuvor bekannten Typs des Arguments schmaler als **R** sein.
from collections.abc import Awaitable
from typing import Any, TypeIs, assert_type
import inspect
def isawaitable(x: object) -> TypeIs[Awaitable[Any]]:
return inspect.isawaitable(x)
def f(x: Awaitable[int] | int) -> None:
if isawaitable(x):
# Type checkers may also infer the more precise type
# "Awaitable[int] | (int & Awaitable[Any])"
assert_type(x, Awaitable[int])
else:
assert_type(x, int)
Es ist ein Fehler, auf einen Typ einzuschränken, der nicht mit dem Eingabetyp konsistent ist.
from typing import TypeIs
def is_str(x: int) -> TypeIs[str]: # Type checker error
...
Subtyping
TypeIs ist auch als Rückgabetyp eines Callables gültig, zum Beispiel in Callback-Protokollen und in der speziellen Form Callable. In diesen Kontexten wird es als Subtyp von bool behandelt. Zum Beispiel ist Callable[..., TypeIs[int]] zu Callable[..., bool] zuweisbar.
Im Gegensatz zu TypeGuard ist TypeIs in seinem Argumenttyp invariant: TypeIs[B] ist kein Subtyp von TypeIs[A], auch wenn B ein Subtyp von A ist. Um den Grund zu verstehen, betrachten wir folgendes Beispiel:
def takes_narrower(x: int | str, narrower: Callable[[object], TypeIs[int]]):
if narrower(x):
print(x + 1) # x is an int
else:
print("Hello " + x) # x is a str
def is_bool(x: object) -> TypeIs[bool]:
return isinstance(x, bool)
takes_narrower(1, is_bool) # Error: is_bool is not a TypeIs[int]
(Beachten Sie, dass bool ein Subtyp von int ist.) Dieser Code schlägt zur Laufzeit fehl, da der Einschränker False zurückgibt (1 ist keine bool) und der else-Zweig in takes_narrower() genommen wird. Wenn der Aufruf takes_narrower(1, is_bool) erlaubt wäre, würden Typ-Checker diesen Fehler nicht erkennen.
Abwärtskompatibilität
Da dieses PEP nur eine neue spezielle Form vorschlägt, gibt es keine Auswirkungen auf die Abwärtskompatibilität.
Sicherheitsimplikationen
Keine bekannt.
Wie man das lehrt
Einführungen in Typing sollten TypeIs behandeln, wenn sie diskutieren, wie Typen eingeschränkt werden können, zusammen mit Diskussionen über andere Einschränkungskonstrukte wie isinstance(). Die Dokumentation sollte TypeIs gegenüber typing.TypeGuard hervorheben; während letzteres nicht veraltet ist und sein Verhalten gelegentlich nützlich ist, erwarten wir, dass das Verhalten von TypeIs normalerweise intuitiver ist und die meisten Benutzer sollten zuerst zu TypeIs greifen. Der Rest dieses Abschnitts enthält einige Beispielinhalte, die in einleitender benutzerorientierter Dokumentation verwendet werden könnten.
Wann sollte man TypeIs verwenden
Python-Code verwendet oft Funktionen wie isinstance(), um zwischen verschiedenen möglichen Typen eines Wertes zu unterscheiden. Typ-Checker verstehen isinstance() und verschiedene andere Prüfungen und verwenden sie, um den Typ einer Variablen einzuschränken. Manchmal möchte man jedoch eine kompliziertere Prüfung an mehreren Stellen wiederverwenden oder verwendet eine Prüfung, die der Typ-Checker nicht versteht. In diesen Fällen können Sie eine TypeIs-Funktion definieren, um die Prüfung durchzuführen und Typ-Checkern zu ermöglichen, sie zur Einschränkung des Variablentyps zu verwenden.
Eine TypeIs-Funktion nimmt ein einzelnes Argument und ist so annotiert, dass sie TypeIs[T] zurückgibt, wobei T der Typ ist, zu dem Sie einschränken möchten. Die Funktion muss True zurückgeben, wenn das Argument vom Typ T ist, und andernfalls False. Die Funktion kann dann in if-Prüfungen verwendet werden, genau wie Sie isinstance() verwenden würden. Zum Beispiel:
from typing import TypeIs, Literal
type Direction = Literal["N", "E", "S", "W"]
def is_direction(x: str) -> TypeIs[Direction]:
return x in {"N", "E", "S", "W"}
def maybe_direction(x: str) -> None:
if is_direction(x):
print(f"{x} is a cardinal direction")
else:
print(f"{x} is not a cardinal direction")
Eine sichere TypeIs-Funktion schreiben
Eine TypeIs-Funktion ermöglicht es Ihnen, das Typ-Einschränkungsverhalten Ihres Typ-Checkers zu überschreiben. Dies ist ein mächtiges Werkzeug, kann aber gefährlich sein, da eine falsch geschriebene TypeIs-Funktion zu inkonsistenten Typ-Prüfungen führen kann und Typ-Checker solche Fehler nicht erkennen können.
Damit eine Funktion, die TypeIs[T] zurückgibt, sicher ist, muss sie genau dann True zurückgeben, wenn das Argument mit Typ T kompatibel ist, und andernfalls False. Wenn diese Bedingung nicht erfüllt ist, kann der Typ-Checker falsche Typen ableiten.
Nachfolgend einige Beispiele für korrekte und falsche TypeIs-Funktionen:
from typing import TypeIs
# Correct
def good_typeis(x: object) -> TypeIs[int]:
return isinstance(x, int)
# Incorrect: does not return True for all ints
def bad_typeis1(x: object) -> TypeIs[int]:
return isinstance(x, int) and x > 0
# Incorrect: returns True for some non-ints
def bad_typeis2(x: object) -> TypeIs[int]:
return isinstance(x, (int, float))
Diese Funktion demonstriert einige Fehler, die bei der Verwendung einer schlecht geschriebenen TypeIs-Funktion auftreten können. Diese Fehler werden von Typ-Checkern nicht erkannt.
def caller(x: int | str, y: int | float) -> None:
if bad_typeis1(x): # narrowed to int
print(x + 1)
else: # narrowed to str (incorrectly)
print("Hello " + x) # runtime error if x is a negative int
if bad_typeis2(y): # narrowed to int
# Because of the incorrect TypeIs, this branch is taken at runtime if
# y is a float.
print(y.bit_count()) # runtime error: this method exists only on int, not float
else: # narrowed to float (though never executed at runtime)
pass
Hier ist ein Beispiel für eine korrekte TypeIs-Funktion für einen komplizierteren Typ:
from typing import TypedDict, TypeIs
class Point(TypedDict):
x: int
y: int
def is_point(x: object) -> TypeIs[Point]:
return (
isinstance(x, dict)
and all(isinstance(key, str) for key in x)
and "x" in x
and "y" in x
and isinstance(x["x"], int)
and isinstance(x["y"], int)
)
TypeIs und TypeGuard
TypeIs und typing.TypeGuard sind beides Werkzeuge zur Einschränkung des Variablentyps basierend auf einer benutzerdefinierten Funktion. Beide können verwendet werden, um Funktionen zu annotieren, die ein Argument nehmen und einen booleschen Wert zurückgeben, je nachdem, ob das Eingabeargument mit dem eingeschränkten Typ kompatibel ist. Diese Funktionen können dann in if-Prüfungen verwendet werden, um den Variablentyp einzuschränken.
TypeIs hat normalerweise das intuitivste Verhalten, führt aber mehr Einschränkungen ein. TypeGuard ist das richtige Werkzeug, wenn:
- Sie auf einen Typ einschränken möchten, der nicht mit dem Eingabetyp kompatibel ist, z. B. von
list[object]zulist[int].TypeIserlaubt nur Einschränkungen zwischen kompatiblen Typen. - Ihre Funktion gibt nicht für alle Eingabewerte, die mit dem eingeschränkten Typ kompatibel sind,
Truezurück. Zum Beispiel könnten Sie einenTypeGuard[int]haben, der nur für positive ganze ZahlenTruezurückgibt.
TypeIs und TypeGuard unterscheiden sich in folgenden Punkten:
TypeIserfordert, dass der eingeschränkte Typ ein Subtyp des Eingabetyps ist, währendTypeGuarddies nicht tut.- Wenn eine
TypeGuard-FunktionTruezurückgibt, schränken Typ-Checker den Typ der Variablen genau auf denTypeGuard-Typ ein. Wenn eineTypeIs-FunktionTruezurückgibt, können Typ-Checker einen präziseren Typ ableiten, der den zuvor bekannten Typ der Variablen mit demTypeIs-Typ kombiniert. (Technisch gesehen ist dies als Schnittmengentyp bekannt.) - Wenn eine
TypeGuard-FunktionFalsezurückgibt, können Typ-Checker den Variablentyp überhaupt nicht einschränken. Wenn eineTypeIs-FunktionFalsezurückgibt, können Typ-Checker den Variablentyp einschränken, um denTypeIs-Typ auszuschließen.
Dieses Verhalten kann im folgenden Beispiel beobachtet werden:
from typing import TypeGuard, TypeIs, reveal_type, final
class Base: ...
class Child(Base): ...
@final
class Unrelated: ...
def is_base_typeguard(x: object) -> TypeGuard[Base]:
return isinstance(x, Base)
def is_base_typeis(x: object) -> TypeIs[Base]:
return isinstance(x, Base)
def use_typeguard(x: Child | Unrelated) -> None:
if is_base_typeguard(x):
reveal_type(x) # Base
else:
reveal_type(x) # Child | Unrelated
def use_typeis(x: Child | Unrelated) -> None:
if is_base_typeis(x):
reveal_type(x) # Child
else:
reveal_type(x) # Unrelated
Referenzimplementierung
Die spezielle Form TypeIs wurde implementiert im Modul typing_extensions und wird in typing_extensions 4.10.0 veröffentlicht.
Implementierungen sind für mehrere Typ-Checker verfügbar:
- Mypy: Pull Request offen
- Pyanalyze: Pull Request
- Pyright: eingeführt in Version 1.1.351
Abgelehnte Ideen
Verhalten von TypeGuard ändern
PEP 724 schlug zuvor vor, das spezifizierte Verhalten von typing.TypeGuard zu ändern, sodass, wenn der Rückgabetyp des Guards mit dem Eingabetyp konsistent ist, das hier für TypeIs vorgeschlagene Verhalten angewendet würde. Dieser Vorschlag hat einige wichtige Vorteile: da er keine Laufzeitänderungen erfordert, sind nur Änderungen an Typ-Checkern notwendig, was es für Benutzer einfacher macht, die Vorteile des neuen, meist intuitiveren Verhaltens zu nutzen.
Dieser Ansatz hat jedoch einige gravierende Nachteile. Benutzer, die TypeGuard-Funktionen im Erwartungshorizont der bestehenden Semantiken gemäß PEP 647 geschrieben haben, würden subtile und potenziell brechende Änderungen in der Art und Weise erfahren, wie Typ-Checker ihren Code interpretieren. Das gespaltene Verhalten von TypeGuard, wo es auf eine Weise funktioniert, wenn der Rückgabetyp mit dem Eingabetyp konsistent ist, und auf eine andere Weise, wenn nicht, könnte für Benutzer verwirrend sein. Der Typing Council konnte keine Einigung zugunsten von PEP 724 erzielen; infolgedessen schlagen wir dieses alternative PEP vor.
Nichts tun
Sowohl dieses PEP als auch der in PEP 724 vorgeschlagene alternative Vorschlag haben Schwächen. Letztere werden oben diskutiert. Was dieses PEP betrifft, so führt es zwei spezielle Formen mit sehr ähnlichen Semantiken ein und schafft potenziell einen langen Migrationspfad für Benutzer, die derzeit TypeGuard verwenden und mit anderen Einschränkungssemantiken besser bedient wären.
Ein möglicher Weg nach vorn wäre es also, nichts zu tun und mit den aktuellen Einschränkungen des Typsystems zu leben. Wir glauben jedoch, dass die Einschränkungen des aktuellen TypeGuard, wie im Abschnitt „Motivation“ dargelegt, signifikant genug sind, dass es sich lohnt, das Typsystem zu ändern, um sie zu beheben. Wenn wir keine Änderung vornehmen, werden Benutzer weiterhin mit den gleichen unintuitiven Verhaltensweisen von TypeGuard konfrontiert, und das Typsystem wird nicht in der Lage sein, gängige Typ-Einschränkungsfunktionen wie inspect.isawaitable korrekt darzustellen.
Alternative Namen
Dieses PEP schlägt derzeit den Namen TypeIs vor, der betont, dass die spezielle Form TypeIs[T] zurückgibt, ob das Argument vom Typ T ist, und die TypeScript-Syntax widerspiegelt. Andere Namen wurden erwogen, einschließlich in einer früheren Version dieses PEP.
Optionen umfassen:
IsInstance(Post von Paul Moore): betont, dass das neue Konstrukt ähnlich der eingebauten Funktionisinstance()verhält.NarrowedoderNarrowedTo: kürzer alsTypeNarrower, behält aber die Verbindung zu „Type Narrowing“ (von Eric Traut vorgeschlagen).PredicateoderTypePredicate: spiegelt den Namen von TypeScript für die Funktion wider, „type predicates“.StrictTypeGuard(frühere Entwürfe von PEP 724): betont, dass das neue Konstrukt eine strengere Form der Typ-Einschränkung alstyping.TypeGuarddurchführt.TypeCheck(Post von Nicolas Tessore): betont die binäre Natur der Prüfung.TypeNarrower: betont, dass die Funktion den Typ ihres Arguments einschränkt. In einer früheren Version dieses PEP verwendet.
Danksagungen
Ein Großteil der Motivation und Spezifikation für dieses PEP leitet sich von PEP 724 ab. Während dieses PEP eine andere Lösung für das vorliegende Problem vorschlägt, haben die Autoren von PEP 724, Eric Traut, Rich Chiodo und Erik De Bonte, einen starken Fall für ihren Vorschlag gemacht, und dieser Vorschlag wäre ohne ihre Arbeit nicht möglich gewesen.
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-0742.rst
Zuletzt geändert: 2024-10-17 12:49:39 GMT