PEP 646 – Variadische Generics
- Autor:
- Mark Mendoza <mendoza.mark.a at gmail.com>, Matthew Rahtz <mrahtz at google.com>, Pradeep Kumar Srinivasan <gohanpra at gmail.com>, Vincent Siles <vsiles at fb.com>
- Sponsor:
- Guido van Rossum <guido at python.org>
- Status:
- Final
- Typ:
- Standards Track
- Thema:
- Typisierung
- Erstellt:
- 16. Sep. 2020
- Python-Version:
- 3.11
- Post-History:
- 07. Okt. 2020, 23. Dez. 2020, 29. Dez. 2020
- Resolution:
- Python-Dev Nachricht
Inhaltsverzeichnis
- Zusammenfassung
- Akzeptanz
- Motivation
- Zusammenfassung Beispiele
- Spezifikation
- Typ-Variable-Tupel
- Verwendung von Typ-Variable-Tupeln in generischen Klassen
- Verwendung von Typ-Variable-Tupeln in Funktionen
- Typ-Variable-Tupel müssen immer entpackt werden
Unpackfür Abwärtskompatibilität- Varianz, Typbeschränkungen und Typgrenzen: (noch) nicht unterstützt
- Gleichheit von Typ-Variable-Tupeln
- Mehrere Typ-Variable-Tupel: Nicht erlaubt
- Typ-Konkatenation
- Entpacken von Tupel-Typen
*argsals Typ-Variable-Tupel- Typ-Variable-Tupel mit
Callable - Verhalten, wenn Typparameter nicht angegeben sind
- Aliase
- Substitution in Aliassen
- Überladungen für den Zugriff auf einzelne Typen
- Typ-Variable-Tupel
- Begründung und abgelehnte Ideen
- Alternativen
- Grammatikänderungen
- Abwärtskompatibilität
- Referenzimplementierung
- Anhang A: Anwendungsfälle für Form-Typisierung
- Anhang B: Geformte Typen vs. benannte Achsen
- Fußnoten
- Zustimmungen
- Danksagungen
- Ressourcen
- Referenzen
- Urheberrecht
Zusammenfassung
PEP 484 führte TypeVar ein, was die Erstellung von mit einem einzelnen Typ parametrisierten Generics ermöglichte. In diesem PEP führen wir TypeVarTuple ein, was die Parametrisierung mit einer *beliebigen* Anzahl von Typen ermöglicht – also eine *variadische* Typvariable, die *variadische* Generics ermöglicht. Dies ermöglicht eine breite Palette von Anwendungsfällen. Insbesondere ermöglicht es, den Typ von Array-ähnlichen Strukturen in numerischen Bibliotheken wie NumPy und TensorFlow mit der *Form* des Arrays zu parametrisieren, was es statischen Typenprüfern ermöglicht, Form-bezogene Fehler in Code, der diese Bibliotheken verwendet, zu erkennen.
Akzeptanz
Dieses PEP wurde für Python 3.11 akzeptiert, mit der Einschränkung, dass die Details bezüglich mehrerer Entpackungen in einem Typausdruck nicht präzise spezifiziert sind. Dies gibt einzelnen Typenprüfern einigen Spielraum, kann aber in zukünftigen PEPs weiter präzisiert werden.
Motivation
Variadische Generics sind seit langem eine gefragte Funktion für eine Vielzahl von Anwendungsfällen [4]. Ein besonderer Anwendungsfall – ein Anwendungsfall mit potenziell großer Auswirkung und der Hauptfall, auf den dieses PEP abzielt – betrifft die Typisierung in numerischen Bibliotheken.
Im Kontext der numerischen Berechnung mit Bibliotheken wie NumPy und TensorFlow ist die *Form* von Variablen oft genauso wichtig wie der *Typ* der Variable. Betrachten wir zum Beispiel die folgende Funktion, die einen Batch [1] von Videos in Graustufen umwandelt
def to_gray(videos: Array): ...
Allein aus der Signatur geht nicht hervor, welche Array-Form [2] wir für das Argument videos übergeben sollen. Mögliche Formen sind zum Beispiel:
Batch × Zeit × Höhe × Breite × Kanäle
und
Zeit × Batch × Kanäle × Höhe × Breite. [3]
Dies ist aus drei Gründen wichtig:
- Dokumentation. Ohne dass die erforderliche Form in der Signatur klar ist, muss der Benutzer die Docstring oder den betreffenden Code durchsuchen, um zu ermitteln, welche Eingabe-/Ausgabeform-Anforderungen bestehen.
- Erkennung von Form-Fehlern vor Laufzeit. Idealerweise sollten falsche Formen ein Fehler sein, den wir mit statischer Analyse im Voraus erkennen können. (Dies ist besonders wichtig für Machine-Learning-Code, bei dem die Iterationszeiten langsam sein können.)
- Verhindern subtiler Form-Fehler. Im schlimmsten Fall führt die Verwendung der falschen Form dazu, dass das Programm scheinbar normal läuft, aber mit einem subtilen Fehler, dessen Aufspürung Tage dauern kann. (Siehe diese Übung in einem beliebten Machine-Learning-Tutorial für ein besonders heimtückisches Beispiel.)
Idealerweise sollten wir eine Möglichkeit haben, Form-Anforderungen in Typ-Signaturen explizit zu machen. Mehrere Vorschläge [6] [7] [9] schlugen die Verwendung der Standard-Generics-Syntax für diesen Zweck vor. Wir würden schreiben:
def to_gray(videos: Array[Time, Batch, Height, Width, Channels]): ...
Beachten Sie jedoch, dass Arrays beliebigen Ranges sein können – Array wie oben verwendet, ist generisch in einer beliebigen Anzahl von Achsen. Eine Möglichkeit, dies zu umgehen, wäre die Verwendung einer anderen Array-Klasse für jeden Range…
Axis1 = TypeVar('Axis1')
Axis2 = TypeVar('Axis2')
class Array1(Generic[Axis1]): ...
class Array2(Generic[Axis1, Axis2]): ...
…aber das wäre umständlich, sowohl für Benutzer (die 1en, 2en und so weiter in ihrem Code verteilen müssten) als auch für die Autoren von Array-Bibliotheken (die Implementierungen in mehreren Klassen duplizieren müssten).
Variadische Generics sind notwendig, damit eine Array-Klasse, die generisch in einer beliebigen Anzahl von Achsen ist, sauber als eine einzige Klasse definiert werden kann.
Zusammenfassung Beispiele
Um auf den Punkt zu kommen: Dieses PEP ermöglicht die Definition einer Array-Klasse, die generisch in ihrer Form (und ihrem Datentyp) ist, mithilfe einer neu eingeführten Typvariable mit variabler Länge, TypeVarTuple, wie folgt:
from typing import TypeVar, TypeVarTuple
DType = TypeVar('DType')
Shape = TypeVarTuple('Shape')
class Array(Generic[DType, *Shape]):
def __abs__(self) -> Array[DType, *Shape]: ...
def __add__(self, other: Array[DType, *Shape]) -> Array[DType, *Shape]: ...
Ein solches Array kann verwendet werden, um eine Reihe verschiedener Arten von Form-Annotationen zu unterstützen. Zum Beispiel können wir Labels hinzufügen, die die semantische Bedeutung jeder Achse beschreiben:
from typing import NewType
Height = NewType('Height', int)
Width = NewType('Width', int)
x: Array[float, Height, Width] = Array()
Wir könnten auch Annotationen hinzufügen, die die tatsächliche Größe jeder Achse beschreiben:
from typing import Literal as L
x: Array[float, L[480], L[640]] = Array()
Der Konsistenz halber verwenden wir semantische Achsenannotationen als Grundlage für die Beispiele in diesem PEP, aber dieses PEP ist gleichgültig gegenüber der Frage, welche dieser beiden (oder möglicherweise anderen) Wege zur Verwendung von Array vorzuziehen ist; diese Entscheidung wird den Bibliotheksautoren überlassen.
(Beachten Sie auch, dass wir für den Rest dieses PEPs der Kürze halber eine einfachere Version von Array verwenden, die nur generisch in der Form ist – nicht im Datentyp.)
Spezifikation
Um die oben genannten Anwendungsfälle zu unterstützen, führen wir TypeVarTuple ein. Dies dient als Platzhalter nicht für einen einzelnen Typ, sondern für ein *Tupel* von Typen.
Zusätzlich führen wir eine neue Verwendung für den Stern-Operator ein: um TypeVarTuple-Instanzen und Tupel-Typen wie Tuple[int, str] zu "entpacken". Das Entpacken eines TypeVarTuple oder eines Tupel-Typs ist das Typ-Äquivalent des Entpackens einer Variablen oder eines Tupels von Werten.
Typ-Variable-Tupel
Auf die gleiche Weise, wie eine normale Typvariable ein Platzhalter für einen einzelnen Typ wie int ist, ist eine Typvariable *Tupel* ein Platzhalter für einen *Tupel*-Typ wie Tuple[int, str].
Typ-Variable-Tupel werden erstellt mit
from typing import TypeVarTuple
Ts = TypeVarTuple('Ts')
Verwendung von Typ-Variable-Tupeln in generischen Klassen
Typ-Variable-Tupel verhalten sich wie eine Anzahl einzelner Typvariablen, die in einem Tuple gepackt sind. Um dies zu verstehen, betrachten wir das folgende Beispiel:
Shape = TypeVarTuple('Shape')
class Array(Generic[*Shape]): ...
Height = NewType('Height', int)
Width = NewType('Width', int)
x: Array[Height, Width] = Array()
Das Shape-Typ-Variable-Tupel verhält sich hier wie Tuple[T1, T2], wobei T1 und T2 Typvariablen sind. Um diese Typvariablen als Typparameter von Array zu verwenden, müssen wir das Typ-Variable-Tupel mithilfe des Stern-Operators entpacken: *Shape. Die Signatur von Array verhält sich dann so, als hätten wir einfach class Array(Generic[T1, T2]): ... geschrieben.
Im Gegensatz zu Generic[T1, T2] ermöglicht Generic[*Shape] jedoch, die Klasse mit einer *beliebigen* Anzahl von Typparametern zu parametrisieren. Das heißt, zusätzlich zur Definition von Rang-2-Arrays wie Array[Height, Width] könnten wir auch Rang-3-Arrays, Rang-4-Arrays usw. definieren.
Time = NewType('Time', int)
Batch = NewType('Batch', int)
y: Array[Batch, Height, Width] = Array()
z: Array[Time, Batch, Height, Width] = Array()
Verwendung von Typ-Variable-Tupeln in Funktionen
Typ-Variable-Tupel können überall dort verwendet werden, wo ein normales TypeVar verwendet werden kann. Dies schließt Klassendefinitionen ein, wie oben gezeigt, sowie Funktionssignaturen und Variablenannotationen:
class Array(Generic[*Shape]):
def __init__(self, shape: Tuple[*Shape]):
self._shape: Tuple[*Shape] = shape
def get_shape(self) -> Tuple[*Shape]:
return self._shape
shape = (Height(480), Width(640))
x: Array[Height, Width] = Array(shape)
y = abs(x) # Inferred type is Array[Height, Width]
z = x + x # ... is Array[Height, Width]
Typ-Variable-Tupel müssen immer entpackt werden
Beachten Sie, dass im vorherigen Beispiel das Argument shape für __init__ als Tuple[*Shape] annotiert wurde. Warum ist das notwendig – wenn Shape sich wie Tuple[T1, T2, ...] verhält, könnten wir dann nicht das Argument shape direkt als Shape annotiert haben?
Dies ist tatsächlich bewusst nicht möglich: Typ-Variable-Tupel müssen *immer* entpackt verwendet werden (d. h. mit einem Stern-Operator davor). Dies hat zwei Gründe:
- Um mögliche Verwechslungen darüber zu vermeiden, ob ein Typ-Variable-Tupel in gepackter oder entpackter Form verwendet werden soll („Hm, soll ich ‚
-> Shape‘ oder ‚-> Tuple[Shape]‘ oder ‚-> Tuple[*Shape]‘ schreiben?“...) - Um die Lesbarkeit zu verbessern: Der Stern dient auch als expliziter visueller Indikator dafür, dass das Typ-Variable-Tupel kein normales Typvariable ist.
Unpack für Abwärtskompatibilität
Beachten Sie, dass die Verwendung des Stern-Operators in diesem Kontext eine Grammatikänderung erfordert und daher nur in neueren Versionen von Python verfügbar ist. Um die Verwendung von Typ-Variable-Tupeln in älteren Python-Versionen zu ermöglichen, führen wir den Typ-Operator Unpack ein, der anstelle des Stern-Operators verwendet werden kann.
# Unpacking using the star operator in new versions of Python
class Array(Generic[*Shape]): ...
# Unpacking using ``Unpack`` in older versions of Python
class Array(Generic[Unpack[Shape]]): ...
Varianz, Typbeschränkungen und Typgrenzen: (noch) nicht unterstützt
Um dieses PEP minimalistisch zu halten, unterstützt TypeVarTuple noch nicht die Spezifikation von:
- Varianz (z. B.
TypeVar('T', covariant=True)) - Typbeschränkungen (
TypeVar('T', int, float)) - Typgrenzen (
TypeVar('T', bound=ParentClass))
Wir überlassen die Entscheidung, wie sich diese Argumente verhalten sollen, einem zukünftigen PEP, wenn variadische Generics im Feld getestet wurden. Ab diesem PEP sind Typ-Variable-Tupel invariant.
Gleichheit von Typ-Variable-Tupeln
Wenn dieselbe TypeVarTuple-Instanz an mehreren Stellen einer Signatur oder Klasse verwendet wird, könnte eine gültige Typinferenz sein, das TypeVarTuple an eine Tuple aus einer Union von Typen zu binden:
def foo(arg1: Tuple[*Ts], arg2: Tuple[*Ts]): ...
a = (0,)
b = ('0',)
foo(a, b) # Can Ts be bound to Tuple[int | str]?
Dies erlauben wir *nicht*; Typen-Unions dürfen *nicht* innerhalb des Tuple erscheinen. Wenn ein Typ-Variable-Tupel an mehreren Stellen einer Signatur erscheint, müssen die Typen exakt übereinstimmen (die Liste der Typparameter muss die gleiche Länge haben und die Typparameter selbst müssen identisch sein).
def pointwise_multiply(
x: Array[*Shape],
y: Array[*Shape]
) -> Array[*Shape]: ...
x: Array[Height]
y: Array[Width]
z: Array[Height, Width]
pointwise_multiply(x, x) # Valid
pointwise_multiply(x, y) # Error
pointwise_multiply(x, z) # Error
Mehrere Typ-Variable-Tupel: Nicht erlaubt
Ab diesem PEP darf nur ein Typ-Variable-Tupel in einer Typparameterliste erscheinen.
class Array(Generic[*Ts1, *Ts2]): ... # Error
Der Grund dafür ist, dass mehrere Typ-Variable-Tupel unklar machen, welche Parameter an welches Typ-Variable-Tupel gebunden werden.
x: Array[int, str, bool] # Ts1 = ???, Ts2 = ???
Typ-Konkatenation
Typ-Variable-Tupel müssen nicht alleine stehen; normale Typen können vorangestellt und/oder nachgestellt werden.
Shape = TypeVarTuple('Shape')
Batch = NewType('Batch', int)
Channels = NewType('Channels', int)
def add_batch_axis(x: Array[*Shape]) -> Array[Batch, *Shape]: ...
def del_batch_axis(x: Array[Batch, *Shape]) -> Array[*Shape]: ...
def add_batch_channels(
x: Array[*Shape]
) -> Array[Batch, *Shape, Channels]: ...
a: Array[Height, Width]
b = add_batch_axis(a) # Inferred type is Array[Batch, Height, Width]
c = del_batch_axis(b) # Array[Height, Width]
d = add_batch_channels(a) # Array[Batch, Height, Width, Channels]
Normale TypeVar-Instanzen können ebenfalls vorangestellt und/oder nachgestellt werden.
T = TypeVar('T')
Ts = TypeVarTuple('Ts')
def prefix_tuple(
x: T,
y: Tuple[*Ts]
) -> Tuple[T, *Ts]: ...
z = prefix_tuple(x=0, y=(True, 'a'))
# Inferred type of z is Tuple[int, bool, str]
Entpacken von Tupel-Typen
Wir erwähnten, dass ein TypeVarTuple für ein Tupel von Typen steht. Da wir ein TypeVarTuple entpacken können, erlauben wir aus Konsistenzgründen auch das Entpacken eines Tupel-Typs. Wie wir sehen werden, ermöglicht dies auch eine Reihe interessanter Funktionen.
Entpacken von konkreten Tupel-Typen
Das Entpacken eines konkreten Tupel-Typs ist analog zum Entpacken eines Tupels von Werten zur Laufzeit. Tuple[int, *Tuple[bool, bool], str] ist äquivalent zu Tuple[int, bool, bool, str].
Entpacken von unbegrenzten Tupel-Typen
Das Entpacken eines unbegrenzten Tupels behält das unbegrenzte Tupel bei, wie es ist. Das heißt, *Tuple[int, ...] bleibt *Tuple[int, ...]; es gibt keine einfachere Form. Dies ermöglicht es uns, Typen wie Tuple[int, *Tuple[str, ...], str] zu spezifizieren – ein Tupel-Typ, bei dem das erste Element garantiert vom Typ int ist, das letzte Element garantiert vom Typ str ist und die Elemente dazwischen null oder mehr Elemente vom Typ str sind. Beachten Sie, dass Tuple[*Tuple[int, ...]] äquivalent zu Tuple[int, ...] ist.
Das Entpacken unbegrenzter Tupel ist auch in Funktionssignaturen nützlich, bei denen wir uns nicht um die genauen Elemente kümmern und kein unnötiges TypeVarTuple definieren wollen:
def process_batch_channels(
x: Array[Batch, *Tuple[Any, ...], Channels]
) -> None:
...
x: Array[Batch, Height, Width, Channels]
process_batch_channels(x) # OK
y: Array[Batch, Channels]
process_batch_channels(y) # OK
z: Array[Batch]
process_batch_channels(z) # Error: Expected Channels.
Wir können auch ein *Tuple[int, ...] übergeben, wo immer ein *Ts erwartet wird. Dies ist nützlich, wenn wir besonders dynamischen Code haben und die genaue Anzahl der Dimensionen oder die genauen Typen für jede Dimension nicht angeben können. In diesen Fällen können wir reibungslos auf ein unbegrenztes Tupel zurückfallen:
y: Array[*Tuple[Any, ...]] = read_from_file()
def expect_variadic_array(
x: Array[Batch, *Shape]
) -> None: ...
expect_variadic_array(y) # OK
def expect_precise_array(
x: Array[Batch, Height, Width, Channels]
) -> None: ...
expect_precise_array(y) # OK
Array[*Tuple[Any, ...]] steht für ein Array mit einer beliebigen Anzahl von Dimensionen vom Typ Any. Das bedeutet, dass in dem Aufruf von expect_variadic_array, Batch an Any gebunden wird und Shape an Tuple[Any, ...]. In dem Aufruf von expect_precise_array werden die Variablen Batch, Height, Width und Channels alle an Any gebunden.
Dies ermöglicht es Benutzern, dynamischen Code elegant zu handhaben und gleichzeitig den Code explizit als unsicher zu kennzeichnen (durch Verwendung von y: Array[*Tuple[Any, ...]]). Andernfalls würden Benutzer bei jeder Verwendung der Variable y störende Fehler vom Typenprüfer erhalten, was sie bei der Migration einer Altkodierungsbasis zur Verwendung von TypeVarTuple behindern würde.
Mehrere Entpackungen in einem Tupel: Nicht erlaubt
Wie bei TypeVarTuples darf auch hier nur eine Entpackung in einem Tupel erscheinen Mehrere Typ-Variable-Tupel: Nicht erlaubt.
x: Tuple[int, *Ts, str, *Ts2] # Error
y: Tuple[int, *Tuple[int, ...], str, *Tuple[str, ...]] # Error
*args als Typ-Variable-Tupel
PEP 484 besagt, dass, wenn eine Typannotation für *args bereitgestellt wird, jedes Argument vom annotierten Typ sein muss. Das heißt, wenn wir *args als Typ int spezifizieren, dann müssen *alle* Argumente vom Typ int sein. Dies schränkt unsere Fähigkeit ein, die Typ-Signaturen von Funktionen zu spezifizieren, die heterogene Argumenttypen annehmen.
Wenn jedoch *args als Typ-Variable-Tupel annotiert wird, werden die Typen der einzelnen Argumente zu den Typen im Typ-Variable-Tupel:
Ts = TypeVarTuple('Ts')
def args_to_tuple(*args: *Ts) -> Tuple[*Ts]: ...
args_to_tuple(1, 'a') # Inferred type is Tuple[int, str]
Im obigen Beispiel wird Ts an Tuple[int, str] gebunden. Wenn keine Argumente übergeben werden, verhält sich das Typ-Variable-Tupel wie ein leeres Tupel, Tuple[()].
Wie üblich können wir beliebige Tupel-Typen entpacken. Zum Beispiel können wir durch die Verwendung eines Typ-Variable-Tupels innerhalb eines Tupels anderer Typen auf Präfixe oder Suffixe der variadischen Argumentliste verweisen. Zum Beispiel:
# os.execle takes arguments 'path, arg0, arg1, ..., env'
def execle(path: str, *args: *Tuple[*Ts, Env]) -> None: ...
Beachten Sie, dass dies sich von
def execle(path: str, *args: *Ts, env: Env) -> None: ...
unterscheidet, da env hier ein nur-keyword-Argument wäre.
Die Verwendung eines entpackten, unbegrenzten Tupels ist äquivalent zum Verhalten von *args: int gemäß PEP 484, das null oder mehr Werte vom Typ int akzeptiert.
def foo(*args: *Tuple[int, ...]) -> None: ...
# equivalent to:
def foo(*args: int) -> None: ...
Das Entpacken von Tupel-Typen ermöglicht auch präzisere Typen für heterogene *args. Die folgende Funktion erwartet am Anfang eine int, null oder mehr str-Werte und am Ende eine str:
def foo(*args: *Tuple[int, *Tuple[str, ...], str]) -> None: ...
Der Vollständigkeit halber erwähnen wir, dass das Entpacken eines konkreten Tupels die Spezifikation von *args mit einer festen Anzahl von heterogenen Typen ermöglicht:
def foo(*args: *Tuple[int, str]) -> None: ...
foo(1, "hello") # OK
Beachten Sie, dass in Übereinstimmung mit der Regel, dass Typ-Variable-Tupel immer entpackt verwendet werden müssen, die Annotation von *args als ein einfaches Typ-Variable-Tupel-Instanz *nicht* erlaubt ist.
def foo(*args: Ts): ... # NOT valid
*args ist der einzige Fall, in dem ein Argument direkt als *Ts annotiert werden kann; andere Argumente sollten *Ts verwenden, um etwas anderes zu parametrisieren, z. B. Tuple[*Ts]. Wenn *args selbst als Tuple[*Ts] annotiert wird, gilt weiterhin das alte Verhalten: Alle Argumente müssen ein Tuple sein, das mit denselben Typen parametrisiert ist.
def foo(*args: Tuple[*Ts]): ...
foo((0,), (1,)) # Valid
foo((0,), (1, 2)) # Error
foo((0,), ('1',)) # Error
Schließlich ist zu beachten, dass ein Typ-Variable-Tupel *nicht* als Typ für **kwargs verwendet werden kann. (Wir kennen noch keinen Anwendungsfall dafür, daher ziehen wir es vor, den Boden für ein mögliches zukünftiges PEP frei zu halten.)
# NOT valid
def foo(**kwargs: *Ts): ...
Typ-Variable-Tupel mit Callable
Typ-Variable-Tupel können auch in den Argument-Abschnitten eines Callable verwendet werden.
class Process:
def __init__(
self,
target: Callable[[*Ts], None],
args: Tuple[*Ts],
) -> None: ...
def func(arg1: int, arg2: str) -> None: ...
Process(target=func, args=(0, 'foo')) # Valid
Process(target=func, args=('foo', 0)) # Error
Andere Typen und normale Typvariablen können ebenfalls vor oder nach dem Typ-Variable-Tupel stehen.
T = TypeVar('T')
def foo(f: Callable[[int, *Ts, T], Tuple[T, *Ts]]): ...
Das Verhalten eines Callable, das ein entpacktes Element enthält, sei es ein TypeVarTuple oder ein Tupel-Typ, ist so, dass die Elemente so behandelt werden, als wären sie der Typ für *args. Also wird Callable[[*Ts], None] als Typ der Funktion behandelt:
def foo(*args: *Ts) -> None: ...
Callable[[int, *Ts, T], Tuple[T, *Ts]] wird als Typ der Funktion behandelt:
def foo(*args: *Tuple[int, *Ts, T]) -> Tuple[T, *Ts]: ...
Verhalten, wenn Typparameter nicht angegeben sind
Wenn eine generische Klasse, die durch ein Typ-Variable-Tupel parametrisiert ist, ohne Typ-Parameter verwendet wird, verhält sie sich so, als ob das Typ-Variable-Tupel durch Tuple[Any, ...] ersetzt würde.
def takes_any_array(arr: Array): ...
# equivalent to:
def takes_any_array(arr: Array[*Tuple[Any, ...]]): ...
x: Array[Height, Width]
takes_any_array(x) # Valid
y: Array[Time, Height, Width]
takes_any_array(y) # Also valid
Dies ermöglicht graduelles Typisieren: bestehende Funktionen, die zum Beispiel einen einfachen TensorFlow Tensor akzeptieren, bleiben gültig, auch wenn Tensor generisch gemacht wird und der aufrufende Code einen Tensor[Height, Width] übergibt.
Dies funktioniert auch in umgekehrter Richtung:
def takes_specific_array(arr: Array[Height, Width]): ...
z: Array
# equivalent to Array[*Tuple[Any, ...]]
takes_specific_array(z)
(Einzelheiten finden Sie im Abschnitt Entpacken von unbegrenzten Tupel-Typen.)
Auf diese Weise müssen Benutzer von Bibliotheken, selbst wenn diese aktualisiert werden, um Typen wie Array[Height, Width] zu verwenden, nicht auch Typannotationen auf ihren gesamten Code anwenden; Benutzer haben weiterhin die Wahl, welche Teile ihres Codes sie typisieren und welche nicht.
Aliase
Generische Aliase können mithilfe eines Typ-Variable-Tupels ähnlich wie reguläre Typvariablen erstellt werden.
IntTuple = Tuple[int, *Ts]
NamedArray = Tuple[str, Array[*Ts]]
IntTuple[float, bool] # Equivalent to Tuple[int, float, bool]
NamedArray[Height] # Equivalent to Tuple[str, Array[Height]]
Wie dieses Beispiel zeigt, werden alle an den Alias übergebenen Typparameter an das Typ-Variable-Tupel gebunden.
Für unser ursprüngliches Array-Beispiel (siehe Zusammenfassung Beispiele) ist dies wichtig, da es uns ermöglicht, praktische Aliase für Arrays mit fester Form oder festem Datentyp zu definieren:
Shape = TypeVarTuple('Shape')
DType = TypeVar('DType')
class Array(Generic[DType, *Shape]):
# E.g. Float32Array[Height, Width, Channels]
Float32Array = Array[np.float32, *Shape]
# E.g. Array1D[np.uint8]
Array1D = Array[DType, Any]
Wenn eine explizit leere Typparameterliste angegeben wird, wird das Typ-Variable-Tupel im Alias als leer gesetzt.
IntTuple[()] # Equivalent to Tuple[int]
NamedArray[()] # Equivalent to Tuple[str, Array[()]]
Wenn die Typparameterliste vollständig weggelassen wird, werden die nicht spezifizierten Typ-Variable-Tupel als Tuple[Any, ...] behandelt (ähnlich wie in Verhalten, wenn Typparameter nicht angegeben sind).
def takes_float_array_of_any_shape(x: Float32Array): ...
x: Float32Array[Height, Width] = Array()
takes_float_array_of_any_shape(x) # Valid
def takes_float_array_with_specific_shape(
y: Float32Array[Height, Width]
): ...
y: Float32Array = Array()
takes_float_array_with_specific_shape(y) # Valid
Normale TypeVar-Instanzen können ebenfalls in solchen Aliasen verwendet werden.
T = TypeVar('T')
Foo = Tuple[T, *Ts]
# T bound to str, Ts to Tuple[int]
Foo[str, int]
# T bound to float, Ts to Tuple[()]
Foo[float]
# T bound to Any, Ts to an Tuple[Any, ...]
Foo
Substitution in Aliassen
Im vorherigen Abschnitt haben wir nur die einfache Verwendung von generischen Aliasen besprochen, bei denen die Typargumente nur einfache Typen waren. Es sind jedoch auch eine Reihe exotischerer Konstruktionen möglich.
Typargumente können variadisch sein
Erstens können Typargumente für generische Aliase variadisch sein. Zum Beispiel kann ein TypeVarTuple als Typargument verwendet werden:
Ts1 = TypeVar('Ts1')
Ts2 = TypeVar('Ts2')
IntTuple = Tuple[int, *Ts1]
IntFloatTuple = IntTuple[float, *Ts2] # Valid
Hier wird *Ts1 im IntTuple-Alias an Tuple[float, *Ts2] gebunden, was zu einem Alias IntFloatTuple führt, der äquivalent zu Tuple[int, float, *Ts2] ist.
Entpackte Tupel mit variabler Länge können ebenfalls als Typargumente verwendet werden, mit ähnlichen Effekten:
IntFloatsTuple = IntTuple[*Tuple[float, ...]] # Valid
Hier wird *Ts1 an *Tuple[float, ...] gebunden, was zu IntFloatsTuple führt, das äquivalent zu Tuple[int, *Tuple[float, ...]] ist: ein Tupel, das aus einem int und dann null oder mehr floats besteht.
Variadische Argumente erfordern variadische Aliase
Variadische Typargumente können nur mit generischen Aliasen verwendet werden, die selbst variadisch sind. Zum Beispiel:
T = TypeVar('T')
IntTuple = Tuple[int, T]
IntTuple[str] # Valid
IntTuple[*Ts] # NOT valid
IntTuple[*Tuple[float, ...]] # NOT valid
Hier ist IntTuple ein *nicht*-variadischer generischer Alias, der genau ein Typargument nimmt. Daher kann er *Ts oder *Tuple[float, ...] nicht als Typargumente akzeptieren, da sie eine beliebige Anzahl von Typen darstellen.
Aliase mit sowohl TypeVars als auch TypeVarTuples
In Aliase haben wir kurz erwähnt, dass Aliase sowohl für TypeVars als auch für TypeVarTuples generisch sein können.
T = TypeVar('T')
Foo = Tuple[T, *Ts]
Foo[str, int] # T bound to str, Ts to Tuple[int]
Foo[str, int, float] # T bound to str, Ts to Tuple[int, float]
Gemäß Mehrere Typ-Variable-Tupel: Nicht erlaubt darf höchstens ein TypeVarTuple in den Typparametern eines Alias erscheinen. Ein TypeVarTuple kann jedoch mit einer beliebigen Anzahl von TypeVars kombiniert werden, sowohl davor als auch danach.
T1 = TypeVar('T1')
T2 = TypeVar('T2')
T3 = TypeVar('T3')
Tuple[*Ts, T1, T2] # Valid
Tuple[T1, T2, *Ts] # Valid
Tuple[T1, *Ts, T2, T3] # Valid
Um diese Typvariablen durch bereitgestellte Typargumente zu ersetzen, verbrauchen Typvariablen am Anfang oder Ende der Typparameterliste zuerst Typargumente, und dann werden alle verbleibenden Typargumente an das TypeVarTuple gebunden.
Shrubbery = Tuple[*Ts, T1, T2]
Shrubbery[str, bool] # T2=bool, T1=str, Ts=Tuple[()]
Shrubbery[str, bool, float] # T2=float, T1=bool, Ts=Tuple[str]
Shrubbery[str, bool, float, int] # T2=int, T1=float, Ts=Tuple[str, bool]
Ptang = Tuple[T1, *Ts, T2, T3]
Ptang[str, bool, float] # T1=str, T3=float, T2=bool, Ts=Tuple[()]
Ptang[str, bool, float, int] # T1=str, T3=int, T2=float, Ts=Tuple[bool]
Beachten Sie, dass die Mindestanzahl von Typargumenten in solchen Fällen durch die Anzahl der TypeVars bestimmt wird.
Shrubbery[int] # Not valid; Shrubbery needs at least two type arguments
Aufteilen von Tupeln beliebiger Länge
Eine letzte Komplikation tritt auf, wenn ein entpacktes Tupel mit variabler Länge als Typargument für einen Alias verwendet wird, der sowohl TypeVars als auch ein TypeVarTuple enthält.
Elderberries = Tuple[*Ts, T1]
Hamster = Elderberries[*Tuple[int, ...]] # valid
In solchen Fällen wird das Tupel mit variabler Länge zwischen den TypeVars und dem TypeVarTuple aufgeteilt. Wir gehen davon aus, dass das Tupel mit variabler Länge mindestens so viele Elemente enthält, wie es TypeVars gibt, so dass einzelne Instanzen des inneren Typs – hier int – an alle vorhandenen TypeVars gebunden werden. Der "Rest" des Tupels mit variabler Länge – hier *Tuple[int, ...], da ein Tupel mit variabler Länge minus zwei Elemente immer noch variable Länge hat – wird an das TypeVarTuple gebunden.
Hier ist Hamster daher äquivalent zu Tuple[*Tuple[int, ...], int]: ein Tupel, das aus null oder mehr ints besteht, gefolgt von einem abschließenden int.
Natürlich tritt eine solche Aufteilung nur auf, wenn sie notwendig ist. Zum Beispiel, wenn wir stattdessen Folgendes tun würden:
Elderberries[*Tuple[int, ...], str]
Dann würde keine Aufteilung stattfinden; T1 würde an str gebunden, und Ts an *Tuple[int, ...].
In besonders kniffligen Fällen kann ein TypeVarTuple sowohl einen Typ *als auch* einen Teil eines Tupels mit variabler Länge verbrauchen:
Elderberries[str, *Tuple[int, ...]]
Hier ist T1 an int gebunden und Ts an Tuple[str, *Tuple[int, ...]]. Dieser Ausdruck ist daher äquivalent zu Tuple[str, *Tuple[int, ...], int]: ein Tupel, das aus einem str, dann null oder mehr ints und schließlich einem int besteht.
TypeVarTuples können nicht aufgeteilt werden
Schließlich, obwohl beliebige Tupel beliebiger Länge in der Liste der Typargumente zwischen den Typvariablen und dem Tupel der Typvariablen aufgeteilt werden können, gilt dies nicht für TypeVarTuples in der Argumentliste.
Ts1 = TypeVarTuple('Ts1')
Ts2 = TypeVarTuple('Ts2')
Camelot = Tuple[T, *Ts1]
Camelot[*Ts2] # NOT valid
Dies ist nicht möglich, da im Gegensatz zu einem entpackten Tupel beliebiger Länge kein Weg besteht, in das TypeVarTuple "hineinzusehen", um seine einzelnen Typen zu erkennen.
Überladungen für den Zugriff auf einzelne Typen
Für Situationen, in denen wir Zugriff auf jeden einzelnen Typ im Tupel der Typvariablen benötigen, können Überladungen mit einzelnen TypeVar-Instanzen anstelle des Tupels der Typvariablen verwendet werden.
Shape = TypeVarTuple('Shape')
Axis1 = TypeVar('Axis1')
Axis2 = TypeVar('Axis2')
Axis3 = TypeVar('Axis3')
class Array(Generic[*Shape]):
@overload
def transpose(
self: Array[Axis1, Axis2]
) -> Array[Axis2, Axis1]: ...
@overload
def transpose(
self: Array[Axis1, Axis2, Axis3]
) -> Array[Axis3, Axis2, Axis1]: ...
(Insbesondere für Array-Form-Operationen ist die Angabe von Überladungen für jeden möglichen Rang eine ziemlich umständliche Lösung. Sie ist jedoch das Beste, was wir ohne zusätzliche Typmanipulationsmechanismen tun können. Wir planen, diese in einem zukünftigen PEP einzuführen.)
Begründung und abgelehnte Ideen
Form-Arithmetik
Betrachtet man insbesondere den Anwendungsfall von Array-Formen, so ist anzumerken, dass es nach diesem PEP noch nicht möglich ist, arithmetische Transformationen von Array-Dimensionen zu beschreiben - zum Beispiel def repeat_each_element(x: Array[N]) -> Array[2*N]. Wir betrachten dies als außer Reichweite für den aktuellen PEP, planen jedoch, zusätzliche Mechanismen vorzuschlagen, die dies in einem zukünftigen PEP ermöglichen werden.
Unterstützung von Variadizität durch Aliase
Wie in der Einleitung erwähnt, ist es möglich, auf variadische Generics zu verzichten, indem einfach Aliase für jede mögliche Anzahl von Typparametern definiert werden.
class Array1(Generic[Axis1]): ...
class Array2(Generic[Axis1, Axis2]): ...
Dies scheint jedoch etwas umständlich zu sein – es erfordert, dass Benutzer ihren Code unnötigerweise mit 1, 2 usw. für jeden benötigten Rang durchsetzen.
Konstruktion von TypeVarTuple
TypeVarTuple begann als ListVariadic, basierend auf seiner Benennung in einer frühen Implementierung in Pyre.
Wir haben dies dann zu TypeVar(list=True) geändert, mit der Begründung, dass a) dies die Ähnlichkeit zu TypeVar besser hervorhebt und b) die Bedeutung von 'list' leichter zu verstehen ist als der Jargon von 'variadic'.
Sobald wir beschlossen hatten, dass eine variadische Typvariable sich wie ein Tuple verhalten sollte, erwogen wir auch TypeVar(bound=Tuple), was ähnlich intuitiv ist und die meisten unserer Wünsche erfüllt, ohne neue Argumente für TypeVar zu benötigen. Wir erkannten jedoch, dass dies uns zukünftig einschränken könnte, wenn wir zum Beispiel wünschen, dass Typgrenzen oder Varianz für variadische Typvariablen leicht anders funktionieren als die Semantik von TypeVar sonst implizieren würde. Außerdem möchten wir später möglicherweise Argumente unterstützen, die von regulären Typvariablen nicht unterstützt werden sollten (wie z. B. arbitrary_len [10]).
Wir haben uns daher für TypeVarTuple entschieden.
Nicht spezifizierte Typparameter: Tupel vs. TypeVarTuple
Um graduelles Tippen zu unterstützen, besagt dieser PEP, dass *beide* der folgenden Beispiele korrekt typgeprüft werden sollten.
def takes_any_array(x: Array): ...
x: Array[Height, Width]
takes_any_array(x)
def takes_specific_array(y: Array[Height, Width]): ...
y: Array
takes_specific_array(y)
Beachten Sie, dass dies im Gegensatz zum Verhalten des derzeit einzigen variadischen Typs in Python, Tuple, steht.
def takes_any_tuple(x: Tuple): ...
x: Tuple[int, str]
takes_any_tuple(x) # Valid
def takes_specific_tuple(y: Tuple[int, str]): ...
y: Tuple
takes_specific_tuple(y) # Error
Die Regeln für Tuple wurden bewusst so gewählt, dass der letztere Fall ein Fehler ist: Es wurde angenommen, dass der Programmierer eher einen Fehler gemacht hat, als dass die Funktion einen bestimmten Tuple erwartet, aber die spezifische Art des übergebenen Tuple dem Typüberprüfer unbekannt ist. Darüber hinaus ist Tuple eine Art Sonderfall, insofern als es zur Darstellung unveränderlicher Sequenzen verwendet wird. Das heißt, wenn der Typ eines Objekts als nicht-parametrisierter Tuple inferiert wird, ist dies nicht unbedingt auf unvollständiges Tippen zurückzuführen.
Im Gegensatz dazu ist es viel wahrscheinlicher, dass der Benutzer seinen Code einfach noch nicht vollständig annotiert hat oder dass die Signatur einer Shape-manipulierenden Bibliotheksfunktion noch nicht mit dem Typsystem ausgedrückt werden kann und daher die Rückgabe eines einfachen Array die einzige Option ist. Wir beschäftigen uns selten mit Arrays wahrhaft beliebiger Form; in bestimmten Fällen sind *einige* Teile der Form beliebig - zum Beispiel bei Sequenzen sind die ersten beiden Teile der Form oft 'batch' und 'time' - aber wir planen, diese Fälle in einem zukünftigen PEP explizit mit einer Syntax wie Array[Batch, Time, ...] zu unterstützen.
Wir trafen daher die Entscheidung, variadische Generics *außer* Tuple anders zu handhaben, um dem Benutzer mehr Flexibilität bei der Entscheidung zu geben, wie viel seines Codes er annotieren möchte, und um die Kompatibilität zwischen altem, nicht annotiertem Code und neuen Versionen von Bibliotheken, die diese Typannotationen verwenden, zu ermöglichen.
Alternativen
Es sollte angemerkt werden, dass der in diesem PEP umrissene Ansatz zur Lösung des Problems der Formüberprüfung in numerischen Bibliotheken *nicht* der einzig mögliche ist. Beispiele für leichtere Alternativen, die auf Laufzeitüberprüfungen basieren, sind ShapeGuard [13], tsanley [11] und PyContracts [12].
Während diese bestehenden Ansätze die Standardsituation, in der die Formüberprüfung nur durch langwierige und ausführliche Assert-Anweisungen möglich ist, erheblich verbessern, ermöglichen sie keine *statische* Analyse der Formkorrektheit. Wie in der Motivation erwähnt, ist dies besonders wünschenswert für Anwendungen des maschinellen Lernens, bei denen aufgrund der Komplexität von Bibliotheken und Infrastruktur selbst relativ einfache Programme lange Startzeiten in Kauf nehmen müssen; das Iterieren durch Ausführen des Programms, bis es abstürzt, wie es bei diesen bestehenden laufzeitbasierten Ansätzen notwendig ist, kann eine mühsame und frustrierende Erfahrung sein.
Unsere Hoffnung mit diesem PEP ist es, generische Typannotationen als offiziellen, sprachgestützten Weg zur Behandlung von Formkorrektheit zu kodifizieren. Mit einem gewissen Standard wird dies langfristig hoffentlich ein florierendes Ökosystem von Werkzeugen zur Analyse und Überprüfung von Formeigenschaften numerischer Berechnungsprogramme ermöglichen.
Grammatikänderungen
Dieser PEP erfordert zwei Grammatikänderungen.
Änderung 1: Stern-Ausdrücke in Indizes
Die erste Grammatikänderung ermöglicht die Verwendung von Sternausdrücken in Indexoperationen (d. h. innerhalb von eckigen Klammern), was für die Unterstützung des Stern-Entpackens von TypeVarTuples erforderlich ist.
DType = TypeVar('DType')
Shape = TypeVarTuple('Shape')
class Array(Generic[DType, *Shape]):
...
Vorher
slices:
| slice !','
| ','.slice+ [',']
Nachher
slices:
| slice !','
| ','.(slice | starred_expression)+ [',']
Wie beim Stern-Entpacken in anderen Kontexten ruft der Stern-Operator __iter__ auf dem Aufrufer auf und fügt den Inhalt des resultierenden Iterators zu den an __getitem__ übergebenen Argumenten hinzu. Wenn wir beispielsweise foo[a, *b, c] machen und b.__iter__ einen Iterator erzeugt, der d und e liefert, erhält foo.__getitem__ (a, d, e, c).
Anders ausgedrückt, beachten Sie, dass x[..., *a, ...] dasselbe Ergebnis liefert wie x[(..., *a, ...)] (wobei beliebige Slices i:j in ... durch slice(i, j) ersetzt werden, mit der einzigen Ausnahme, dass x[*a] zu x[(*a,)] wird).
TypeVarTuple-Implementierung
Mit dieser Grammatikänderung wird TypeVarTuple wie folgt implementiert. Beachten Sie, dass diese Implementierung nur zum Nutzen von a) korrektem repr() und b) Laufzeitanalysatoren nützlich ist; statische Analysatoren würden die Implementierung nicht verwenden.
class TypeVarTuple:
def __init__(self, name):
self._name = name
self._unpacked = UnpackedTypeVarTuple(name)
def __iter__(self):
yield self._unpacked
def __repr__(self):
return self._name
class UnpackedTypeVarTuple:
def __init__(self, name):
self._name = name
def __repr__(self):
return '*' + self._name
Implikationen
Diese Grammatikänderung impliziert eine Reihe zusätzlicher Verhaltensänderungen, die von diesem PEP nicht gefordert werden. Wir entscheiden uns, diese zusätzlichen Änderungen zuzulassen, anstatt sie auf Syntaxebene zu verbieten, um die Syntaxänderung so klein wie möglich zu halten.
Erstens ermöglicht die Grammatikänderung das Stern-Entpacken anderer Strukturen, wie z. B. Listen, innerhalb von Indexierungsoperationen.
idxs = (1, 2)
array_slice = array[0, *idxs, -1] # Equivalent to [0, 1, 2, -1]
array[0, *idxs, -1] = array_slice # Also allowed
Zweitens kann mehr als ein Stern-Entpackung innerhalb eines Index vorkommen.
array[*idxs_to_select, *idxs_to_select] # Equivalent to array[1, 2, 1, 2]
Beachten Sie, dass dieser PEP mehrere entpackte TypeVarTuples innerhalb einer einzelnen Typparameterliste verbietet. Diese Anforderung müsste daher in Typüberprüfungswerkzeugen selbst und nicht auf Syntaxebene implementiert werden.
Drittens können Slices mit gestarteten Ausdrücken koexistieren.
array[3:5, *idxs_to_select] # Equivalent to array[3:5, 1, 2]
Beachten Sie jedoch, dass Slices, die gestartete Ausdrücke beinhalten, immer noch ungültig sind.
# Syntax error
array[*idxs_start:*idxs_end]
Änderung 2: *args als TypeVarTuple
Die zweite Änderung ermöglicht die Verwendung von *args: *Ts in Funktionsdefinitionen.
Vorher
star_etc:
| '*' param_no_default param_maybe_default* [kwds]
| '*' ',' param_maybe_default+ [kwds]
| kwds
Nachher
star_etc:
| '*' param_no_default param_maybe_default* [kwds]
| '*' param_no_default_star_annotation param_maybe_default* [kwds] # New
| '*' ',' param_maybe_default+ [kwds]
| kwds
Wo
param_no_default_star_annotation:
| param_star_annotation ',' TYPE_COMMENT?
| param_star_annotation TYPE_COMMENT? &')'
param_star_annotation: NAME star_annotation
star_annotation: ':' star_expression
Wir müssen uns auch mit dem star_expression befassen, der sich aus dieser Konstruktion ergibt. Normalerweise tritt ein star_expression im Kontext von z. B. einer Liste auf, so dass ein star_expression behandelt wird, indem im Wesentlichen iter() auf dem gestarteten Objekt aufgerufen wird und die Ergebnisse des resultierenden Iterators an der entsprechenden Stelle in die Liste eingefügt werden. Für *args: *Ts müssen wir jedoch das star_expression auf eine andere Weise verarbeiten.
Wir tun dies, indem wir stattdessen einen Sonderfall für das star_expression, das aus *args: *Ts resultiert, erstellen und Code ausgeben, der äquivalent zu [annotation_value] = [*Ts] ist. Das heißt, wir erstellen einen Iterator aus Ts, indem wir Ts.__iter__ aufrufen, einen einzelnen Wert aus dem Iterator abrufen, verifizieren, dass der Iterator erschöpft ist, und diesen Wert als Annotationswert festlegen. Dies führt dazu, dass das entpackte TypeVarTuple direkt als Laufzeitanotation für *args gesetzt wird.
>>> Ts = TypeVarTuple('Ts')
>>> def foo(*args: *Ts): pass
>>> foo.__annotations__
{'args': *Ts}
# *Ts is the repr() of Ts._unpacked, an instance of UnpackedTypeVarTuple
Dies ermöglicht es der Laufzeitannotation, mit einer AST-Darstellung übereinzustimmen, die einen Starred-Knoten für die Annotationen von args verwendet - was wiederum für Werkzeuge wichtig ist, die auf dem AST basieren, wie z. B. mypy, um die Konstruktion korrekt zu erkennen.
>>> print(ast.dump(ast.parse('def foo(*args: *Ts): pass'), indent=2))
Module(
body=[
FunctionDef(
name='foo',
args=arguments(
posonlyargs=[],
args=[],
vararg=arg(
arg='args',
annotation=Starred(
value=Name(id='Ts', ctx=Load()),
ctx=Load())),
kwonlyargs=[],
kw_defaults=[],
defaults=[]),
body=[
Pass()],
decorator_list=[])],
type_ignores=[])
Beachten Sie, dass das einzige Szenario, in dem diese Grammatikänderung die Verwendung von *Ts als direkte Annotation (anstatt z. B. in Tuple[*Ts] verpackt) zulässt, *args ist. Andere Verwendungen sind immer noch ungültig.
x: *Ts # Syntax error
def foo(x: *Ts): pass # Syntax error
Implikationen
Wie bei der ersten Grammatikänderung hat diese Änderung auch eine Reihe von Nebeneffekten. Insbesondere kann die Annotation von *args auf ein gestartetes Objekt gesetzt werden, das kein TypeVarTuple ist - zum Beispiel sind die folgenden unsinnigen Annotationen möglich.
>>> foo = [1]
>>> def bar(*args: *foo): pass
>>> bar.__annotations__
{'args': 1}
>>> foo = [1, 2]
>>> def bar(*args: *foo): pass
ValueError: too many values to unpack (expected 1)
Wiederum muss die Verhinderung solcher Annotationen beispielsweise durch statische Prüfer und nicht auf der Ebene der Syntax erfolgen.
Alternativen (Warum nicht einfach Unpack verwenden?)
Wenn diese Grammatikänderungen als zu belastend erachtet werden, gibt es zwei Alternativen.
Die erste wäre, **Änderung 1 zu unterstützen, aber nicht Änderung 2**. Variadische Generics sind uns wichtiger als die Fähigkeit, *args zu annotieren.
Die zweite Alternative wäre, **stattdessen ``Unpack`` zu verwenden**, was keine Grammatikänderungen erfordert. Wir betrachten dies jedoch aus zwei Gründen als suboptimal.
- Lesbarkeit.
class Array(Generic[DType, Unpack[Shape]])ist etwas sperrig; der Lesefluss wird durch die Länge vonUnpackund die zusätzlichen eckigen Klammern unterbrochen.class Array(Generic[DType, *Shape])ist viel einfacher zu überfliegen und kennzeichnetShapedennoch als besonders. - Intuitivität. Wir glauben, dass ein Benutzer die Bedeutung von
*Tsintuitiver verstehen wird – insbesondere wenn er sieht, dassTsein TypeVar**Tuple** ist – als die Bedeutung vonUnpack[Ts]. (Dies setzt voraus, dass der Benutzer mit Stern-Entpacken in anderen Kontexten vertraut ist; wenn der Benutzer Code liest oder schreibt, der variadische Generics verwendet, erscheint dies vernünftig.)
Wenn selbst Änderung 1 als zu bedeutend angesehen wird, wäre es daher besser, wenn wir unsere Optionen überdenken, bevor wir mit dieser zweiten Alternative fortfahren.
Abwärtskompatibilität
Die Unpack-Version des PEP sollte für frühere Python-Versionen rückwärtskompatibel sein.
Graduelles Tippen wird dadurch ermöglicht, dass unparametrisierte variadische Klassen mit einer beliebigen Anzahl von Typargumenten kompatibel sind. Das bedeutet, wenn bestehende Klassen generisch gemacht werden, a) alle bestehenden (unparametrisierten) Verwendungen der Klasse weiterhin funktionieren und b) parametrisierte und unparametrisierte Versionen der Klasse zusammen verwendet werden können (relevant, wenn z. B. Bibliotheks-Code zur Verwendung von Parametern aktualisiert wird, während Benutzer-Code dies nicht tut, oder umgekehrt).
Referenzimplementierung
Zwei Referenzimplementierungen für Typüberprüfungsfunktionalität existieren: eine in Pyre, ab v0.9.0, und eine in Pyright, ab v1.1.108.
Eine vorläufige Implementierung der Unpack-Version des PEP in CPython ist in cpython/23527 verfügbar. Eine vorläufige Version der Version mit dem Sternoperator, basierend auf einer frühen Implementierung von PEP 637, ist ebenfalls unter mrahtz/cpython/pep637+646 verfügbar.
Anhang A: Anwendungsfälle für Form-Typisierung
Um diesem PEP zusätzlichen Kontext für diejenigen zu geben, die sich besonders für den Anwendungsfall der Array-Typisierung interessieren, erweitern wir in diesem Anhang die verschiedenen Möglichkeiten, wie dieser PEP zur Angabe von Shape-basierten Untertypen verwendet werden kann.
Anwendungsfall 1: Angabe von Form-Werten
Die einfachste Methode zur Parametrisierung von Array-Typen ist die Verwendung von Literal-Typparametern – z. B. Array[Literal[64], Literal[64]].
Wir können jedem Parameter Namen mit normalen Typvariablen zuweisen.
K = TypeVar('K')
N = TypeVar('N')
def matrix_vector_multiply(x: Array[K, N], y: Array[N]) -> Array[K]: ...
a: Array[Literal[64], Literal[32]]
b: Array[Literal[32]]
matrix_vector_multiply(a, b)
# Result is Array[Literal[64]]
Beachten Sie, dass solche Namen einen rein lokalen Geltungsbereich haben. Das heißt, der Name K ist nur innerhalb von matrix_vector_multiply an Literal[64] gebunden. Anders ausgedrückt, es besteht kein Zusammenhang zwischen dem Wert von K in verschiedenen Signaturen. Dies ist wichtig: Es wäre umständlich, wenn jede mit K benannte Achse im gesamten Programm den gleichen Wert haben müsste.
Der Nachteil dieses Ansatzes ist, dass wir keine Möglichkeit haben, Shape-Semantiken über verschiedene Aufrufe hinweg zu erzwingen. Zum Beispiel können wir das in der Motivation erwähnte Problem nicht lösen: Wenn eine Funktion ein Array mit den führenden Dimensionen 'Zeit × Stapel' zurückgibt und eine andere Funktion dasselbe Array unter der Annahme führender Dimensionen 'Stapel × Zeit' annimmt, haben wir keine Möglichkeit, dies zu erkennen.
Der Hauptvorteil ist, dass in einigen Fällen die Achsengrößen tatsächlich das sind, was uns interessiert. Dies gilt sowohl für einfache lineare Algebra-Operationen wie die obigen Matrixmanipulationen, als auch für kompliziertere Transformationen wie Faltungsschichten in neuronalen Netzen, bei denen es für den Programmierer von großem Nutzen wäre, die Array-Größe nach jeder Schicht mithilfe statischer Analyse inspizieren zu können. Um dies zu unterstützen, möchten wir in Zukunft Möglichkeiten für zusätzliche Typoperatoren erforschen, die Arithmetik auf Array-Shapes ermöglichen – zum Beispiel.
def repeat_each_element(x: Array[N]) -> Array[Mul[2, N]]: ...
Solche arithmetischen Typoperatoren wären nur sinnvoll, wenn Namen wie N sich auf die Achsengröße beziehen.
Anwendungsfall 2: Angabe von Form-Semantik
Ein zweiter Ansatz (der, auf dem die meisten Beispiele in diesem PEP basieren) besteht darin, auf die Annotation mit tatsächlicher Achsengröße zu verzichten und stattdessen den Achsen-*Typ* zu annotieren.
Dies würde es uns ermöglichen, das Problem der Durchsetzung von Shape-Eigenschaften über Aufrufe hinweg zu lösen. Zum Beispiel.
# lib.py
class Batch: pass
class Time: pass
def make_array() -> Array[Batch, Time]: ...
# user.py
from lib import Batch, Time
# `Batch` and `Time` have the same identity as in `lib`,
# so must take array as produced by `lib.make_array`
def use_array(x: Array[Batch, Time]): ...
Beachten Sie, dass in diesem Fall Namen *global* sind (in dem Umfang, in dem wir denselben Batch-Typ an verschiedenen Stellen verwenden). Da Namen sich jedoch nur auf Achsen-*Typen* beziehen, schränkt dies nicht den *Wert* bestimmter Achsen ein (d. h. dies schränkt nicht alle Achsen namens Height so ein, dass sie den gleichen Wert haben, sagen wir, durchgehend 480).
Das Argument *für* diesen Ansatz ist, dass in vielen Fällen der Achsen-*Typ* das Wichtigere ist, das es zu überprüfen gilt; uns ist wichtiger, welche Achse welche ist, als die genaue Größe jeder Achse.
Es schließt auch Fälle nicht aus, in denen wir Shape-Transformationen beschreiben möchten, ohne den Typ im Voraus zu kennen. Zum Beispiel können wir immer noch schreiben.
K = TypeVar('K')
N = TypeVar('N')
def matrix_vector_multiply(x: Array[K, N], y: Array[N]) -> Array[K]: ...
Wir können dies dann mit verwenden.
class Batch: pass
class Values: pass
batch_of_values: Array[Batch, Values]
value_weights: Array[Values]
matrix_vector_multiply(batch_of_values, value_weights)
# Result is Array[Batch]
Die Nachteile sind die Umkehrung der Vorteile aus Anwendungsfall 1. Insbesondere eignet sich dieser Ansatz nicht gut für Arithmetik auf Achsentypen: Mul[2, Batch] wäre genauso bedeutungslos wie 2 * int.
Diskussion
Beachten Sie, dass Anwendungsfälle 1 und 2 im Benutzercode sich gegenseitig ausschließen. Benutzer können Größe oder semantischen Typ überprüfen, aber nicht beides.
Nach diesem PEP sind wir uns nicht sicher, welcher Ansatz den größten Nutzen bringen wird. Da die in diesem PEP eingeführten Funktionen mit beiden Ansätzen kompatibel sind, lassen wir die Tür jedoch offen.
Warum nicht beides?
Betrachten Sie den folgenden 'normalen' Code.
def f(x: int): ...
Beachten Sie, dass wir Symbole sowohl für den Wert der Sache (x) als auch für den Typ der Sache (int) haben. Warum können wir nicht dasselbe mit Achsen tun? Zum Beispiel könnten wir mit einer imaginären Syntax schreiben.
def f(array: Array[TimeValue: TimeType]): ...
Dies würde es uns ermöglichen, auf die Achsengröße (sagen wir, 32) über das Symbol TimeValue *und* den Typ über das Symbol TypeType zuzugreifen.
Dies könnte sogar mit vorhandener Syntax über eine zweite Ebene der Parametrisierung möglich sein.
def f(array: array[TimeValue[TimeType]]): ..
Wir überlassen die Erkundung dieses Ansatzes jedoch der Zukunft.
Anhang B: Geformte Typen vs. benannte Achsen
Ein Problem, das mit den von diesem PEP behandelten Problemen zusammenhängt, betrifft die Achsen-*Auswahl*. Wenn wir beispielsweise ein Bild haben, das in einem Array der Form 64×64x3 gespeichert ist, möchten wir es möglicherweise in Schwarzweiß umwandeln, indem wir den Mittelwert über die dritte Achse berechnen, mean(image, axis=2). Leider ist ein einfacher Tippfehler axis=1 schwer zu erkennen und liefert ein Ergebnis, das etwas völlig anderes bedeutet (während das Programm wahrscheinlich weiterläuft und einen ernsthaften, aber stillen Fehler verursacht).
Als Reaktion darauf haben einige Bibliotheken sogenannte 'benannte Tensoren' (in diesem Zusammenhang ist 'Tensor' gleichbedeutend mit 'Array') implementiert, bei denen Achsen nicht nach Index, sondern nach Bezeichner ausgewählt werden – z. B. mean(image, axis='channels').
Eine Frage, die uns zu diesem PEP oft gestellt wird, ist: Warum nicht einfach benannte Tensoren verwenden? Die Antwort ist, dass wir den Ansatz der benannten Tensoren aus zwei Hauptgründen für unzureichend halten.
- Statische Überprüfung der Formkorrektheit ist nicht möglich. Wie in der Motivation erwähnt, ist dies ein sehr wünschenswertes Merkmal in Code für maschinelles Lernen, wo die Iterationszeiten standardmäßig langsam sind.
- Schnittstellendokumentation ist mit diesem Ansatz immer noch nicht möglich. Wenn eine Funktion *nur* Array-Argumente annehmen soll, die bildähnliche Formen haben, kann dies mit benannten Tensoren nicht festgelegt werden.
Darüber hinaus gibt es das Problem der **geringen Akzeptanz**. Zum Zeitpunkt der Erstellung dieses Dokuments wurden benannte Tensoren nur in einer kleinen Anzahl von numerischen Rechenbibliotheken implementiert. Mögliche Erklärungen dafür sind Implementierungsschwierigkeiten (die gesamte API muss modifiziert werden, um die Auswahl nach Achsenname anstelle von Index zu ermöglichen) und mangelnde Nützlichkeit, da die Konventionen für die Achsenreihenfolge oft so stark sind, dass Achsennamen wenig Nutzen bringen (z. B. bei Bildern sind 3D-Tensoren fast immer Höhe × Breite × Kanäle). Letztendlich sind wir uns jedoch immer noch unsicher, warum dies der Fall ist.
Kann der Ansatz der benannten Tensoren mit dem Ansatz, den wir in diesem PEP befürworten, kombiniert werden? Wir sind uns nicht sicher. Ein Überschneidungsbereich ist, dass wir in einigen Kontexten Folgendes tun könnten.
Image: Array[Height, Width, Channels]
im: Image
mean(im, axis=Image.axes.index(Channels)
Idealerweise könnten wir etwas schreiben wie im: Array[Height=64, Width=64, Channels=3] – aber das wird kurzfristig nicht möglich sein, da PEP 637 abgelehnt wurde. Auf jeden Fall ist unsere Haltung dazu meist "Warten und sehen, was passiert, bevor weitere Schritte unternommen werden".
Fußnoten
Zustimmungen
Variadische Generics haben eine breite Palette von Verwendungsmöglichkeiten. Wie wahrscheinlich ist es, dass die relevanten Bibliotheken für den Teil dieser Bandbreite, der sich auf numerisches Rechnen bezieht, die in diesem PEP vorgeschlagenen Funktionen nutzen werden?
Wir haben uns mit dieser Frage an eine Reihe von Personen gewandt und die folgenden Zusagen erhalten.
Von Stephan Hoyer, Mitglied des NumPy Steering Council: [14]
Ich wollte Matthew & Pradeep nur für das Schreiben dieses PEP und für die Klarstellungen zum breiteren Kontext von PEP 646 für die Array-Typisierung in https://github.com/python/peps/pull/1904 danken.Als jemand, der stark in der Community für numerisches Rechnen in Python involviert ist (z. B. NumPy, JAX, Xarray), aber nicht so vertraut mit den Details des Python-Typsystems ist, ist es beruhigend zu sehen, dass eine breite Palette von Anwendungsfällen im Zusammenhang mit der Typüberprüfung von benannten Achsen & Formen berücksichtigt wurde und auf der Infrastruktur dieses PEP aufbauen könnte.
Die Typüberprüfung von Formen ist etwas, an dem die NumPy-Community sehr interessiert ist – es gibt mehr Daumen nach oben für das relevante Thema auf GitHub von NumPy als für jedes andere (https://github.com/numpy/numpy/issues/7370) und wir haben kürzlich ein „Typing“-Modul hinzugefügt, das aktiv entwickelt wird.
Es wird sicherlich Experimente erfordern, um herauszufinden, wie Typüberprüfungen für ndarrays am besten genutzt werden können, aber dieser PEP sieht wie eine ausgezeichnete Grundlage für solche Arbeiten aus.
Von Bas van Beek, der an vorläufiger Unterstützung für Shape-Generics in NumPy gearbeitet hat.
Ich teile Stephans Meinung hier sehr und freue mich darauf, die neuen PEP 646 Variadics in NumPy zu integrieren.Im Kontext von NumPy (und der Tensor-Typisierung im Allgemeinen): Die Typisierung von Array-Formen ist ein ziemlich komplexes Thema, und die Einführung von Variadics wird wahrscheinlich eine große Rolle bei deren Fundament spielen, da sie sowohl die Dimensionalität als auch die grundlegende Shape-Manipulation ermöglicht.
Alles in allem bin ich sehr daran interessiert, wohin uns sowohl PEP 646 als auch zukünftige PEPs bringen werden, und freue mich auf weitere Entwicklungen.
Von Dan Moldovan, Senior Software Engineer im TensorFlow Dev Team und Autor des TensorFlow RFC, TensorFlow Canonical Type System: [15]
Ich wäre daran interessiert, die in diesem PEP definierten Mechanismen zur Definition von rang-generischen Tensor-Typen in TensorFlow zu verwenden, die für die Angabe vontf.function-Signaturen auf Python-Art mit Typannotationen wichtig sind (anstelle des benutzerdefinierteninput_signature-Mechanismus, den wir heute haben – siehe dieses Issue: https://github.com/tensorflow/tensorflow/issues/31579). Variadische Generics gehören zu den letzten fehlenden Teilen, um einen eleganten Satz von Typdefinitionen für Tensoren und Formen zu erstellen.
(Der Transparenz halber – wir haben uns auch an Personen von einer dritten beliebten numerischen Computing-Bibliothek, PyTorch, gewandt, aber keine Bestätigungserklärung von ihnen erhalten. Unser Verständnis ist, dass sie, obwohl sie an einigen der gleichen Probleme interessiert sind – z. B. statische Forminferenz –, sich derzeit darauf konzentrieren, dies über eine DSL anstelle des Python-Typsystems zu ermöglichen.)
Danksagungen
Vielen Dank an Alfonso Castaño, Antoine Pitrou, Bas v.B., David Foster, Dimitris Vardoulakis, Eric Traut, Guido van Rossum, Jia Chen, Lucio Fernandez-Arjona, Nikita Sobolev, Peilonrayz, Rebecca Chen, Sergei Lebedev und Vladimir Mikulik für hilfreiches Feedback und Vorschläge zu Entwürfen dieses PEPs.
Besonderer Dank gilt Lucio für den Vorschlag der Stern-Syntax (die mehrere Aspekte dieses Vorschlags wesentlich prägnanter und intuitiver gemacht hat) und Stephan Hoyer und Dan Moldovan für ihre Unterstützung.
Ressourcen
Diskussionen über variable Generika in Python begannen 2016 mit Issue 193 im GitHub-Repository python/typing [4].
Inspiriert von dieser Diskussion machte Ivan Levkivskyi auf der PyCon 2019 einen konkreten Vorschlag, der in den Notizen zu „Type system improvements“ [5] und „Static typing of Python numeric stack“ [6] zusammengefasst ist.
Aufbauend auf diesen Ideen gaben Mark Mendoza und Vincent Siles auf dem Python Typing Summit 2019 eine Präsentation über „Variadic Type Variables for Decorators and Tensors“ [8].
Die Diskussion darüber, wie sich Typersetzungen in generischen Aliasen verhalten sollen, fand in cpython#91162 statt.
Referenzen
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-0646.rst
Zuletzt geändert: 2025-02-01 08:55:40 GMT