PEP 677 – Callable Type Syntax
- Autor:
- Steven Troxler <steven.troxler at gmail.com>, Pradeep Kumar Srinivasan <gohanpra at gmail.com>
- Sponsor:
- Guido van Rossum <guido at python.org>
- Discussions-To:
- Python-Dev Liste
- Status:
- Abgelehnt
- Typ:
- Standards Track
- Thema:
- Typisierung
- Erstellt:
- 13-Dez-2021
- Python-Version:
- 3.11
- Post-History:
- 16-Dez-2021
- Resolution:
- Python-Dev Nachricht
Inhaltsverzeichnis
- Zusammenfassung
- Motivation
- Begründung
- Spezifikation
- Abgelehnte Alternativen
- Abwärtskompatibilität
- Referenzimplementierung
- Offene Fragen
- Ressourcen
- Urheberrecht
Zusammenfassung
Diese PEP führt eine prägnante und benutzerfreundliche Syntax für Callable-Typen ein, die die gleiche Funktionalität wie typing.Callable unterstützt, jedoch mit einer Pfeilsyntax, die von der Syntax für typisierte Funktionssignaturen inspiriert ist. Dies ermöglicht es, Typen wie Callable[[int, str], bool] als (int, str) -> bool zu schreiben.
Die vorgeschlagene Syntax unterstützt alle Funktionalitäten, die von typing.Callable und typing.Concatenate bereitgestellt werden, und soll als Drop-in-Ersatz dienen.
Motivation
Eine Möglichkeit, Code sicherer und leichter analysierbar zu machen, besteht darin, sicherzustellen, dass Funktionen und Klassen gut typisiert sind. In Python gibt es Typ-Annotationen, deren Framework in PEP 484 definiert ist, um Typ-Hints bereitzustellen, die Fehler finden können und auch bei Editor-Tools wie Tab-Vervollständigung, statischen Analyse-Tools und Code-Reviews helfen.
Betrachten Sie den folgenden nicht-typisierten Code
def flat_map(func, l):
out = []
for element in l:
out.extend(func(element))
return out
def wrap(x: int) -> list[int]:
return [x]
def add(x: int, y: int) -> int:
return x + y
flat_map(wrap, [1, 2, 3]) # no runtime error, output is [1, 2, 3]
flat_map(add, [1, 2, 3]) # runtime error: `add` expects 2 arguments, got 1
Wir können diesem Beispiel Typen hinzufügen, um den Laufzeitfehler zu erkennen
from typing import Callable
def flat_map(
func: Callable[[int], list[int]],
l: list[int]
) -> list[int]:
....
...
flat_map(wrap, [1, 2, 3]) # type checks okay, output is [1, 2, 3]
flat_map(add, [1, 2, 3]) # type check error
Es gibt einige Benutzerfreundlichkeitsherausforderungen mit Callable, wie wir hier sehen können
- Er ist umständlich, besonders für komplexere Funktionssignaturen.
- Er basiert auf zwei Ebenen verschachtelter Klammern, im Gegensatz zu jedem anderen generischen Typ. Dies kann besonders schwer zu lesen sein, wenn einige der Typparameter selbst generische Typen sind.
- Die Klammerstruktur ähnelt visuell nicht der Art und Weise, wie Funktionssignaturen geschrieben werden.
- Er erfordert einen expliziten Import, im Gegensatz zu vielen anderen gängigsten Typen wie
listunddict.
Möglicherweise schreiben Programmierer als Ergebnis oft keine vollständigen Callable-Typen. Solche nicht-typisierten oder teiltypisierten Callable-Typen prüfen die Parametertypen oder Rückgabetypen des gegebenen Callables nicht und negieren somit die Vorteile der statischen Typisierung. Zum Beispiel schreiben sie vielleicht dies
from typing import Callable
def flat_map(
func: Callable[..., Any],
l: list[int]
) -> list[int]:
....
...
flat_map(add, [1, 2, 3]) # oops, no type check error!
Hier sind einige teilweise Typinformationen vorhanden – wir wissen zumindest, dass func aufrufbar sein muss. Aber wir haben zu viele Typinformationen verloren, als dass Typüberprüfer den Fehler finden könnten.
Mit unserem Vorschlag sieht das Beispiel so aus
def flat_map(
func: (int) -> list[int],
l: list[int]
) -> list[int]:
out = []
for element in l:
out.extend(f(element))
return out
...
Der Typ (int) -> list[int] ist prägnanter, verwendet einen Pfeil ähnlich dem, der einen Rückgabetyp in einer Funktionsüberschrift angibt, vermeidet verschachtelte Klammern und erfordert keinen Import.
Begründung
Der Typ Callable wird häufig verwendet. Zum Beispiel war er im Oktober 2021 der fünfhäufigste komplexe Typ in typeshed, nach Optional, Tuple, Union und List.
Die anderen haben ihre Syntax verbessert und die Notwendigkeit von Importen durch PEP 604 oder PEP 585 eliminiert.
typing.Optional[int]wird geschrieben alsint | Nonetyping.Union[int, str]wird geschrieben alsint | strtyping.List[int]wird geschrieben alslist[int]typing.Tuple[int, str]wird geschrieben alstuple[int, str]
Der Typ typing.Callable wird fast so oft wie diese anderen Typen verwendet, ist komplizierter zu lesen und zu schreiben, und erfordert immer noch einen Import und eine klammerbasierte Syntax.
In diesem Vorschlag haben wir uns entschieden, die gesamte bestehende Semantik von typing.Callable zu unterstützen, ohne die Unterstützung für neue Funktionen hinzuzufügen. Wir trafen diese Entscheidung, nachdem wir untersucht hatten, wie häufig jede Funktion in bestehendem typisiertem und nicht-typisiertem Open-Source-Code verwendet wird. Wir stellten fest, dass die überwiegende Mehrheit der Anwendungsfälle abgedeckt ist.
Wir erwogen, Unterstützung für benannte, optionale und variable Argumente hinzuzufügen. Wir entschieden uns jedoch gegen die Einbeziehung dieser Funktionen, da unsere Analyse zeigte, dass sie selten verwendet werden. Wenn sie wirklich benötigt werden, ist es möglich, sie mithilfe von Callback-Protokollen zu typisieren.
Eine Pfeilsyntax für Callable-Typen
Wir schlagen eine prägnante, einfach zu bedienende Syntax für typing.Callable vor, die Funktionsüberschriften in Python ähnelt. Unser Vorschlag folgt eng der Syntax, die von mehreren beliebten Sprachen wie TypeScript, Kotlin und Scala verwendet wird.
Unsere Ziele sind, dass
- Callable-Typen mit dieser Syntax einfacher zu erlernen und zu verwenden sein werden, insbesondere für Entwickler mit Erfahrung in anderen Sprachen.
- Bibliotheksautoren eher ausdrucksstarke Typen für Callables verwenden werden, die es Typüberprüfern ermöglichen, Code besser zu verstehen und Fehler zu finden, wie im obigen
decorator-Beispiel.
Betrachten Sie dieses vereinfachte reale Beispiel von einem Webserver, geschrieben mit dem bestehenden typing.Callable
from typing import Awaitable, Callable
from app_logic import Response, UserSetting
def customize_response(
response: Response,
customizer: Callable[[Response, list[UserSetting]], Awaitable[Response]]
) -> Response:
...
Mit unserem Vorschlag kann dieser Code abgekürzt werden zu
from app_logic import Response, UserSetting
def customize_response(
response: Response,
customizer: async (Response, list[UserSetting]) -> Response,
) -> Response:
...
Dies ist kürzer und erfordert weniger Importe. Es gibt auch wesentlich weniger verschachtelte eckige Klammern – nur eine Ebene, im Gegensatz zu drei im ursprünglichen Code.
Kompakte Syntax für ParamSpec
Ein besonders häufiger Fall, bei dem Bibliotheksautoren Typinformationen für Callables weglassen, ist die Definition von Decorators. Betrachten Sie Folgendes
from typing import Any, Callable
def with_retries(
f: Callable[..., Any]
) -> Callable[..., Any]:
def wrapper(retry_once, *args, **kwargs):
if retry_once:
try: return f(*args, **kwargs)
except Exception: pass
return f(*args, **kwargs)
return wrapper
@with_retries
def f(x: int) -> int:
return x
f(y=10) # oops - no type error!
Im obigen Code ist klar, dass der Decorator eine Funktion erzeugen soll, deren Signatur der des Arguments f ähnelt, abgesehen von einem zusätzlichen Argument retry_once. Aber die Verwendung von ... verhindert, dass ein Typüberprüfer dies erkennt und einen Benutzer darauf hinweist, dass f(y=10) ungültig ist.
Mit PEP 612 ist es möglich, Decorators wie folgt korrekt zu typisieren
from typing import Any, Callable, Concatenate, ParamSpec, TypeVar
R = TypeVar("R")
P = ParamSpec("P")
def with_retries(
f: Callable[P, R]
) -> Callable[Concatenate[bool, P] R]:
def wrapper(retry_once: bool, *args: P.args, **kwargs: P.kwargs) -> R:
...
return wrapper
...
Mit unserer vorgeschlagenen Syntax wird das korrekt typisierte Decorator-Beispiel prägnant und die Typdarstellungen sind visuell beschreibend.
from typing import Any, ParamSpec, TypeVar
R = TypeVar("R")
P = ParamSpec("P")
def with_retries(
f: (**P) -> R
) -> (bool, **P) -> R:
...
Vergleich mit anderen Sprachen
Viele populäre Programmiersprachen verwenden eine Pfeilsyntax, die der von uns hier vorgeschlagenen ähnelt.
TypeScript
In TypeScript werden Funktionstypen in einer Syntax ausgedrückt, die fast identisch mit der von uns vorgeschlagenen ist, aber das Pfeiltoken ist => und Argumente haben Namen.
(x: int, y: str) => bool
Die Namen der Argumente sind für den Typ eigentlich nicht relevant. So ist zum Beispiel dieser der gleiche Callable-Typ
(a: int, b: str) => bool
Kotlin
Funktionstypen in Kotlin erlauben eine identische Syntax wie die von uns vorgeschlagene, zum Beispiel
(Int, String) -> Bool
Es erlaubt auch optional das Hinzufügen von Namen zu den Argumenten, zum Beispiel
(x: Int, y: String) -> Bool
Wie in TypeScript sind die Argumentnamen (falls vorhanden) nur zur Dokumentation da und nicht Teil des Typs selbst.
Scala
Scala verwendet den Pfeil => für Funktionstypen. Ansonsten ist ihre Syntax dieselbe wie die von uns vorgeschlagene, zum Beispiel
(Int, String) => Bool
Scala hat, wie Python, die Möglichkeit, Funktionsargumente nach Namen bereitzustellen. Funktionstypen können optional Namen enthalten, zum Beispiel
(x: Int, y: String) => Bool
Im Gegensatz zu TypeScript und Kotlin sind diese Namen, falls angegeben, Teil des Typs – jede Funktion, die den Typ implementiert, muss dieselben Namen verwenden. Dies ähnelt dem erweiterten Syntaxvorschlag, den wir in unserem Abschnitt Abgelehnte Alternativen beschreiben.
Funktionsdefinitionen vs. Annotationen für Callable-Typen
In all den oben genannten Sprachen verwenden Typ-Annotationen für Funktionsdefinitionen einen Doppelpunkt : anstelle eines Pfeils ->. Zum Beispiel sieht eine einfache Additionsfunktion in TypeScript so aus
function higher_order(fn: (a: string) => string): string {
return fn("Hello, World");
}
Scala und Kotlin verwenden im Wesentlichen die gleiche :-Syntax für Rückgabe-Annotationen. Der Doppelpunkt : ergibt in diesen Sprachen Sinn, da sie alle : für Typ-Annotationen von Parametern und Variablen verwenden und die Verwendung für Funktion-Rückgabetypen ähnlich ist.
In Python verwenden wir :, um den Beginn eines Funktionskörpers zu kennzeichnen, und -> für Rückgabe-Annotationen. Infolgedessen gibt es, obwohl unser Vorschlag oberflächlich gesehen derselbe ist wie in diesen anderen Sprachen, einen Kontextunterschied. Es besteht die Möglichkeit für mehr Verwirrung in Python beim Lesen von Funktionsdefinitionen, die Callable-Typen enthalten.
Dies ist ein zentrales Anliegen, zu dem wir Feedback für unseren Entwurf der PEP suchen. Eine Idee, die wir diskutiert haben, ist die Verwendung von => anstelle von, um die Unterscheidung zu erleichtern.
Die ML-Sprachfamilie
Sprachen der ML-Familie, einschließlich F#, OCaml und Haskell, verwenden alle -> zur Darstellung von Funktionstypen. Alle verwenden eine klammerfreie Syntax mit mehreren Pfeilen, zum Beispiel in Haskell
Integer -> String -> Bool
Die Verwendung mehrerer Pfeile, die sich von unserem Vorschlag unterscheidet, ergibt Sinn für Sprachen dieser Familie, da sie automatische Currying von Funktionsargumenten verwenden, was bedeutet, dass eine Mehrfachargumentfunktion wie eine Einfachargumentfunktion agiert, die eine Funktion zurückgibt.
Spezifikation
Typisierungsverhalten
Typüberprüfer sollten die neue Syntax mit exakt denselben Semantiken behandeln wie typing.Callable.
Daher sollte ein Typüberprüfer die folgenden Paare exakt gleich behandeln
from typing import Awaitable, Callable, Concatenate, ParamSpec, TypeVarTuple
P = ParamSpec("P")
Ts = TypeVarTuple('Ts')
f0: () -> bool
f0: Callable[[], bool]
f1: (int, str) -> bool
f1: Callable[[int, str], bool]
f2: (...) -> bool
f2: Callable[..., bool]
f3: async (str) -> str
f3: Callable[[str], Awaitable[str]]
f4: (**P) -> bool
f4: Callable[P, bool]
f5: (int, **P) -> bool
f5: Callable[Concatenate[int, P], bool]
f6: (*Ts) -> bool
f6: Callable[[*Ts], bool]
f7: (int, *Ts, str) -> bool
f7: Callable[[int, *Ts, str], bool]
Grammatik und AST
Die vorgeschlagene neue Syntax kann durch diese AST-Änderungen an Parser/Python.asdl beschrieben werden
expr = <prexisting_expr_kinds>
| AsyncCallableType(callable_type_arguments args, expr returns)
| CallableType(callable_type_arguments args, expr returns)
callable_type_arguments = AnyArguments
| ArgumentsList(expr* posonlyargs)
| Concatenation(expr* posonlyargs, expr param_spec)
Hier sind unsere vorgeschlagenen Änderungen an der Python-Grammatik <https://docs.pythonlang.de/3/reference/grammar.htm>
expression:
| disjunction disjunction 'else' expression
| callable_type_expression
| disjunction
| lambdef
callable_type_expression:
| callable_type_arguments '->' expression
| ASYNC callable_type_arguments '->' expression
callable_type_arguments:
| '(' '...' [','] ')'
| '(' callable_type_positional_argument* ')'
| '(' callable_type_positional_argument* callable_type_param_spec ')'
callable_type_positional_argument:
| !'...' expression ','
| !'...' expression &')'
callable_type_param_spec:
| '**' expression ','
| '**' expression &')'
Wenn PEP 646 angenommen wird, beabsichtigen wir, Unterstützung für entpackte Typen auf zwei Arten einzubeziehen. Um die in PEP 646 vorgeschlagene "Stern für Entpacken"-Syntax zu unterstützen, werden wir die Grammatik für callable_type_positional_argument wie folgt ändern
callable_type_positional_argument:
| !'...' expression ','
| !'...' expression &')'
| '*' expression ','
| '*' expression &')'
Mit dieser Änderung sollte ein Typ der Form (int, *Ts) -> bool die AST-Form
CallableType(
ArgumentsList(Name("int"), Starred(Name("Ts")),
Name("bool")
)
auswerten und von Typüberprüfern als äquivalent zu Callable[[int, *Ts], bool] oder Callable[[int, Unpack[Ts]], bool] behandelt werden.
Implikationen der Grammatik
Präzedenz von ->
-> bindet schwächer als andere Operatoren, sowohl innerhalb von Typen als auch in Funktionssignaturen, daher sind die folgenden beiden Callable-Typen äquivalent
(int) -> str | bool
(int) -> (str | bool)
-> assoziiert nach rechts, sowohl innerhalb von Typen als auch in Funktionssignaturen. Daher sind die folgenden Paare äquivalent
(int) -> (str) -> bool
(int) -> ((str) -> bool)
def f() -> (int, str) -> bool: pass
def f() -> ((int, str) -> bool): pass
def f() -> (int) -> (str) -> bool: pass
def f() -> ((int) -> ((str) -> bool)): pass
Da Operatoren enger als -> binden, sind Klammern erforderlich, wenn ein Pfeiltyp als Argument zu einem Operator wie | vorgesehen ist.
(int) -> () -> int | () -> bool # syntax error!
(int) -> (() -> int) | (() -> bool) # okay
Wir haben jedes dieser Verhaltensweisen diskutiert und glauben, dass sie wünschenswert sind.
- Union-Typen (dargestellt durch
A | Bgemäß PEP 604) sind in Funktionssignatur-Rückgaben gültig, daher müssen wir zur Konsistenz Operatoren in der Rückgabeposition zulassen. - Da Operatoren enger als
->binden, ist es korrekt, dass ein Typ wiebool | () -> boolein Syntaxfehler sein muss. Wir sollten sicherstellen, dass die Fehlermeldung klar ist, da dies ein häufiger Fehler sein könnte. - Die Assoziation von
->nach rechts, anstatt explizite Klammern zu verlangen, ist konsistent mit anderen Sprachen wie TypeScript und respektiert das Prinzip, dass gültige Ausdrücke normal substituiert werden können.
async Schlüsselwort
Alle Bindungsregeln gelten weiterhin für asynchrone Callable-Typen.
(int) -> async (float) -> str | bool
(int) -> (async (float) -> (str | bool))
def f() -> async (int, str) -> bool: pass
def f() -> (async (int, str) -> bool): pass
def f() -> async (int) -> async (str) -> bool: pass
def f() -> (async (int) -> (async (str) -> bool)): pass
Nachgestellte Kommas
- Nach dem Vorbild von Funktionssignaturen ist es illegal, ein Komma in einer leeren Argumentenliste zu setzen:
(,) -> boolist ein Syntaxfehler. - Folgt man wieder dem Vorbild, sind nachgestellte Kommas ansonsten immer erlaubt.
((int,) -> bool == (int) -> bool ((int, **P,) -> bool == (int, **P) -> bool ((...,) -> bool) == ((...) -> bool)
Das Zulassen nachgestellter Kommas gibt auch Autoformattern mehr Flexibilität beim Aufteilen von Callable-Typen über mehrere Zeilen, was immer nach Standard-Python-Leerzeichenregeln legal ist.
Verbot von ... als Argumenttyp
Unter normalen Umständen ist jeder gültige Ausdruck dort zulässig, wo wir eine Typ-Annotation wünschen, und ... ist ein gültiger Ausdruck. Dies ist niemals semantisch gültig und alle Typüberprüfer würden es ablehnen, aber die Grammatik würde es zulassen, wenn wir dies nicht explizit verhindern würden.
Da ... als Typ bedeutungslos ist und es Benutzerfreundlichkeitsbedenken gibt, schließt unsere Grammatik dies aus und das Folgende ist ein Syntaxfehler
(int, ...) -> bool
Wir haben entschieden, dass es dafür überzeugende Gründe gab.
- Die Semantik von
(...) -> boolunterscheidet sich von(T) -> boolfür jeden gültigen Typ T:(...)ist eine spezielle Form, dieAnyArgumentsanzeigt, währendTein Typparameter in der Argumentenliste ist. ...wird als Platzhalter-Standardwert verwendet, um ein optionales Argument in Stubs und Callback-Protokollen anzuzeigen. Die Zulassung an der Position eines Typs könnte leicht zu Verwirrung und möglicherweise zu Fehlern aufgrund von Tippfehlern führen.- Im generischen Typ
tuplebehandeln wir...als Sonderfall für "mehr vom Gleichen", z. B. bedeutettuple[int, ...]ein Tupel mit einem oder mehreren Integern. Wir verwenden...nicht auf ähnliche Weise in Callable-Typen, daher ist es sinnvoll, dies zu verhindern, um Missverständnisse zu vermeiden.
Inkompatibilität mit anderen möglichen Verwendungen von * und **
Die Verwendung von **P zur Unterstützung von PEP 612 ParamSpec schließt jeden zukünftigen Vorschlag aus, der ein nacktes **<irgendein_Typ> zur Typisierung von kwargs verwendet. Dies scheint akzeptabel, weil
- Wenn wir jemals eine solche Syntax wollen würden, wäre es klarer, trotzdem einen Argumentnamen zu verlangen. Dies würde auch den Typ ähnlicher zu einer Funktionssignatur machen. Mit anderen Worten, wenn wir jemals
kwargsin Callable-Typen typisieren würden, würden wir(int, **kwargs: str)gegenüber(int, **str)bevorzugen. - Die Entpackungssyntax von PEP 646 würde die Verwendung von
*<irgendein_Typ>fürargsausschließen. Der Fallkwargsist ähnlich genug, dass dies ein nacktes**<irgendein_Typ>ohnehin ausschließt.
Kompatibilität mit Pfeilbasierter Lambda-Syntax
Nach unserem besten Wissen gibt es keine aktive Diskussion über eine Pfeil-ähnliche Lambda-Syntax, die uns bekannt wäre, aber es lohnt sich dennoch, die Möglichkeiten zu bedenken, die durch die Annahme dieses Vorschlags ausgeschlossen würden.
Es wäre unvereinbar mit diesem Vorschlag, die gleiche klammerbasierte Pfeil-Syntax -> für Lambdas zu übernehmen, z. B. (x, y) -> x + y für lambda x, y: x + y.
Unser Standpunkt ist, dass, wenn wir in Zukunft eine Pfeilsyntax für Lambdas wollen, es eine bessere Wahl wäre, => zu verwenden, z. B. (x, y) => x + y. Viele Sprachen verwenden dasselbe Pfeiltoken für sowohl Lambdas als auch Callable-Typen, aber Python ist einzigartig darin, dass Typen Ausdrücke sind und zu Laufzeitwerten ausgewertet werden müssen. Unser Standpunkt ist, dass dies die Verwendung getrennter Token rechtfertigt und angesichts der bestehenden Verwendung von -> für Rückgabetypen in Funktionssignaturen es kohärenter wäre, -> für Callable-Typen und => für Lambdas zu verwenden.
Laufzeitverhalten
Die neuen AST-Knoten müssen zu Laufzeittypen ausgewertet werden, und wir haben zwei Ziele für das Verhalten dieser Laufzeittypen.
- Sie sollten eine strukturierte API bereitstellen, die beschreibend und mächtig genug ist, um mit der Erweiterung des Typs um neue Funktionen wie benannte und variable Argumente kompatibel zu sein.
- Sie sollten auch eine API bereitstellen, die abwärtskompatibel mit
typing.Callableist.
Bewertung und strukturierte API
Wir beabsichtigen, neue eingebaute Typen zu erstellen, zu denen die neuen AST-Knoten ausgewertet werden, und sie im Modul types bereitzustellen.
Unser Plan ist, eine strukturierte API so bereitzustellen, als wären sie wie folgt definiert.
class CallableType:
is_async: bool
arguments: Ellipsis | tuple[CallableTypeArgument]
return_type: object
class CallableTypeArgument:
kind: CallableTypeArgumentKind
annotation: object
@enum.global_enum
class CallableTypeArgumentKind(enum.IntEnum):
POSITIONAL_ONLY: int = ...
PARAM_SPEC: int = ...
Die Auswertungsregeln sind in Form des folgenden Pseudocodes ausgedrückt.
def evaluate_callable_type(
callable_type: ast.CallableType | ast.AsyncCallableType:
) -> CallableType:
return CallableType(
is_async=isinstance(callable_type, ast.AsyncCallableType),
arguments=_evaluate_arguments(callable_type.arguments),
return_type=evaluate_expression(callable_type.returns),
)
def _evaluate_arguments(arguments):
match arguments:
case ast.AnyArguments():
return Ellipsis
case ast.ArgumentsList(posonlyargs):
return tuple(
_evaluate_arg(arg) for arg in args
)
case ast.ArgumentsListConcatenation(posonlyargs, param_spec):
return tuple(
*(evaluate_arg(arg) for arg in args),
_evaluate_arg(arg=param_spec, kind=PARAM_SPEC)
)
if isinstance(arguments, Any
return Ellipsis
def _evaluate_arg(arg, kind=POSITIONAL_ONLY):
return CallableTypeArgument(
kind=POSITIONAL_ONLY,
annotation=evaluate_expression(value)
)
Abwärtskompatible API
Um Abwärtskompatibilität mit der bestehenden API von types.Callable zu gewährleisten, die auf den Feldern __args__ und __parameters__ basiert, können wir sie so definieren, als wären sie wie folgt geschrieben.
import itertools
import typing
def get_args(t: CallableType) -> tuple[object]:
return_type_arg = (
typing.Awaitable[t.return_type]
if t.is_async
else t.return_type
)
arguments = t.arguments
if isinstance(arguments, Ellipsis):
argument_args = (Ellipsis,)
else:
argument_args = (arg.annotation for arg in arguments)
return (
*arguments_args,
return_type_arg
)
def get_parameters(t: CallableType) -> tuple[object]:
out = []
for arg in get_args(t):
if isinstance(arg, typing.ParamSpec):
out.append(t)
else:
out.extend(arg.__parameters__)
return tuple(out)
Zusätzliche Verhaltensweisen von types.CallableType
Ähnlich wie bei der A | B-Syntax für Unions, die in PEP 604 eingeführt wurde.
- Die Methode
__eq__sollte äquivalentetyping.Callable-Werte als gleich zu Werten behandeln, die mit der eingebauten Syntax konstruiert wurden, und ansonsten wie die__eq__vontyping.Callablefunktionieren. - Die Methode
__repr__sollte eine Pfeil-Syntax-Darstellung erzeugen, die, wenn sie ausgewertet wird, uns wieder eine gleiche Instanz vontypes.CallableTypeliefert.
Abgelehnte Alternativen
Viele der Alternativen, die wir in Betracht gezogen haben, wären ausdrucksstärker gewesen als typing.Callable, z. B. durch Hinzufügen von Unterstützung für die Beschreibung von Signaturen, die benannte, optionale und variable Argumente enthalten.
Um zu bestimmen, welche Funktionen wir am besten mit einer Callable-Typ-Syntax unterstützen mussten, haben wir eine ausführliche Analyse bestehender Projekte durchgeführt.
- Statistiken zur Verwendung des Callable-Typs;
- Statistiken darüber, wie nicht-typisierte und teiltypisierte Callbacks tatsächlich verwendet werden.
Wir haben uns für einen einfachen Vorschlag mit verbesserter Syntax für den bestehenden Typ Callable entschieden, da die überwiegende Mehrheit der Callbacks korrekt durch die bestehenden Semantiken von typing.Callable beschrieben werden kann.
- Positionsargumente: Der wichtigste Fall, der gut behandelt werden muss, sind einfache Callable-Typen mit Positionsargumenten, wie z. B.
(int, str) -> bool. - ParamSpec und Concatenate: Die nächstwichtigste Funktion ist die gute Unterstützung für PEP 612
ParamSpecundConcatenate-Typen wie(**P) -> boolund(int, **P) -> bool. Diese sind hauptsächlich wegen der intensiven Nutzung von Decorator-Mustern im Python-Code verbreitet. - TypeVarTuples: Die nächstwichtigste Funktion, vorausgesetzt PEP 646 wird angenommen, sind entpackte Typen, die wegen Fällen verbreitet sind, in denen ein Wrapper
*argsan eine andere Funktion weitergibt.
Funktionen, die andere, kompliziertere Vorschläge unterstützen würden, machen weniger als 2% der von uns gefundenen Anwendungsfälle aus. Diese sind bereits mit Callback-Protokollen ausdrucksfähig, und da sie unüblich sind, haben wir entschieden, dass es sinnvoller ist, mit einer einfacheren Syntax fortzufahren.
Erweiterte Syntax für benannte und optionale Argumente
Eine weitere Alternative war eine kompatible, aber komplexere Syntax, die alles in dieser PEP ausdrücken könnte, aber auch benannte, optionale und variable Argumente. In diesem "erweiterten" Syntaxvorschlag wären die folgenden Typen äquivalent gewesen.
class Function(typing.Protocol):
def f(self, x: int, /, y: float, *, z: bool = ..., **kwargs: str) -> bool:
...
Function = (int, y: float, *, z: bool = ..., **kwargs: str) -> bool
Vorteile dieser Syntax sind: - Die meisten Vorteile des Vorschlags in dieser PEP (Prägnanz, Unterstützung für PEP 612 usw.) - Außerdem die Fähigkeit, benannte, optionale und variable Argumente zu handhaben.
Wir haben uns aus folgenden Gründen dagegen entschieden, ihn vorzuschlagen.
- Die Implementierung wäre schwieriger gewesen, und Nutzungsstatistiken zeigen, dass weniger als 3% der Anwendungsfälle von den zusätzlichen Funktionen profitieren würden.
- Die Gruppe, die diese Vorschläge debattierte, war sich uneins darüber, ob diese Änderungen wünschenswert sind.
- Einerseits machen sie Callable-Typen ausdrucksstärker. Andererseits könnten sie Benutzer, die die vollständige Spezifikation der Callable-Typ-Syntax nicht gelesen haben, leicht verwirren.
- Wir glauben, dass die in dieser PEP vorgeschlagene einfachere Syntax, die keine neuen Semantiken einführt und die Syntax in anderen beliebten Sprachen wie Kotlin, Scala und TypeScript eng nachahmt, viel weniger wahrscheinlich Benutzer verwirrt.
- Wir beabsichtigen, den aktuellen Vorschlag so zu implementieren, dass er mit der komplizierteren erweiterten Syntax vorwärtskompatibel ist. Wenn die Community nach mehr Erfahrung und Diskussion entscheidet, dass wir die zusätzlichen Funktionen wollen, sollte es einfach sein, sie in Zukunft vorzuschlagen.
- Selbst eine vollständige erweiterte Syntax kann die Verwendung von Callback-Protokollen für Überladungen nicht ersetzen. Zum Beispiel könnte keine geschlossene Form eines Callable-Typs eine Funktion ausdrücken, die boolesche Werte auf boolesche Werte und Ganzzahlen auf Fließkommazahlen abbildet, wie dieses Callback-Protokoll.
from typing import overload, Protocol class OverloadedCallback(Protocol) @overload def __call__(self, x: int) -> float: ... @overload def __call__(self, x: bool) -> bool: ... def __call__(self, x: int | bool) -> float | bool: ... f: OverloadedCallback = ... f(True) # bool f(3) # float
Wir haben bestätigt, dass der aktuelle Vorschlag mit der erweiterten Syntax vorwärtskompatibel ist, indem wir eine Grammatik und einen AST für diese erweiterte Syntax auf unserer Referenzimplementierung der Grammatik dieser PEP implementiert haben.
Syntax näher an Funktionssignaturen
Eine Alternative, die wir diskutiert hatten, war eine Syntax, die Funktionssignaturen sehr ähnlich ist.
In diesem Vorschlag wären die folgenden Typen äquivalent gewesen.
class Function(typing.Protocol):
def f(self, x: int, /, y: float, *, z: bool = ..., **kwargs: str) -> bool:
...
Function = (x: int, /, y: float, *, z: bool = ..., **kwargs: str) -> bool
Die Vorteile dieses Vorschlags wären gewesen.
- Perfekte syntaktische Konsistenz zwischen Signaturen und Callable-Typen.
- Unterstützung für mehr Funktionen von Funktionssignaturen (benannte, optionale, variable Argumente), die diese PEP nicht unterstützt.
Schlüssel-Nachteile, die uns dazu veranlasst haben, die Idee abzulehnen, sind die folgenden.
- Eine große Mehrheit der Anwendungsfälle verwendet nur positionsabhängige Argumente. Diese Syntax wäre für diesen Anwendungsfall umständlicher, sowohl wegen der erforderlichen Argumentnamen als auch wegen eines expliziten
/, zum Beispiel(int, /) -> bool, wo unser Vorschlag(int) -> boolerlaubt. - Die Anforderung eines expliziten
/für positionsabhängige Argumente birgt ein hohes Risiko, häufige Fehler zu verursachen – die oft nicht von Unit-Tests erkannt würden –, bei denen Bibliotheksautoren versehentlich Typen mit benannten Argumenten verwenden würden. - Unsere Analyse legt nahe, dass die Unterstützung für
ParamSpecentscheidend ist, aber die in PEP 612 dargelegten Scoping-Regeln hätten dies schwierig gemacht.
Andere betrachtete Vorschläge
Funktionen als Typen
Eine Idee, die wir uns sehr früh angesehen haben, war, die Verwendung von Funktionen als Typen zu erlauben. Die Idee ist, einer Funktion zu erlauben, für ihre eigene Aufrufsignatur einzustehen, mit ungefähr denselben Semantik wie die __call__ Methode von Callback-Protokollen.
def CallableType(
positional_only: int,
/,
named: str,
*args: float,
keyword_only: int = ...,
**kwargs: str
) -> bool: ...
f: CallableType = ...
f(5, 6.6, 6.7, named=6, x="hello", y="world") # typechecks as bool
Dies mag eine gute Idee sein, aber wir betrachten es nicht als eine brauchbare Alternative zu aufrufbaren Typen.
- Es wäre schwierig,
ParamSpeczu behandeln, was wir als eine kritische Funktion für die Unterstützung betrachten. - Bei der Verwendung von Funktionen als Typen sind die aufrufbaren Typen keine First-Class-Werte. Stattdessen erfordern sie eine separate, ausserhalb des Zeilenflusses liegende Funktionsdefinition, um einen Typ-Alias zu definieren.
- Es würde nicht mehr Funktionen unterstützen als Callback-Protokolle und scheint eher ein kürzerer Weg, diese zu schreiben, als eine Alternative zu
Callablezu sein.
Hybride Schlüsselwort-Pfeil-Syntax
In der Sprache Rust wird ein Schlüsselwort fn verwendet, um Funktionen in vielerlei Hinsicht wie Pythons def anzuzeigen, und aufrufbare Typen werden mit einer hybriden Pfeilsyntax Fn(i64, String) -> bool angezeigt.
Wir könnten das Schlüsselwort def in aufrufbaren Typen für Python verwenden, zum Beispiel könnte unsere boolesche Funktion mit zwei Parametern als def(int, str) -> bool geschrieben werden. Aber wir denken, dass dies Leser verwirren könnte, indem sie denken, def(A, B) -> C sei ein Lambda, insbesondere weil das JavaScript-Schlüsselwort function sowohl für benannte als auch für anonyme Funktionen verwendet wird.
Klammerfreie Syntax
Wir haben eine klammerfreie Syntax in Betracht gezogen, die noch prägnanter gewesen wäre.
int, str -> bool
Wir haben uns dagegen entschieden, da dies visuell nicht so ähnlich zur bestehenden Funktionskopf-Syntax ist. Außerdem ist es visuell ähnlich zu Lambdas, die Namen ohne Klammern binden: lambda x, y: x == y.
Erfordernis äußerer Klammern
Ein Bedenken bei dem aktuellen Vorschlag ist die Lesbarkeit, insbesondere wenn aufrufbare Typen in der Rückgabe-Typ-Position verwendet werden, was zu mehreren Top-Level -> Token führt, zum Beispiel.
def make_adder() -> (int) -> int:
return lambda x: x + 1
Wir haben einige Ideen in Betracht gezogen, um dies zu verhindern, indem wir Regeln bezüglich Klammern ändern. Eine war, die Klammern nach aussen zu verschieben, so dass eine boolesche Funktion mit zwei Argumenten als (int, str -> bool) geschrieben wird. Mit dieser Änderung wird das obige Beispiel zu
def make_adder() -> (int -> int):
return lambda x: x + 1
Dies macht die Verschachtelung vieler Beispiele, die schwer zu verfolgen sind, klar, aber wir haben sie abgelehnt, weil.
- Derzeit binden Kommas in Python sehr locker, was bedeutet, dass es üblich sein könnte,
(int, str -> bool)als Tupel zu missverstehen, dessen erstes Element ein Integer ist, anstatt eines zweistelligen aufrufbaren Typs. - Es ist der Funktionskopf-Syntax nicht sehr ähnlich, und eines unserer Ziele war vertraute Syntax, inspiriert von Funktionsköpfen.
- Diese Syntax mag für tief verschachtelte aufrufbare Typen wie die obige besser lesbar sein, aber tiefe Verschachtelung ist nicht sehr üblich. Die Ermutigung zu zusätzlichen Klammern um aufrufbare Typen in der Rückgabeposition durch einen Styleguide hätte die meisten Lesbarkeitsvorteile ohne die Nachteile.
Wir haben auch in Betracht gezogen, Klammern sowohl für die Parameterliste als auch für die Aussenseite zu verlangen, z. B. ((int, str) -> bool). Mit dieser Änderung wird das obige Beispiel zu
def make_adder() -> ((int) -> int):
return lambda x: x + 1
Wir haben diese Änderung abgelehnt, weil.
- Die äusseren Klammern helfen nur in einigen Fällen bei der Lesbarkeit, hauptsächlich wenn ein aufrufbarer Typ in der Rückgabeposition verwendet wird. In vielen anderen Fällen beeinträchtigen sie die Lesbarkeit eher, als sie zu helfen.
- Wir stimmen zu, dass es sinnvoll sein könnte, äussere Klammern in mehreren Fällen zu empfehlen, insbesondere für aufrufbare Typen in Funktionsrückgabe-Annotationen. Aber.
- Wir glauben, dass es angemessener ist, dies in Styleguides, Linters und Autoformattern zu empfehlen, als es in den Parser einzubacken und Syntaxfehler auszulösen.
- Darüber hinaus können wir, wenn ein Typ komplex genug ist, dass Lesbarkeit ein Problem darstellt, immer Typ-Aliase verwenden, zum Beispiel.
IntToIntFunction: (int) -> int def make_adder() -> IntToIntFunction: return lambda x: x + 1
Binden von -> enger als |
Um sowohl -> als auch | Token in Typausdrücken zuzulassen, mussten wir eine Präzedenzordnung wählen. Im aktuellen Vorschlag handelt es sich um eine Funktion, die ein optionales boolesches Ergebnis zurückgibt.
(int, str) -> bool | None # equivalent to (int, str) -> (bool | None)
Wir haben überlegt, -> stärker binden zu lassen, so dass der Ausdruck stattdessen als ((int, str) -> bool) | None geparst würde. Dies hat zwei Vorteile.
- Es bedeutet, dass wir
None | (int, str) -> boolnicht mehr als Syntaxfehler behandeln müssten. - Wenn wir uns heute Typeshed ansehen, sind optionale aufrufbare Argumente sehr üblich, da die Verwendung von
Noneals Standardwert eine Standard-Python-Idiom ist. Wenn->stärker binden würde, wäre dies einfacher zu schreiben.
Wir haben uns aus mehreren Gründen dagegen entschieden.
- Der Funktionskopf
def f() -> int | None: ...ist legal und zeigt eine Funktion an, die ein optionales Integer zurückgibt. Um mit Funktionsköpfen konsistent zu sein, sollten aufrufbare Typen dasselbe tun. - TypeScript ist die andere populäre Sprache, die uns bekannt ist und sowohl
->als auch|Token in Typausdrücken verwendet, und sie haben|, das stärker bindet. Obwohl wir ihrem Beispiel nicht folgen müssen, tun wir dies vor. - Wir erkennen an, dass optionale aufrufbare Typen üblich sind und dass
|stärker bindet, was zusätzliche Klammern erzwingt und diese Typen schwerer zu schreiben macht. Aber Code wird öfter gelesen als geschrieben, und wir glauben, dass das Erfordernis der äusseren Klammern für einen optionalen aufrufbaren Typ wie((int, str) -> bool) | Nonefür die Lesbarkeit vorzuziehen ist.
Einführung von Typ-Strings
Eine weitere Idee war, eine neue "spezielle Zeichenketten"-Syntax hinzuzufügen und den Typ darin einzubetten, zum Beispiel t”(int, str) -> bool”. Wir haben dies abgelehnt, weil es nicht so lesbar ist und im Widerspruch zu den Richtlinien des Steering Council steht, um sicherzustellen, dass Typausdrücke nicht von der übrigen Syntax von Python abweichen.
Verbesserung der Benutzerfreundlichkeit des indizierten Callable-Typs
Wenn wir keine neue Syntax für aufrufbare Typen hinzufügen wollen, könnten wir uns ansehen, wie der vorhandene Typ leichter zu lesen ist. Ein Vorschlag wäre, die eingebaute callable Funktion indizierbar zu machen, so dass sie als Typ verwendet werden könnte.
callable[[int, str], bool]
Diese Änderung wäre analog zu PEP 585, die eingebaute Sammlungen wie list und dict als Typen verwendbar machte und Importe bequemer machen würde, aber sie würde die Lesbarkeit der Typen selbst nicht wesentlich verbessern.
Um die Anzahl der Klammern in komplexen aufrufbaren Typen zu reduzieren, wäre es möglich, Tupel für die Argumentliste zuzulassen.
callable[(int, str), bool]
Dies ist tatsächlich eine signifikante Verbesserung der Lesbarkeit für Funktionen mit mehreren Argumenten, aber das Problem ist, dass es Aufrufbare mit einem Argument, die die häufigste Kardinalität sind, schwer zu schreiben macht: da (x) zu x ausgewertet wird, müssten sie als callable[(int,), bool] geschrieben werden. Wir finden das umständlich.
Darüber hinaus helfen keine dieser Ideen so sehr bei der Reduzierung der Ausführlichkeit wie der aktuelle Vorschlag, noch führen sie zu einer so starken visuellen Kennzeichnung wie dem -> zwischen den Parametertypen und dem Rückgabetyp.
Alternative Laufzeitverhalten
Die harten Anforderungen an unsere Laufzeit-API sind, dass
- Sie muss die Abwärtskompatibilität mit
typing.Callableüber__args__und__params__erhalten. - Sie muss eine strukturierte API bereitstellen, die erweiterbar sein sollte, wenn wir in Zukunft benannte und variable Argumente unterstützen wollen.
Alternative APIs
Wir haben überlegt, ob die Laufzeitdaten types.CallableType eine strukturiertere API verwenden sollten, mit separaten Feldern für posonlyargs und param_spec. Der aktuelle Vorschlag wurde von der inspect.Signature Typ inspiriert.
Wir verwenden "argument" in unseren Feld- und Tynamen, im Gegensatz zu "parameter" wie in inspect.Signature, um Verwechslungen mit dem Feld callable_type.__parameters__ aus der Legacy-API zu vermeiden, das sich auf Typparameter und nicht auf aufrufbare Parameter bezieht.
Verwendung des reinen Rückgabetyps in __args__ für asynchrone Typen
Es ist umstritten, ob wir die Abwärtskompatibilität von __args__ für asynchrone aufrufbare Typen wie async (int) -> str erhalten müssen. Der Grund ist, dass man argumentieren könnte, dass sie nicht direkt mit typing.Callable ausgedrückt werden können und es daher in Ordnung wäre, __args__ als (int, int) anstelle von (int, typing.Awaitable[int]) festzulegen.
Aber wir glauben, dass dies problematisch wäre. Indem wir das Erscheinungsbild einer abwärtskompatiblen API beibehalten, aber ihre Semantik für asynchrone Typen tatsächlich brechen, würden wir Laufzeit-Typbibliotheken, die versuchen, Callable unter Verwendung von __args__ zu interpretieren, stillschweigend fehlschlagen lassen.
Aus diesem Grund wickeln wir den Rückgabetyp automatisch in Awaitable.
Abwärtskompatibilität
Diese PEP schlägt eine wesentliche Syntaxverbesserung gegenüber typing.Callable vor, aber die statische Semantik ist dieselbe.
Daher müssen wir für die Abwärtskompatibilität nur sicherstellen, dass Typen, die über die neue Syntax spezifiziert werden, sich genauso verhalten wie die äquivalenten typing.Callable und typing.Concatenate Werte, die sie ersetzen sollen.
Es gibt keine besondere Interaktion zwischen diesem Vorschlag und from __future__ import annotations - genau wie jede andere Typannotation wird sie beim Modulimport als Zeichenkette unanalysiert und typing.get_type_hints sollte die resultierenden Zeichenketten korrekt auswerten, wenn dies möglich ist.
Dies wird im Abschnitt "Runtime Behavior" detaillierter diskutiert.
Referenzimplementierung
Wir haben eine funktionierende Implementierung des AST und der Grammatik mit Tests, die bestätigen, dass die hier vorgeschlagene Grammatik die gewünschten Verhaltensweisen aufweist.
Das Laufzeitverhalten ist noch nicht implementiert. Wie im Abschnitt Runtime Behavior des Spezifikation erläutert, haben wir einen detaillierten Plan sowohl für eine abwärtskompatible API als auch für eine strukturiertere API in einem separaten Dokument, in dem wir auch offen für Diskussionen und alternative Ideen sind.
Offene Fragen
Details der Laufzeit-API
Wir haben versucht, eine vollständige Verhaltensspezifikation im Abschnitt Runtime Behavior dieser PEP bereitzustellen.
Aber es gibt wahrscheinlich noch mehr Details, die wir erst feststellen werden, wenn wir eine vollständige Referenzimplementierung erstellen.
Optimierung von SyntaxError Meldungen
Die aktuelle Referenzimplementierung verfügt über einen voll funktionsfähigen Parser und alle hier vorgestellten Randfälle wurden getestet.
Es gibt jedoch einige bekannte Fälle, in denen die Fehler nicht so informativ sind, wie wir uns das wünschen. Zum Beispiel, da (int, ...) -> bool illegal ist, aber (int, ...) ein gültiges Tupel ist, produzieren wir derzeit einen Syntaxfehler, der den -> als Problem kennzeichnet, obwohl die tatsächliche Ursache des Fehlers die Verwendung von ... als Argumenttyp ist.
Dies ist nicht Teil der Spezifikation *per se*, aber ein wichtiges Detail, das in unserer Implementierung behandelt werden muss. Die Lösung wird wahrscheinlich die Hinzufügung von invalid_.* Regeln zu python.gram und die Anpassung von Fehlermeldungen beinhalten.
Ressourcen
Hintergrund und Geschichte
PEP 484 spezifiziert eine sehr ähnliche Syntax für Funktions-Typ-Hint-Kommentare zur Verwendung in Code, der auf Python 2.7 funktionieren muss. Zum Beispiel.
def f(x, y):
# type: (int, str) -> bool
...
Damals verwendeten wir Indexierungsoperationen, um generische Typen wie typing.Callable zu spezifizieren, da wir uns entschieden hatten, keine Syntax für Typen hinzuzufügen. Wir haben jedoch inzwischen damit begonnen, z. B. mit PEP 604.
Maggie schlug eine bessere Syntax für aufrufbare Typen als Teil einer grösseren Präsentation über Typvereinfachungen auf dem PyCon Typing Summit 2021 vor.
Steven brachte diesen Vorschlag auf typing-sig. Wir hatten mehrere Treffen, um Alternativen zu diskutieren, und diese Präsentation führte uns zu dem aktuellen Vorschlag.
Pradeep brachte diesen Vorschlag zur Diskussion auf python-dev.
Danksagungen
Vielen Dank an die folgenden Personen für ihr Feedback zum PEP und ihre Hilfe bei der Planung der Referenzimplementierung.
Alex Waygood, Eric Traut, Guido van Rossum, James Hilton-Balfe, Jelle Zijlstra, Maggie Moss, Tuomas Suutari, Shannon Zhu.
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-0677.rst
Zuletzt geändert: 2025-02-01 08:55:40 GMT