Zum Inhalt springen
aviral gupta

// A2.3 · ca. 30 Min. · Vertiefung

Strukturelle Typisierung mit Protocol

Nach dieser Lektion beschreiben Sie mit einem Protocol, was ein Objekt können muss, prüfen es zur Laufzeit im Rahmen dieser Prüfung und wählen zwischen Protocol und ABC.

Lektion 3 von 6 in A2 Typisierung

Danach können Sie

  • Ein Protocol definieren und als Typ verwenden, den jede Klasse mit passenden Membern erfüllt
  • Protocols zur Laufzeit mit @runtime_checkable prüfen und wissen, was diese Prüfung nicht testet
  • Zwischen Protocol und ABC wählen: strukturell oder nominal, und was beide zur Laufzeit tun
  1. Aufwärmen · Aufgabe 1 von 7

    Aufwärmen aus Modul A1: Welche Methoden muss eine Klasse definieren, damit len(x) und for item in x mit ihren Objekten funktionieren?

  2. Vorhersagen · Aufgabe 2 von 7

    Sagen Sie es vorher, bevor Sie weiterlesen: File erbt nicht von Closer. Was gibt das aus?

    from typing import Protocol
    
    
    class Closer(Protocol):
        def close(self) -> str: ...
    
    
    class File:
        def close(self) -> str:
            return "file closed"
    
    
    def shut(item: Closer) -> str:
        return item.close()
    
    
    print(shut(File()), Closer in File.__mro__)
  3. Üben · Aufgabe 3 von 7

    Setzen Sie die Basisklasse ein, die Closer zu einem Protocol macht, sodass jede Klasse mit einer Methode close() dazu passt.

    class Closer(____):
    class Closer():
  4. Üben · Aufgabe 4 von 7

    mypy lehnt die letzte Zeile ab und ergänzt Hinweise, die Door mit Closer vergleichen. Was zeigt es unter Got:?

    from typing import Protocol
    
    
    class Closer(Protocol):
        def close(self) -> None: ...
    
    
    class Door:
        def close(self, force: bool) -> None:
            pass
    
    
    def close_one(item: Closer) -> None:
        item.close()
    
    
    close_one(Door())
  5. Üben · Aufgabe 5 von 7

    Ordnen Sie jedem Code zu, was er bewirkt.

  6. Denksport · Aufgabe 6 von 7

    Knobelaufgabe. Door.close nimmt ein zusätzliches Argument und liefert einen str. Was gibt das aus?

    from typing import Protocol, runtime_checkable
    
    
    @runtime_checkable
    class Closer(Protocol):
        def close(self) -> None: ...
    
    
    class Door:
        def close(self, force: bool) -> str:
            return "slam"
    
    
    print(isinstance(Door(), Closer))
  7. Anwenden · Aufgabe 7 von 7

    Mini-Aufgabe. Schreiben Sie ein Protocol Writer mit einer Methode write(self, text: str) -> int und eine Funktion log(target: Writer, message: str), die "[log] ", die Nachricht und einen Zeilenumbruch schreibt. Rufen Sie sie mit einem io.StringIO und mit sys.stdout auf: Keines von beiden erbt von Writer, und mypy --strict soll beide akzeptieren.

    Prüfen Sie Ihr Ergebnis anhand dieser Liste

Selbst programmieren

Lesen Sie das ausgearbeitete Beispiel und lösen Sie dann die Übungen. Ihr Code läuft in Ihrem Browser oder auf Ihrem Computer und wird nie hochgeladen.

Ausgearbeitetes Beispiel

Ein Protocol und eine ABC nebeneinander

Closer ist ein zur Laufzeit prüfbares Protocol; Resource ist eine ABC mit abstraktem close() und gemeinsamem describe(). Connection erbt von Resource, bekommt also describe() und muss close() umsetzen. TempFile erbt von nichts, trotzdem akzeptiert close_all beide, weil beide close() haben. Die letzten Zeilen zeigen, welche Laufzeitprüfung welche Beziehung sieht.

main.py

from abc import ABC, abstractmethod
from typing import Protocol, runtime_checkable


@runtime_checkable
class Closer(Protocol):
    def close(self) -> None: ...


class Resource(ABC):
    """A family of our own classes: shared code plus a required method."""

    def __init__(self, name: str) -> None:
        self.name = name

    @abstractmethod
    def close(self) -> None: ...

    def describe(self) -> str:
        return f"{type(self).__name__} {self.name}"


class Connection(Resource):
    def close(self) -> None:
        print("closing", self.describe())


class TempFile:  # no base class: it only has the right method
    def close(self) -> None:
        print("deleting a temporary file")


def close_all(items: list[Closer]) -> None:
    for item in items:
        item.close()


close_all([Connection("db"), TempFile()])
print(isinstance(TempFile(), Closer), isinstance(TempFile(), Resource))
print(isinstance(Connection("db"), Closer))

Ausführen mit

python main.py

Ausgabe

closing Connection db
deleting a temporary file
True False
True
  • close_all verlangt nur close(), nimmt also Klassen von überall, auch solche, die Sie nicht ändern können.
  • TempFile ist ein Closer, aber keine Resource: Die ABC zählt nur Unterklassen.
  • Connection ist beides: Es erbt von Resource und hat close(), also passt auch das Protocol.
  • Connection("db") funktioniert, weil es das abstrakte close() überschreibt; Resource("db") löste TypeError aus.
Ändern und ausführen

Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.

Beim ersten Ausführen lädt Ihr Browser Python herunter (bis zu 6.5 MB) und speichert es im Cache. Ihr Code bleibt auf Ihrem Gerät.

Übungen

Übung 1 von 2

Formen, die Sie nicht ändern können

Square und Circle stammen aus einer Bibliothek, die Sie nicht ändern können, und erben nicht von HasArea. Machen Sie HasArea von einer ABC zu einem zur Laufzeit prüfbaren Protocol, damit beide Klassen ohne Änderung zu total_area passen. Schreiben Sie dann only_shapes(items), das mit isinstance() und HasArea die Elemente behält, die eine Methode area() haben.

Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.

Beim ersten Ausführen lädt Ihr Browser Python herunter (bis zu 6.5 MB) und speichert es im Cache. Ihr Code bleibt auf Ihrem Gerät.

Hinweise
  1. Hinweis 1

    Ersetzen Sie ABC durch Protocol und streichen Sie @abstractmethod: Eine Protocol-Methode hat nur ... als Rumpf.

  2. Hinweis 2

    Ohne @runtime_checkable löst isinstance(item, HasArea) TypeError aus. Importieren Sie es aus typing und dekorieren Sie die Klasse.

  3. Hinweis 3

    only_shapes ist eine List Comprehension: [item for item in items if isinstance(item, HasArea)].

Eine Lösung zeigen

Ein möglicher Lösungsweg. Ihrer kann anders aussehen und trotzdem alle Prüfungen bestehen.

import math
from typing import Protocol, runtime_checkable


@runtime_checkable
class HasArea(Protocol):
    def area(self) -> float: ...


# From a library: do not change these two classes.
class Square:
    def __init__(self, side: float) -> None:
        self.side = side

    def area(self) -> float:
        return self.side**2


class Circle:
    def __init__(self, radius: float) -> None:
        self.radius = radius

    def area(self) -> float:
        return math.pi * self.radius**2


def total_area(shapes: list[HasArea]) -> float:
    return sum(shape.area() for shape in shapes)


def only_shapes(items: list[object]) -> list[HasArea]:
    return [item for item in items if isinstance(item, HasArea)]
Auf dem eigenen Computer ausführen

Installieren Sie Python 3.14 oder neuer. Speichern Sie diese Dateien in einem Ordner, öffnen Sie dort ein Terminal und führen Sie die Befehle unten aus.

main.py

import math
from abc import ABC, abstractmethod


class HasArea(ABC):
    @abstractmethod
    def area(self) -> float: ...


# From a library: do not change these two classes.
class Square:
    def __init__(self, side: float) -> None:
        self.side = side

    def area(self) -> float:
        return self.side**2


class Circle:
    def __init__(self, radius: float) -> None:
        self.radius = radius

    def area(self) -> float:
        return math.pi * self.radius**2


def total_area(shapes: list[HasArea]) -> float:
    return sum(shape.area() for shape in shapes)


def only_shapes(items: list[object]) -> list[HasArea]:
    return []

test_main.py

import math
from typing import is_protocol

from main import Circle, HasArea, Square, only_shapes, total_area


def test_is_protocol():
    """HasArea ist ein Protocol, keine ABC"""
    assert is_protocol(HasArea), "HasArea sollte von typing.Protocol erben"


def test_runtime_checkable():
    """isinstance() funktioniert mit HasArea, und Square und Circle passen"""
    got = isinstance(Square(2), HasArea), isinstance(Circle(1), HasArea), isinstance("text", HasArea)
    assert got == (True, True, False), f"isinstance() ergab {got!r}, erwartet war (True, True, False)"


def test_total_area():
    """total_area addiert 4 für ein Quadrat der Seite 2 und pi für einen Kreis mit Radius 1"""
    got = total_area([Square(2), Circle(1)])
    assert math.isclose(got, 4 + math.pi), f"total_area lieferte {got!r}, erwartet war {4 + math.pi!r}"


def test_only_shapes():
    """only_shapes behält Quadrat und Kreis und verwirft den Rest"""
    square, circle = Square(1), Circle(1)
    got = only_shapes([square, "text", 3, circle])
    assert got == [square, circle], f"only_shapes lieferte {got!r}, erwartet waren das Quadrat und der Kreis"

Unter macOS und Linux tippen Sie python3, wo in diesen Befehlen python steht, wie in der ersten Lektion.

Programm ausführen:

python main.py

Prüfungen ausführen (learnrun.py muss im selben Ordner liegen):

python learnrun.py test
learnrun.py herunterladen

Übung 2 von 2

Ein abstrakter Exporter

Hier sind die Klassen Ihre eigenen und teilen sich export(), also passt eine ABC. Machen Sie Exporter zu einer ABC mit abstraktem render(rows); export(rows) bleibt eine gemeinsame Methode. Exporter() und jede Unterklasse ohne render() müssen dann mit TypeError scheitern. Vervollständigen Sie JsonExporter: render liefert json.dumps(rows).

Tab rückt ein, Umschalt+Tab rückt aus. Um den Editor mit der Tastatur zu verlassen, drücken Sie Esc und dann Tab.

Beim ersten Ausführen lädt Ihr Browser Python herunter (bis zu 6.5 MB) und speichert es im Cache. Ihr Code bleibt auf Ihrem Gerät.

Hinweise
  1. Hinweis 1

    from abc import ABC, abstractmethod, dann class Exporter(ABC): und @abstractmethod über render.

  2. Hinweis 2

    Eine abstrakte Methode darf einen Rumpf behalten; ... genügt, weil niemand sie direkt aufruft.

  3. Hinweis 3

    JsonExporter braucht ein eigenes render(self, rows), das json.dumps(rows) liefert.

Eine Lösung zeigen

Ein möglicher Lösungsweg. Ihrer kann anders aussehen und trotzdem alle Prüfungen bestehen.

import json
from abc import ABC, abstractmethod


class Exporter(ABC):
    """Base class for exporters: subclasses must implement render()."""

    @abstractmethod
    def render(self, rows: list[dict[str, str]]) -> str: ...

    def export(self, rows: list[dict[str, str]]) -> str:
        if not rows:
            return ""
        return self.render(rows) + "\n"


class CsvExporter(Exporter):
    def render(self, rows: list[dict[str, str]]) -> str:
        header = ",".join(rows[0])
        lines = [",".join(row.values()) for row in rows]
        return "\n".join([header, *lines])


class JsonExporter(Exporter):
    def render(self, rows: list[dict[str, str]]) -> str:
        return json.dumps(rows)
Auf dem eigenen Computer ausführen

Installieren Sie Python 3.14 oder neuer. Speichern Sie diese Dateien in einem Ordner, öffnen Sie dort ein Terminal und führen Sie die Befehle unten aus.

main.py

import json


class Exporter:
    """Base class for exporters: subclasses must implement render()."""

    def render(self, rows: list[dict[str, str]]) -> str:
        raise NotImplementedError

    def export(self, rows: list[dict[str, str]]) -> str:
        if not rows:
            return ""
        return self.render(rows) + "\n"


class CsvExporter(Exporter):
    def render(self, rows: list[dict[str, str]]) -> str:
        header = ",".join(rows[0])
        lines = [",".join(row.values()) for row in rows]
        return "\n".join([header, *lines])


class JsonExporter(Exporter):
    pass

test_main.py

import json

from main import CsvExporter, Exporter, JsonExporter

ROWS = [{"name": "Ada", "city": "London"}, {"name": "Alan", "city": "Wilmslow"}]


def test_base_is_abstract():
    """Exporter() löst TypeError aus"""
    try:
        Exporter()
    except TypeError:
        return
    assert False, "Exporter() hat ein Objekt erzeugt; machen Sie render zu einer @abstractmethod einer ABC"


def test_missing_render():
    """Eine Unterklasse ohne render() lässt sich nicht instanziieren"""

    class Broken(Exporter):
        pass

    try:
        Broken()
    except TypeError:
        return
    assert False, "eine Unterklasse ohne render() wurde instanziiert"


def test_csv():
    """CsvExporter schreibt eine Kopfzeile und eine Zeile pro Datensatz"""
    got = CsvExporter().export(ROWS)
    assert got == "name,city\nAda,London\nAlan,Wilmslow\n", f"CsvExporter().export lieferte {got!r}"


def test_json():
    """JsonExporter schreibt die Datensätze als JSON, plus Zeilenumbruch"""
    got = JsonExporter().export(ROWS)
    assert got == json.dumps(ROWS) + "\n", f"JsonExporter().export lieferte {got!r}"


def test_empty():
    """export([]) liefert einen leeren String"""
    got = JsonExporter().export([])
    assert got == "", f"export([]) lieferte {got!r}, erwartet war ''"

Unter macOS und Linux tippen Sie python3, wo in diesen Befehlen python steht, wie in der ersten Lektion.

Programm ausführen:

python main.py

Prüfungen ausführen (learnrun.py muss im selben Ordner liegen):

python learnrun.py test
learnrun.py herunterladen

Häufige Fehler

isinstance() mit einem einfachen Protocol

from typing import Protocol


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


class File:
    def close(self) -> None:
        pass


print(isinstance(File(), Closer))

Was Python ausgibt

TypeError: Instance and class checks can only be used with @runtime_checkable protocols

Warum, und die Lösung

Ein Protocol ist standardmäßig für den Typprüfer da. Um es zur Laufzeit zu testen, dekorieren Sie es: from typing import runtime_checkable, dann @runtime_checkable über class Closer(Protocol):. Denken Sie daran, dass die Prüfung dann nur testet, ob close existiert, nicht seine Signatur. mypy meldet die ursprüngliche Zeile ebenfalls: Only @runtime_checkable protocols can be used with instance and class checks [misc].

Ein Protocol instanziieren

from typing import Protocol


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


item = Closer()

Was Python ausgibt

TypeError: Protocols cannot be instantiated

Warum, und die Lösung

Ein Protocol beschreibt Objekte, es ist selbst keins. Erzeugen Sie ein Objekt einer Klasse, die die Member hat, und annotieren Sie mit dem Protocol: item: Closer = File(). Wollen Sie gemeinsame Standard-Methoden, kann eine Klasse ausdrücklich vom Protocol erben und ist dann instanziierbar, oder Sie nehmen eine ABC.

Eine abstrakte Methode in der Unterklasse vergessen

from abc import ABC, abstractmethod


class Shape(ABC):
    @abstractmethod
    def area(self) -> float: ...


class Square(Shape):
    def __init__(self, side: float) -> None:
        self.side = side


Square(2)

Was Python ausgibt

TypeError: Can't instantiate abstract class Square without an implementation for abstract method 'area'

Warum, und die Lösung

Eine ABC verweigert ein Objekt, solange eine @abstractmethod nicht überschrieben ist. Das ist ihre Garantie zur Laufzeit, und der Fehler nennt die fehlende Methode. Ergänzen Sie in Square def area(self) -> float: return self.side ** 2. mypy meldet dasselbe vor dem Ausführen: Cannot instantiate abstract class "Square" with abstract attribute "area".

Python im Browser: Pyodide 314.0.7, MPL-2.0. Lizenz und Quellcode

Abschlussquiz

5 Fragen, ohne Hinweise. Ab 80 % ist die Lektion abgeschlossen.

Erledigen Sie zuerst alle Aufgaben oben, um das Abschlussquiz freizuschalten.

Problem melden

Etwas ist falsch oder unklar? Beschreiben Sie es kurz, dann wird es geprüft und korrigiert.

#

Mindestens 20 Zeichen.

Nur, wenn Sie eine Antwort wünschen.

Kernideen

Ein Protocol benennt, was ein Objekt kann

class Closer(Protocol): def close(self) -> None: ... listet Member auf; das ... ist der ganze Rumpf. Jede Klasse mit passendem close() ist für mypy ein Closer, ohne davon zu erben: Das ist strukturelle Typisierung, statisches Duck Typing. mypy vergleicht ganze Signaturen, close(self, force: bool) passt also nicht, und sein Hinweis zeigt Expected und Got. Auch Attribute zählen: name: str. Ein Protocol lässt sich nicht instanziieren. Eine Klasse darf trotzdem ausdrücklich davon erben, um seine Standard-Methoden zu übernehmen.

@runtime_checkable: nur eine Anwesenheitsprüfung

isinstance(x, Closer) löst TypeError aus, außer das Protocol ist mit @runtime_checkable dekoriert. Dann prüft isinstance() nur, ob die Member existieren: nicht ihre Signaturen, Parameter- oder Rückgabetypen. Eine Methode close(self, force) besteht; ein Attribut close = None gilt als fehlend. Ein Protocol mit Datenmembern wie name: str funktioniert mit isinstance(), aber nicht mit issubclass(). Nutzen Sie die Laufzeitprüfung zum groben Sortieren von Objekten und überlassen Sie die Korrektheit mypy.

Protocol oder ABC

Eine abstrakte Basisklasse ist nominal: Eine Klasse ist nur dann eine Resource, wenn sie davon erbt oder mit Resource.register() registriert ist. Dafür wirkt eine ABC zur Laufzeit: Eine Unterklasse ohne eine @abstractmethod lässt sich nicht instanziieren, und konkrete Methoden wie describe() werden geteilt. register() macht isinstance() wahr, prüft und teilt aber nichts. Nehmen Sie ein Protocol, um festzuhalten, was Ihre Funktion von fremden Objekten braucht, und eine ABC für eine Familie eigener Klassen mit gemeinsamem Code.

Quellen

Zuletzt geprüft am 29. September 2026