Zum Inhalt springen

6. Polymorphismus und Interfaces

Zu Zen-Modus wechseln

Vererbung (Kapitel 5) beantwortet die Frage, wie Klassen Gemeinsamkeiten teilen. Dieses Kapitel behandelt die Konsequenz daraus, und die ist für den Programmentwurf wichtiger als die Vererbung selbst: Polymorphismus, die Fähigkeit, unterschiedliche Objekte über eine gemeinsame Schnittstelle einheitlich zu behandeln. Polymorphismus ist der Mechanismus, mit dem Programme erweiterbar werden: Neue Varianten kommen hinzu, ohne dass bestehender Code angefasst wird. Genau diese Eigenschaft verlangt der Lehrplan mit der Formulierung “erweiterbare und wartbare Programme”.

Der Begriff kommt aus dem Griechischen und bedeutet “Vielgestaltigkeit”. Gemeint ist: Derselbe Methodenaufruf führt bei Objekten verschiedener Klassen zu unterschiedlichem, jeweils passendem Verhalten.

Ausgangspunkt ist die Mediendatenbank aus Kapitel 5, ergänzt um eine Methode describe(), die jede Subklasse überschreibt:

class MediaAsset:
def __init__(self, filename, size_bytes):
self.filename = filename
self.size_bytes = size_bytes
def describe(self):
return f"{self.filename}"
class ImageAsset(MediaAsset):
def __init__(self, filename, size_bytes, width, height):
super().__init__(filename, size_bytes)
self.width = width
self.height = height
def describe(self):
return f"image {self.filename}, {self.width}x{self.height} px"
class VideoAsset(MediaAsset):
def __init__(self, filename, size_bytes, duration_seconds, fps):
super().__init__(filename, size_bytes)
self.duration_seconds = duration_seconds
self.fps = fps
def describe(self):
return f"video {self.filename}, {self.duration_seconds} s @ {self.fps} fps"
class AudioAsset(MediaAsset):
def __init__(self, filename, size_bytes, duration_seconds):
super().__init__(filename, size_bytes)
self.duration_seconds = duration_seconds
def describe(self):
return f"audio {self.filename}, {self.duration_seconds} s"

Der entscheidende Code ist die Schleife, die mit der gemischten Sammlung arbeitet:

library = [
ImageAsset("sunset.jpg", 4_200_000, 6000, 4000),
VideoAsset("intro.mp4", 380_000_000, 90, 25),
AudioAsset("voiceover.wav", 52_000_000, 300),
]
for asset in library:
print(asset.describe())
image sunset.jpg, 6000x4000 px
video intro.mp4, 90 s @ 25 fps
audio voiceover.wav, 300 s

Die Schleife enthält keinerlei Fallunterscheidung. Sie sendet an jedes Objekt dieselbe Botschaft describe(); welche Implementierung ausgeführt wird, entscheidet zur Laufzeit die Klasse des jeweiligen Objekts. Das ist Polymorphismus in einem Satz: Der Aufrufer kennt die Schnittstelle, das Objekt kennt sein Verhalten.

Ohne Polymorphismus sähe dieselbe Schleife so aus:

# anti-pattern: type dispatch by hand
for asset in library:
if isinstance(asset, ImageAsset):
print(f"image {asset.filename}, {asset.width}x{asset.height} px")
elif isinstance(asset, VideoAsset):
print(f"video {asset.filename}, {asset.duration_seconds} s @ {asset.fps} fps")
elif isinstance(asset, AudioAsset):
print(f"audio {asset.filename}, {asset.duration_seconds} s")

Beide Varianten liefern dieselbe Ausgabe, aber sie “altern” verschieden. Kommt eine vierte Medienart hinzu, muss die isinstance-Kette gefunden und erweitert werden, und zwar in jeder Schleife dieser Art im gesamten Programm. In der polymorphen Variante bekommt die neue Klasse ihre eigene describe()-Methode, und sämtliche Schleifen funktionieren unverändert. isinstance-Ketten über die eigenen Klassen sind deshalb fast immer ein Entwurfsfehler: Die Fallunterscheidung gehört als überschriebene Methode in die Klassen selbst.

In Python ist für Polymorphismus nicht einmal eine gemeinsame Basisklasse nötig. Die Schleife oben ruft schlicht asset.describe() auf; sie funktioniert mit jedem Objekt, das eine solche Methode hat, egal aus welcher Klassenhierarchie es stammt:

class Note:
"""A text note, unrelated to the MediaAsset hierarchy."""
def __init__(self, text):
self.text = text
def describe(self):
return f"note: {self.text[:40]}"
library.append(Note("Color grading for scene 4 still missing."))
for asset in library:
print(asset.describe()) # works, Note included

Dieses Prinzip heißt Duck Typing, nach der englischen Redewendung: “If it walks like a duck and it quacks like a duck, then it must be a duck.” Übersetzt auf Programme: Nicht die Klassenzugehörigkeit eines Objekts zählt, sondern ob es die benötigten Methoden tatsächlich anbietet. Python prüft das erst im Moment des Aufrufs.

Duck Typing macht Python-Code kurz und flexibel, hat aber eine Kehrseite: Der Vertrag steht nirgends. Welche Methoden ein Objekt anbieten muss, um in der Schleife zu funktionieren, ist dem Code nicht anzusehen; es steht bestenfalls in der Dokumentation. Vergisst eine Klasse die Methode describe(), fällt das nicht beim Anlegen der Klasse auf, sondern erst zur Laufzeit, beim ersten Aufruf, möglicherweise in einer seltenen Programmsituation:

class BrokenAsset:
def __init__(self, filename):
self.filename = filename
# describe() is missing
library.append(BrokenAsset("odd.bin"))
for asset in library:
print(asset.describe())
# AttributeError: 'BrokenAsset' object has no attribute 'describe'

Für kleine Skripte ist das verschmerzbar. In größeren Programmen, an denen mehrere Personen arbeiten, braucht der Vertrag eine verbindliche, prüfbare Form.

Ein Interface (Schnittstelle) ist die formale Fassung eines solchen Vertrags: eine Liste von Methoden, die eine Klasse anbieten muss, ohne Vorgabe, wie sie implementiert werden. Manche Sprachen haben dafür ein eigenes Schlüsselwort interface; Python bildet Interfaces mit abstrakten Basisklassen aus dem Modul abc (abstract base class) ab:

from abc import ABC, abstractmethod
class MediaAsset(ABC):
"""Contract for all files in the media database."""
def __init__(self, filename, size_bytes):
self.filename = filename
self.size_bytes = size_bytes
@abstractmethod
def describe(self):
"""Return a one-line description of the asset."""
def size_megabytes(self):
return self.size_bytes / (1024 * 1024)

Zwei neue Elemente:

  • Die Klasse erbt von ABC und wird damit zur abstrakten Basisklasse.
  • @abstractmethod markiert Methoden, die jede Subklasse implementieren muss. Die abstrakte Methode selbst hat keinen Rumpf außer dem Docstring, der den Vertrag beschreibt.

Die Wirkung zeigt sich an zwei Stellen. Erstens: Abstrakte Klassen können nicht instanziert werden, denn ein “Medien-Asset an sich” ergibt fachlich keinen Sinn, es gibt nur konkrete Bilder, Videos und Audiodateien:

>>> MediaAsset("x.bin", 100)
TypeError: Can't instantiate abstract class MediaAsset with abstract method describe

Zweitens: Eine Subklasse, die den Vertrag nicht erfüllt, scheitert beim Erzeugen des Objekts, nicht erst irgendwann beim Methodenaufruf:

class BrokenAsset(MediaAsset):
pass # describe() not implemented
>>> BrokenAsset("odd.bin", 100)
TypeError: Can't instantiate abstract class BrokenAsset with abstract method describe

Der Fehler wandert damit von der Laufzeit an die frühestmögliche Stelle und nennt Ross und Reiter. Genau das unterscheidet einen dokumentierten Vertrag von einem erzwungenen.

Beachtenswert ist die Mischung in MediaAsset: describe() ist abstrakt (jede Subklasse muss es selbst wissen), size_megabytes() ist konkret geerbt (für alle gleich). Abstrakte Basisklassen dürfen beides kombinieren; ein reines Interface enthält nur abstrakte Methoden.

Abstrakte Klassen und Methoden werden in UML kursiv geschrieben, alternativ mit dem Zusatz {abstract}. Die Notation wird in Kapitel 12 vertieft; hier das Diagramm des aktuellen Entwurfs:

classDiagram
    class MediaAsset {
        <<abstract>>
        +filename
        +size_bytes
        +describe()*
        +size_megabytes()
    }
    MediaAsset <|-- ImageAsset
    MediaAsset <|-- VideoAsset
    MediaAsset <|-- AudioAsset

Der praktische Wert dieses Entwurfs zeigt sich, wenn das Programm wächst. Angenommen, die Mediendatenbank soll eine Gesamtübersicht drucken und die Gesamtgröße berechnen:

def print_report(assets):
for asset in assets:
print(f"- {asset.describe()}")
total = sum(asset.size_megabytes() for asset in assets)
print(f"total: {total:.1f} MB")

Diese Funktion ist gegen alle konkreten Klassen blind; sie kennt nur den Vertrag MediaAsset. Kommt nun eine neue Medienart hinzu, etwa 3D-Modelle für eine Produktion:

class ModelAsset(MediaAsset):
def __init__(self, filename, size_bytes, polygon_count):
super().__init__(filename, size_bytes)
self.polygon_count = polygon_count
def describe(self):
return f"3D model {self.filename}, {self.polygon_count:,} polygons"

print_report und jeder andere Code, der mit MediaAsset arbeitet, funktioniert mit ModelAsset sofort, ohne eine einzige geänderte Zeile. Das ist das Open-Closed-Prinzip: Ein gut entworfenes Programm ist offen für Erweiterung (neue Klassen hinzufügen), aber geschlossen für Modifikation (bestehender, getesteter Code bleibt unberührt).

Warum das zählt: Jede Änderung an bestehendem Code kann Fehler in Funktionen einschleppen, die vorher funktioniert haben, und zieht erneutes Testen nach sich. Eine neue Klasse dagegen ist ein reiner Zubau; das Risiko bleibt auf sie begrenzt. In diesem Sinn ist Polymorphismus nicht Komfort, sondern Risikomanagement.

Auch die umgekehrte Richtung des Lehrplans (“externe Programmbausteine integrieren, eigene zur Verfügung stellen”) beruht auf diesem Mechanismus: Wer den Vertrag MediaAsset implementiert, kann seinen Baustein in die fertige Anwendung einstecken, und wer fremde Bausteine nutzt, verlässt sich auf deren Verträge statt auf deren Innenleben. Bibliotheken und Plugin-Systeme funktionieren nach genau diesem Prinzip: Ein Bildbearbeitungsprogramm definiert den Vertrag “Filter” (eine Methode: Bild hinein, Bild heraus), und jedes Plugin, das ihn erfüllt, erscheint im Menü, ohne dass der Hersteller sein Programm ändert.

Die drei Mechanismen dieses Kapitels lösen dasselbe Problem mit unterschiedlicher Verbindlichkeit:

MittelVertraggeeignet für
Duck Typingimplizit, nur dokumentiertkleine Skripte, interne Hilfsklassen
Basisklasse mit überschreibbaren Methodenteilweise: Standardverhalten vorhandenHierarchien mit sinnvollem Default
Abstrakte Basisklasse (abc)explizit und erzwungenVerträge zwischen Programmteilen, Teamarbeit, Plugin-Schnittstellen

Als Richtschnur: Sobald mehrere Personen (oder mehrere Module) sich auf eine Schnittstelle verlassen, ist die abstrakte Basisklasse die richtige Wahl. Der Mehraufwand beträgt zwei Zeilen (ABC, @abstractmethod) und kauft dafür Fehlermeldungen zum frühestmöglichen Zeitpunkt.

Lernergebnisse: Was Sie nach diesem Kapitel können sollten

Abschnitt betitelt „Lernergebnisse: Was Sie nach diesem Kapitel können sollten“

Nach Abschluss dieses Kapitels sollten Schülerinnen und Schüler in der Lage sein:

  • Erklären: das Konzept des Polymorphismus (gleiche Botschaft, klassen­spezifisches Verhalten) und seinen Nutzen für Wartbarkeit erklären.
  • Anwenden: gemischte Objektsammlungen über eine gemeinsame Schnittstelle verarbeiten, ohne Fallunterscheidungen nach Typ.
  • Erklären: Duck Typing und seine Risiken erläutern und gegen explizite Verträge abgrenzen.
  • Anwenden: Verträge als abstrakte Basisklassen mit ABC und @abstractmethod definieren und implementieren.
  • Beurteilen: isinstance-Ketten als Entwurfsproblem erkennen und in polymorphe Methoden umbauen.
  • Entwerfen: ein Programm so strukturieren, dass neue Varianten ohne Änderung des Bestandscodes hinzugefügt werden können (Open-Closed-Prinzip).
  • Aufgabe 12 - Formen und Flächen
  • Aufgabe 13 - Sensor-Steckplätze