PEP 767 – Annotation von schreibgeschützten Attributen
- Autor:
- Eneg <eneg at discuss.python.org>
- Sponsor:
- Carl Meyer <carl at oddbird.net>
- Discussions-To:
- Discourse thread
- Status:
- Entwurf
- Typ:
- Standards Track
- Thema:
- Typisierung
- Erstellt:
- 18-Nov-2024
- Python-Version:
- 3.15
- Post-History:
- 09-Oct-2024
Zusammenfassung
PEP 705 führte den Typ-Qualifizierer typing.ReadOnly typing.ReadOnly ein, um schreibgeschützte typing.TypedDict Elemente zu definieren.
Dieser PEP schlägt die Verwendung von ReadOnly in Annotationen von Klassen- und Protokollattributen vor, als eine einzige prägnante Methode, um sie als schreibgeschützt zu kennzeichnen.
Ähnlich wie PEP 705 nimmt er keine Änderungen an der Laufzeit-Zuweisung von Attributen vor. Die korrekte Verwendung von schreibgeschützten Attributen soll nur von statischen Typ-Prüfern erzwungen werden.
Motivation
Dem Python-Typsystem fehlt eine einzelne, prägnante Möglichkeit, ein Attribut als schreibgeschützt zu kennzeichnen. Dieses Merkmal ist in anderen statisch und graduell typisierten Sprachen vorhanden (wie C# oder TypeScript) und ist nützlich, um auf Ebene des Typ-Prüfers die Fähigkeit zur Neuzuweisung oder zum Löschen eines Attributs zu entfernen, sowie eine breite Schnittstelle für strukturelles Subtyping zu definieren.
Klassen
Heutzutage gibt es drei Hauptmethoden, um schreibgeschützte Attribute zu erreichen, die von Typ-Prüfern unterstützt werden:
- Annotation des Attributs mit
typing.Finalclass Foo: number: Final[int] def __init__(self, number: int) -> None: self.number = number class Bar: def __init__(self, number: int) -> None: self.number: Final = number
- Unterstützt von
dataclasses(und Typ-Prüfern seit typing#1669). - Die Überschreibung von
numberist nicht möglich – die Spezifikation vonFinalbesagt, dass der Name in Unterklassen nicht überschrieben werden kann.
- Unterstützt von
- Schreibgeschützter Proxy über
@propertyclass Foo: _number: int def __init__(self, number: int) -> None: self._number = number @property def number(self) -> int: return self._number
- Die Überschreibung von
numberist möglich. *Typ-Prüfer sind sich über die genauen Regeln uneinig.* [1] - Schreibgeschützt zur Laufzeit. [2]
- Erfordert zusätzlichen Boilerplate-Code.
- Unterstützt von
dataclasses, aber schlecht zusammensetzbar – das synthetisierte__init__und__repr__verwenden_numberals Parameter-/Attributnamen.
- Die Überschreibung von
- Verwendung eines "Einfrier"-Mechanismus, wie
dataclasses.dataclass()odertyping.NamedTuple@dataclass(frozen=True) class Foo: number: int # implicitly read-only class Bar(NamedTuple): number: int # implicitly read-only
- Die Überschreibung von
numberist im@dataclass-Fall möglich. - Schreibgeschützt zur Laufzeit. [2]
- Keine Kontrolle pro Attribut – diese Mechanismen gelten für die gesamte Klasse.
- Eingefrorene Dataclasses verursachen einen gewissen Laufzeit-Overhead.
NamedTupleist immer noch eintuple. Die meisten Klassen müssen keine Indizierung, Iteration oder Verkettung erben.
- Die Überschreibung von
Protokolle
Angenommen, ein Mitglied name: T eines Protocol, das zwei Anforderungen definiert:
hattr(obj, "name")istinstance(obj.name, T)
Diese Anforderungen sind zur Laufzeit durch alle folgenden Elemente erfüllbar:
- ein Objekt mit einem Attribut
name: T, - eine Klasse mit einer Klassenvariable
name: ClassVar[T], - eine Instanz der obigen Klasse,
- ein Objekt mit einem
@propertydef name(self) -> T, - ein Objekt mit einem benutzerdefinierten Deskriptor, wie
functools.cached_property().
Die aktuelle Typing-Spezifikation erlaubt die Erstellung solcher Protokollmitglieder unter Verwendung von (abstrakten) Eigenschaften
class HasName(Protocol):
@property
def name(self) -> T: ...
Diese Syntax hat mehrere Nachteile:
- Sie ist etwas umständlich.
- Es ist nicht offensichtlich, dass die hier vermittelte Qualität der schreibgeschützte Charakter einer Eigenschaft ist.
- Sie ist nicht mit Typ-Qualifizierern komponierbar.
- Nicht alle Typ-Prüfer stimmen überein [3], dass alle der oben genannten fünf Objekte dieser strukturellen Art zuweisbar sind.
Begründung
Diese Probleme können durch einen Typ-Qualifizierer auf Attribut-Ebene gelöst werden. ReadOnly wurde für diese Rolle gewählt, da sein Name die Absicht gut vermittelt und die neu vorgeschlagenen Änderungen seine in PEP 705 definierten Semantiken ergänzen.
Eine Klasse mit einem schreibgeschützten Instanzattribut kann nun wie folgt definiert werden:
from typing import ReadOnly
class Member:
def __init__(self, id: int) -> None:
self.id: ReadOnly[int] = id
…und das in Protokolle beschriebene Protokoll ist nun einfach:
from typing import Protocol, ReadOnly
class HasName(Protocol):
name: ReadOnly[str]
def greet(obj: HasName, /) -> str:
return f"Hello, {obj.name}!"
- Eine Unterklasse von
Memberkann.idals beschreibbares Attribut oder als Deskriptor neu definieren. Sie kann auch den Typ verengen. - Das Protokoll
HasNamehat eine prägnantere Definition und ist unabhängig von der Schreibbarkeit des Attributs. - Die Funktion
greetkann nun eine Vielzahl kompatibler Objekte akzeptieren und gleichzeitig explizit machen, dass keine Änderungen am Eingabetyp vorgenommen werden.
Spezifikation
Der typing.ReadOnly Typ-Qualifizierer wird eine gültige Annotation für Attribute von Klassen und Protokollen. Er kann auf Klassenebene oder innerhalb von __init__ verwendet werden, um einzelne Attribute als schreibgeschützt zu kennzeichnen.
class Book:
id: ReadOnly[int]
def __init__(self, id: int, name: str) -> None:
self.id = id
self.name: ReadOnly[str] = name
Typ-Prüfer sollten jeden Versuch, ein mit ReadOnly annotiertes Attribut neu zuzuweisen oder zu löschen, mit einem Fehler melden. Typ-Prüfer sollten auch jeden Versuch, ein als Final annotiertes Attribut zu löschen, mit einem Fehler melden. (Dies ist derzeit nicht spezifiziert.)
Die Verwendung von ReadOnly in Annotationen an anderen Stellen, wo es derzeit keine Bedeutung hat (wie lokale/globale Variablen oder Funktionsparameter), wird für diesen PEP als außer Reichweite betrachtet.
Ähnlich wie Final [4], beeinflusst ReadOnly nicht, wie Typ-Prüfer die Veränderlichkeit des zugewiesenen Objekts wahrnehmen. Unveränderliche ABCs und Container können in Kombination mit ReadOnly verwendet werden, um die Mutation solcher Werte auf Ebene des Typ-Prüfers zu verbieten.
from collections import abc
from dataclasses import dataclass
from typing import Protocol, ReadOnly
@dataclass
class Game:
name: str
class HasGames[T: abc.Collection[Game]](Protocol):
games: ReadOnly[T]
def add_games(shelf: HasGames[list[Game]]) -> None:
shelf.games.append(Game("Half-Life")) # ok: list is mutable
shelf.games[-1].name = "Black Mesa" # ok: "name" is not read-only
shelf.games = [] # error: "games" is read-only
del shelf.games # error: "games" is read-only and cannot be deleted
def read_games(shelf: HasGames[abc.Sequence[Game]]) -> None:
shelf.games.append(...) # error: "Sequence" has no attribute "append"
shelf.games[0].name = "Blue Shift" # ok: "name" is not read-only
shelf.games = [] # error: "games" is read-only
Alle Instanzattribute von eingefrorenen Dataclasses und NamedTuple sollten implizit als schreibgeschützt gelten. Typ-Prüfer können darauf hinweisen, dass die Annotation solcher Attribute mit ReadOnly redundant ist, dies sollte jedoch nicht als Fehler angesehen werden.
from dataclasses import dataclass
from typing import NewType, ReadOnly
@dataclass(frozen=True)
class Point:
x: int # implicit read-only
y: ReadOnly[int] # ok, redundant
uint = NewType("uint", int)
@dataclass(frozen=True)
class UnsignedPoint(Point):
x: ReadOnly[uint] # ok, redundant; narrower type
y: Final[uint] # not redundant, Final imposes extra restrictions; narrower type
Initialisierung
Die Zuweisung zu einem schreibgeschützten Attribut kann nur in der Klasse erfolgen, die das Attribut deklariert. Es gibt keine Einschränkung, wie oft das Attribut zugewiesen werden kann. Je nach Art des Attributs kann es an verschiedenen Stellen zugewiesen werden:
Instanzattribute
Die Zuweisung zu einem Instanzattribut muss in den folgenden Kontexten zulässig sein:
- In
__init__, für die als erster Parameter erhaltene Instanz (wahrscheinlichself). - In
__new__, für Instanzen der deklarierenden Klasse, die durch Aufruf der__new__-Methode einer Superklasse erstellt wurden. - Bei der Deklaration im Körper der Klasse.
Zusätzlich kann ein Typ-Prüfer die Zuweisung zulassen:
- In
__new__, für Instanzen der deklarierenden Klasse, unabhängig von der Herkunft der Instanz. (Diese Wahl tauscht eine gewisse Korrektheit ein, da die Instanz möglicherweise bereits initialisiert ist, gegen eine einfachere Implementierung.) - In
@classmethods, für Instanzen der deklarierenden Klasse, die durch Aufruf der__new__-Methode der Klasse oder einer Superklasse erstellt wurden.
from collections import abc
from typing import ReadOnly
class Band:
name: str
songs: ReadOnly[list[str]]
def __init__(self, name: str, songs: abc.Iterable[str] | None = None) -> None:
self.name = name
self.songs = []
if songs is not None:
self.songs = list(songs) # multiple assignments are fine
def clear(self) -> None:
# error: assignment to read-only "songs" outside initialization
self.songs = []
band = Band(name="Bôa", songs=["Duvet"])
band.name = "Python" # ok: "name" is not read-only
band.songs = [] # error: "songs" is read-only
band.songs.append("Twilight") # ok: list is mutable
class SubBand(Band):
def __init__(self) -> None:
self.songs = [] # error: cannot assign to a read-only attribute of a base class
# a simplified immutable Fraction class
class Fraction:
numerator: ReadOnly[int]
denominator: ReadOnly[int]
def __new__(
cls,
numerator: str | int | float | Decimal | Rational = 0,
denominator: int | Rational | None = None
) -> Self:
self = super().__new__(cls)
if denominator is None:
if type(numerator) is int:
self.numerator = numerator
self.denominator = 1
return self
elif isinstance(numerator, Rational): ...
else: ...
@classmethod
def from_float(cls, f: float, /) -> Self:
self = super().__new__(cls)
self.numerator, self.denominator = f.as_integer_ratio()
return self
Klassenattribute
Schreibgeschützte Klassenattribute sind Attribute, die sowohl als ReadOnly als auch als ClassVar annotiert sind. Die Zuweisung zu solchen Attributen muss in den folgenden Kontexten zulässig sein:
- Bei der Deklaration im Körper der Klasse.
- In
__init_subclass__, für das als erster Parameter erhaltene Klassenobjekt (wahrscheinlichcls).
class URI:
protocol: ReadOnly[ClassVar[str]] = ""
def __init_subclass__(cls, protocol: str = "") -> None:
cls.protocol = protocol
class File(URI, protocol="file"): ...
Wenn eine Klassendeklaration einen initialisierenden Wert hat, kann sie als Flyweight-Standard für Instanzen dienen.
class Patient:
number: ReadOnly[int] = 0
def __init__(self, number: int | None = None) -> None:
if number is not None:
self.number = number
Hinweis
Dieses Merkmal steht im Konflikt mit __slots__. Ein Attribut mit einem Wert auf Klassenebene kann nicht in Slots aufgenommen werden, wodurch es effektiv zu einer Klassenvariable wird.
Typ-Prüfer können vor schreibgeschützten Attributen warnen, die nach der Erstellung einer Instanz möglicherweise nicht initialisiert bleiben (außer in Stubs, Protokollen oder ABCs).
class Patient:
id: ReadOnly[int] # error: "id" is not initialized on all code paths
name: ReadOnly[str] # error: "name" is never initialized
def __init__(self) -> None:
if random.random() > 0.5:
self.id = 123
class HasName(Protocol):
name: ReadOnly[str] # ok
Subtyping
Die Unmöglichkeit, schreibgeschützte Attribute neu zuzuweisen, macht sie kovariant. Dies hat einige Subtyping-Implikationen. Übernommen aus PEP 705
- Schreibgeschützte Attribute können als beschreibbare Attribute, Deskriptoren oder Klassenvariablen neu deklariert werden.
@dataclass class HasTitle: title: ReadOnly[str] @dataclass class Game(HasTitle): title: str year: int game = Game(title="DOOM", year=1993) game.year = 1994 game.title = "DOOM II" # ok: attribute is not read-only class TitleProxy(HasTitle): @functools.cached_property def title(self) -> str: ... class SharedTitle(HasTitle): title: ClassVar[str] = "Still Grey"
- Wenn ein schreibgeschütztes Attribut nicht neu deklariert wird, bleibt es schreibgeschützt.
class Game(HasTitle): year: int def __init__(self, title: str, year: int) -> None: super().__init__(title) self.title = title # error: cannot assign to a read-only attribute of base class self.year = year game = Game(title="Robot Wants Kitty", year=2010) game.title = "Robot Wants Puppy" # error: "title" is read-only
- Untertypen können den Typ von schreibgeschützten Attributen verengen.
class GameCollection(Protocol): games: ReadOnly[abc.Collection[Game]] @dataclass class GameSeries(GameCollection): name: str games: ReadOnly[list[Game]] # ok: list[Game] is assignable to Collection[Game]
- Nominale Unterklassen von Protokollen und ABCs sollten schreibgeschützte Attribute neu deklarieren, um sie zu implementieren, es sei denn, die Basisklasse initialisiert sie auf irgendeine Weise.
class MyBase(abc.ABC): foo: ReadOnly[int] bar: ReadOnly[str] = "abc" baz: ReadOnly[float] def __init__(self, baz: float) -> None: self.baz = baz @abstractmethod def pprint(self) -> None: ... @final class MySubclass(MyBase): # error: MySubclass does not override "foo" def pprint(self) -> None: print(self.foo, self.bar, self.baz)
- In einer Protokollattribut-Deklaration zeigt
name: ReadOnly[T]an, dass ein struktureller Subtyp den Zugriff auf.nameunterstützen muss und der zurückgegebene WertTzuweisbar ist.class HasName(Protocol): name: ReadOnly[str] class NamedAttr: name: str class NamedProp: @property def name(self) -> str: ... class NamedClassVar: name: ClassVar[str] class NamedDescriptor: @cached_property def name(self) -> str: ... # all of the following are ok has_name: HasName has_name = NamedAttr() has_name = NamedProp() has_name = NamedClassVar has_name = NamedClassVar() has_name = NamedDescriptor()
Interaktion mit anderen Typ-Qualifizierern
ReadOnly kann mit ClassVar und Annotated in beliebiger Verschachtelungsreihenfolge verwendet werden.
class Foo:
foo: ClassVar[ReadOnly[str]] = "foo"
bar: Annotated[ReadOnly[int], Gt(0)]
class Foo:
foo: ReadOnly[ClassVar[str]] = "foo"
bar: ReadOnly[Annotated[int, Gt(0)]]
Dies steht im Einklang mit der Interaktion von ReadOnly und typing.TypedDict, wie in PEP 705 definiert.
Ein Attribut kann nicht gleichzeitig als ReadOnly und Final annotiert werden, da die beiden Qualifizierer unterschiedliche Semantiken aufweisen und Final im Allgemeinen restriktiver ist. Final bleibt als Annotation von Attributen zulässig, die nur implizit schreibgeschützt sind. Es kann auch verwendet werden, um ein ReadOnly-Attribut einer Basisklasse neu zu deklarieren.
Abwärtskompatibilität
Dieser PEP führt neue Kontexte ein, in denen ReadOnly gültig ist. Programme, die diese Stellen untersuchen, müssen sich ändern, um sie zu unterstützen. Dies wird voraussichtlich hauptsächlich Typ-Prüfer betreffen.
Vorsicht ist jedoch bei der Verwendung der zurückportierten typing_extensions.ReadOnly in älteren Python-Versionen geboten. Mechanismen, die Annotationen inspizieren, können bei der Begegnung mit ReadOnly falsch reagieren; insbesondere kann der Dekorator @dataclass, der nach ClassVar sucht, ReadOnly[ClassVar[...]] fälschlicherweise als Instanzattribut behandeln.
Um Probleme mit der Introspektion zu vermeiden, verwenden Sie ClassVar[ReadOnly[...]] anstelle von ReadOnly[ClassVar[...]].
Sicherheitsimplikationen
Es sind keine bekannten Sicherheitsfolgen aus diesem PEP bekannt.
Wie man das lehrt
Vorgeschlagene Änderungen an der Dokumentation des typing-Moduls, im Gefolge von PEP 705:
- Fügen Sie diesen PEP zu den bereits aufgeführten hinzu.
- Verknüpfen Sie
typing.ReadOnlymit diesem PEP. - Aktualisieren Sie die Beschreibung von
typing.ReadOnly:Eine spezielle Typkonstruktion, um ein Attribut einer Klasse oder ein Element einesTypedDictals schreibgeschützt zu kennzeichnen. - Fügen Sie einen eigenständigen Eintrag für
ReadOnlyunter dem Abschnitt Typ-Qualifizierer hinzu.Der Typ-QualifiziererReadOnlyin Klassenattribut-Annotationen gibt an, dass das Attribut der Klasse gelesen, aber nicht neu zugewiesen oder gelöscht werden kann. Für die Verwendung inTypedDictsiehe ReadOnly.
Abgelehnte Ideen
Klärung der Interaktion von @property und Protokollen
Der Abschnitt Protokolle erwähnt eine Inkonsistenz zwischen Typ-Prüfern bei der Interpretation von Eigenschaften in Protokollen. Das Problem könnte durch eine Änderung der Typing-Spezifikation behoben werden, die klärt, was die schreibgeschützte Qualität solcher Eigenschaften implementiert.
Dieser PEP macht ReadOnly zu einer besseren Alternative für die Definition schreibgeschützter Attribute in Protokollen und ersetzt die Verwendung von Eigenschaften zu diesem Zweck.
Zuweisung nur in __init__ und Klassenkörper
Eine frühere Version dieses PEP schlug vor, dass schreibgeschützte Attribute nur in __init__ und dem Klassenkörper zugewiesen werden könnten. Eine spätere Diskussion ergab, dass diese Einschränkung die Nutzbarkeit von ReadOnly in unveränderlichen Klassen, die typischerweise keine __init__ definieren, stark einschränken würde.
fractions.Fraction ist ein Beispiel für eine unveränderliche Klasse, in der die Initialisierung ihrer Attribute innerhalb von __new__ und Classmethods erfolgt. Im Gegensatz zu __init__ ist die Zuweisung in __new__ und Classmethods jedoch potenziell nicht korrekt, da die Instanz, auf der sie arbeiten, von einer beliebigen Stelle stammen kann, einschließlich einer bereits finalisierten Instanz.
Wir halten es für unerlässlich, dass dieses Typ-Checking-Merkmal für den wichtigsten Anwendungsfall von schreibgeschützten Attributen – unveränderliche Klassen – nützlich ist. Daher wurde der PEP dahingehend geändert, die Zuweisung in __new__ und Classmethods unter einer Reihe von Regeln zuzulassen, die im Abschnitt Initialisierung beschrieben sind.
Offene Fragen
Erweiterung der Initialisierung
Mechanismen wie dataclasses.__post_init__() oder die Initialisierungs-Hooks von attrs erweitern die Objekterstellung, indem sie eine Reihe spezieller Hooks bereitstellen, die während der Initialisierung aufgerufen werden.
Die aktuellen Initialisierungsregeln dieses PEP verbieten die Zuweisung zu schreibgeschützten Attributen in solchen Methoden. Es ist unklar, ob die Regeln zufriedenstellend gestaltet werden könnten, um diese Hooks von Drittanbietern einzubeziehen und gleichzeitig die mit der Schreibgeschütztheit dieser Attribute verbundenen Invarianten aufrechtzuerhalten.
Das Python-Typsystem hat eine lange und detaillierte Spezifikation bezüglich des Verhaltens von __new__ und __init__. Es ist eher unrealistisch, das gleiche Detaillierungsniveau von Hooks Dritter zu erwarten.
Eine mögliche Lösung bestünde darin, dass Typ-Prüfer diesbezüglich Konfigurationen bereitstellen und Endbenutzer manuell eine Reihe von Methoden angeben müssen, für die sie eine Initialisierung zulassen möchten. Dies könnte jedoch leicht dazu führen, dass Benutzer die oben genannten Invarianten fälschlicherweise oder absichtlich brechen. Es ist auch eine ziemlich große Anforderung für ein relativ nischenhaftes Merkmal.
Fußnoten
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-0767.rst
Zuletzt geändert: 2025-05-06 20:54:28 GMT