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

Python Enhancement Proposals

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

Inhaltsverzeichnis

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.Final
    class 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 number ist nicht möglich – die Spezifikation von Final besagt, dass der Name in Unterklassen nicht überschrieben werden kann.
  • Schreibgeschützter Proxy über @property
    class Foo:
        _number: int
    
        def __init__(self, number: int) -> None:
            self._number = number
    
        @property
        def number(self) -> int:
            return self._number
    
    • Die Überschreibung von number ist 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 _number als Parameter-/Attributnamen.
  • Verwendung eines "Einfrier"-Mechanismus, wie dataclasses.dataclass() oder typing.NamedTuple
    @dataclass(frozen=True)
    class Foo:
        number: int  # implicitly read-only
    
    
    class Bar(NamedTuple):
        number: int  # implicitly read-only
    
    • Die Überschreibung von number ist 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.
    • NamedTuple ist immer noch ein tuple. Die meisten Klassen müssen keine Indizierung, Iteration oder Verkettung erben.

Protokolle

Angenommen, ein Mitglied name: T eines Protocol, das zwei Anforderungen definiert:

  1. hattr(obj, "name")
  2. 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 @property def 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 Member kann .id als beschreibbares Attribut oder als Deskriptor neu definieren. Sie kann auch den Typ verengen.
  • Das Protokoll HasName hat eine prägnantere Definition und ist unabhängig von der Schreibbarkeit des Attributs.
  • Die Funktion greet kann 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 (wahrscheinlich self).
  • 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 (wahrscheinlich cls).
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 .name unterstützen muss und der zurückgegebene Wert T zuweisbar 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.ReadOnly mit diesem PEP.
  • Aktualisieren Sie die Beschreibung von typing.ReadOnly:
    Eine spezielle Typkonstruktion, um ein Attribut einer Klasse oder ein Element eines TypedDict als schreibgeschützt zu kennzeichnen.
  • Fügen Sie einen eigenständigen Eintrag für ReadOnly unter dem Abschnitt Typ-Qualifizierer hinzu.
    Der Typ-Qualifizierer ReadOnly in Klassenattribut-Annotationen gibt an, dass das Attribut der Klasse gelesen, aber nicht neu zugewiesen oder gelöscht werden kann. Für die Verwendung in TypedDict siehe 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


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

Zuletzt geändert: 2025-05-06 20:54:28 GMT