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

Python Enhancement Proposals

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

Inhaltsverzeichnis

Wichtig

Diese PEP ist ein historisches Dokument: siehe ParamSpec und typing.ParamSpec für aktuelle Spezifikationen und Dokumentation. Kanonische Typisierungsspezifikationen werden auf der Typing Specs Website gepflegt; das Laufzeitverhalten der Typisierung wird in der CPython-Dokumentation beschrieben.

×

Siehe den Prozess zur Aktualisierung der Typ-Spezifikation, um Änderungen an der Typ-Spezifikation vorzuschlagen.

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.partial auch 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 args den Typ P.args, nicht Tuple[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/kwargs eines bestimmten Aufrufs zu kapseln, was nicht durch einen unbestimmten Tupel-/Dictionary-Typ ausgedrückt werden kann.
  • Eine Funktion vom Typ Callable[P, R] kann mit (*args, **kwargs) aufgerufen werden, wenn und nur wenn args den Typ P.args und kwargs den Typ P.kwargs hat und diese Typen beide von derselben Funktionsdeklaration stammen.
  • Eine Funktion, die als def inner(*args: P.args, **kwargs: P.kwargs) -> X deklariert ist, hat den Typ Callable[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, wenn args und kwargs die jeweiligen Komponenten von P sind, a vom Typ A und b vom Typ B ist.
  • Eine Funktion, die als def inner(a: A, b: B, *args: P.args, **kwargs: P.kwargs) -> R deklariert ist, hat den Typ Callable[Concatenate[A, B, P], R]. Das Platzieren von nur-Schlüsselwortparametern zwischen *args und **kwargs ist 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 ParamSpec nur 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. unwrapping ist seltsam zu lesen, da F sich 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 Operator RemoveList[List[X]] = X definieren könnten und dann unwrapping F annehmen und Callable[ParametersOf[F], RemoveList[ReturnType[F]]] zurückgeben könnte. Ohne dies geraten wir leider in eine Situation, in der wir eine Function-Variable als improvisierten ParamSpec verwenden 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.


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

Zuletzt geändert: 2024-06-11 22:12:09 GMT