Veränderbare Default-Argumente in Python
Einer der häufigsten Fallstricke in Python ist das Verhalten von veränderbaren (mutables) Default-Argumenten in Funktionsdefinitionen. Dies kann zu unerwartetem Verhalten und schwer zu findenden Bugs führen, besonders in größeren Codebasen.
Das Problem verstehen
In Python werden Default-Argumente zum Zeitpunkt der Funktionsdefinition ausgewertet und gespeichert - nicht bei jedem Funktionsaufruf. Dieses Verhalten, obwohl konsistent mit Pythons Ausführungsmodell, kann zu überraschenden Ergebnissen führen, wenn das Default-Argument ein veränderbares Objekt wie eine Liste, ein Dictionary oder ein Set ist.
Wichtige Konzepte:
- Zeitpunkt der Funktionsdefinition: Default-Argumente werden einmalig bei der Funktionsdefinition ausgewertet.
- Objektreferenz: Dasselbe Objekt wird über alle Funktionsaufrufe hinweg wiederverwendet.
- Veränderbarkeit: Änderungen am Objekt bleiben zwischen Funktionsaufrufen bestehen.
Häufige Szenarien
Hier sind typische Situationen, in denen dieses Problem auftritt:
# Szenario 1: Listen-Akkumulation def nutzer_hinzufuegen(name, nutzer=[]): nutzer.append(name) return nutzer print(nutzer_hinzufuegen("Alice")) # Ausgabe: ["Alice"] print(nutzer_hinzufuegen("Bob")) # Ausgabe: ["Alice", "Bob"] - Unerwartet! # Szenario 2: Dictionary-Caching def daten_cachen(schluessel, wert, cache={}): cache[schluessel] = wert return cache print(daten_cachen("a", 1)) # Ausgabe: {"a": 1} print(daten_cachen("b", 2)) # Ausgabe: {"a": 1, "b": 2} - Cache bleibt erhalten! # Szenario 3: Klassen-Attribut-Initialisierung class Benutzer: def __init__(self, items=[]): self.items = items benutzer1 = Benutzer() benutzer1.items.append("buch") benutzer2 = Benutzer() # benutzer2.items enthält bereits "buch"!
Wie du das Problem erkennst
-
Code-Review-Muster:
- Funktionen mit veränderbaren Default-Argumenten (Listen, Dictionaries, Sets)
- Klassenattribute, die mit leeren Sammlungen initialisiert werden
- Caching- oder Akkumulator-Funktionen
-
Laufzeit-Symptome:
- Unerwartete Datenansammlung
- Gemeinsamer Zustand zwischen Funktionsaufrufen
- Objekte, die Daten von vorherigen Operationen enthalten
-
Test-Probleme:
- Tests laufen einzeln erfolgreich, schlagen aber zusammen fehl
- Reihenfolgenabhängige Testfehler
- Inkonsistentes Verhalten in lang laufenden Anwendungen
Die Lösung
Hier sind verschiedene Möglichkeiten, dieses Problem zu beheben:
# Lösung 1: None als Default verwenden def nutzer_hinzufuegen(name, nutzer=None): if nutzer is None: nutzer = [] nutzer.append(name) return nutzer # Lösung 2: Unveränderlichen Default + Konvertierung verwenden def items_verarbeiten(items=()): # Tuple als Default items_liste = list(items) # items_liste verarbeiten... return items_liste # Lösung 3: Factory-Funktions-Muster def neue_liste_erstellen(): return [] def nutzer_hinzufuegen(name, nutzer=None): nutzer = neue_liste_erstellen() if nutzer is None else nutzer nutzer.append(name) return nutzer
Praxisbeispiel
Hier ist ein komplexeres Praxisbeispiel mit einem Cache-Dekorator:
# Problematische Implementierung def ergebnisse_cachen(func, cache={}): def wrapper(*args): if args not in cache: cache[args] = func(*args) return cache[args] return wrapper # Bessere Implementierung def ergebnisse_cachen(func): def wrapper(cache=None, *args): if cache is None: cache = {} if args not in cache: cache[args] = func(*args) return cache[args] return wrapper # Beste Implementierung mit functools from functools import lru_cache @lru_cache(maxsize=None) def aufwendige_funktion(n): # Komplexe Berechnung hier return n ** 2
Vorbeugende Maßnahmen
-
Code-Review-Richtlinien:
- Achte auf veränderbare Default-Argumente in Funktionsdefinitionen
- Überprüfe Klassenattribut-Initialisierungen
- Prüfe Caching-Implementierungen
-
Verwende statische Analyse-Tools:
# pylint-Konfiguration [BASIC] disable=dangerous-default-value
-
Test-Strategien:
def test_nutzer_hinzufuegen(): # Test-Isolation ergebnis1 = nutzer_hinzufuegen("Alice") assert ergebnis1 == ["Alice"] # Test neue Instanz ergebnis2 = nutzer_hinzufuegen("Bob") assert ergebnis2 == ["Bob"] # Test explizite Liste eigene_liste = ["Charlie"] ergebnis3 = nutzer_hinzufuegen("David", eigene_liste) assert ergebnis3 == ["Charlie", "David"]
Best Practices
-
Verwende unveränderliche Defaults:
- None für optionale veränderbare Argumente
- Tuples statt Listen wo möglich
- Frozen sets statt normaler Sets
-
Factory-Funktionen:
- Erstelle neue Instanzen bei Bedarf
- Implementiere klare Objekt-Erstellungsmuster
- Dokumentiere das Verhalten von Factory-Funktionen
-
Dokumentation:
def daten_verarbeiten(items=None): """Verarbeitet eine Liste von Items. Args: items: Optionale Liste von zu verarbeitenden Items. Wenn None, wird eine neue Liste erstellt. Hinweis: Diese Funktion erstellt eine neue Liste wenn items None ist, um Probleme mit veränderbaren Default-Argumenten zu vermeiden. """ items = [] if items is None else items # Items verarbeiten... return items
Häufige Fehler, die du vermeiden solltest
-
Mehrere veränderbare Defaults:
# Falsch def verarbeiten(items=[], cache={}, gesehen=set()): # Das ist problematisch! pass # Richtig def verarbeiten(items=None, cache=None, gesehen=None): items = [] if items is None else items cache = {} if cache is None else cache gesehen = set() if gesehen is None else gesehen
-
Verschachtelte veränderbare Strukturen:
# Falsch def verschachtelte_defaults(daten={'nutzer': []}): # Verschachtelte veränderbare Strukturen sind noch problematischer pass # Richtig def verschachtelte_defaults(daten=None): if daten is None: daten = {'nutzer': []}
Denk dran: Auch wenn Pythons veränderbare Default-Argumente tückisch sein können, hilft das Verständnis des zugrundeliegenden Mechanismus dabei, zuverlässigeren Code zu schreiben. Bevorzuge immer explizite Initialisierung gegenüber impliziter Zustandsverwaltung.