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

Python Enhancement Proposals

PEP 544 – Protokolle: Strukturelles Subtyping (statisches Duck-Typing)

Autor:
Ivan Levkivskyi <levkivskyi at gmail.com>, Jukka Lehtosalo <jukka.lehtosalo at iki.fi>, Łukasz Langa <lukasz at python.org>
BDFL-Delegate:
Guido van Rossum <guido at python.org>
Discussions-To:
Python-Dev Liste
Status:
Final
Typ:
Standards Track
Thema:
Typisierung
Erstellt:
05-Mrz-2017
Python-Version:
3.8
Resolution:
Typing-SIG Nachricht

Inhaltsverzeichnis

Wichtig

Dieses PEP ist ein historisches Dokument: siehe Protokolle und typing.Protocol für aktuelle Spezifikationen und Dokumentation. Kanonische Tippspezifikationen werden auf der Website für Tippspezifikationen gepflegt; das Laufzeit-Tippverhalten wird in der CPython-Dokumentation beschrieben.

×

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

Zusammenfassung

Typ-Hinweise, die in PEP 484 eingeführt wurden, können verwendet werden, um Typmetadaten für statische Typ-Checker und andere Drittanbieter-Tools zu spezifizieren. PEP 484 spezifiziert jedoch nur die Semantik des *nominalen* Subtypings. In diesem PEP spezifizieren wir statische und Laufzeitsemantik von Protokollklassen, die Unterstützung für *strukturelles* Subtyping (statisches Duck-Typing) bieten.

Begründung und Ziele

Derzeit definieren PEP 484 und das typing-Modul [typing] abstrakte Basisklassen für mehrere gängige Python-Protokolle wie Iterable und Sized. Das Problem dabei ist, dass eine Klasse explizit markiert werden muss, um sie zu unterstützen, was unpythonisch ist und nicht dem entspricht, was man normalerweise im idiomatischen dynamisch typisierten Python-Code tun würde. Zum Beispiel entspricht dies PEP 484

from typing import Sized, Iterable, Iterator

class Bucket(Sized, Iterable[int]):
    ...
    def __len__(self) -> int: ...
    def __iter__(self) -> Iterator[int]: ...

Das gleiche Problem tritt bei benutzerdefinierten ABCs auf: sie müssen explizit abgeleitet oder registriert werden. Dies ist besonders schwierig mit Bibliothekstypen zu machen, da die Typobjekte tief in der Implementierung der Bibliothek verborgen sein können. Außerdem kann die extensive Nutzung von ABCs zusätzliche Laufzeitkosten verursachen.

Die Absicht dieses PEPs ist es, all diese Probleme zu lösen, indem es Benutzern ermöglicht, den obigen Code ohne explizite Basisklassen in der Klassendefinition zu schreiben, sodass Bucket von statischen Typ-Checkern, die strukturelles [wiki-structural] Subtyping verwenden, implizit als Subtyp von sowohl Sized als auch Iterable[int] betrachtet wird.

from typing import Iterator, Iterable

class Bucket:
    ...
    def __len__(self) -> int: ...
    def __iter__(self) -> Iterator[int]: ...

def collect(items: Iterable[int]) -> int: ...
result: int = collect(Bucket())  # Passes type check

Beachten Sie, dass ABCs im typing-Modul bereits strukturelles Verhalten zur Laufzeit bieten, isinstance(Bucket(), Iterable) gibt True zurück. Das Hauptziel dieses Vorschlags ist die statische Unterstützung dieses Verhaltens. Die gleiche Funktionalität wird für benutzerdefinierte Protokolle bereitgestellt, wie unten spezifiziert. Der obige Code mit einer Protokollklasse passt viel besser zu gängigen Python-Konventionen. Er ist auch automatisch erweiterbar und funktioniert mit zusätzlichen, nicht verwandten Klassen, die zufällig das erforderliche Protokoll implementieren.

Nominales vs. strukturelles Subtyping

Strukturelles Subtyping ist für Python-Programmierer natürlich, da es mit der Laufzeitsemantik des Duck-Typings übereinstimmt: Ein Objekt, das bestimmte Eigenschaften hat, wird unabhängig von seiner tatsächlichen Laufzeitklasse behandelt. Wie jedoch in PEP 483 diskutiert, haben sowohl nominales als auch strukturelles Subtyping ihre Stärken und Schwächen. Daher schlagen wir in diesem PEP *nicht* vor, das von PEP 484 beschriebene nominale Subtyping vollständig durch strukturelles Subtyping zu ersetzen. Stattdessen ergänzen die in diesem PEP spezifizierten Protokollklassen normale Klassen, und Benutzer können frei wählen, wo sie eine bestimmte Lösung anwenden möchten. Siehe den Abschnitt über abgelehnte Ideen am Ende dieses PEPs für zusätzliche Motivation.

Non-goals

Zur Laufzeit werden Protokollklassen einfache ABCs sein. Es ist nicht beabsichtigt, ausgefeilte Laufzeit-Instanz- und Klassenprüfungen gegen Protokollklassen bereitzustellen. Dies wäre schwierig und fehleranfällig und würde der Logik von PEP 484 widersprechen. Ebenso geben wir in Anlehnung an PEP 484 und PEP 526 an, dass Protokolle **vollständig optional** sind.

  • Für Variablen oder Parameter, die mit einer Protokollklasse annotiert sind, werden keine Laufzeitsemantiken auferlegt.
  • Prüfungen werden ausschließlich von Drittanbieter-Typ-Checkern und anderen Tools durchgeführt.
  • Programmierer sind frei, sie nicht zu verwenden, auch wenn sie Typannotationen verwenden.
  • Es ist nicht beabsichtigt, Protokolle zukünftig nicht-optional zu machen.

Um es noch einmal zu betonen: Die Bereitstellung komplexer Laufzeitsemantik für Protokollklassen ist kein Ziel dieses PEPs, das Hauptziel ist die Bereitstellung von Unterstützung und Standards für *statisches* strukturelles Subtyping. Die Möglichkeit, Protokolle im Laufzeitkontext als ABCs zu verwenden, ist eher ein kleiner Bonus, der hauptsächlich dazu dient, eine nahtlose Übergabe für Projekte zu ermöglichen, die bereits ABCs verwenden.

Bestehende Ansätze für strukturelles Subtyping

Bevor die eigentliche Spezifikation beschrieben wird, überprüfen und kommentieren wir bestehende Ansätze, die sich auf strukturelles Subtyping in Python und anderen Sprachen beziehen.

  • zope.interface [zope-interfaces] war einer der ersten weit verbreiteten Ansätze für strukturelles Subtyping in Python. Er wird implementiert, indem spezielle Klassen bereitgestellt werden, um Interface-Klassen von normalen Klassen zu unterscheiden, Interface-Attribute zu markieren und die Implementierung explizit zu deklarieren. Zum Beispiel
    from zope.interface import Interface, Attribute, implementer
    
    class IEmployee(Interface):
    
        name = Attribute("Name of employee")
    
        def do(work):
            """Do some work"""
    
    @implementer(IEmployee)
    class Employee:
    
        name = 'Anonymous'
    
        def do(self, work):
            return work.start()
    

    Zope-Interfaces unterstützen verschiedene Verträge und Einschränkungen für Interface-Klassen. Zum Beispiel

    from zope.interface import invariant
    
    def required_contact(obj):
        if not (obj.email or obj.phone):
            raise Exception("At least one contact info is required")
    
    class IPerson(Interface):
    
        name = Attribute("Name")
        email = Attribute("Email Address")
        phone = Attribute("Phone Number")
    
        invariant(required_contact)
    

    Noch detailliertere Invarianten werden unterstützt. Zope-Interfaces verlassen sich jedoch vollständig auf Laufzeitvalidierung. Ein solcher Fokus auf Laufzeiteigenschaften geht über den Umfang des aktuellen Vorschlags hinaus, und eine statische Unterstützung für Invarianten könnte schwierig zu implementieren sein. Die Idee, eine Interface-Klasse mit einer speziellen Basisklasse zu markieren, ist jedoch vernünftig und sowohl statisch als auch zur Laufzeit einfach zu implementieren.

  • Python Abstract Base Classes [abstract-classes] sind das Standardwerkzeug der Standardbibliothek, um eine Funktionalität bereitzustellen, die der strukturellen Subtyping ähnelt. Der Nachteil dieses Ansatzes ist die Notwendigkeit, die abstrakte Klasse entweder abzuleiten oder eine Implementierung explizit zu registrieren.
    from abc import ABC
    
    class MyTuple(ABC):
        pass
    
    MyTuple.register(tuple)
    
    assert issubclass(tuple, MyTuple)
    assert isinstance((), MyTuple)
    

    Wie im Abschnitt Rationale erwähnt, möchten wir eine solche Notwendigkeit vermeiden, insbesondere im statischen Kontext. Im Laufzeitkontext sind ABCs jedoch gute Kandidaten für Protokollklassen und werden bereits ausgiebig im typing-Modul verwendet.

  • Abstrakte Klassen, die im Modul collections.abc [collections-abc] definiert sind, sind etwas fortgeschrittener, da sie eine benutzerdefinierte Methode __subclasshook__() implementieren, die Laufzeit-Strukturprüfungen ohne explizite Registrierung ermöglicht.
    from collections.abc import Iterable
    
    class MyIterable:
        def __iter__(self):
            return []
    
    assert isinstance(MyIterable(), Iterable)
    

    Ein solches Verhalten scheint sowohl für das Laufzeit- als auch für das statische Verhalten von Protokollen perfekt geeignet. Wie im Abschnitt Rationale diskutiert, schlagen wir vor, statische Unterstützung für ein solches Verhalten hinzuzufügen. Zusätzlich wird, um Benutzern zu ermöglichen, ein solches Laufzeitverhalten für *benutzerdefinierte* Protokolle zu erzielen, ein spezieller Decorator @runtime_checkable bereitgestellt, siehe detaillierte Diskussion unten.

  • TypeScript [typescript] bietet Unterstützung für benutzerdefinierte Klassen und Schnittstellen. Eine explizite Implementierungsdeklaration ist nicht erforderlich und strukturelles Subtyping wird statisch verifiziert. Zum Beispiel
    interface LabeledItem {
        label: string;
        size?: int;
    }
    
    function printLabel(obj: LabeledItem) {
        console.log(obj.label);
    }
    
    let myObj = {size: 10, label: "Size 10 Object"};
    printLabel(myObj);
    

    Beachten Sie, dass optionale Interface-Member unterstützt werden. Außerdem verbietet TypeScript redundante Member in Implementierungen. Obwohl die Idee optionaler Member interessant aussieht, würde sie diesen Vorschlag verkomplizieren und es ist nicht klar, wie nützlich sie sein wird. Daher wird vorgeschlagen, dies zurückzustellen; siehe abgelehnte Ideen. Im Allgemeinen sieht die Idee der statischen Protokollprüfung ohne Laufzeitimplikationen vernünftig aus, und im Grunde folgt dieser Vorschlag demselben Ansatz.

  • Go [golang] verwendet einen radikaleren Ansatz und macht Schnittstellen zur primären Methode, um Typinformationen bereitzustellen. Außerdem werden Zuweisungen verwendet, um die Implementierung explizit sicherzustellen.
    type SomeInterface interface {
        SomeMethod() ([]byte, error)
    }
    
    if _, ok := someval.(SomeInterface); ok {
        fmt.Printf("value implements some interface")
    }
    

    Beide Ideen sind im Kontext dieses Vorschlags fragwürdig. Siehe den Abschnitt über abgelehnte Ideen.

Spezifikation

Terminologie

Wir schlagen vor, den Begriff *Protokolle* für Typen zu verwenden, die strukturelles Subtyping unterstützen. Der Grund ist, dass der Begriff *Iterator-Protokoll* zum Beispiel in der Community weithin verstanden wird, und das Erfinden eines neuen Begriffs für dieses Konzept in einem statisch typisierten Kontext nur zu Verwirrung führen würde.

Dies hat den Nachteil, dass der Begriff *Protokoll* mit zwei subtil unterschiedlichen Bedeutungen überladen wird: die erste ist das traditionelle, bekannte, aber leicht vage Konzept von Protokollen wie dem Iterator; die zweite ist das expliziter definierte Konzept von Protokollen in statisch typisiertem Code. Die Unterscheidung ist meistens nicht wichtig, und in anderen Fällen schlagen wir vor, einfach einen Zusatz wie *Protokollklassen* hinzuzufügen, wenn wir uns auf das statische Typenkonzept beziehen.

Wenn eine Klasse ein Protokoll in ihrer MRO enthält, wird die Klasse als *expliziter* Subtyp des Protokolls bezeichnet. Wenn eine Klasse ein struktureller Subtyp eines Protokolls ist, wird gesagt, dass sie das Protokoll implementiert und mit einem Protokoll kompatibel ist. Wenn eine Klasse mit einem Protokoll kompatibel ist, das Protokoll aber nicht in der MRO enthalten ist, ist die Klasse ein *impliziter* Subtyp des Protokolls. (Beachten Sie, dass man explizit von einem Protokoll ableiten und es immer noch nicht implementieren kann, wenn ein Protokollattribut in der abgeleiteten Klasse auf None gesetzt ist, siehe Python [data-model] für Details.)

Die Attribute (Variablen und Methoden) eines Protokolls, die für eine andere Klasse zwingend erforderlich sind, damit sie als struktureller Subtyp betrachtet wird, werden als Protokollmember bezeichnet.

Definition eines Protokolls

Protokolle werden definiert, indem eine spezielle neue Klasse typing.Protocol (eine Instanz von abc.ABCMeta) in die Liste der Basisklassen aufgenommen wird, typischerweise am Ende der Liste. Hier ist ein einfaches Beispiel

from typing import Protocol

class SupportsClose(Protocol):
    def close(self) -> None:
        ...

Wenn nun eine Klasse Resource mit einer close()-Methode mit einer kompatiblen Signatur definiert wird, wäre sie implizit ein Subtyp von SupportsClose, da für Protokolltypen strukturelles Subtyping verwendet wird.

class Resource:
    ...
    def close(self) -> None:
        self.file.close()
        self.lock.release()

Abgesehen von wenigen explizit unten erwähnten Einschränkungen können Protokolltypen in jedem Kontext verwendet werden, in dem normale Typen verwendet werden können.

def close_all(things: Iterable[SupportsClose]) -> None:
    for t in things:
        t.close()

f = open('foo.txt')
r = Resource()
close_all([f, r])  # OK!
close_all([1])     # Error: 'int' has no 'close' method

Beachten Sie, dass sowohl die benutzerdefinierte Klasse Resource als auch der eingebaute Typ IO (der Rückgabetyp von open()) als Subtypen von SupportsClose betrachtet werden, da sie eine close()-Methode mit einer kompatiblen Typensignatur bereitstellen.

Protokollmember

Alle in der Protokollklassendefinition definierten Methoden sind Protokollmember, sowohl normale als auch mit @abstractmethod dekorierte. Wenn ein Parameter einer Protokollmethode nicht annotiert ist, wird sein Typ als Any (siehe PEP 484) angenommen. Körper von Protokollmethoden werden typgeprüft. Eine abstrakte Methode, die nicht über super() aufgerufen werden sollte, muss NotImplementedError auslösen. Beispiel

from typing import Protocol
from abc import abstractmethod

class Example(Protocol):
    def first(self) -> int:     # This is a protocol member
        return 42

    @abstractmethod
    def second(self) -> int:    # Method without a default implementation
        raise NotImplementedError

Statische Methoden, Klassenmethoden und Eigenschaften sind in Protokollen gleichermaßen zulässig.

Um eine Protokollvariable zu definieren, kann man die Variable-Annotationssyntax von PEP 526 im Klassenkörper verwenden. Zusätzliche Attribute, die *nur* im Körper einer Methode durch Zuweisung über self definiert werden, sind nicht erlaubt. Der Grund dafür ist, dass die Implementierung der Protokollklasse oft nicht von den Subtypen geteilt wird, sodass die Schnittstelle nicht von der Standardimplementierung abhängen sollte. Beispiele

from typing import Protocol, List

class Template(Protocol):
    name: str        # This is a protocol member
    value: int = 0   # This one too (with default)

    def method(self) -> None:
        self.temp: List[int] = [] # Error in type checker

class Concrete:
    def __init__(self, name: str, value: int) -> None:
        self.name = name
        self.value = value

    def method(self) -> None:
        return

var: Template = Concrete('value', 42)  # OK

Um zwischen Protokollklassenvariablen und Protokollinstanzvariablen zu unterscheiden, sollte die spezielle Annotation ClassVar wie in PEP 526 angegeben verwendet werden. Standardmäßig gelten die oben definierten Protokollvariablen als lesbar und schreibbar. Um eine schreibgeschützte Protokollvariable zu definieren, kann eine (abstrakte) Eigenschaft verwendet werden.

Explizite Deklaration der Implementierung

Um explizit zu deklarieren, dass eine bestimmte Klasse ein gegebenes Protokoll implementiert, kann sie als reguläre Basisklasse verwendet werden. In diesem Fall könnte eine Klasse Standardimplementierungen von Protokollmembern verwenden. Statische Analysetools sollen automatisch erkennen, dass eine Klasse ein gegebenes Protokoll implementiert. Obwohl es also möglich ist, explizit von einem Protokoll abzuleiten, ist es *nicht notwendig*, dies zu tun, um die Typüberprüfung zu ermöglichen.

Die Standardimplementierungen können nicht verwendet werden, wenn die Subtyp-Beziehung implizit und nur über strukturelles Subtyping erfolgt – die Semantik der Vererbung wird nicht geändert. Beispiele

class PColor(Protocol):
    @abstractmethod
    def draw(self) -> str:
        ...
    def complex_method(self) -> int:
        # some complex code here
        ...

class NiceColor(PColor):
    def draw(self) -> str:
        return "deep blue"

class BadColor(PColor):
    def draw(self) -> str:
        return super().draw()  # Error, no default implementation

class ImplicitColor:   # Note no 'PColor' base here
    def draw(self) -> str:
        return "probably gray"
    def complex_method(self) -> int:
        # class needs to implement this
        ...

nice: NiceColor
another: ImplicitColor

def represent(c: PColor) -> None:
    print(c.draw(), c.complex_method())

represent(nice) # OK
represent(another) # Also OK

Beachten Sie, dass es wenig Unterschied zwischen expliziten und impliziten Subtypen gibt, der Hauptvorteil der expliziten Ableitung besteht darin, einige Protokollmethoden „kostenlos“ zu erhalten. Außerdem können Typ-Checker statisch überprüfen, ob die Klasse das Protokoll korrekt implementiert.

class RGB(Protocol):
    rgb: Tuple[int, int, int]

    @abstractmethod
    def intensity(self) -> int:
        return 0

class Point(RGB):
    def __init__(self, red: int, green: int, blue: str) -> None:
        self.rgb = red, green, blue  # Error, 'blue' must be 'int'

    # Type checker might warn that 'intensity' is not defined

Eine Klasse kann explizit von mehreren Protokollen und auch von normalen Klassen erben. In diesem Fall werden Methoden über die normale MRO aufgelöst, und ein Typ-Checker überprüft, ob alle Subtyp-Beziehungen korrekt sind. Die Semantik von @abstractmethod bleibt unverändert, alle müssen von einer expliziten abgeleiteten Klasse implementiert werden, bevor sie instanziiert werden kann.

Zusammenführung und Erweiterung von Protokollen

Die allgemeine Philosophie ist, dass Protokolle weitgehend wie reguläre ABCs sind, aber ein statischer Typ-Checker behandelt sie speziell. Das Ableiten von einer Protokollklasse macht die abgeleitete Klasse nicht zu einem Protokoll, es sei denn, sie hat auch typing.Protocol als explizite Basisklasse. Ohne diese Basisklasse wird die Klasse zu einer regulären ABC „heruntergestuft“, die nicht mit strukturellem Subtyping verwendet werden kann. Der Grund für diese Regel ist, dass wir nicht wollen, dass eine Klasse versehentlich als Protokoll fungiert, nur weil eine ihrer Basisklassen zufällig eine ist. Wir bevorzugen im statischen Tippen immer noch nominales Subtyping gegenüber strukturellem Subtyping.

Ein Subprotokoll kann definiert werden, indem *sowohl* ein oder mehrere Protokolle als direkte Basisklassen *als auch* typing.Protocol als direkte Basisklasse vorhanden sind.

from typing import Sized, Protocol

class SizedAndClosable(Sized, Protocol):
    def close(self) -> None:
        ...

Nun ist das Protokoll SizedAndClosable ein Protokoll mit zwei Methoden: __len__ und close. Wenn Protocol in der Liste der Basisklassen weggelassen wird, wäre dies eine reguläre (Nicht-Protokoll-)Klasse, die Sized implementieren muss. Alternativ kann man das Protokoll SizedAndClosable implementieren, indem man das Protokoll SupportsClose aus dem Beispiel im Abschnitt Definition mit typing.Sized zusammenführt.

from typing import Sized

class SupportsClose(Protocol):
    def close(self) -> None:
        ...

class SizedAndClosable(Sized, SupportsClose, Protocol):
    pass

Die beiden Definitionen von SizedAndClosable sind äquivalent. Unterklassbeziehungen zwischen Protokollen sind nicht aussagekräftig, wenn man das Subtyping betrachtet, da die strukturelle Kompatibilität das Kriterium ist, nicht die MRO.

Wenn Protocol in der Liste der Basisklassen enthalten ist, müssen alle anderen Basisklassen Protokolle sein. Ein Protokoll kann keine reguläre Klasse erweitern, siehe abgelehnte Ideen für die Begründung. Beachten Sie, dass die Regeln für explizites Ableiten anders sind als bei regulären ABCs, wo die Abstraktheit einfach durch das Vorhandensein von mindestens einer abstrakten Methode, die nicht implementiert ist, definiert wird. Protokollklassen müssen *explizit* markiert werden.

Generische Protokolle

Generische Protokolle sind wichtig. Zum Beispiel sind SupportsAbs, Iterable und Iterator generische Protokolle. Sie werden ähnlich wie normale, nicht-protokollbasierte generische Typen definiert.

class Iterable(Protocol[T]):
    @abstractmethod
    def __iter__(self) -> Iterator[T]:
        ...

Protocol[T, S, ...] ist als Kurzschreibweise für Protocol, Generic[T, S, ...] erlaubt.

Benutzerdefinierte generische Protokolle unterstützen explizit deklarierte Varianz. Typ-Checker warnen, wenn die abgeleitete Varianz von der deklarierten Varianz abweicht. Beispiele

T = TypeVar('T')
T_co = TypeVar('T_co', covariant=True)
T_contra = TypeVar('T_contra', contravariant=True)

class Box(Protocol[T_co]):
    def content(self) -> T_co:
        ...

box: Box[float]
second_box: Box[int]
box = second_box  # This is OK due to the covariance of 'Box'.

class Sender(Protocol[T_contra]):
    def send(self, data: T_contra) -> int:
        ...

sender: Sender[float]
new_sender: Sender[int]
new_sender = sender  # OK, 'Sender' is contravariant.

class Proto(Protocol[T]):
    attr: T  # this class is invariant, since it has a mutable attribute

var: Proto[float]
another_var: Proto[int]
var = another_var  # Error! 'Proto[float]' is incompatible with 'Proto[int]'.

Beachten Sie, dass de facto kovariante Protokolle im Gegensatz zu nominalen Klassen nicht als invariant deklariert werden können, da dies die Transitivität des Subtypings brechen kann (siehe abgelehnte Ideen für Details). Zum Beispiel

T = TypeVar('T')

class AnotherBox(Protocol[T]):  # Error, this protocol is covariant in T,
    def content(self) -> T:     # not invariant.
        ...

Rekursive Protokolle

Rekursive Protokolle werden ebenfalls unterstützt. Vorwärtsreferenzen auf Protokollklassennamen können als Zeichenketten angegeben werden, wie in PEP 484 spezifiziert. Rekursive Protokolle sind nützlich, um selbstverweisende Datenstrukturen wie Bäume abstrakt darzustellen.

class Traversable(Protocol):
    def leaves(self) -> Iterable['Traversable']:
        ...

Beachten Sie, dass bei rekursiven Protokollen eine Klasse als Subtyp des Protokolls gilt, wenn die Entscheidung von ihr selbst abhängt. Weiterführung des vorherigen Beispiels

class SimpleTree:
    def leaves(self) -> List['SimpleTree']:
        ...

root: Traversable = SimpleTree()  # OK

class Tree(Generic[T]):
    def leaves(self) -> List['Tree[T]']:
        ...

def walk(graph: Traversable) -> None:
    ...
tree: Tree[float] = Tree()
walk(tree)  # OK, 'Tree[float]' is a subtype of 'Traversable'

Self-Types in Protokollen

Die Self-Types in Protokollen folgen der entsprechenden Spezifikation von PEP 484. Zum Beispiel

C = TypeVar('C', bound='Copyable')
class Copyable(Protocol):
    def copy(self: C) -> C:

class One:
    def copy(self) -> 'One':
        ...

T = TypeVar('T', bound='Other')
class Other:
    def copy(self: T) -> T:
        ...

c: Copyable
c = One()  # OK
c = Other()  # Also OK

Callback-Protokolle

Protokolle können verwendet werden, um flexible Callback-Typen zu definieren, die schwer (oder sogar unmöglich) mit der Callable[...]-Syntax auszudrücken sind, die in PEP 484 spezifiziert ist, wie z. B. variadische, überladene und komplexe generische Callbacks. Sie können als Protokolle mit einem __call__-Member definiert werden.

from typing import Optional, List, Protocol

class Combiner(Protocol):
    def __call__(self, *vals: bytes,
                 maxlen: Optional[int] = None) -> List[bytes]: ...

def good_cb(*vals: bytes, maxlen: Optional[int] = None) -> List[bytes]:
    ...
def bad_cb(*vals: bytes, maxitems: Optional[int]) -> List[bytes]:
    ...

comb: Combiner = good_cb  # OK
comb = bad_cb  # Error! Argument 2 has incompatible type because of
               # different name and kind in the callback

Callback-Protokolle und Callable[...]-Typen können austauschbar verwendet werden.

Verwendung von Protokollen

Subtyping-Beziehungen zu anderen Typen

Protokolle können nicht instanziiert werden, daher gibt es keine Werte, deren Laufzeittyp ein Protokoll ist. Für Variablen und Parameter mit Protokolltypen unterliegen die Subtyp-Beziehungen den folgenden Regeln:

  • Ein Protokoll ist niemals ein Subtyp eines konkreten Typs.
  • Ein konkreter Typ X ist ein Subtyp des Protokolls P genau dann, wenn X alle Protokollmember von P mit kompatiblen Typen implementiert. Mit anderen Worten, Subtyping in Bezug auf ein Protokoll ist immer strukturell.
  • Ein Protokoll P1 ist ein Subtyp eines anderen Protokolls P2, wenn P1 alle Protokollmember von P2 mit kompatiblen Typen definiert.

Generische Protokolltypen folgen den gleichen Varianzregeln wie nicht-protokollbasierte Typen. Protokolltypen können in allen Kontexten verwendet werden, in denen andere Typen verwendet werden können, wie z. B. in Union, ClassVar, Typvariablen-Bounds usw. Generische Protokolle folgen den Regeln für generische abstrakte Klassen, mit Ausnahme der Verwendung struktureller Kompatibilität anstelle von durch Vererbungsbeziehungen definierten Kompatibilität.

Statische Typ-Checker erkennen Protokollimplementierungen, auch wenn die entsprechenden Protokolle *nicht importiert* sind.

# file lib.py
from typing import Sized

T = TypeVar('T', contravariant=True)
class ListLike(Sized, Protocol[T]):
    def append(self, x: T) -> None:
        pass

def populate(lst: ListLike[int]) -> None:
    ...

# file main.py
from lib import populate  # Note that ListLike is NOT imported

class MockStack:
    def __len__(self) -> int:
        return 42
    def append(self, x: int) -> None:
        print(x)

populate([1, 2, 3])    # Passes type check
populate(MockStack())  # Also OK

Vereinigungen und Schnitte von Protokollen

Union von Protokollklassen verhält sich genauso wie bei nicht-protokollbasierten Klassen. Zum Beispiel

from typing import Union, Optional, Protocol

class Exitable(Protocol):
    def exit(self) -> int:
        ...
class Quittable(Protocol):
    def quit(self) -> Optional[int]:
        ...

def finish(task: Union[Exitable, Quittable]) -> int:
    ...
class DefaultJob:
    ...
    def quit(self) -> int:
        return 0
finish(DefaultJob()) # OK

Man kann Mehrfachvererbung verwenden, um einen Schnitt von Protokollen zu definieren. Beispiel

from typing import Iterable, Hashable

class HashableFloats(Iterable[float], Hashable, Protocol):
    pass

def cached_func(args: HashableFloats) -> float:
    ...
cached_func((1, 2, 3)) # OK, tuple is both hashable and iterable

Wenn sich dies als häufiges Szenario erweist, könnte zukünftig ein spezieller Schnittstellentypkonstrukt hinzugefügt werden, wie in PEP 483 spezifiziert, siehe abgelehnte Ideen für weitere Details.

Type[] und Klassenobjekte vs. Protokolle

Variablen und Parameter, die mit Type[Proto] annotiert sind, akzeptieren nur konkrete (nicht-protokollbasierte) Subtypen von Proto. Der Hauptgrund dafür ist die Möglichkeit, Parameter mit diesem Typ zu instanziieren. Zum Beispiel

class Proto(Protocol):
    @abstractmethod
    def meth(self) -> int:
        ...
class Concrete:
    def meth(self) -> int:
        return 42

def fun(cls: Type[Proto]) -> int:
    return cls().meth() # OK
fun(Proto)              # Error
fun(Concrete)           # OK

Die gleiche Regel gilt für Variablen.

var: Type[Proto]
var = Proto    # Error
var = Concrete # OK
var().meth()   # OK

Das Zuweisen einer ABC oder einer Protokollklasse zu einer Variablen ist erlaubt, wenn sie nicht explizit typisiert ist, und eine solche Zuweisung erstellt einen Typ-Alias. Für normale (nicht-abstrakte) Klassen ändert sich das Verhalten von Type[] nicht.

Ein Klassenobjekt wird als Implementierung eines Protokolls betrachtet, wenn der Zugriff auf alle Member darauf zu Typen führt, die mit den Protokollmembern kompatibel sind. Zum Beispiel

from typing import Any, Protocol

class ProtoA(Protocol):
    def meth(self, x: int) -> int: ...
class ProtoB(Protocol):
    def meth(self, obj: Any, x: int) -> int: ...

class C:
    def meth(self, x: int) -> int: ...

a: ProtoA = C  # Type check error, signatures don't match!
b: ProtoB = C  # OK

NewType() und Typ-Aliase

Protokolle sind im Wesentlichen anonym. Um diesen Punkt zu betonen, lehnen statische Typ-Checker Protokollklassen möglicherweise innerhalb von NewType() ab, um die Illusion zu vermeiden, dass ein eigenständiger Typ bereitgestellt wird.

from typing import NewType, Protocol, Iterator

class Id(Protocol):
    code: int
    secrets: Iterator[bytes]

UserId = NewType('UserId', Id)  # Error, can't provide distinct type

Im Gegensatz dazu werden Typ-Aliase vollständig unterstützt, einschließlich generischer Typ-Aliase.

from typing import TypeVar, Reversible, Iterable, Sized

T = TypeVar('T')
class SizedIterable(Iterable[T], Sized, Protocol):
    pass
CompatReversible = Union[Reversible[T], SizedIterable[T]]

Module als Implementierungen von Protokollen

Ein Modulobjekt wird dort akzeptiert, wo ein Protokoll erwartet wird, wenn die öffentliche Schnittstelle des gegebenen Moduls mit dem erwarteten Protokoll kompatibel ist. Zum Beispiel

# file default_config.py
timeout = 100
one_flag = True
other_flag = False

# file main.py
import default_config
from typing import Protocol

class Options(Protocol):
    timeout: int
    one_flag: bool
    other_flag: bool

def setup(options: Options) -> None:
    ...

setup(default_config)  # OK

Um die Kompatibilität von Funktionen auf Modulebene zu bestimmen, wird das self-Argument der entsprechenden Protokollmethoden fallen gelassen. Zum Beispiel

# callbacks.py
def on_error(x: int) -> None:
    ...
def on_success() -> None:
    ...

# main.py
import callbacks
from typing import Protocol

class Reporter(Protocol):
    def on_error(self, x: int) -> None:
        ...
    def on_success(self) -> None:
        ...

rp: Reporter = callbacks  # Passes type check

Decorator @runtime_checkable und Typen-Verengung durch isinstance()

Die Standardsemantik ist, dass isinstance() und issubclass() für Protokolltypen fehlschlagen. Dies liegt im Geiste des Duck-Typings – Protokolle würden im Grunde verwendet, um Duck-Typing statisch zu modellieren, nicht explizit zur Laufzeit.

Es sollte jedoch möglich sein, dass Protokolltypen benutzerdefinierte Instanz- und Klassenprüfungen implementieren, wenn dies sinnvoll ist, ähnlich wie Iterable und andere ABCs in collections.abc und typing dies bereits tun, aber dies ist auf nicht-generische und nicht-subskribierte generische Protokolle beschränkt (Iterable ist statisch äquivalent zu Iterable[Any]). Das typing-Modul wird einen speziellen Klassen-Decorator @runtime_checkable definieren, der die gleiche Semantik für Klassen- und Instanzprüfungen wie für Klassen von collections.abc bereitstellt und sie im Wesentlichen zu „Laufzeitprotokollen“ macht.

from typing import runtime_checkable, Protocol

@runtime_checkable
class SupportsClose(Protocol):
    def close(self):
        ...

assert isinstance(open('some/file'), SupportsClose)

Beachten Sie, dass Instanzprüfungen statisch nicht zu 100 % zuverlässig sind, weshalb dieses Verhalten optional ist. Siehe Abschnitt abgelehnte Ideen für Beispiele. Das Beste, was Typ-Checker tun können, ist, isinstance(x, Iterator) ungefähr als einfachere Schreibweise für hasattr(x, '__iter__') and hasattr(x, '__next__') zu behandeln. Um die Risiken für diese Funktion zu minimieren, werden die folgenden Regeln angewendet.

Definitionen:

  • Daten- und Nicht-Daten-Protokolle: Ein Protokoll wird als Nicht-Daten-Protokoll bezeichnet, wenn es nur Methoden als Member enthält (z. B. Sized, Iterator usw.). Ein Protokoll, das mindestens einen Nicht-Methoden-Member enthält (wie x: int), wird als Datenprotokoll bezeichnet.
  • Unsichere Überlappung: Ein Typ X wird als unsicher mit einem Protokoll P überlappend bezeichnet, wenn X kein Subtyp von P ist, aber ein Subtyp der typ-erased Version von P ist, bei der alle Member den Typ Any haben. Wenn außerdem mindestens ein Element einer Union unsicher mit einem Protokoll P überlappt, dann überlappt die gesamte Union unsicher mit P.

Spezifikation:

  • Ein Protokoll kann nur dann als zweites Argument in isinstance() und issubclass() verwendet werden, wenn es explizit mit dem Decorator @runtime_checkable opt-in ist. Diese Anforderung besteht, da Protokollprüfungen bei dynamisch gesetzten Attributen nicht typsicher sind und da Typ-Checker nur beweisen können, dass eine isinstance()-Prüfung für eine gegebene Klasse sicher ist, nicht aber für alle ihre Subklassen.
  • isinstance() kann sowohl mit Daten- als auch mit Nicht-Daten-Protokollen verwendet werden, während issubclass() nur mit Nicht-Daten-Protokollen verwendet werden kann. Diese Einschränkung besteht, da einige Datenattribute auf einer Instanz im Konstruktor gesetzt werden können und diese Informationen nicht immer im Klassenobjekt verfügbar sind.
  • Typ-Checker sollten einen isinstance()- oder issubclass()-Aufruf ablehnen, wenn es eine unsichere Überlappung zwischen dem Typ des ersten Arguments und dem Protokoll gibt.
  • Typ-Checker sollten in der Lage sein, ein korrektes Element aus einer Union nach einem sicheren isinstance()- oder issubclass()-Aufruf auszuwählen. Für die Verengung von Nicht-Union-Typen können Typ-Checker ihr Bestes tun (dies ist bewusst nicht spezifiziert, da eine präzise Spezifikation Schnittstellentypen erfordern würde).

Verwendung von Protokollen in Python 2.7 - 3.5

Die Variable-Annotationssyntax wurde in Python 3.6 hinzugefügt, sodass die in der Spezifikations-Sektion Spezifikation vorgeschlagene Syntax für die Definition von Protokollvariablen nicht verwendet werden kann, wenn Unterstützung für frühere Versionen benötigt wird. Um diese auf eine Weise zu definieren, die mit älteren Python-Versionen kompatibel ist, können Eigenschaften verwendet werden. Eigenschaften können, falls erforderlich, setzbar und/oder abstrakt sein.

class Foo(Protocol):
    @property
    def c(self) -> int:
        return 42         # Default value can be provided for property...

    @abstractproperty
    def d(self) -> int:   # ... or it can be abstract
        return 0

Außerdem können Funktionstyp-Kommentare gemäß PEP 484 verwendet werden (z. B. um Kompatibilität mit Python 2 zu gewährleisten). Die in diesem PEP vorgeschlagenen Änderungen am typing-Modul werden auch über die auf PyPI verfügbare Backport-Version auf frühere Versionen portiert.

Laufzeitimplementierung von Protokollklassen

Implementierungsdetails

Die Laufzeitimplementierung könnte in reinem Python erfolgen, ohne Auswirkungen auf den Kerninterpreter und die Standardbibliothek, abgesehen vom typing-Modul und einer geringfügigen Aktualisierung von collections.abc.

  • Definieren Sie die Klasse typing.Protocol ähnlich wie typing.Generic.
  • Implementieren Sie Funktionalität, um zu erkennen, ob eine Klasse ein Protokoll ist oder nicht. Fügen Sie ein Klassenattribut _is_protocol = True hinzu, wenn dies der Fall ist. Verifizieren Sie, dass eine Protokollklasse nur Protokoll-Basisklassen in der MRO hat (außer object).
  • Implementieren Sie @runtime_checkable, das __subclasshook__() ermöglicht, strukturelle Instanz- und Unterklassenprüfungen wie bei Klassen von collections.abc durchzuführen.
  • Alle strukturellen Subtyping-Prüfungen werden von statischen Typ-Checkern wie mypy [mypy] durchgeführt. Es wird keine zusätzliche Unterstützung für Protokollvalidierung zur Laufzeit bereitgestellt.

Änderungen im `typing`-Modul

Die folgenden Klassen im typing-Modul werden Protokolle sein:

  • Callable
  • Awaitable
  • Iterable, Iterator
  • AsyncIterable, AsyncIterator
  • Hashable
  • Sized
  • Container
  • Collection
  • Reversible
  • ContextManager, AsyncContextManager
  • SupportsAbs (und andere Supports* Klassen)

Die meisten dieser Klassen sind klein und konzeptionell einfach. Es ist leicht zu erkennen, welche Methoden diese Protokolle implementieren, und die entsprechenden Laufzeit-Protokollgegenstücke sofort zu erkennen. Praktisch werden wenige Änderungen in typing benötigt, da einige dieser Klassen sich zur Laufzeit bereits auf die erforderliche Weise verhalten. Die meisten davon müssen nur in den entsprechenden typeshed Stubs aktualisiert werden [typeshed].

Alle anderen konkreten generischen Klassen wie List, Set, IO, Deque usw. sind komplex genug, dass es sinnvoll ist, sie nicht als Protokolle zu behandeln (d. h. es wird erwartet, dass der Code explizit damit umgeht). Außerdem ist es zu einfach, versehentlich einige Methoden nicht zu implementieren, und die explizite Kennzeichnung der Unterklassenbeziehung ermöglicht es Typenprüfern, fehlende Implementierungen zu erkennen.

Introspektion

Die vorhandene Klassen-Introspektionsmaschinerie (dir, __annotations__ usw.) kann mit Protokollen verwendet werden. Darüber hinaus unterstützen alle im Modul typing implementierten Introspektionswerkzeuge Protokolle. Da gemäß diesem Vorschlag alle Attribute im Klassenrumpf definiert werden müssen, werden Protokollklassen eine noch bessere Perspektive für die Introspektion haben als reguläre Klassen, bei denen Attribute implizit definiert werden können – Protokollattribute können nicht auf Arten initialisiert werden, die für die Introspektion nicht sichtbar sind (mittels setattr(), Zuweisung über self usw.). Dennoch werden einige Dinge wie Attributtypen in Python 3.5 und früher zur Laufzeit nicht sichtbar sein, aber dies erscheint als eine vernünftige Einschränkung.

Es wird nur eine begrenzte Unterstützung für isinstance() und issubclass() geben, wie oben diskutiert (diese schlagen immer mit TypeError fehl für subscripted generische Protokolle, da in diesem Fall keine zuverlässige Antwort zur Laufzeit gegeben werden könnte). Aber zusammen mit anderen Introspektionswerkzeugen bietet dies eine vernünftige Perspektive für Laufzeit-Typprüfwerkzeuge.

Abgelehnte/zurückgestellte Ideen

Die Ideen in diesem Abschnitt wurden zuvor in [several] [discussions] [elsewhere] diskutiert.

Jede Klasse standardmäßig zu einem Protokoll machen

Einige Sprachen wie Go machen strukturelle Untertypen zur einzigen oder primären Form der Untertypen. Wir könnten ein ähnliches Ergebnis erzielen, indem wir alle Klassen standardmäßig (oder sogar immer) zu Protokollen machen. Wir glauben jedoch, dass es besser ist, Klassen explizit als Protokolle zu kennzeichnen, aus folgenden Gründen:

  • Protokolle haben nicht alle Eigenschaften regulärer Klassen. Insbesondere basiert isinstance(), wie für normale Klassen definiert, auf der nominalen Hierarchie. Um alles standardmäßig zu einem Protokoll zu machen und isinstance() funktionieren zu lassen, müsste seine Semantik geändert werden, was nicht geschehen wird.
  • Protokollklassen sollten im Allgemeinen nicht viele Methodenimplementierungen haben, da sie eine Schnittstelle beschreiben, keine Implementierung. Die meisten Klassen haben viele Methodenimplementierungen, was sie zu schlechten Protokollklassen macht.
  • Erfahrungen deuten darauf hin, dass viele Klassen ohnehin nicht praktisch als Protokolle geeignet sind, hauptsächlich weil ihre Schnittstellen zu groß, komplex oder implementierungsorientiert sind (z. B. können sie de facto private Attribute und Methoden ohne __ Präfix enthalten).
  • Die meisten tatsächlich nützlichen Protokolle im vorhandenen Python-Code scheinen implizit zu sein. Die ABCs in typing und collections.abc sind eher eine Ausnahme, aber selbst sie sind neuere Ergänzungen zu Python und die meisten Programmierer nutzen sie noch nicht.
  • Viele eingebaute Funktionen akzeptieren nur konkrete Instanzen von int (und Instanzen von Unterklassen), und ähnlich für andere eingebaute Klassen. int zu einem strukturellen Typ zu machen, wäre ohne größere Änderungen am Python-Laufzeitsystem nicht sicher, was nicht geschehen wird.

Protokolle, die normale Klassen erben

Das Hauptargument, dies zu verbieten, ist die Wahrung der Transitivität von Untertypen. Betrachten Sie dieses Beispiel:

from typing import Protocol

class Base:
    attr: str

class Proto(Base, Protocol):
    def meth(self) -> int:
        ...

class C:
    attr: str
    def meth(self) -> int:
        return 0

Nun ist C ein Subtyp von Proto, und Proto ist ein Subtyp von Base. Aber C kann kein Subtyp von Base sein (da letzteres kein Protokoll ist). Diese Situation wäre wirklich seltsam. Darüber hinaus besteht eine Mehrdeutigkeit darüber, ob Attribute von Base zu Protokollmitgliedern von Proto werden sollten.

Unterstützung optionaler Protokollmember

Wir können Beispiele dafür finden, wo es praktisch wäre, sagen zu können, dass eine Methode oder ein Datenattribut nicht in einer Klasse vorhanden sein muss, die ein Protokoll implementiert, aber wenn es vorhanden ist, muss es einer bestimmten Signatur oder einem bestimmten Typ entsprechen. Man könnte eine hasattr() Prüfung verwenden, um festzustellen, ob das Attribut auf einer bestimmten Instanz verwendet werden kann.

Sprachen wie TypeScript haben ähnliche Funktionen, und anscheinend werden sie ziemlich häufig verwendet. Die derzeit realistischen potenziellen Anwendungsfälle für Protokolle in Python erfordern diese nicht. Im Interesse der Einfachheit schlagen wir vor, optionale Methoden oder Attribute nicht zu unterstützen. Wir können dies jederzeit später wieder aufgreifen, wenn ein tatsächlicher Bedarf besteht.

Nur Protokollmethoden zulassen und die Verwendung von Gettern und Settern erzwingen

Man könnte argumentieren, dass Protokolle typischerweise nur Methoden, aber keine Variablen definieren. Die Verwendung von Gettern und Settern in Fällen, in denen nur eine einfache Variable benötigt wird, wäre jedoch ziemlich unpythonisch. Darüber hinaus ist die weit verbreitete Verwendung von Eigenschaften (die oft als Typvalidatoren fungieren) in großen Codebasen teilweise auf die frühere Abwesenheit von statischen Typenprüfern für Python zurückzuführen, dem Problem, das PEP 484 und diese PEP zu lösen versuchen. Zum Beispiel:

# without static types

class MyClass:
    @property
    def my_attr(self):
        return self._my_attr
    @my_attr.setter
    def my_attr(self, value):
        if not isinstance(value, int):
            raise ValidationError("An integer expected for my_attr")
        self._my_attr = value

# with static types

class MyClass:
    my_attr: int

Unterstützung für Nicht-Protokollmember

Es gab die Idee, einige Methoden "nicht-protokoll" zu machen (d. h. nicht unbedingt zu implementieren und in expliziten Unterklassen zu erben), aber dies wurde abgelehnt, da es die Dinge verkompliziert. Betrachten Sie zum Beispiel diese Situation:

class Proto(Protocol):
    @abstractmethod
    def first(self) -> int:
        raise NotImplementedError
    def second(self) -> int:
        return self.first() + 1

def fun(arg: Proto) -> None:
    arg.second()

Die Frage ist, ob dies ein Fehler sein sollte? Wir denken, die meisten Leute würden erwarten, dass dies gültig ist. Daher müssen wir auf Nummer sicher gehen und in impliziten Unterklassen beide Methoden als implementiert voraussetzen. Außerdem gibt es, wenn man sich die Definitionen in collections.abc ansieht, nur sehr wenige Methoden, die als "nicht-protokoll" betrachtet werden könnten. Daher wurde beschlossen, keine "nicht-protokoll" Methoden einzuführen.

Es gibt nur einen Nachteil: Es erfordert etwas Boilerplate für implizite Subtypen von "großen" Protokollen. Dies gilt jedoch nicht für "eingebaute" Protokolle, die alle "klein" sind (d. h. nur wenige abstrakte Methoden haben). Außerdem wird ein solcher Stil für benutzerdefinierte Protokolle abgeraten. Es wird empfohlen, kompakte Protokolle zu erstellen und sie zu kombinieren.

Protokolle interoperabel mit anderen Ansätzen machen

Die hier beschriebenen Protokolle sind im Grunde eine minimale Erweiterung des bestehenden Konzepts von ABCs. Wir argumentieren, dass sie so verstanden werden sollten, anstatt beispielsweise Zope-Schnittstellen zu ersetzen. Der Versuch solcher Interoperabilitäten würde sowohl das Konzept als auch die Implementierung erheblich verkomplizieren.

Andererseits sind Zope-Schnittstellen konzeptionell eine Obermenge der hier definierten Protokolle, verwenden jedoch eine inkompatible Syntax, um sie zu definieren, da es vor PEP 526 keine einfache Möglichkeit gab, Attribute zu annotieren. In der Welt von 3.6+ könnte zope.interface potenziell die Protocol Syntax übernehmen. In diesem Fall könnten Typenprüfer so unterrichtet werden, dass sie Schnittstellen als Protokolle erkennen und einfache strukturelle Prüfungen in Bezug auf sie durchführen.

Zuweisungen verwenden, um explizit zu überprüfen, ob eine Klasse ein Protokoll implementiert

In der Go-Sprache werden explizite Prüfungen auf Implementierung durch Dummy-Zuweisungen durchgeführt [golang]. Eine solche Methode ist auch mit dem aktuellen Vorschlag möglich. Beispiel:

class A:
    def __len__(self) -> float:
        return ...

_: Sized = A()  # Error: A.__len__ doesn't conform to 'Sized'
                # (Incompatible return type 'float')

Dieser Ansatz verlagert die Prüfung von der Klassendefinition weg und erfordert fast einen Kommentar, da der Code sonst für den durchschnittlichen Leser wahrscheinlich keinen Sinn ergeben würde – er sieht wie toter Code aus. Außerdem erfordert er in seiner einfachsten Form, dass man eine Instanz von A erstellt, was problematisch sein könnte, wenn dies den Zugriff oder die Allokation von Ressourcen wie Dateien oder Sockets erfordert. Wir könnten letzteres durch die Verwendung einer Typumwandlung umgehen, aber dann wäre der Code hässlich. Daher raten wir von der Verwendung dieses Musters ab.

isinstance()-Überprüfungen standardmäßig unterstützen

Das Problem dabei ist, dass Instanzprüfungen unzuverlässig sein können, außer in Situationen, in denen es eine gemeinsame Signaturkonvention gibt, wie z. B. Iterable. Zum Beispiel:

class P(Protocol):
    def common_method_name(self, x: int) -> int: ...

class X:
    <a bunch of methods>
    def common_method_name(self) -> None: ... # Note different signature

def do_stuff(o: Union[P, X]) -> int:
    if isinstance(o, P):
        return o.common_method_name(1)  # Results in TypeError not caught
                                        # statically if o is an X instance.

Ein weiterer potenziell problematischer Fall ist die Zuweisung von Attributen *nach* der Instanziierung:

class P(Protocol):
    x: int

class C:
    def initialize(self) -> None:
        self.x = 0

c = C()
isinstance(c, P)  # False
c.initialize()
isinstance(c, P)  # True

def f(x: Union[P, int]) -> None:
    if isinstance(x, P):
        # Static type of x is P here.
        ...
    else:
        # Static type of x is int, but can be other type at runtime...
        print(x + 1)

f(C())  # ...causing a TypeError.

Wir argumentieren, dass ein expliziter Klassen-Decorator besser wäre, da man dann Warnungen über solche Probleme in der Dokumentation anbringen kann. Der Benutzer könnte dann beurteilen, ob die Vorteile den potenziellen Verwirrung für jedes Protokoll überwiegen, und sich explizit dafür entscheiden – aber das Standardverhalten wäre sicherer. Schließlich wird es einfach sein, dieses Verhalten standardmäßig zu machen, wenn nötig, während es problematisch sein könnte, es opt-in zu machen, nachdem es Standard war.

Einen speziellen Schnittstellentypkonstrukt bereitstellen

Es gab die Idee, Proto = All[Proto1, Proto2, ...] als Kurzform für

class Proto(Proto1, Proto2, ..., Protocol):
    pass

Es ist jedoch noch unklar, wie beliebt/nützlich dies sein wird und die Implementierung in Typenprüfern für Nicht-Protokollklassen könnte schwierig sein. Schließlich wird es sehr einfach sein, dies später hinzuzufügen, wenn nötig.

Explizites Erben von Protokollen durch Nicht-Protokolle verbieten

Dies wurde aus folgenden Gründen abgelehnt:

  • Abwärtskompatibilität: Leute verwenden bereits ABCs, einschließlich generischer ABCs aus dem typing Modul. Wenn wir explizites Subclassing dieser ABCs verbieten, werden ziemlich viele Codes brechen.
  • Komfort: Es gibt bestehende protokollähnliche ABCs (die in Protokolle umgewandelt werden können), die viele nützliche "Mix-in"- (nicht-abstrakte) Methoden haben. Im Fall von Sequence muss man zum Beispiel in einer expliziten Unterklasse nur __getitem__ und __len__ implementieren, und man erhält __iter__, __contains__, __reversed__, index und count kostenlos.
  • Explizites Subclassing macht es deutlich, dass eine Klasse ein bestimmtes Protokoll implementiert, wodurch Untertypenbeziehungen leichter erkennbar werden.
  • Typenprüfer können leichter vor fehlenden Protokollmitgliedern oder Mitgliedern mit inkompatiblen Typen warnen, ohne Hacks wie die oben in diesem Abschnitt erwähnten Dummy-Zuweisungen verwenden zu müssen.
  • Explizites Subclassing ermöglicht es, eine Klasse als Subtyp eines Protokolls zu erzwingen (durch Verwendung von # type: ignore zusammen mit einer expliziten Basisklasse), wenn sie nicht streng kompatibel ist, z. B. wenn sie eine unsichere Überschreibung aufweist.

Kovariantes Subtyping von veränderbaren Attributen

Abgelehnt, da kovarianter Subtyp von veränderbaren Attributen nicht sicher ist. Betrachten Sie dieses Beispiel:

class P(Protocol):
    x: float

def f(arg: P) -> None:
    arg.x = 0.42

class C:
    x: int

c = C()
f(c)  # Would typecheck if covariant subtyping
      # of mutable attributes were allowed.
c.x >> 1  # But this fails at runtime

Dies wurde ursprünglich aus praktischen Gründen erlaubt, wurde aber anschließend abgelehnt, da dies einige schwer zu entdeckende Fehler maskieren kann.

Überschreiben der abgeleiteten Varianz von Protokollklassen

Es wurde vorgeschlagen, Protokolle als invariant zu deklarieren, wenn sie tatsächlich kovariant oder kontravariant sind (wie es für nominale Klassen möglich ist, siehe PEP 484). Es wurde jedoch entschieden, dies nicht zu tun, da dies mehrere Nachteile hat:

  • Deklarierte Protokollinvarianz bricht die Transitivität von Subtyping. Betrachten Sie diese Situation:
    T = TypeVar('T')
    
    class P(Protocol[T]):  # Protocol is declared as invariant.
        def meth(self) -> T:
            ...
    class C:
        def meth(self) -> float:
            ...
    class D(C):
        def meth(self) -> int:
            ...
    

    Nun haben wir, dass D ein Subtyp von C ist und C ein Subtyp von P[float] ist. Aber D ist *kein* Subtyp von P[float], da D P[int] implementiert und P invariant ist. Es besteht die Möglichkeit, dies zu "heilen", indem nach Protokollimplementierungen in MROs gesucht wird, aber dies wäre im Allgemeinen zu komplex, und diese "Heilung" erfordert die Aufgabe der einfachen Idee eines rein strukturellen Subtypings für Protokolle.

  • Subtyping-Prüfungen erfordern immer Typinferenz für Protokolle. Im obigen Beispiel könnte ein Benutzer sich beschweren: "Warum hast du P[int] für mein D inferiert? Es implementiert P[float]!". Normalerweise kann die Inferenz durch eine explizite Annotation überschrieben werden, aber hier erfordert dies explizites Subclassing, was den Zweck der Verwendung von Protokollen zunichte macht.
  • Die Erlaubnis, Kovarianz zu überschreiben, wird detailliertere Fehlermeldungen in Typenprüfern unmöglich machen, die auf bestimmte Konflikte in Member-Typsignaturen hinweisen.
  • Schließlich ist explizit in diesem Fall besser als implizit. Der Benutzer muss die korrekte Varianz deklarieren, was das Verständnis des Codes vereinfacht und unerwartete Fehler an der Verwendungsstelle vermeidet.

Unterstützung für Adapter und Anpassung

Adaptation wurde von PEP 246 (abgelehnt) vorgeschlagen und wird von zope.interface unterstützt, siehe die Zope-Dokumentation zu Adapter-Registries. Adapter sind ein ziemlich fortgeschrittenes Konzept, und PEP 484 unterstützt Unions und generische Aliase, die anstelle von Adaptern verwendet werden können. Dies kann anhand eines Beispiels für das Iterable Protokoll veranschaulicht werden: Es gibt eine weitere Möglichkeit, Iteration zu unterstützen, indem __getitem__ und __len__ bereitgestellt werden. Wenn eine Funktion beide Wege und die nun standardmäßige __iter__ Methode unterstützt, dann könnte sie durch einen Union-Typ annotiert werden:

class OldIterable(Sized, Protocol[T]):
    def __getitem__(self, item: int) -> T: ...

CompatIterable = Union[Iterable[T], OldIterable[T]]

class A:
    def __iter__(self) -> Iterator[str]: ...
class B:
    def __len__(self) -> int: ...
    def __getitem__(self, item: int) -> str: ...

def iterate(it: CompatIterable[str]) -> None:
    ...

iterate(A())  # OK
iterate(B())  # OK

Da es in solchen Fällen eine vernünftige Alternative mit vorhandenen Werkzeugen gibt, wird daher vorgeschlagen, keine Anpassung in diese PEP aufzunehmen.

Strukturelle Basistypen als „Interfaces“ bezeichnen

"Protocol" ist ein Begriff, der in Python bereits weit verbreitet ist, um Duck-Typing-Verträge zu beschreiben, wie das Iterator-Protokoll (das __iter__ und __next__ bereitstellt) und das Descriptor-Protokoll (das __get__, __set__ und __delete__ bereitstellt). Zusätzlich zu diesem und anderen in der Spezifikation genannten Gründen unterscheiden sich Protokolle in mehrfacher Hinsicht von Java-Interfaces: Protokolle erfordern keine explizite Deklaration der Implementierung (sie sind hauptsächlich auf Duck-Typing ausgerichtet), Protokolle können Standardimplementierungen von Mitgliedern haben und Zustände speichern.

Protokolle zu speziellen Objekten zur Laufzeit statt zu normalen ABCs machen

Die Umwandlung von Protokollen in Nicht-ABCs würde die Abwärtskompatibilität problematisch, wenn überhaupt möglich, machen. Zum Beispiel ist collections.abc.Iterable bereits ein ABC, und viele bestehende Codes verwenden Muster wie isinstance(obj, collections.abc.Iterable) und ähnliche Prüfungen mit anderen ABCs (auch strukturell, d. h. über __subclasshook__). Das Deaktivieren dieses Verhaltens würde zu Fehlern führen. Wenn wir dieses Verhalten für ABCs in collections.abc beibehalten, aber kein ähnliches Laufzeitverhalten für Protokolle in typing bereitstellen, ist ein reibungsloser Übergang zu Protokollen nicht möglich. Darüber hinaus kann die Existenz zweier paralleler Hierarchien zu Verwirrung führen.

Abwärtskompatibilität

Dieser PEP ist vollständig abwärtskompatibel.

Implementierung

Der mypy Typenprüfer unterstützt Protokolle vollständig (abgesehen von einigen bekannten Fehlern). Dazu gehört die strukturelle Behandlung aller integrierten Protokolle wie Iterable. Die Laufzeitimplementierung von Protokollen ist im Modul typing_extensions auf PyPI verfügbar.

Referenzen


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

Zuletzt geändert: 2025-02-01 07:28:42 GMT