Skip to content

5. Objektorientierte Programmierung in Python

Klassen, Objekte und moderne OOP-Konzepte in Python professionell einsetzen.

Objektorientierte Programmierung gehört seit vielen Jahren zu den wichtigsten Programmierparadigmen der Softwareentwicklung. Entwickler aus Java, C# oder C++ werden viele bekannte Konzepte wiederfinden, jedoch verfolgt Python in einigen Bereichen bewusst andere Ansätze.

Die Sprache bietet klassische Mechanismen wie Klassen, Vererbung und Polymorphie, ergänzt diese jedoch um flexible Konzepte wie Duck Typing, Protocols, Mixins und Dataclasses. Dadurch entstehen oft kompaktere und besser wartbare Lösungen als in traditionellen objektorientierten Sprachen.

Gleichzeitig haben sich in den letzten Jahren neue Werkzeuge etabliert. Bibliotheken wie Pydantic ermöglichen eine komfortable Modellierung und Validierung von Datenobjekten und gehören heute zum Standard vieler moderner Python-Anwendungen.

In diesem Kapitel lernen Sie die wichtigsten objektorientierten Konzepte von Python kennen und erfahren, wie diese in professionellen Projekten eingesetzt werden.

Klassen und Objekte

Eigene Klassen definieren und Objekte erzeugen.

Klassen in Python: Definition und Instanziierung

In Python sind Klassen die Baupläne für Objekte, ähnlich wie in Java, C# oder C++. Allerdings verfolgt Python eine dynamischere und flexiblere Philosophie: Klassen sind selbst Objekte und können zur Laufzeit verändert oder erzeugt werden. Die Definition erfolgt mit dem Schlüsselwort class, gefolgt vom Klassennamen und optional einer Basisklasse.

class User:
    def __init__(self, username: str, email: str) -> None:
        self.username = username
        self.email = email

# Objektinstanziierung
user = User("alice", "alice@example.com")

Die Methode __init__ entspricht dem Konstruktor in anderen Sprachen, ist aber streng genommen nur eine Initialisierungsmethode, die nach der Objekterzeugung aufgerufen wird. Python trennt die Speicherreservierung (__new__) von der Initialisierung (__init__).

Attribute und Methoden: Organisation von Daten und Verhalten

Attribute werden in Python meist direkt an self gebunden, was einer Instanzvariable entspricht. Methoden sind Funktionen, die mindestens einen Parameter self erwarten, um auf die Instanz zuzugreifen.

class User:
    def __init__(self, username: str, email: str) -> None:
        self.username = username
        self.email = email

    def greet(self) -> str:
        return f"Hello, {self.username}!"

Im Gegensatz zu Java oder C# gibt es in Python keine expliziten Zugriffsmodifikatoren. Konventionell signalisiert ein führender Unterstrich _ geschützte Attribute, die nicht Teil der öffentlichen API sind.

Objektinstanziierung und Speicher

Beim Erzeugen eines Objekts wird die Klasse aufgerufen, was intern __new__ und anschließend __init__ aufruft.

Klassen- und Objektdiagramm

classDiagram
    class User {
        - username: str
        - email: str
        + __init__(username: str, email: str)
        + greet() str
    }

    User <|-- userInstance : Instanz

Vergleich zu Java/C

  • Keine Zugriffsmodifikatoren: Python verlässt sich auf Namenskonventionen und nicht auf Compiler-Sichtbarkeit.
  • Dynamische Typisierung: Attribute können zur Laufzeit hinzugefügt oder verändert werden.
  • Trennung von __new__ und __init__: Mehr Flexibilität bei der Objektkreation.

Best Practices

  • Verwenden Sie __init__ für Initialisierungen, nicht für komplexe Logik.
  • Nutzen Sie Namenskonventionen (_attribut) für geschützte Attribute.
  • Vermeiden Sie das Hinzufügen von Attributen außerhalb von __init__, um die Objektstruktur klar zu halten.

Häufige Fehler

  • self als ersten Methodenparameter vergessen.
  • Attribute außerhalb von __init__ oder Methoden definieren und so inkonsistente Objekte erzeugen.

Diese Grundlagen bilden die Basis für das Verständnis von Python-Klassen und Objekten, bevor wir uns im nächsten Abschnitt mit Attributen, Methoden und Konstruktoren detaillierter beschäftigen.

Attribute und Methoden

Daten und Verhalten innerhalb von Klassen organisieren.

Attribute: Daten in Klassen

In Python sind Attribute die Mittel, mit denen Klassen und ihre Instanzen Daten speichern. Anders als in Java oder C# gibt es keine expliziten Zugriffsmodifikatoren wie private oder protected. Stattdessen folgt Python einer Konvention: Ein führender Unterstrich (_attribut) signalisiert, dass ein Attribut intern ist und nicht direkt von außen genutzt werden sollte.

Ein weiterer Unterschied ist, dass Attribute dynamisch hinzugefügt werden können, was viel Flexibilität bietet, aber auch Disziplin erfordert, um die Struktur der Objekte klar zu halten.

class User:
    def __init__(self, username: str, email: str):
        self.username = username  # öffentliches Attribut
        self._email = email       # geschütztes Attribut (Konvention)

user = User('alice', 'alice@example.com')
print(user.username)  # alice
print(user._email)    # alice@example.com (Zugriff möglich, aber nicht empfohlen)

Methoden: Verhalten kapseln

Methoden sind Funktionen, die an Klassen gebunden sind und das Verhalten von Objekten definieren. Die erste Parameter ist stets self, der Verweis auf die aktuelle Instanz.

Methoden können Attribute lesen und verändern und so den internen Zustand kontrollieren.

class User:
    def __init__(self, username: str):
        self.username = username

    def greet(self) -> str:
        return f'Hello, {self.username}!'

user = User('bob')
print(user.greet())  # Hello, bob!

Klassenattribute vs. Instanzattribute

Klassenattribute sind auf der Klasse selbst definiert und werden von allen Instanzen geteilt. Instanzattribute sind individuell für jede Instanz. Das ist vergleichbar mit static Feldern in Java oder C#, aber Python unterscheidet sich darin, dass Instanzen Klassenattribute überschreiben können.

class Config:
    default_timeout = 30  # Klassenattribut

conf1 = Config()
conf2 = Config()

print(conf1.default_timeout)  # 30
Config.default_timeout = 60
print(conf2.default_timeout)  # 60

conf1.default_timeout = 10   # überschreibt für diese Instanz
print(conf1.default_timeout) # 10
print(conf2.default_timeout) # 60

Dynamische Attribute und slots

Python erlaubt es, Attribute jederzeit hinzuzufügen, was in statisch typisierten Sprachen unüblich ist. Das kann zu unerwartetem Verhalten führen. Mit __slots__ kann man die erlaubten Attribute einschränken, was Speicher spart und Fehler vermeidet.

class Point:
    __slots__ = ('x', 'y')

    def __init__(self, x: float, y: float):
        self.x = x
        self.y = y

p = Point(1.0, 2.0)
p.z = 3.0  # AttributeError: 'Point' object has no attribute 'z'

Methodenbindung und Aufruf

Methoden sind gebundene Funktionen: Wird eine Methode über eine Instanz aufgerufen, wird self automatisch übergeben. Das unterscheidet sich von C++ oder Java, wo Methodenaufrufe syntaktisch anders sind, aber das Konzept ähnlich.

Man kann Methoden auch ungebunden aufrufen, indem man die Instanz explizit übergibt.

class Calculator:
    def add(self, a: int, b: int) -> int:
        return a + b

calc = Calculator()
print(calc.add(2, 3))         # 5
print(Calculator.add(calc, 2, 3))  # 5, expliziter self-Parameter

Best Practices für Attribute und Methoden

  • Nutze Instanzattribute für Zustandsdaten, die individuell pro Objekt sind.
  • Verwende Klassenattribute für Konstanten oder gemeinsam genutzte Daten.
  • Halte Attribute möglichst privat (mit _), um Implementierungsdetails zu kapseln.
  • Vermeide dynamisches Hinzufügen von Attributen ohne Notwendigkeit.
  • Methoden sollten klar das Verhalten kapseln und self explizit als ersten Parameter haben.

Vergleich mit Java/C

Konzept Python Java / C#
Sichtbarkeit Konvention mit _, keine enforced private, protected, public
Methoden-Receiver Explizites self Implizites this
Klassenattribute Direkt auf Klasse, dynamisch static Felder
Dynamische Attribute Möglich, aber nicht empfohlen Nicht möglich

Zusammenfassung

Attribute und Methoden sind die Grundbausteine, um Daten und Verhalten in Python-Klassen zu organisieren. Die explizite Verwendung von self und die Konventionen für Sichtbarkeit fördern Klarheit und Lesbarkeit. Dynamische Attribute bieten Flexibilität, erfordern aber Disziplin. Klassenattribute und Instanzattribute unterscheiden sich in ihrer Sichtbarkeit und Lebensdauer, was bei der Gestaltung von Klassen bedacht werden muss.

Konstruktoren und Objektinitialisierung

Objekte mit init erstellen und Initialisierungslogik implementieren.

Konstruktoren in Python: Das __init__-Verfahren

In Python übernimmt die Methode __init__ die Rolle des Konstruktors, wie Sie es aus Java oder C# kennen. Anders als in diesen Sprachen wird das Objekt bereits vor dem Aufruf von __init__ durch __new__ erzeugt, sodass __init__ ausschließlich für die Initialisierung der Instanz verantwortlich ist, nicht für deren Erzeugung.

class ApiClient:
    def __init__(self, base_url: str, timeout: float = 5.0) -> None:
        self.base_url = base_url
        self.timeout = timeout
        self.session = self._create_session()

    def _create_session(self):
        # Beispiel: Initialisierung einer HTTP-Session
        import requests
        session = requests.Session()
        session.headers.update({'Accept': 'application/json'})
        return session

Hier sehen Sie, wie Initialisierungslogik sauber in __init__ und Hilfsmethoden ausgelagert wird. Das entspricht der Python-Philosophie, Konstruktoren schlank zu halten und komplexe Initialisierung in private Methoden auszulagern.

Unterschiede zu Konstruktoren in Java/C

  • Kein explizites new: Instanziierung erfolgt durch Aufruf der Klasse, z.B. obj = ApiClient("https://api.example.com").
  • Trennung von Erzeugung und Initialisierung: __new__ erzeugt das Objekt, __init__ initialisiert es.
  • Keine Überladung: Python unterstützt keine Methodenüberladung. Stattdessen werden optionale Parameter mit Default-Werten oder *args/**kwargs verwendet.

Initialisierung mit optionalen und variablen Parametern

Python-Idiome nutzen häufig optionale Parameter oder **kwargs, um flexible Initialisierung zu ermöglichen, ohne mehrere Konstruktoren zu definieren.

class Logger:
    def __init__(self, level: str = "INFO", **handlers) -> None:
        self.level = level
        self.handlers = handlers

logger = Logger(file="app.log", console=True)

Best Practice: Keine Seiteneffekte im Konstruktor

Vermeiden Sie in __init__ komplexe Seiteneffekte wie Netzwerkaufrufe oder Datenbankzugriffe. Initialisieren Sie nur den internen Zustand. Ressourcenintensive Operationen sollten explizit in Methoden ausgelagert werden, um Kontrolle über Fehlerbehandlung und Lebenszyklus zu behalten.

Beispiel: Objektinitialisierung mit Validierung

class User:
    def __init__(self, username: str, email: str) -> None:
        if '@' not in email:
            raise ValueError(f"Ungültige Email-Adresse: {email}")
        self.username = username
        self.email = email

Hier wird Validierung direkt im Konstruktor durchgeführt, was in Python üblich ist.

Zusammenfassung

  • __init__ ist der Initialisierer, nicht der Erzeuger.
  • Konstruktoren sind schlank und delegieren komplexe Logik an Hilfsmethoden.
  • Flexible Parameter mit Defaults und **kwargs ersetzen Konstruktorüberladung.
  • Validierung und einfache Initialisierung gehören in __init__, komplexe Seiteneffekte nicht.
  • Python-Objekte werden durch Aufruf der Klasse instanziiert, ohne new.

Diese Designprinzipien fördern lesbaren, wartbaren und idiomatischen Python-Code, der sich deutlich von Konstruktoren in statisch typisierten Sprachen unterscheidet.

Klassenmethoden und statische Methoden

Die Unterschiede zwischen Instanz-, Klassen- und statischen Methoden verstehen.

Instanzmethoden, Klassenmethoden und statische Methoden – ein Überblick

In Python unterscheiden sich Instanzmethoden, Klassenmethoden und statische Methoden vor allem durch den impliziten ersten Parameter und ihren Anwendungsfall. Anders als in Java oder C#, wo Methoden strikt an Instanzen oder Klassen gebunden sind, bietet Python mit @classmethod und @staticmethod eine flexible, aber klar differenzierte Methodentrennung.

Methodentyp Erster Parameter Zugriff auf Typische Verwendung
Instanzmethode self Instanzattribute Objekt-spezifisches Verhalten
Klassenmethode cls Klassenattribute Alternative Konstruktoren, Klassenstatus
Statische Methode kein impliziter weder Instanz- noch Klassenattribute Hilfsmethoden ohne Klassen- oder Instanzbezug

Klassenmethoden: Zugriff auf die Klasse, nicht die Instanz

Klassenmethoden erhalten als ersten Parameter die Klasse selbst (cls), nicht eine Instanz. Dies erlaubt den Zugriff auf Klassenattribute und das Erzeugen von Instanzen über alternative Konstruktoren.

class ApiClient:
    default_timeout: int = 30  # Klassenattribut

    def __init__(self, base_url: str, timeout: int):
        self.base_url = base_url
        self.timeout = timeout

    @classmethod
    def with_default_timeout(cls, base_url: str) -> 'ApiClient':
        # Alternative Konstruktion mit Klassenattribut
        return cls(base_url, cls.default_timeout)

# Nutzung
client = ApiClient.with_default_timeout("https://example.com")
print(client.timeout)  # 30

Diese Technik ist in Python idiomatisch, um verschiedene Konstruktorvarianten anzubieten, ohne explizite Factory-Klassen zu benötigen, wie es in Java üblich ist.

Statische Methoden: Hilfsfunktionen ohne Klassen- oder Instanzbezug

Statische Methoden sind einfache Funktionen, die innerhalb einer Klasse logisch gruppiert sind, aber weder auf Instanz- noch auf Klassenattribute zugreifen.

class JsonUtils:
    @staticmethod
    def is_valid_json(data: str) -> bool:
        import json
        try:
            json.loads(data)
            return True
        except json.JSONDecodeError:
            return False

# Nutzung
print(JsonUtils.is_valid_json('{"key": "value"}'))  # True

Typische Fallstricke und Best Practices

  • Kein Zugriff auf self in Klassen- oder statischen Methoden: Versuchen Sie nicht, Instanzattribute in Klassenmethoden zu verwenden.
  • Vermeiden Sie unnötige statische Methoden: Wenn eine Methode auf Klassenattribute zugreifen muss, sollte sie eine Klassenmethode sein.
  • Alternative Konstruktoren immer als Klassenmethoden: So bleibt die Vererbung korrekt und flexibel.

Vergleich mit Java und C

In Java und C# sind static Methoden ähnlich den statischen Methoden in Python, jedoch gibt es keine direkte Entsprechung zu Klassenmethoden mit cls-Parameter. Dort werden alternative Konstruktoren durch Überladung realisiert.

Zusammenfassung

  • Instanzmethoden arbeiten mit individuellen Objektdaten (self).
  • Klassenmethoden arbeiten mit der Klasse selbst (cls), ideal für alternative Konstruktoren und Klassenstatus.
  • Statische Methoden sind Hilfsfunktionen, die weder Instanz- noch Klassenstatus benötigen.

Merken Sie sich: Die Wahl zwischen diesen Methodenarten ist ein Designentscheid, der Lesbarkeit und Wartbarkeit Ihrer Klassen maßgeblich beeinflusst.

classDiagram
    class ApiClient {
        +int default_timeout
        +__init__(base_url: str, timeout: int)
        +with_default_timeout(base_url: str) ApiClient
    }
    class JsonUtils {
        +is_valid_json(data: str) bool
    }
    ApiClient : +default_timeout
    ApiClient : +with_default_timeout()
    JsonUtils : +is_valid_json()

Properties

Kontrollierten Zugriff auf Attribute ermöglichen und klassische Getter- und Setter-Methoden vermeiden.

Properties als idiomatischer Zugriffsschutz

In Python sind Properties ein zentrales Mittel, um kontrollierten Zugriff auf Attribute zu ermöglichen, ohne die einfache und klare Syntax des direkten Attributzugriffs aufzugeben. Python verwendet das @property-Dekorator-Muster, das die Kapselung elegant mit der Lesbarkeit verbindet.

Properties erlauben es, intern ein Attribut zu verstecken und dennoch eine öffentliche Schnittstelle zu bieten, die sich bei Bedarf mit Validierung, Berechnung oder Seiteneffekten erweitern lässt. Dies entspricht dem Python-Prinzip „We are all consenting adults here“: Entwickler können direkt auf Attribute zugreifen, sollten aber Properties verwenden, wenn Kontrolle nötig ist.

Syntax und Beispiel

class User:
    def __init__(self, username: str) -> None:
        self._username = username  # Konvention: geschütztes Attribut

    @property
    def username(self) -> str:
        """Liefert den Benutzernamen."""
        return self._username

    @username.setter
    def username(self, value: str) -> None:
        if not value or not value.isidentifier():
            raise ValueError(f"Ungültiger Benutzername: {value!r}")
        self._username = value

# Verwendung
user = User("alice")
print(user.username)  # Direkter Zugriff wie Attribut
user.username = "bob"  # Setter mit Validierung

# user.username = "123 invalid"  # ValueError

Hier wird das Attribut _username als interne Repräsentation verwendet. Der Zugriff erfolgt über das Property username, das sowohl Lese- als auch Schreibzugriff kontrolliert. Der Aufruf sieht aus wie ein Attributzugriff, verbirgt aber Methodenaufrufe.

Erweiterte Nutzung: Berechnete Properties

Properties sind nicht auf einfache Feldzugriffe beschränkt. Sie können auch berechnete Werte oder dynamische Zustände kapseln.

from datetime import datetime, timedelta

class Subscription:
    def __init__(self, start_date: datetime, duration_days: int) -> None:
        self.start_date = start_date
        self.duration_days = duration_days

    @property
    def end_date(self) -> datetime:
        return self.start_date + timedelta(days=self.duration_days)

    @property
    def is_active(self) -> bool:
        return datetime.now() < self.end_date

sub = Subscription(datetime(2024, 1, 1), 30)
print(sub.end_date)  # Berechneter Wert
print(sub.is_active)  # Dynamischer Zustand

Hinweise zu Implementierung und Performance

  • Properties sind in Python leichtgewichtig und haben keine nennenswerten Performance-Nachteile gegenüber direkten Attributzugriffen.
  • Sie sind ideal, um API-Stabilität zu gewährleisten: Intern kann sich die Implementierung ändern, ohne dass der Nutzercode angepasst werden muss.
  • Der Unterstrich als Präfix (_attribut) ist eine Konvention für "protected" Attribute, die nicht direkt von außen verändert werden sollen.

Best Practices

  • Nutze Properties, wenn du später Validierung, Logging oder andere Logik beim Zugriff benötigst.
  • Beginne mit öffentlichen Attributen, wenn keine Kontrolle nötig ist, und wandle sie bei Bedarf in Properties um.
  • Vermeide es, Properties zu komplex oder zeitintensiv zu gestalten, um Überraschungen bei scheinbar einfachen Attributzugriffen zu vermeiden.

Vererbung

Grundlagen der Vererbung in Python

In Python ist Vererbung ein zentrales Mittel, um Klassenhierarchien zu modellieren und bestehende Funktionalität zu erweitern. Anders als in Java oder C# ist die Syntax in Python bewusst minimalistisch gehalten: Die Basisklasse wird in Klammern direkt nach dem Klassennamen angegeben.

class BaseHandler:
    def handle(self, request: dict) -> dict:
        # Grundlegende Verarbeitung
        return {"status": 200, "data": request}

class AuthenticatedHandler(BaseHandler):
    def handle(self, request: dict) -> dict:
        if not request.get("user"):
            return {"status": 401, "error": "Unauthorized"}
        return super().handle(request)

Hier erweitert AuthenticatedHandler die Funktionalität von BaseHandler um eine Authentifizierungsprüfung. Die Verwendung von super() ist idiomatisch und vermeidet explizite Klassen- oder Self-Bezüge, was insbesondere bei Mehrfachvererbung wichtig ist.

Merkmale und Unterschiede zu statisch typisierten Sprachen

  • Dynamische Bindung: Methoden werden zur Laufzeit aufgelöst. Das erlaubt flexiblere Hierarchien und Duck Typing.
  • Keine explizite Zugriffsmodifikatoren: Python setzt auf Konventionen (_protected, __private), nicht auf Compiler-Regeln.
  • Mehrfachvererbung: Python unterstützt sie nativ mit dem C3-Linearization-Algorithmus, der die Method Resolution Order (MRO) definiert.

Mehrfachvererbung und MRO

Die MRO bestimmt, in welcher Reihenfolge Basisklassen durchsucht werden. Dies ist essentiell, um Mehrfachvererbung sicher und vorhersehbar zu gestalten.

class BaseHandler:
    def handle(self, request: dict) -> dict:
        return request


class AuthenticatedHandler(BaseHandler):
    def handle(self, request: dict) -> dict:
        if not request.get("authenticated"):
            raise PermissionError("Authentication required")

        return super().handle(request)


class Logger:
    def log(self, message: str) -> None:
        print(f"LOG: {message}")


class FileHandler(BaseHandler, Logger):
    def handle(self, request: dict) -> dict:
        self.log("Handling request")
        return super().handle(request)
classDiagram
    class BaseHandler {
        +handle(request: dict) dict
    }
    class AuthenticatedHandler {
        +handle(request: dict) dict
    }
    class Logger {
        +log(message: str) void
    }
    class FileHandler {
        +handle(request: dict) dict
    }
    AuthenticatedHandler --|> BaseHandler
    FileHandler --|> BaseHandler
    FileHandler --|> Logger

Praktische Hinweise

  • Verwenden Sie super() immer ohne Klassen- oder Objektbezug, um Kompatibilität mit Mehrfachvererbung zu gewährleisten.
  • Vermeiden Sie komplexe Mehrfachvererbungen, wenn Komposition eine klarere Lösung bietet.
  • Nutzen Sie Vererbung primär zur Spezialisierung, nicht zur bloßen Codewiederverwendung.

Zusammenfassung

Vererbung in Python ist ein mächtiges Werkzeug, das durch seine dynamische und flexible Natur besticht. Die klare Syntax, die konsequente Nutzung von super() und die Unterstützung von Mehrfachvererbung ermöglichen elegante und wartbare Klassenhierarchien. Dabei ist stets abzuwägen, ob Vererbung oder Komposition die passendere Lösung ist.


Polymorphie in Python

Austauschbare Objekte nutzen und polymorphes Verhalten implementieren.

Polymorphie in Python: Austauschbare Objekte idiomatisch nutzen

Polymorphie ist ein zentrales Konzept der objektorientierten Programmierung (OOP), das auch in Python eine Schlüsselrolle spielt. Für erfahrene Entwickler aus Java, C# oder C++ ist wichtig zu verstehen, wie Python Polymorphie anders handhabt – weniger strikt typisiert, dynamisch und mit Fokus auf Verhalten statt auf explizite Schnittstellen.

Dynamische Typisierung und Duck Typing

In Python ist Polymorphie primär durch Duck Typing realisiert: Objekte werden nicht über ihre Klassenhierarchie, sondern über ihr Verhalten (Methoden und Attribute) austauschbar. Dies bedeutet, dass kein explizites Interface oder abstrakte Basisklasse notwendig ist, solange das Objekt die erwarteten Methoden bereitstellt.

class JSONSerializable:
    def to_json(self) -> str:
        raise NotImplementedError

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

    def to_json(self) -> str:
        import json
        return json.dumps({'name': self.name, 'age': self.age})

class Product:
    def __init__(self, id: int, price: float) -> None:
        self.id = id
        self.price = price

    def to_json(self) -> str:
        import json
        return json.dumps({'id': self.id, 'price': self.price})

# Funktion akzeptiert jedes Objekt mit to_json-Methode
from typing import Protocol

class JsonSerializableProtocol(Protocol):
    def to_json(self) -> str: ...

def serialize(obj: JsonSerializableProtocol) -> str:
    return obj.to_json()

user = User('Alice', 30)
product = Product(101, 19.99)

print(serialize(user))    # JSON von User
print(serialize(product)) # JSON von Product

Hier ist keine gemeinsame Basisklasse erforderlich. Solange das Objekt to_json() implementiert, funktioniert es. Das ist der Kern von Python-Polymorphie: Verhalten zählt, nicht Typ.

Vergleich zu Java und C

In Java oder C# würde man typischerweise ein Interface IJsonSerializable definieren und Klassen dieses implementieren. Python verzichtet bewusst auf solche formalen Zwänge zugunsten von Flexibilität und Einfachheit. Dies erfordert jedoch Disziplin bei der Dokumentation und Tests.

Polymorphie mit abstrakten Basisklassen (abc)

Für stärkere Typensicherheit und explizite Verträge bietet Python das abc-Modul. Dies ist nützlich, wenn Sie Schnittstellen explizit definieren möchten, z.B. in größeren Systemen oder Bibliotheken.

from abc import ABC, abstractmethod

class Serializer(ABC):
    @abstractmethod
    def to_json(self) -> str:
        pass

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

    def to_json(self) -> str:
        import json
        return json.dumps({'name': self.name})

# Serializer kann nicht instanziiert werden
# Serializer enforce to_json implementation

Polymorphie und Methodenaufrufe

Python löst Methodenaufrufe dynamisch zur Laufzeit auf. Dies ermöglicht flexible Erweiterungen, z.B. durch Duck Typing, aber auch Risiken, wenn Methoden fehlen.

def process(obj: object) -> None:
    # Wir erwarten, dass obj eine 'process' Methode hat
    obj.process()

class A:
    def process(self) -> None:
        print('Processing A')

class B:
    def process(self) -> None:
        print('Processing B')

for obj in [A(), B()]:
    process(obj)

Fehlt die Methode process(), führt das zu einem AttributeError. Anders als in statisch typisierten Sprachen gibt es keine Compile-Time-Prüfung, was mehr Flexibilität, aber auch mehr Verantwortung bedeutet.

Mermaid-Diagramm: Polymorphie durch Duck Typing

classDiagram
    class User {
        +to_json() str
    }
    class Product {
        +to_json() str
    }
    class Serializer {
        <<interface>>
        +to_json() str
    }
    User ..|> Serializer
    Product ..|> Serializer

Best Practices

  • Nutzen Sie Duck Typing für einfache, flexible APIs, die auf Verhalten basieren.
  • Dokumentieren Sie erwartete Methoden klar, um Missverständnisse zu vermeiden.
  • Verwenden Sie abc und Protocol für explizite Schnittstellen, wenn Typensicherheit und Lesbarkeit wichtiger sind.
  • Testen Sie polymorphe Funktionen mit verschiedenen Objekten, um Laufzeitfehler zu vermeiden.

Merksatz

In Python ist Polymorphie Verhalten, nicht Typ. Vertrauen Sie auf Methoden, nicht auf Klassenhierarchien.

Diese Denkweise unterscheidet Python grundlegend von statisch typisierten Sprachen und eröffnet neue Möglichkeiten für flexible, wartbare Softwarearchitekturen.

Abstrakte Basisklassen mit abc

Abstrakte Basisklassen mit abc

In Python werden abstrakte Basisklassen (Abstract Base Classes, ABCs) verwendet, um gemeinsame Schnittstellen zu definieren, ohne dabei eine konkrete Implementierung vorzugeben. Dies unterstützt ein klar strukturiertes Design, insbesondere bei Frameworks, Plugins oder komplexen Architekturen, wo verschiedene Klassen einheitliches Verhalten garantieren müssen.

Python unterscheidet sich hier deutlich von Java oder C#, wo abstrakte Klassen und Interfaces strikt getrennt sind. Python nutzt das Modul abc, das sowohl abstrakte Methoden als auch abstrakte Eigenschaften unterstützt und dabei die dynamische Natur der Sprache respektiert.

Grundlagen und Syntax

Eine abstrakte Basisklasse wird durch Vererbung von abc.ABC definiert. Abstrakte Methoden werden mit dem Decorator @abstractmethod markiert. Eine Klasse, die mindestens eine abstrakte Methode enthält, kann nicht instanziiert werden.

from abc import ABC, abstractmethod

class DataProcessor(ABC):
    @abstractmethod
    def load_data(self, source: str) -> None:
        pass

    @abstractmethod
    def process_data(self) -> dict:
        pass

    def save_results(self, destination: str) -> None:
        print(f"Saving results to {destination}")

Hier definiert DataProcessor eine Schnittstelle für Datenverarbeitung. Die Methode save_results ist konkret implementiert, während load_data und process_data abstrakt sind.

Implementierung und Instanziierung

Unterklassen müssen alle abstrakten Methoden implementieren, um instanziierbar zu sein:

class JSONProcessor(DataProcessor):
    def __init__(self):
        self.data = None

    def load_data(self, source: str) -> None:
        import json
        with open(source, 'r') as f:
            self.data = json.load(f)

    def process_data(self) -> dict:
        # Beispiel: Filtere nur Einträge mit Schlüssel 'active'
        return {k: v for k, v in self.data.items() if v.get('active')}

# Instanziierung
processor = JSONProcessor()
processor.load_data('data.json')
results = processor.process_data()
processor.save_results('results.json')

Warum abc nutzen?

  • Vertragssicherheit: Anders als bei Duck Typing erzwingt abc explizit die Implementierung bestimmter Methoden.
  • Fehlerfrüherkennung: Instanziierung abstrakter Klassen schlägt sofort fehl, was Fehlerquellen minimiert.
  • Dokumentation und Design: ABCs dienen als klare Spezifikation für Entwickler und Tools.

Vergleich zu Java und C

  • In Java/C# sind abstrakte Klassen und Interfaces getrennt. Python kombiniert diese Konzepte.
  • Python erlaubt Mehrfachvererbung von ABCs, was Interfaces ähnelt, aber mit konkreten Methoden kombiniert werden kann.
  • Die dynamische Typisierung in Python macht ABCs optional, aber sie sind nützlich für größere Codebasen und statische Analyse.

Erweiterte Features: Abstrakte Eigenschaften

Abstrakte Eigenschaften werden mit @property und @abstractmethod kombiniert:

class Config(ABC):
    @property
    @abstractmethod
    def database_url(self) -> str:
        pass

class EnvConfig(Config):
    @property
    def database_url(self) -> str:
        import os
        return os.getenv('DATABASE_URL', 'sqlite:///:memory:')

Mermaid-Diagramm: Klassenhierarchie mit ABC

classDiagram
    class DataProcessor {
        <<abstract>>
        +load_data(source: str)
        +process_data() dict
        +save_results(destination: str)
    }
    class JSONProcessor {
        +load_data(source: str)
        +process_data() dict
    }

    DataProcessor <|-- JSONProcessor

Best Practices

  • Verwenden Sie ABCs, wenn Sie klare Schnittstellen definieren wollen, die von mehreren Implementierungen garantiert werden müssen.
  • Kombinieren Sie ABCs mit Duck Typing: Nutzen Sie ABCs für kritische Schnittstellen, aber verlassen Sie sich ansonsten auf Python-typische Flexibilität.
  • Vermeiden Sie unnötige ABCs für einfache Klassenhierarchien, um unnötigen Boilerplate-Code zu reduzieren.

Häufige Fehler

  • Nicht alle abstrakten Methoden implementieren und trotzdem instanziieren wollen – führt zu TypeError.
  • Abstrakte Methoden ohne @abstractmethod dekorieren, was die Abstraktion wirkungslos macht.

Mixin-Klassen

Verhalten flexibel wiederverwenden und Mehrfachvererbung sinnvoll einsetzen.

Mixin-Klassen sind ein idiomatisches Mittel in Python, um Verhalten modular und wiederverwendbar zu gestalten, ohne dabei die klassische Vererbungshierarchie zu überfrachten.

Was sind Mixins?

Mixins sind spezialisierte Klassen, die keine eigenständigen Objekte repräsentieren, sondern ausschließlich Methoden (und gelegentlich Attribute) bereitstellen, die von anderen Klassen „hineingemischt“ werden. Sie sind nicht für sich allein gedacht, sondern ergänzen andere Basisklassen.

Typischerweise enthalten Mixins: - Ein oder mehrere zusammenhängende Verhaltensweisen - Keine eigene Zustandsverwaltung (oder nur minimal) - Keine eigenständige Instanziierung

Vorteile von Mixins

  • Vermeidung von Code-Duplikation: Gemeinsame Funktionalität kann in einem Mixin zentralisiert werden.
  • Flexibilität: Klassen können durch Kombination verschiedener Mixins modular erweitert werden.
  • Klarheit: Die Trennung von Kernfunktionalität und Zusatzverhalten bleibt erhalten.

Beispiel: Logging-Mixin

Statt Logging-Code in jede Klasse zu kopieren, kann ein LoggingMixin definiert werden:

from typing import Any
import logging

class LoggingMixin:
    def log(self, message: str) -> None:
        logging.info(f"[{self.__class__.__name__}] {message}")

class DataProcessor(LoggingMixin):
    def process(self, data: Any) -> None:
        self.log("Start processing")
        # Verarbeitungscode
        self.log("Finished processing")

processor = DataProcessor()
processor.process({'key': 'value'})

Hier erweitert DataProcessor durch das Mixin seine Funktionalität um Logging, ohne dass Logging selbst Teil der Kernklasse ist.

Mehrfachvererbung und Method Resolution Order (MRO)

Python löst Mehrfachvererbung über die C3 Linearization, die eine konsistente Reihenfolge der Methodensuche definiert. Bei Mixins ist es wichtig, dass sie keine konkurrierenden Methoden mit den Hauptklassen definieren, um unerwartete Überschreibungen zu vermeiden.

Best Practices für Mixins

  • Single Responsibility: Ein Mixin sollte genau eine Funktionalität bereitstellen.
  • Keine Initialisierung: Vermeide __init__ in Mixins oder rufe super().__init__() korrekt, um Initialisierungskonflikte zu vermeiden.
  • Keine Zustandsverwaltung: Mixins sollten möglichst zustandslos sein, um Seiteneffekte zu minimieren.
  • Namenskonvention: Oft enden Mixin-Klassen auf Mixin, um ihre Rolle klar zu machen.

Mermaid-Diagramm: Beispielhafte Mixin-Struktur

classDiagram
    class LoggingMixin {
        +log(message: str) void
    }
    class JsonSerializableMixin {
        +to_json() str
    }
    class BaseService {
        +run() void
    }
    class MyService {
        +run() void
    }

    MyService --|> BaseService
    MyService --|> LoggingMixin
    MyService --|> JsonSerializableMixin

Zusammenfassung

Mixin-Klassen sind ein mächtiges Werkzeug, um Verhalten in Python modular und übersichtlich zu gestalten. Sie nutzen die Mehrfachvererbung gezielt, um Klassen flexibel zu erweitern, ohne die Komplexität der Vererbungshierarchie unnötig zu erhöhen. Für Entwickler aus statisch typisierten Sprachen ist das Konzept eine elegante Erweiterung, die durch Python-spezifische Mechanismen wie die MRO sicher und wartbar bleibt.

Dataclasses

Einführung in Dataclasses

Python bietet mit dem Modul dataclasses seit Version 3.7 eine elegante Möglichkeit, datenorientierte Klassen mit minimalem Boilerplate-Code zu definieren. Das Konzept ähnelt den Records in Java, die ebenfalls dazu dienen, hauptsächlich Daten zu modellieren und automatisch Methoden wie Konstruktoren, Vergleiche oder String-Repräsentationen bereitzustellen.

Grundlegende Verwendung

Eine Dataclass wird mit dem Dekorator @dataclass versehen. Die Attribute werden als Klassenvariablen mit Typannotationen definiert. Python generiert automatisch:

  • Einen Konstruktor (__init__)
  • __repr__ für eine lesbare String-Darstellung
  • __eq__ für Wertvergleich
  • Optional weitere Methoden wie __hash__
from dataclasses import dataclass

@dataclass
class User:
    id: int
    name: str
    email: str

user = User(1, "Alice", "alice@example.com")
print(user)  # User(id=1, name='Alice', email='alice@example.com')

Erweiterte Dataclass-Features

Default-Werte und Feldoptionen

Standardwerte können direkt angegeben werden. Für komplexe Defaults (z.B. Listen) nutzt man field(default_factory=...):

from dataclasses import dataclass, field
from typing import list

@dataclass
class Order:
    id: int
    items: list[str] = field(default_factory=list)

order = Order(42)
order.items.append("apple")
print(order)  # Order(id=42, items=['apple'])

Feldoptionen: init, repr, compare

Man kann steuern, ob ein Feld im Konstruktor verwendet wird, in repr auftaucht oder für Vergleiche berücksichtigt wird:

from dataclasses import dataclass, field

@dataclass
class Config:
    host: str
    port: int
    _secret: str = field(repr=False, compare=False)

config = Config('localhost', 8080, 'topsecret')
print(config)  # Config(host='localhost', port=8080)

Unveränderliche Dataclasses

Mit frozen=True wird eine Dataclass unveränderlich. Das entspricht final-Klassen in Java oder readonly-Structs in C#:

@dataclass(frozen=True)
class Point:
    x: float
    y: float

p = Point(1.0, 2.0)
# p.x = 3.0  # Fehler: FrozenInstanceError

Vererbung mit Dataclasses

Dataclasses unterstützen Vererbung nahtlos, was die Wiederverwendung von Datenstrukturen erleichtert:

@dataclass
class Employee(User):
    position: str

emp = Employee(2, "Bob", "bob@example.com", "Developer")
print(emp)

Mermaid-Diagramm: Dataclass-Beziehungen

classDiagram
    class User {
        +int id
        +str name
        +str email
    }
    class Employee {
        +str position
    }
    Employee --|> User

Best Practices

  • Nutze Dataclasses für reine Datencontainer, um Redundanz zu vermeiden.
  • Verwende frozen=True für unveränderliche Objekte, um Fehler durch Seiteneffekte zu minimieren.
  • Nutze field() für komplexe Default-Werte und zur Steuerung von Konstruktor und Vergleich.
  • Vermeide mutable Default-Argumente außerhalb von field(default_factory=...).

Datenmodelle mit Pydantic

Daten validieren, konvertieren und robuste Modelle für APIs und Anwendungen entwickeln.

Einführung in Pydantic

Pydantic ist ein modernes Python-Framework zur Definition und Validierung von Datenmodellen mittels Typannotationen. Es nutzt Python-Typen und -Annotationssysteme, um robuste, selbstvalidierende Klassen zu erzeugen.

Grundprinzipien und Vorteile

  • Typbasierte Validierung: Pydantic nutzt Python-Typen zur Validierung und Konvertierung, z.B. von Strings zu datetime.
  • Automatische Konvertierung: Eingabedaten werden beim Instanziieren automatisch in die korrekten Typen transformiert.
  • Fehlertoleranz: Bei ungültigen Daten wirft Pydantic aussagekräftige ValidationErrors.
  • Integration: Ideal für Web-APIs (FastAPI), Konfigurationsmanagement oder Datenverarbeitung.

Beispiel: Ein einfaches Datenmodell

from pydantic import BaseModel, EmailStr
from datetime import date

class User(BaseModel):
    id: int
    name: str
    email: EmailStr
    signup_ts: date | None = None

# Beispielinstanz mit automatischer Typkonvertierung
user = User(id=123, name='Max Mustermann', email='max@example.com', signup_ts='2024-06-01')
print(user)

Pydantic validiert email als gültige E-Mail und konvertiert signup_ts vom String zum date.

Validierung und Fehlerbehandlung

Ungültige Daten führen zu einer ValidationError mit detaillierten Fehlermeldungen:

from pydantic import ValidationError

try:
    User(id='abc', name='Anna', email='invalid-email')
except ValidationError as e:
    print(e.json())

Erweiterte Typen und Constraints

Pydantic unterstützt komplexe Typen und Validierungsregeln:

  • Eingebaute Typen wie EmailStr, HttpUrl, IPv4Address
  • conint, constr für eingeschränkte Wertebereiche
  • Verschachtelte Modelle
from pydantic import conint

class Product(BaseModel):
    name: str
    price_cents: conint(gt=0)  # Preis muss positiv sein

product = Product(name='Widget', price_cents=1000)

Verschachtelte Modelle und JSON-Serialisierung

Pydantic-Modelle können als Attribute andere Modelle enthalten, was komplexe Datenstrukturen ermöglicht:

class Address(BaseModel):
    street: str
    city: str

class Customer(BaseModel):
    name: str
    address: Address

customer = Customer(name='Eva', address={'street': 'Hauptstr. 1', 'city': 'Berlin'})
print(customer.json())

Best Practices

  • Definieren Sie Pydantic-Modelle als unveränderliche Datenstrukturen, wenn möglich mit Config-Klasse frozen = True.
  • Nutzen Sie Typen aus Pydantic und typing für präzise Validierung.
  • Verwenden Sie verschachtelte Modelle zur Strukturierung komplexer Daten.
  • Fangen Sie ValidationError gezielt ab, um Fehlermeldungen nutzerfreundlich aufzubereiten.
classDiagram
    class BaseModel {
        +__init__(**data)
        +dict()
        +json()
    }
    class User {
        +int id
        +str name
        +EmailStr email
        +date | None signup_ts
    }
    class Address {
        +str street
        +str city
    }
    class Customer {
        +str name
        +Address address
    }

    User --|> BaseModel
    Address --|> BaseModel
    Customer --|> BaseModel
    Customer o-- Address : contains

Dieses Diagramm zeigt die Vererbung von BaseModel und die Komposition verschachtelter Modelle.

Duck Typing

Das zentrale Typisierungskonzept von Python verstehen und praktisch anwenden.

Duck Typing: Das Prinzip hinter Python-Typisierung

In Python steht nicht der explizite Typ eines Objekts im Vordergrund, sondern dessen Verhalten. Dieses Konzept wird als Duck Typing bezeichnet, abgeleitet vom Sprichwort: „If it looks like a duck and quacks like a duck, it’s a duck.“ Anders als in statisch typisierten Sprachen wie Java oder C# wird hier nicht über die Deklaration oder Vererbung geprüft, ob ein Objekt einen bestimmten Typ hat, sondern ob es die erforderlichen Methoden und Attribute besitzt.

Diese Philosophie fördert flexible und dynamische Designs, bei denen Schnittstellen implizit durch Verhalten definiert werden. Das bedeutet, dass verschiedene Klassen austauschbar sind, solange sie die erwarteten Methoden implementieren.

Praktisches Beispiel: Flexible Logger-Interfaces

class Logger:
    def log(self, message: str) -> None: ...

class FileLogger:
    def log(self, message: str) -> None:
        with open('app.log', 'a') as f:
            f.write(message + '\n')

class ConsoleLogger:
    def log(self, message: str) -> None:
        print(message)


def process_data(data: list[int], logger: Logger) -> None:
    # Verarbeitung ...
    logger.log(f'Processed {len(data)} items')

file_logger = FileLogger()
console_logger = ConsoleLogger()

process_data([1, 2, 3], file_logger)    # Akzeptiert, da log() vorhanden
process_data([1, 2, 3], console_logger) # Ebenfalls akzeptiert

Hier ist keine gemeinsame Basisklasse nötig, um process_data mit unterschiedlichen Loggern zu verwenden. Entscheidend ist, dass das Objekt eine log-Methode mit passender Signatur besitzt.

Protocols

Strukturelle Typisierung mit Protocols

In Python ermöglicht das typing-Modul mit Protocol eine Form der strukturellen Typisierung, die sich von der nominalen Typisierung in Sprachen wie Java oder C# unterscheidet. Während in diesen Sprachen die Typkompatibilität hauptsächlich durch explizite Vererbung oder Schnittstellen definiert wird, prüft Python mit Protocols, ob ein Objekt die erforderlichen Methoden und Attribute besitzt – unabhängig von seiner tatsächlichen Vererbungshierarchie.

Diese Herangehensweise entspricht dem Duck Typing, wird aber durch statische Typprüfer wie mypy überprüfbar und dokumentierbar gemacht. Protocols definieren also eine Schnittstelle, ohne dass Klassen explizit davon erben müssen.

Definition eines Protocols

from typing import Protocol

class Serializer(Protocol):
    def serialize(self) -> str:
        ...

class JsonSerializer:
    def serialize(self) -> str:
        return '{"key": "value"}'

class XmlSerializer:
    def serialize(self) -> str:
        return '<key>value</key>'

# JsonSerializer und XmlSerializer erfüllen das Protocol implizit
def save(obj: Serializer) -> None:
    data = obj.serialize()
    print(f"Saving data: {data}")
Die Funktion save akzeptiert jedes Objekt, das eine serialize-Methode bereitstellt, ohne dass diese Klassen von Serializer erben müssen.

Vorteile gegenüber nominaler Typisierung

  • Flexibilität: Klassen müssen nicht explizit von einem Interface erben, um kompatibel zu sein.
  • Erleichterte Integration: Fremde Klassen oder Drittanbieter-Code können ohne Anpassung in eigene APIs eingebunden werden.
  • Dokumentation und Typprüfung: Protokolle dokumentieren erwartetes Verhalten und erlauben statische Typprüfungen mit mypy.

Beispiel: Protokoll für eine API-Response

from typing import Protocol, runtime_checkable

@runtime_checkable
class ApiResponse(Protocol):
    status_code: int
    def json(self) -> dict[str, object]:
        ...

class HttpResponse:
    def __init__(self, code: int, data: dict[str, object]) -> None:
        self.status_code = code
        self._data = data

    def json(self) -> dict[str, object]:
        return self._data


def handle_response(response: ApiResponse) -> None:
    if response.status_code == 200:
        print("Success:", response.json())
    else:
        print("Error with status", response.status_code)

resp = HttpResponse(200, {"user": "alice"})
handle_response(resp)  # Typprüfung und Laufzeit funktionieren

Das Decorator @runtime_checkable erweitert ein Protocol um Laufzeitprüfungen mit isinstance() und issubclass(). Ohne dieses Decorator existiert ein Protocol lediglich für Type Checker wie mypy und kann nicht zur Laufzeit überprüft werden.

Mit @runtime_checkable betrachtet Python jedes Objekt als Instanz des Protocols, das die geforderten Methoden und Attribute bereitstellt – unabhängig davon, ob die Klasse tatsächlich von dem Protocol erbt. Die Prüfung basiert somit auf der Struktur des Objekts und nicht auf seiner Vererbungshierarchie. Unter Umständen wird davon die Performance beeinträchtigt, da die Überprüfung zur Laufzeit erfolgt, weshalb es mit Bedacht eingesetzt werden sollte.

Mermaid-Diagramm: Protocols und Implementierungen

classDiagram
    class ApiResponse {
        <<Protocol>>
        +int status_code
        +json() dict
    }
    class HttpResponse {
        +int status_code
        +json() dict
    }
    ApiResponse <|.. HttpResponse : implizit

Best Practices

  • Verwenden Sie Protocols, wenn Sie APIs definieren, die auf Verhalten statt auf konkrete Klassen abzielen.
  • Nutzen Sie @runtime_checkable nur bei Bedarf, da die Hauptstärke von Protocols in der statischen Typprüfung liegt.
  • Kombinieren Sie Protocols mit @dataclass oder anderen modernen Python-Features, um klare und wartbare Schnittstellen zu schaffen.

Häufige Fehler

  • Protocols mit Implementierungen verwechseln: Protocols definieren nur Schnittstellen, keine konkrete Logik.
  • Annahmen über nominale Typkompatibilität treffen: Ein Objekt muss nicht von einem Protocol erben, um kompatibel zu sein.
  • Runtime-Checks ohne @runtime_checkable verwenden, was zu Fehlern führt.

Spezielle Methoden und Operator Overloading

Das Verhalten eigener Objekte über dunder-Methoden anpassen.

Konzept und Zweck spezieller Methoden

Spezielle Methoden, oft auch "dunder methods" (double underscore) genannt, sind in Python das zentrale Mittel, um das Verhalten von Objekten an die Sprache anzupassen. Anders als in Java oder C#, wo Operatoren und grundlegende Objektinteraktionen meist nicht überladbar sind, erlaubt Python durch diese Methoden eine nahtlose Integration eigener Klassen in die Sprachelemente.

Diese Methoden definieren, wie Objekte auf eingebaute Operationen reagieren, z. B. wie sie dargestellt, verglichen, iteriert oder arithmetisch kombiniert werden. Das ermöglicht idiomatische und lesbare APIs, die sich wie native Datentypen verhalten.

Wichtige Kategorien spezieller Methoden

Objektkonstruktion und -repräsentation

  • __init__(self, ...): Initialisiert ein Objekt nach der Erstellung (vergleichbar mit Konstruktoren in Java/C#).
  • __new__(cls, ...): Erzeugt eine neue Instanz; selten überschrieben, außer bei Immutable-Typen.
  • __repr__(self) -> str: Offizielle String-Repräsentation für Debugging, sollte möglichst eindeutig sein.
  • __str__(self) -> str: Informelle, benutzerfreundliche Darstellung, z. B. für print().

Operator Overloading

Python erlaubt das Überladen von Operatoren durch spezielle Methoden, die mit Operatoren verknüpft sind:

Operator Methode Beschreibung
+ __add__ Addition
- __sub__ Subtraktion
* __mul__ Multiplikation
/ __truediv__ Division (float)
// __floordiv__ Ganzzahldivision
% __mod__ Modulo
** __pow__ Potenz
== __eq__ Gleichheit
< __lt__ Kleiner als
<= __le__ Kleiner gleich
> __gt__ Größer als
>= __ge__ Größer gleich

Beispiel: Ein Vektorobjekt mit Addition:

from typing import Any

class Vector2D:
    def __init__(self, x: float, y: float) -> None:
        self.x = x
        self.y = y

    def __add__(self, other: Any) -> 'Vector2D':
        if not isinstance(other, Vector2D):
            return NotImplemented
        return Vector2D(self.x + other.x, self.y + other.y)

    def __repr__(self) -> str:
        return f"Vector2D(x={self.x}, y={self.y})"

v1 = Vector2D(1.0, 2.0)
v2 = Vector2D(3.0, 4.0)
print(v1 + v2)  # Vector2D(x=4.0, y=6.0)

Container- und Iterationsprotokolle

  • __len__(self) -> int: Länge eines Containers, z. B. für len(obj).
  • __getitem__(self, key): Zugriff via Index oder Schlüssel.
  • __setitem__(self, key, value): Zuweisung zu Index oder Schlüssel.
  • __iter__(self): Rückgabe eines Iterators.

Diese Methoden ermöglichen, dass eigene Klassen wie native Container genutzt werden können.

Allerdings ist Zurückhaltung geboten: Überladen sollte semantisch sinnvoll sein und nicht zu überraschendem Verhalten führen.

Beispiel: Operator Overloading für eine API-Datenstruktur

from typing import Any

class HttpHeaders:
    def __init__(self, headers: dict[str, str]) -> None:
        self._headers = headers.copy()

    def __add__(self, other: Any) -> 'HttpHeaders':
        if not isinstance(other, HttpHeaders):
            return NotImplemented
        combined = self._headers.copy()
        combined.update(other._headers)
        return HttpHeaders(combined)

    def __getitem__(self, key: str) -> str:
        return self._headers[key.lower()]

    def __repr__(self) -> str:
        return f"HttpHeaders({self._headers})"

h1 = HttpHeaders({'content-type': 'application/json'})
h2 = HttpHeaders({'authorization': 'Bearer token'})
h3 = h1 + h2
print(h3)  # HttpHeaders({'content-type': 'application/json', 'authorization': 'Bearer token'})

Häufige Fehler und Best Practices

  • Nicht NotImplemented zurückgeben: Bei inkompatiblen Typen sollte NotImplemented zurückgegeben werden, nicht None oder eine Exception, damit Python den rechten Operand versucht.

  • Symmetrisches Verhalten sicherstellen: Wenn a + b funktioniert, sollte auch b + a funktionieren oder zumindest b.__radd__(a) definiert sein.

  • Vermeide Seiteneffekte: Operatoren sollten keine unerwarteten Zustandsänderungen auslösen.

  • Klare Semantik: Überlade nur Operatoren, wenn die Bedeutung klar und intuitiv ist.

Zusammenfassung

Spezielle Methoden sind der Python-idiomatische Weg, um eigene Klassen nahtlos in die Sprache zu integrieren und Operatoren, Containerzugriffe oder Darstellungen anzupassen. Sie fördern lesbaren, ausdrucksstarken Code und sind ein mächtiges Werkzeug, das erfahrene Entwickler nutzen sollten, um Python-typische APIs zu gestalten.

Komposition oder Vererbung?

Geeignete Entwurfsentscheidungen treffen und typische Fehlentwicklungen vermeiden.

Komposition vs. Vererbung: Grundlegende Designentscheidungen

In der objektorientierten Programmierung sind Vererbung und Komposition zwei fundamentale Techniken zur Wiederverwendung von Code und zur Strukturierung von Software. Während Vererbung eine "ist-ein"-Beziehung modelliert, beschreibt Komposition eine "hat-ein"-Beziehung.

Beispiel: Logger mit Komposition

from typing import Protocol

class Logger(Protocol):
    def log(self, message: str) -> None:
        ...

class ConsoleLogger:
    def log(self, message: str) -> None:
        print(f"Console: {message}")

class FileLogger:
    def __init__(self, filepath: str) -> None:
        self.filepath = filepath

    def log(self, message: str) -> None:
        with open(self.filepath, 'a') as f:
            f.write(f"File: {message}\n")

class Application:
    def __init__(self, logger: Logger) -> None:
        self._logger = logger

    def run(self) -> None:
        self._logger.log("Application started")
        # ... weitere Logik

app_console = Application(ConsoleLogger())
app_file = Application(FileLogger("app.log"))

app_console.run()
app_file.run()

Hier wird das Logger-Verhalten durch Komposition eingebracht. Die Application-Klasse ist nicht an eine konkrete Logger-Implementierung gebunden, was Flexibilität und Testbarkeit erhöht.

Vergleich mit Vererbung

Eine Vererbungsalternative könnte so aussehen:

class LoggerBase:
    def log(self, message: str) -> None:
        raise NotImplementedError

class ConsoleLogger(LoggerBase):
    def log(self, message: str) -> None:
        print(f"Console: {message}")

class Application(ConsoleLogger):
    def run(self) -> None:
        self.log("Application started")

Diese Lösung bindet Application direkt an ConsoleLogger, was spätere Änderungen erschwert und Mehrfachvererbung kompliziert macht.

Mermaid-Diagramm: Komposition vs. Vererbung

classDiagram
    class Application {
        - logger: Logger
        + run()
    }
    class Logger {
        <<interface>>
        + log(message: str)
    }
    class ConsoleLogger {
        + log(message: str)
    }
    Application --> Logger : uses
    ConsoleLogger ..|> Logger

    class ApplicationV {
        + run()
    }
    class ConsoleLoggerV {
        + log(message: str)
    }
    ApplicationV --|> ConsoleLoggerV

Best Practices

  • Bevorzugen Sie Komposition für flexible und wartbare Architekturen.
  • Verwenden Sie Vererbung nur, wenn eine klare "ist-ein"-Beziehung besteht und Verhalten sinnvoll erweitert wird.
  • Nutzen Sie Protokolle (Protocols) oder abstrakte Basisklassen, um Schnittstellen zu definieren und lose Kopplung zu fördern.
  • Vermeiden Sie tiefe Vererbungshierarchien, um Komplexität und unerwartetes Verhalten zu minimieren.

Merksatz

"Favor composition over inheritance" ist nicht nur ein Designprinzip, sondern in Python eine praktische Leitlinie, die zu klareren, flexibleren und besser testbaren Programmen führt.

Typische Fallstricke bei Vererbungsdesigns

  • Übermäßige Nutzung von Vererbung führt zu schwer verständlichen Hierarchien.
  • Vererbte Methoden können unerwartet überschrieben werden (fragile Base Class Problem).
  • Mehrfachvererbung kann zu Konflikten in der Method Resolution Order (MRO) führen.

Fazit

Python bietet mit seiner dynamischen Typisierung und flexiblen Objektmodel eine hervorragende Basis, um Komposition idiomatisch einzusetzen. Dies entspricht der Python-Denkweise, die auf Klarheit, Einfachheit und Flexibilität setzt – Eigenschaften, die in statisch typisierten Sprachen oft durch komplexe Vererbungsstrukturen erkauft werden.

Praxisbeispiele für moderne OOP

Die vorgestellten Konzepte anhand realistischer Anwendungen kombinieren und bewerten.

Realistische Anwendung: API-Client mit OOP-Prinzipien

In professionellen Python-Projekten ist es üblich, API-Clients objektorientiert zu modellieren. Dabei zeigen sich die Vorteile von Konstruktoren, Properties, Vererbung und Polymorphie besonders deutlich.

from typing import Any, Optional
import requests

class APIClient:
    def __init__(self, base_url: str, token: Optional[str] = None) -> None:
        self._base_url = base_url.rstrip('/')
        self._token = token

    @property
    def headers(self) -> dict[str, str]:
        headers = {'Accept': 'application/json'}
        if self._token:
            headers['Authorization'] = f'Bearer {self._token}'
        return headers

    def get(self, endpoint: str, params: Optional[dict[str, Any]] = None) -> dict[str, Any]:
        url = f'{self._base_url}/{endpoint.lstrip("/")}'
        response = requests.get(url, headers=self.headers, params=params)
        response.raise_for_status()
        return response.json()

# Erweiterung durch Vererbung und Polymorphie
class GitHubClient(APIClient):
    def __init__(self, token: str) -> None:
        super().__init__('https://api.github.com', token)

    def get_user_repos(self, username: str) -> list[dict[str, Any]]:
        return self.get(f'users/{username}/repos')

class GitLabClient(APIClient):
    def __init__(self, token: str) -> None:
        super().__init__('https://gitlab.com/api/v4', token)

    def get_user_repos(self, user_id: int) -> list[dict[str, Any]]:
        return self.get(f'users/{user_id}/projects')

# Polymorpher Einsatz
def list_repos(client: APIClient, user_identifier: Any) -> None:
    repos = client.get_user_repos(user_identifier)  # Polymorpher Aufruf
    for repo in repos:
        print(repo['name'])

# Beispiel
if __name__ == '__main__':
    gh_client = GitHubClient(token='ghp_exampletoken')
    gl_client = GitLabClient(token='glpat-exampletoken')

    list_repos(gh_client, 'octocat')
    list_repos(gl_client, 123456)

Erläuterungen

  • Konstruktoren initialisieren Basis-URL und Authentifizierung.
  • Properties kapseln Header-Generierung und erlauben kontrollierten Zugriff.
  • Vererbung ermöglicht spezifische API-Clients mit gemeinsamen Basisfunktionen.
  • Polymorphie erlaubt Austauschbarkeit der Clients in Funktionen.

Designentscheidungen und Python-Philosophie

Im Vergleich zu Java oder C# ist Python weniger strikt typisiert, was Flexibilität bei Polymorphie und dynamischer Methodensignatur erlaubt. Statt Interfaces oder abstrakten Klassen wird hier oft auf duck typing gesetzt: Solange ein Objekt die erwartete Methode anbietet, funktioniert es.

Die Verwendung von Properties anstelle klassischer Getter/Setter reduziert Boilerplate und verbessert Lesbarkeit. Konstruktoren sind schlank gehalten, komplexe Logik sollte ausgelagert werden, um Testbarkeit zu erhöhen.

Mermaid-Diagramm: Klassenstruktur und Vererbung

classDiagram
    class APIClient {
        - _base_url: str
        - _token: Optional[str]
        + __init__(base_url: str, token: Optional[str])
        + headers: dict[str, str]
        + get(endpoint: str, params: Optional[dict]) -> dict
    }
    class GitHubClient {
        + __init__(token: str)
        + get_user_repos(username: str) -> list[dict]
    }
    class GitLabClient {
        + __init__(token: str)
        + get_user_repos(user_id: int) -> list[dict]
    }
    APIClient <|-- GitHubClient
    APIClient <|-- GitLabClient

Best Practices

  • Nutze Composition over Inheritance, wenn gemeinsame Funktionalität nicht klar hierarchisch ist.
  • Verwende abc.ABC und abstrakte Methoden, wenn eine gemeinsame Schnittstelle explizit erzwungen werden soll.
  • Halte API-Clients schlank und delegiere komplexe Logik an Services oder Utilities.
  • Nutze Typannotationen konsequent, um Lesbarkeit und Tool-Unterstützung zu verbessern.

Zusammenfassung

Dieses Beispiel zeigt, wie moderne Python-OOP-Konzepte in realen Anwendungen kombiniert werden, um flexible, wartbare und idiomatische Lösungen zu schaffen. Die bewusste Nutzung von Properties, Vererbung und Polymorphie unterstützt die Entwicklung erweiterbarer Komponenten, während die Python-typische Dynamik und duck typing die API-Entwicklung erleichtern.