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

Python Enhancement Proposals

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

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 list und dict.

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 als int | None
  • typing.Union[int, str] wird geschrieben als int | str
  • typing.List[int] wird geschrieben als list[int]
  • typing.Tuple[int, str] wird geschrieben als tuple[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 | B gemäß 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 wie bool | () -> bool ein 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: (,) -> bool ist 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 (...) -> bool unterscheidet sich von (T) -> bool für jeden gültigen Typ T: (...) ist eine spezielle Form, die AnyArguments anzeigt, während T ein 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 tuple behandeln wir ... als Sonderfall für "mehr vom Gleichen", z. B. bedeutet tuple[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 kwargs in 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ür args ausschließen. Der Fall kwargs ist ä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.Callable ist.

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 äquivalente typing.Callable-Werte als gleich zu Werten behandeln, die mit der eingebauten Syntax konstruiert wurden, und ansonsten wie die __eq__ von typing.Callable funktionieren.
  • Die Methode __repr__ sollte eine Pfeil-Syntax-Darstellung erzeugen, die, wenn sie ausgewertet wird, uns wieder eine gleiche Instanz von types.CallableType liefert.

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.

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 ParamSpec und Concatenate-Typen wie (**P) -> bool und (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 *args an 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) -> bool erlaubt.
  • 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 ParamSpec entscheidend 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, ParamSpec zu 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 Callable zu 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) -> bool nicht mehr als Syntaxfehler behandeln müssten.
  • Wenn wir uns heute Typeshed ansehen, sind optionale aufrufbare Argumente sehr üblich, da die Verwendung von None als 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) | None fü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.


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

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