PEP 612 – Parameter Specification Variables
- Autor:
- Mark Mendoza <mendoza.mark.a at gmail.com>
- Sponsor:
- Guido van Rossum <guido at python.org>
- BDFL-Delegate:
- Guido van Rossum <guido at python.org>
- Discussions-To:
- Typing-SIG list
- Status:
- Final
- Typ:
- Standards Track
- Thema:
- Typisierung
- Erstellt:
- 18. Dez. 2019
- Python-Version:
- 3.10
- Post-History:
- 18. Dez. 2019, 13. Juli 2020
Zusammenfassung
Derzeit gibt es zwei Möglichkeiten, den Typ eines Aufrufs anzugeben: die Syntax Callable[[int, str], bool], definiert in PEP 484, und Callback-Protokolle aus PEP 544. Keine dieser beiden unterstützt die Weiterleitung der Parametertypen eines Aufrufs an einen anderen Aufruf, was die Annotation von Funktionsdekoratoren erschwert. Diese PEP schlägt typing.ParamSpec und typing.Concatenate vor, um die Ausdrucksweise solcher Beziehungen zu unterstützen.
Motivation
Die bestehenden Standards zur Annotation von höherrangigen Funktionen geben uns nicht die Werkzeuge, um das folgende gängige Dekorationsmuster zufriedenstellend zu annotieren
from typing import Awaitable, Callable, TypeVar
R = TypeVar("R")
def add_logging(f: Callable[..., R]) -> Callable[..., Awaitable[R]]:
async def inner(*args: object, **kwargs: object) -> R:
await log_to_database()
return f(*args, **kwargs)
return inner
@add_logging
def takes_int_str(x: int, y: str) -> int:
return x + 7
await takes_int_str(1, "A")
await takes_int_str("B", 2) # fails at runtime
add_logging, ein Dekorator, der vor jedem Aufruf der dekorierten Funktion protokolliert, ist ein Beispiel für das Python-Idiom einer Funktion, die alle ihr übergebenen Argumente an eine andere Funktion weitergibt. Dies geschieht durch die Kombination der *args und **kwargs Features sowohl in Parametern als auch in Argumenten. Wenn man eine Funktion (wie inner) definiert, die (*args, **kwargs) annimmt und dann eine andere Funktion mit (*args, **kwargs) aufruft, kann die umgebende Funktion nur so sicher aufgerufen werden, wie die umgebene Funktion sicher aufgerufen werden konnte. Um diesen Dekorator zu typisieren, möchten wir eine Abhängigkeit zwischen den Parametern des Aufrufs f und den Parametern der zurückgegebenen Funktion herstellen können. PEP 484 unterstützt Abhängigkeiten zwischen einzelnen Typen, wie in def append(l: typing.List[T], e: T) -> typing.List[T]: ..., aber es gibt keine bestehende Möglichkeit, dies mit einer so komplexen Entität wie den Parametern einer Funktion zu tun.
Aufgrund der Einschränkungen des Status quo wird das add_logging-Beispiel zwar typisiert, schlägt aber zur Laufzeit fehl. inner übergibt den String „B“ an takes_int_str, das versucht, 7 dazu zu addieren, was einen Typfehler auslöst. Dies wurde vom Typ-Checker nicht erfasst, da das dekorierte takes_int_str den Typ Callable[..., Awaitable[int]] erhielt (ein Ellipse anstelle von Parametertypen bedeutet, dass keine Validierung der Argumente erfolgt).
Ohne die Möglichkeit, Abhängigkeiten zwischen den Parametern verschiedener Aufruftypen zu definieren, gibt es derzeit keine Möglichkeit, add_logging mit allen Funktionen kompatibel zu machen und gleichzeitig die Erzwingung der Parameter der dekorierten Funktion beizubehalten.
Mit der Einführung der von dieser PEP vorgeschlagenen ParamSpec-Variablen können wir das vorherige Beispiel so umschreiben, dass die Flexibilität des Dekorators und die Parametereinforderung der dekorierten Funktion erhalten bleiben.
from typing import Awaitable, Callable, ParamSpec, TypeVar
P = ParamSpec("P")
R = TypeVar("R")
def add_logging(f: Callable[P, R]) -> Callable[P, Awaitable[R]]:
async def inner(*args: P.args, **kwargs: P.kwargs) -> R:
await log_to_database()
return f(*args, **kwargs)
return inner
@add_logging
def takes_int_str(x: int, y: str) -> int:
return x + 7
await takes_int_str(1, "A") # Accepted
await takes_int_str("B", 2) # Correctly rejected by the type checker
Ein weiteres gängiges Dekorationsmuster, das bisher nicht typisiert werden konnte, ist die Praxis, Argumente der dekorierten Funktion hinzuzufügen oder zu entfernen. Zum Beispiel
class Request:
...
def with_request(f: Callable[..., R]) -> Callable[..., R]:
def inner(*args: object, **kwargs: object) -> R:
return f(Request(), *args, **kwargs)
return inner
@with_request
def takes_int_str(request: Request, x: int, y: str) -> int:
# use request
return x + 7
takes_int_str(1, "A")
takes_int_str("B", 2) # fails at runtime
Mit der Einführung des Concatenate-Operators aus dieser PEP können wir sogar diesen komplexeren Dekorator typisieren.
from typing import Concatenate
def with_request(f: Callable[Concatenate[Request, P], R]) -> Callable[P, R]:
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
return f(Request(), *args, **kwargs)
return inner
@with_request
def takes_int_str(request: Request, x: int, y: str) -> int:
# use request
return x + 7
takes_int_str(1, "A") # Accepted
takes_int_str("B", 2) # Correctly rejected by the type checker
Spezifikation
Variablen für ParamSpec
Deklaration
Eine Parameterspezifikationsvariable wird ähnlich wie eine normale Typvariable mit typing.TypeVar definiert.
from typing import ParamSpec
P = ParamSpec("P") # Accepted
P = ParamSpec("WrongName") # Rejected because P =/= WrongName
Die Laufzeit sollte bounds und covariant und contravariant Argumente in der Deklaration akzeptieren, genau wie typing.TypeVar, aber vorerst werden wir die Standardisierung der Semantik dieser Optionen auf eine spätere PEP verschieben.
Gültige Verwendungspositionen
Zuvor waren nur eine Liste von Parameterargumenten ([A, B, C]) oder eine Ellipse (die „undefinierte Parameter“ bedeutet) als erstes „Argument“ für typing.Callable akzeptabel. Nun erweitern wir dies um zwei neue Optionen: eine Parameterspezifikationsvariable (Callable[P, int]) oder eine Verkettung einer Parameterspezifikationsvariable (Callable[Concatenate[int, P], int]).
callable ::= Callable "[" parameters_expression, type_expression "]"
parameters_expression ::=
| "..."
| "[" [ type_expression ("," type_expression)* ] "]"
| parameter_specification_variable
| concatenate "["
type_expression ("," type_expression)* ","
parameter_specification_variable
"]"
wobei parameter_specification_variable eine typing.ParamSpec Variable ist, die wie oben definiert deklariert wurde, und concatenate typing.Concatenate ist.
Wie zuvor sind parameters_expressions allein an Stellen, wo ein Typ erwartet wird, nicht akzeptabel
def foo(x: P) -> P: ... # Rejected
def foo(x: Concatenate[int, P]) -> int: ... # Rejected
def foo(x: typing.List[P]) -> None: ... # Rejected
def foo(x: Callable[[int, str], P]) -> None: ... # Rejected
Benutzerdefinierte generische Klassen
So wie das Erben einer Klasse von Generic[T] eine Klasse generisch für einen einzelnen Parameter macht (wenn T eine TypeVar ist), macht das Erben einer Klasse von Generic[P] eine Klasse generisch für parameters_expressions (wenn P ein ParamSpec ist).
T = TypeVar("T")
P_2 = ParamSpec("P_2")
class X(Generic[T, P]):
f: Callable[P, int]
x: T
def f(x: X[int, P_2]) -> str: ... # Accepted
def f(x: X[int, Concatenate[int, P_2]]) -> str: ... # Accepted
def f(x: X[int, [int, bool]]) -> str: ... # Accepted
def f(x: X[int, ...]) -> str: ... # Accepted
def f(x: X[int, int]) -> str: ... # Rejected
Nach den oben definierten Regeln würde das Ausschreiben einer konkreten Instanz einer Klasse, die nur in Bezug auf einen einzigen ParamSpec generisch ist, hässliche doppelte Klammern erfordern. Aus ästhetischen Gründen erlauben wir, diese wegzulassen.
class Z(Generic[P]):
f: Callable[P, int]
def f(x: Z[[int, str, bool]]) -> str: ... # Accepted
def f(x: Z[int, str, bool]) -> str: ... # Equivalent
# Both Z[[int, str, bool]] and Z[int, str, bool] express this:
class Z_instantiated:
f: Callable[[int, str, bool], int]
Semantik
Die Inferenzregeln für den Rückgabetyp einer Funktionsaufrufs, dessen Signatur eine ParamSpec-Variable enthält, sind analog zu denen bei der Auswertung von TypeVars.
def changes_return_type_to_str(x: Callable[P, int]) -> Callable[P, str]: ...
def returns_int(a: str, b: bool) -> int: ...
f = changes_return_type_to_str(returns_int) # f should have the type:
# (a: str, b: bool) -> str
f("A", True) # Accepted
f(a="A", b=True) # Accepted
f("A", "A") # Rejected
expects_str(f("A", True)) # Accepted
expects_int(f("A", True)) # Rejected
Genau wie bei herkömmlichen TypeVars kann ein Benutzer denselben ParamSpec mehrmals in den Argumenten derselben Funktion einfügen, um eine Abhängigkeit zwischen mehreren Argumenten anzuzeigen. In diesen Fällen kann der Typ-Checker zu einem gemeinsamen Verhalten-Obertyp auflösen (d. h. eine Menge von Parametern, für die alle gültigen Aufrufe in beiden Untertypen gültig sind), ist aber nicht dazu verpflichtet.
P = ParamSpec("P")
def foo(x: Callable[P, int], y: Callable[P, int]) -> Callable[P, bool]: ...
def x_y(x: int, y: str) -> int: ...
def y_x(y: int, x: str) -> int: ...
foo(x_y, x_y) # Should return (x: int, y: str) -> bool
foo(x_y, y_x) # Could return (__a: int, __b: str) -> bool
# This works because both callables have types that are
# behavioral subtypes of Callable[[int, str], int]
def keyword_only_x(*, x: int) -> int: ...
def keyword_only_y(*, y: int) -> int: ...
foo(keyword_only_x, keyword_only_y) # Rejected
Die Konstruktoren benutzerdefinierter Klassen, die auf ParamSpecs generisch sind, sollten auf die gleiche Weise ausgewertet werden.
U = TypeVar("U")
class Y(Generic[U, P]):
f: Callable[P, str]
prop: U
def __init__(self, f: Callable[P, str], prop: U) -> None:
self.f = f
self.prop = prop
def a(q: int) -> str: ...
Y(a, 1) # Should resolve to Y[(q: int), int]
Y(a, 1).f # Should resolve to (q: int) -> str
Die Semantik von Concatenate[X, Y, P] bedeutet, dass es die von P repräsentierten Parameter mit zwei vorangestellten positionsabhängigen Parametern darstellt. Das bedeutet, wir können es verwenden, um höherrangige Funktionen darzustellen, die eine endliche Anzahl von Parametern eines Aufrufs hinzufügen, entfernen oder transformieren.
def bar(x: int, *args: bool) -> int: ...
def add(x: Callable[P, int]) -> Callable[Concatenate[str, P], bool]: ...
add(bar) # Should return (__a: str, x: int, *args: bool) -> bool
def remove(x: Callable[Concatenate[int, P], int]) -> Callable[P, bool]: ...
remove(bar) # Should return (*args: bool) -> bool
def transform(
x: Callable[Concatenate[int, P], int]
) -> Callable[Concatenate[str, P], bool]: ...
transform(bar) # Should return (__a: str, *args: bool) -> bool
Das bedeutet auch, dass, während jede Funktion, die ein R zurückgibt, typing.Callable[P, R] erfüllen kann, nur Funktionen, die positionsabhängig in ihrer ersten Position mit einem X aufgerufen werden können, typing.Callable[Concatenate[X, P], R] erfüllen können.
def expects_int_first(x: Callable[Concatenate[int, P], int]) -> None: ...
@expects_int_first # Rejected
def one(x: str) -> int: ...
@expects_int_first # Rejected
def two(*, x: int) -> int: ...
@expects_int_first # Rejected
def three(**kwargs: int) -> int: ...
@expects_int_first # Accepted
def four(*args: int) -> int: ...
Es gibt immer noch einige Klassen von Dekoratoren, die mit diesen Features noch nicht unterstützt werden
- die, die eine **variable** Anzahl von Parametern hinzufügen/entfernen/ändern (zum Beispiel wird
functools.partialauch nach dieser PEP nicht typisierbar bleiben) - die, die nur-Schlüsselwortparameter hinzufügen/entfernen/ändern (siehe Verketten von Schlüsselwortparametern für weitere Details).
Die Komponenten eines ParamSpec
Ein ParamSpec erfasst sowohl positionsabhängige als auch schlüsselwortzugängliche Parameter, aber es gibt leider kein Objekt in der Laufzeit, das beides zusammen erfasst. Stattdessen sind wir gezwungen, sie in *args und **kwargs aufzuteilen. Das bedeutet, wir müssen einen einzelnen ParamSpec in diese beiden Komponenten aufteilen und sie dann wieder zu einem Aufruf zusammenführen. Um dies zu tun, führen wir P.args ein, um das Tupel der positionsabhängigen Argumente in einem bestimmten Aufruf darzustellen, und P.kwargs, um das entsprechende Mapping von Schlüsselwörtern zu Werten darzustellen.
Gültige Verwendungspositionen
Diese „Eigenschaften“ können nur als annotierte Typen für *args und **kwargs verwendet werden, zugegriffen von einem bereits im Gültigkeitsbereich befindlichen ParamSpec.
def puts_p_into_scope(f: Callable[P, int]) -> None:
def inner(*args: P.args, **kwargs: P.kwargs) -> None: # Accepted
pass
def mixed_up(*args: P.kwargs, **kwargs: P.args) -> None: # Rejected
pass
def misplaced(x: P.args) -> None: # Rejected
pass
def out_of_scope(*args: P.args, **kwargs: P.kwargs) -> None: # Rejected
pass
Darüber hinaus, da die Standardart von Parametern in Python ((x: int)) sowohl positionsabhängig als auch über seinen Namen angesprochen werden kann, können zwei gültige Aufrufe einer (*args: P.args, **kwargs: P.kwargs)-Funktion unterschiedliche Partitionen derselben Menge von Parametern ergeben. Daher müssen wir sicherstellen, dass diese speziellen Typen nur gemeinsam in die Welt gebracht und gemeinsam verwendet werden, damit unsere Verwendung für alle möglichen Partitionen gültig ist.
def puts_p_into_scope(f: Callable[P, int]) -> None:
stored_args: P.args # Rejected
stored_kwargs: P.kwargs # Rejected
def just_args(*args: P.args) -> None: # Rejected
pass
def just_kwargs(**kwargs: P.kwargs) -> None: # Rejected
pass
Semantik
Mit diesen Anforderungen können wir nun die einzigartigen Eigenschaften nutzen, die uns diese Konfiguration bietet
- Innerhalb der Funktion hat
argsden TypP.args, nichtTuple[P.args, ...], wie es bei einer normalen Annotation der Fall wäre (und ebenso mit den**kwargs)- Dieser Sonderfall ist notwendig, um die heterogenen Inhalte der
args/kwargseines bestimmten Aufrufs zu kapseln, was nicht durch einen unbestimmten Tupel-/Dictionary-Typ ausgedrückt werden kann.
- Dieser Sonderfall ist notwendig, um die heterogenen Inhalte der
- Eine Funktion vom Typ
Callable[P, R]kann mit(*args, **kwargs)aufgerufen werden, wenn und nur wennargsden TypP.argsundkwargsden TypP.kwargshat und diese Typen beide von derselben Funktionsdeklaration stammen. - Eine Funktion, die als
def inner(*args: P.args, **kwargs: P.kwargs) -> Xdeklariert ist, hat den TypCallable[P, X].
Mit diesen drei Eigenschaften haben wir nun die Möglichkeit, parametererhaltende Dekoratoren vollständig zu typisieren.
def decorator(f: Callable[P, int]) -> Callable[P, None]:
def foo(*args: P.args, **kwargs: P.kwargs) -> None:
f(*args, **kwargs) # Accepted, should resolve to int
f(*kwargs, **args) # Rejected
f(1, *args, **kwargs) # Rejected
return foo # Accepted
Um dies auf Concatenate auszudehnen, deklarieren wir die folgenden Eigenschaften
- Eine Funktion vom Typ
Callable[Concatenate[A, B, P], R]kann nur mit(a, b, *args, **kwargs)aufgerufen werden, wennargsundkwargsdie jeweiligen Komponenten vonPsind,avom TypAundbvom TypBist. - Eine Funktion, die als
def inner(a: A, b: B, *args: P.args, **kwargs: P.kwargs) -> Rdeklariert ist, hat den TypCallable[Concatenate[A, B, P], R]. Das Platzieren von nur-Schlüsselwortparametern zwischen*argsund**kwargsist verboten.
def add(f: Callable[P, int]) -> Callable[Concatenate[str, P], None]:
def foo(s: str, *args: P.args, **kwargs: P.kwargs) -> None: # Accepted
pass
def bar(*args: P.args, s: str, **kwargs: P.kwargs) -> None: # Rejected
pass
return foo # Accepted
def remove(f: Callable[Concatenate[int, P], int]) -> Callable[P, None]:
def foo(*args: P.args, **kwargs: P.kwargs) -> None:
f(1, *args, **kwargs) # Accepted
f(*args, 1, **kwargs) # Rejected
f(*args, **kwargs) # Rejected
return foo
Beachten Sie, dass die Namen der Parameter, die den ParamSpec-Komponenten vorausgehen, nicht in der resultierenden Concatenate erwähnt werden. Das bedeutet, dass diese Parameter nicht über ein benanntes Argument angesprochen werden können
def outer(f: Callable[P, None]) -> Callable[P, None]:
def foo(x: int, *args: P.args, **kwargs: P.kwargs) -> None:
f(*args, **kwargs)
def bar(*args: P.args, **kwargs: P.kwargs) -> None:
foo(1, *args, **kwargs) # Accepted
foo(x=1, *args, **kwargs) # Rejected
return bar
Dies ist keine Implementierungsgconvenienz, sondern eine Anforderung an die Fehlerfreiheit. Wenn wir den zweiten Aufrufstil zulassen würden, wäre der folgende Ausschnitt problematisch.
@outer
def problem(*, x: object) -> None:
pass
problem(x="uh-oh")
Innerhalb von bar würden wir TypeError: foo() got multiple values for argument 'x' erhalten. Das Erzwingen, dass diese verketteten Argumente positionsabhängig angesprochen werden, vermeidet diese Art von Problemen und vereinfacht die Syntax zur Darstellung dieser Typen. Beachten Sie, dass dies auch der Grund ist, warum wir Signaturen der Form (*args: P.args, s: str, **kwargs: P.kwargs) ablehnen müssen (siehe Verketten von Schlüsselwortparametern für weitere Details).
Wenn einer dieser vorangestellten Positionsargumente einen freien ParamSpec enthält, betrachten wir diese Variable für die Zwecke der Extraktion der Komponenten dieses ParamSpec als im Gültigkeitsbereich. Das erlaubt uns, Dinge wie diese zu schreiben
def twice(f: Callable[P, int], *args: P.args, **kwargs: P.kwargs) -> int:
return f(*args, **kwargs) + f(*args, **kwargs)
Der Typ von twice im obigen Beispiel ist Callable[Concatenate[Callable[P, int], P], int], wobei P durch das äußere Callable gebunden ist. Dies hat die folgende Semantik
def a_int_b_str(a: int, b: str) -> int:
pass
twice(a_int_b_str, 1, "A") # Accepted
twice(a_int_b_str, b="A", a=1) # Accepted
twice(a_int_b_str, "A", 1) # Rejected
Abwärtskompatibilität
Die einzigen Änderungen an bestehenden Features in typing sind die Zulassung dieser ParamSpec und Concatenate Objekte als erstes Argument für Callable und als Parameter für Generic. Derzeit erwartet Callable eine Liste von Typen dort und Generic erwartet einzelne Typen, so dass sie derzeit gegenseitig ausgeschlossen sind. Andernfalls werden bestehende Codes, die nicht auf die neuen Schnittstellen verweisen, nicht beeinträchtigt.
Referenzimplementierung
Der Pyre-Typ-Checker unterstützt das gesamte oben beschriebene Verhalten. Eine Referenzimplementierung der Laufzeitkomponenten, die für diese Verwendungen benötigt werden, ist im Modul pyre_extensions enthalten. Eine Referenzimplementierung für CPython finden Sie hier.
Abgelehnte Alternativen
Verwendung von Listen-Variablen und Map-Variablen
Wir haben erwogen, etwas wie dies nur mit einem Callback-Protokoll zu machen, das auf einer Listen-Typ-Variable und einer Map-Typ-Variable wie folgt parametrisiert ist
R = typing.TypeVar(“R”)
Tpositionals = ...
Tkeywords = ...
class BetterCallable(typing.Protocol[Tpositionals, Tkeywords, R]):
def __call__(*args: Tpositionals, **kwargs: Tkeywords) -> R: ...
Es gibt jedoch einige Probleme bei dem Versuch, eine konsistente Lösung für diese Typvariablen für einen bestimmten Aufruf zu finden. Dieses Problem tritt selbst bei den einfachsten Aufrufen auf
def simple(x: int) -> None: ...
simple <: BetterCallable[[int], [], None]
simple <: BetterCallable[[], {“x”: int}, None]
BetterCallable[[int], [], None] </: BetterCallable[[], {“x”: int}, None]
Immer dann, wenn ein Typ ein Protokoll auf mehr als eine nicht gegenseitig kompatible Weise implementieren kann, können wir in Situationen geraten, in denen wir Informationen verlieren. Wenn wir einen Dekorator mit diesem Protokoll erstellen würden, müssten wir eine Aufrufkonvention bevorzugen.
def decorator(
f: BetterCallable[[Ts], [Tmap], int],
) -> BetterCallable[[Ts], [Tmap], str]:
def decorated(*args: Ts, **kwargs: Tmap) -> str:
x = f(*args, **kwargs)
return int_to_str(x)
return decorated
@decorator
def foo(x: int) -> int:
return x
reveal_type(foo) # Option A: BetterCallable[[int], {}, str]
# Option B: BetterCallable[[], {x: int}, str]
foo(7) # fails under option B
foo(x=7) # fails under option A
Das Kernproblem hier ist, dass Parameter in Python standardmäßig entweder positionsabhängig oder als Schlüsselwortargument aufgerufen werden können. Das bedeutet, wir haben eigentlich drei Kategorien (nur positionell, positionell oder Schlüsselwort, nur Schlüsselwort), die wir in zwei Kategorien zwängen wollen. Dies ist dasselbe Problem, das wir kurz bei der Diskussion von .args und .kwargs erwähnt haben. Grundsätzlich benötigen wir, um zwei Kategorien zu erfassen, wenn es Dinge gibt, die in beiden Kategorien sein können, ein höheres primitives Element (ParamSpec), um alle drei zu erfassen und sie dann nachträglich aufzuteilen.
Definieren von ParametersOf
Ein weiterer von uns betrachteter Vorschlag war die Definition von Operatoren ParametersOf und ReturnType, die auf einer Domäne eines neu definierten Function-Typs operieren würden. Function wäre mit und nur mit ParametersOf[F] aufrufbar. ParametersOf und ReturnType würden nur auf Typvariablen mit genau dieser Bindung operieren. Die Kombination dieser drei Features könnte alles ausdrücken, was wir mit ParamSpecs ausdrücken können.
F = TypeVar("F", bound=Function)
def no_change(f: F) -> F:
def inner(
*args: ParametersOf[F].args,
**kwargs: ParametersOf[F].kwargs
) -> ReturnType[F]:
return f(*args, **kwargs)
return inner
def wrapping(f: F) -> Callable[ParametersOf[F], List[ReturnType[F]]]:
def inner(
*args: ParametersOf[F].args,
**kwargs: ParametersOf[F].kwargs
) -> List[ReturnType[F]]:
return [f(*args, **kwargs)]
return inner
def unwrapping(
f: Callable[ParametersOf[F], List[R]]
) -> Callable[ParametersOf[F], R]:
def inner(
*args: ParametersOf[F].args,
**kwargs: ParametersOf[F].kwargs
) -> R:
return f(*args, **kwargs)[0]
return inner
Wir haben uns aus mehreren Gründen für ParamSpecs gegenüber diesem Ansatz entschieden
- Der Fußabdruck dieser Änderung wäre größer, da wir zwei neue Operatoren und einen neuen Typ benötigen würden, während
ParamSpecnur eine neue Variable einführt. - Python-Typisierung hat bisher vermieden, Operatoren, ob benutzerdefiniert oder eingebaut, zu unterstützen, zugunsten der Destrukturierung. Daher sehen
ParamSpec-basierte Signaturen bestehendem Python viel ähnlicher. - Der Mangel an benutzerdefinierten Operatoren erschwert das Schreiben gängiger Muster.
unwrappingist seltsam zu lesen, daFsich nicht tatsächlich auf einen Aufruf bezieht. Es wird nur als Container für die Parameter verwendet, die wir weitergeben wollen. Es würde besser lesbar sein, wenn wir einen OperatorRemoveList[List[X]] = Xdefinieren könnten und dannunwrappingFannehmen undCallable[ParametersOf[F], RemoveList[ReturnType[F]]]zurückgeben könnte. Ohne dies geraten wir leider in eine Situation, in der wir eineFunction-Variable als improvisiertenParamSpecverwenden müssen, da wir den Rückgabetyp nie wirklich binden.
Zusammenfassend lässt sich sagen, dass ParamSpec zwischen diesen beiden gleich mächtigen Syntaxen viel natürlicher in den Status Quo passt.
Verketten von Schlüsselwortparametern
Im Prinzip könnte die Idee der Verkettung als Mittel zur Modifikation einer endlichen Anzahl von Positionsargumenten erweitert werden, um Schlüsselwortargumente einzuschließen.
def add_n(f: Callable[P, R]) -> Callable[Concatenate[("n", int), P], R]:
def inner(*args: P.args, n: int, **kwargs: P.kwargs) -> R:
# use n
return f(*args, **kwargs)
return inner
Der Hauptunterschied besteht jedoch darin, dass während das Voranstellen von positionsabhängigen Parametern zu einem gültigen Aufruftyp immer zu einem weiteren gültigen Aufruftyp führt, gilt dies nicht für das Hinzufügen von nur-Schlüsselwortparametern. Wie oben angedeutet, ist das Problem Namenskollisionen. Die Parameter Concatenate[("n", int), P] sind nur gültig, wenn P selbst keinen Parameter namens n hat.
def innocent_wrapper(f: Callable[P, R]) -> Callable[P, R]:
def inner(*args: P.args, **kwargs: P.kwargs) -> R:
added = add_n(f)
return added(*args, n=1, **kwargs)
return inner
@innocent_wrapper
def problem(n: int) -> None:
pass
Der Aufruf von problem(2) funktioniert einwandfrei, aber der Aufruf von problem(n=2) führt zu einem TypeError: problem() got multiple values for argument 'n' durch den Aufruf von added innerhalb von innocent_wrapper.
Diese Art von Situation könnte vermieden werden, und diese Art von Dekorator könnte typisiert werden, wenn wir die Einschränkung, dass eine Menge von Parametern einen bestimmten Namen **nicht** enthält, mit etwas wie diesem reifizieren könnten
P_without_n = ParamSpec("P_without_n", banned_names=["n"])
def add_n(
f: Callable[P_without_n, R]
) -> Callable[Concatenate[("n", int), P_without_n], R]: ...
Der Aufruf von add_n innerhalb von innocent_wrapper könnte dann abgelehnt werden, da der Aufruf nicht garantiert keinen Parameter namens n bereits enthielt.
Die Durchsetzung dieser Einschränkungen würde jedoch so viel zusätzliche Implementierungsarbeit erfordern, dass wir diese Erweiterung als außerhalb des Umfangs dieser PEP beurteilten. Glücklicherweise sind die Designs von ParamSpecs so, dass wir bei ausreichender Nachfrage zu dieser Idee zurückkehren können.
Nennung als ParameterSpecification
Wir haben beschlossen, dass ParameterSpecification etwas zu langatmig für die Verwendung hier war, und dass dieser Stil der abgekürzten Namen es eher wie TypeVar aussehen ließ.
Nennung als ArgSpec
Wir halten es für korrekter, dies ParamSpec zu nennen, als es als ArgSpec zu bezeichnen, da Aufrufe Parameter haben, die sich von den Argumenten unterscheiden, die ihnen an einem bestimmten Aufrufplatz übergeben werden. Eine gegebene Bindung für ein ParamSpec ist eine Menge von Funktionsparametern, nicht die Argumente eines Aufrufplatzes.
Danksagungen
Vielen Dank an alle Mitglieder des Pyre-Teams für ihre Kommentare zu frühen Entwürfen dieser PEP und für ihre Hilfe bei der Referenzimplementierung.
Ein besonderer Dank geht auch an die gesamte Python-Typisierungs-Community für ihr frühes Feedback zu dieser Idee bei einem Python-Typisierungs-Treffen, das direkt zur wesentlich kompakteren .args/.kwargs-Syntax führte.
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-0612.rst
Zuletzt geändert: 2024-06-11 22:12:09 GMT