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¶
selfals 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
selfexplizit 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/**kwargsverwendet.
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
**kwargsersetzen 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
selfin 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
abcundProtocolfü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
abcexplizit 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
@abstractmethoddekorieren, 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 rufesuper().__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=Truefü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,constrfü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-Klassefrozen = True. - Nutzen Sie Typen aus Pydantic und
typingfür präzise Validierung. - Verwenden Sie verschachtelte Modelle zur Strukturierung komplexer Daten.
- Fangen Sie
ValidationErrorgezielt 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}")
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_checkablenur bei Bedarf, da die Hauptstärke von Protocols in der statischen Typprüfung liegt. - Kombinieren Sie Protocols mit
@dataclassoder 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_checkableverwenden, 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ürprint().
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ürlen(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
NotImplementedzurückgeben: Bei inkompatiblen Typen sollteNotImplementedzurückgegeben werden, nichtNoneoder eine Exception, damit Python den rechten Operand versucht. -
Symmetrisches Verhalten sicherstellen: Wenn
a + bfunktioniert, sollte auchb + afunktionieren oder zumindestb.__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.