cat posts/python-lernen-08-wenn-es-schiefgeht.md
Python lernen (Teil 8): Wenn's schiefgeht
Exceptions verstehen und gezielt behandeln: try, except, else, finally, eigene Fehlerklassen und EAFP – damit der Dungeon auf ungültigen Input reagiert, statt abzustürzen.
In Teil 7 haben wir den Dungeon mit Klassen und Objekten strukturiert. Unser Code ist inzwischen gut aufgeteilt, verlässt sich aber weiterhin darauf, dass der Spieler vernünftige Eingaben macht.
Bei dieser Zeile reicht bereits ein falsches Wort:
gold = int(input("Wie viele Goldmünzen bringst Du mit? "))
Gibt der Spieler statt einer Zahl beispielsweise viel ein, endet das Programm
mit einem Fehler:
Traceback (most recent call last):
...
ValueError: invalid literal for int() with base 10: 'viel'
Heute sorgen wir dafür, dass solche Situationen den Dungeon nicht mehr einfach beenden. Bei einer ungültigen Eingabe soll das Spiel erklären, was schiefging, und erneut nachfragen.
Dafür verwendet Python Exceptions.
Syntaxfehler und Exceptions
Bei Fehlern lohnt sich zunächst eine Unterscheidung.
Hier fehlt ein Doppelpunkt:
if hp > 0
print("Du lebst.")
Python kann diesen Quelltext nicht korrekt parsen und meldet einen
SyntaxError.
SyntaxError ist technisch ebenfalls ein Exception Type. Bei einer normalen
Python-Datei tritt er aber bereits auf, wenn Python den Quelltext einliest und
kompiliert. Der eigentliche Programmablauf beginnt an dieser Stelle also gar
nicht erst.
Anders sieht es hier aus:
gold = int("viel")
Der Code ist syntaktisch gültig und kann ausgeführt werden. Erst während der
Ausführung stellt int(...) fest, dass sich "viel" nicht in einen Integer
umwandeln lässt. Deshalb wird ein ValueError ausgelöst.
Einige Exceptions, die Dir in Python häufig begegnen:
| Exception | Typischer Grund |
|---|---|
ValueError |
Der Typ passt, der konkrete Wert aber nicht |
TypeError |
Eine Operation erhält einen unpassenden Typ |
KeyError |
Ein Mapping enthält den gesuchten Schlüssel nicht |
IndexError |
Ein Sequenzindex liegt außerhalb des gültigen Bereichs |
AttributeError |
Ein Objekt besitzt das angesprochene Attribut nicht |
ZeroDivisionError |
Eine Zahl wird durch null geteilt |
FileNotFoundError |
Eine angeforderte Datei wurde nicht gefunden |
Beispiele:
int("viel") # ValueError
"HP: " + 100 # TypeError
raeume["verlies"] # KeyError
inventar[20] # IndexError
spieler.lebenspunkte # möglicherweise AttributeError
10 / 0 # ZeroDivisionError
open("spielstand.json") # möglicherweise FileNotFoundError
Welcher Exception Type auftritt, ist wichtig. Wir wollen später möglichst genau die Fehler behandeln, die an einer bestimmten Stelle tatsächlich erwartet werden.
Was geschieht bei einer Exception?
Wird eine Exception ausgelöst, läuft der aktuelle Code nicht einfach in der nächsten Zeile weiter.
Betrachten wir:
print("Vorher")
gold = int("viel")
print("Nachher")
Die erste Ausgabe erscheint:
Vorher
Dann löst int("viel") einen ValueError aus. Die zweite
print-Anweisung wird nicht mehr erreicht.
Python sucht nun nach einem passenden Exception Handler. Gibt es im aktuellen Code keinen, wird die Exception an die aufrufende Stelle weitergereicht. Das setzt sich durch die Aufrufkette fort, bis ein passender Handler gefunden wird.
Bleibt die Exception unbehandelt, beendet Python das Programm und zeigt einen Traceback.
Das Weiterreichen einer Exception wird als Propagation bezeichnet. Beim Weg durch verschachtelte Funktionsaufrufe wird dabei der Call Stack wieder abgebaut – daher begegnet Dir auch der Begriff Stack Unwinding.
Einen Traceback lesen
Ein Traceback zeigt, über welche Aufrufe Python zur Stelle gelangt ist, an der eine unbehandelte Exception auftrat.
Ein gekürztes Beispiel:
Traceback (most recent call last):
File "dungeon.py", line ..., in <module>
main()
File "dungeon.py", line ..., in main
gold = int(input("Wie viele Goldmünzen bringst Du mit? "))
ValueError: invalid literal for int() with base 10: 'viel'
Beim Lesen lohnt sich meistens der Blick nach unten zuerst.
Die letzte Zeile enthält:
ValueError: invalid literal for int() with base 10: 'viel'
Dort findest Du den Exception Type und die Fehlermeldung. Häufig lässt sich daraus bereits erkennen, was schiefging.
Darüber steht die Stelle, an der die Exception ausgelöst wurde:
File "dungeon.py", line ..., in main
Die weiteren Einträge darüber zeigen, über welche Funktionsaufrufe Python an diese Stelle gelangt ist.
Bei längeren Tracebacks suche ich deshalb zuerst unten nach der Exception und arbeite mich anschließend nach oben durch die Dateien und Funktionen des eigenen Projekts.
try und except
Code, bei dem eine bestimmte Exception auftreten kann, können wir in einen
try-Block setzen:
try:
gold = int(input("Wie viele Goldmünzen bringst Du mit? "))
except ValueError:
print("Bitte gib eine ganze Zahl ein.")
gold = 0
Python führt zunächst den try-Block aus.
Läuft er erfolgreich durch, wird der except-Block übersprungen.
Löst int(...) dagegen einen ValueError aus, springt Python zum passenden
Handler:
except ValueError:
Der restliche try-Block wird dabei nicht mehr ausgeführt. Nach dem
except-Block läuft das Programm hinter dem gesamten try-Statement weiter.
try:
gold = int(input("Wie viele Goldmünzen bringst Du mit? "))
except ValueError:
print("Bitte gib eine ganze Zahl ein.")
gold = 0
print(f"Du startest mit {gold} Goldmünzen.")
Bei der Eingabe viel lautet die Ausgabe:
Bitte gib eine ganze Zahl ein.
Du startest mit 0 Goldmünzen.
Das Programm bricht nicht mehr mit einem Traceback ab.
Für unseren Dungeon ist 0 als Ersatzwert allerdings nicht besonders schön.
Etwas später bauen wir deshalb eine Funktion, die stattdessen erneut nachfragt.
Nur erwartete Exceptions behandeln
Ein except sollte möglichst genau beschreiben, welchen Fehler Du an dieser
Stelle erwartest:
except ValueError:
...
Vermeide normalerweise einen nackten except:
try:
...
except:
...
Ein solcher Handler fängt praktisch alles ab, was von BaseException erbt.
Dazu gehören auch KeyboardInterrupt und SystemExit.
Dadurch könnte beispielsweise ein mit Strg+C ausgelöster Abbruch
versehentlich abgefangen werden.
Auch diese Variante ist sehr breit:
try:
...
except Exception:
...
Exception ist die Basisklasse der meisten Exceptions, mit denen
Anwendungsprogramme normalerweise arbeiten. Anders als ein nackter except
fängt sie unter anderem KeyboardInterrupt und SystemExit nicht ab.
Trotzdem kann ein pauschales except Exception viele Bugs verstecken, wenn Du
die Exception anschließend einfach behandelst und weiterläufst.
Stark vereinfacht sieht ein Teil der Hierarchie so aus:
BaseException
├── KeyboardInterrupt
├── SystemExit
├── GeneratorExit
└── Exception
├── ValueError
├── TypeError
├── KeyError
├── IndexError
├── OSError
└── ...
Eigene Anwendungsfehler leiten wir normalerweise von Exception oder einer
passenderen Unterklasse davon ab, nicht direkt von BaseException.
Die Exception als Objekt verwenden
Mit as kannst Du auf das ausgelöste Exception-Objekt zugreifen:
try:
gold = int(input("Wie viele Goldmünzen bringst Du mit? "))
except ValueError as fehler:
print(f"Die Eingabe konnte nicht umgewandelt werden: {fehler}")
Eine mögliche Ausgabe:
Die Eingabe konnte nicht umgewandelt werden: invalid literal for int() with base 10: 'viel'
Das Exception-Objekt enthält je nach Exception Type Informationen über den Fehler.
Dem Spieler würde ich den internen Text von int(...) allerdings nicht
ungefiltert zeigen. Diese Meldung ist verständlicher:
except ValueError:
print("Bitte gib eine ganze Zahl ein.")
Beim Debugging oder Logging kann die Originalmeldung dagegen sehr hilfreich sein.
Mehrere Exceptions getrennt behandeln
Ein try-Block kann mehrere except-Blöcke besitzen:
try:
nummer = int(input("Welchen Gegenstand möchtest Du? "))
gegenstand = inventar[nummer - 1]
except ValueError:
print("Bitte gib eine Zahl ein.")
except IndexError:
print("Unter dieser Nummer gibt es keinen Gegenstand.")
else:
print(f"Du hast {gegenstand} ausgewählt.")
Python verwendet den ersten Handler, dessen Exception Type zur ausgelösten Exception passt.
Eine andere Exception wird nicht einfach ignoriert. Tritt beispielsweise ein
unerwarteter TypeError auf, wird er von diesen beiden Handlern nicht
abgefangen und weiter nach außen gereicht.
Das ist gut so: Ein Fehler, mit dem wir nicht gerechnet haben, sollte nicht versehentlich als normale Fehleingabe verschwinden.
Mehrere Exception Types gemeinsam behandeln
Sollen mehrere Exception Types gleich behandelt werden, kannst Du sie als Tupel angeben:
try:
wert = int(eingabe)
except (TypeError, ValueError):
print("Der Wert lässt sich nicht in eine Zahl umwandeln.")
Das ist sinnvoll, wenn für beide Fälle wirklich dieselbe Reaktion gewünscht ist.
Brauchen die Fehler unterschiedliche Meldungen oder eine andere Behandlung,
bleiben getrennte except-Blöcke verständlicher.
Den try-Block klein halten
Ein try-Block sollte möglichst nur den Code umfassen, bei dem die erwartete
Exception auftreten kann.
Ungünstig wäre:
try:
gold = int(eingabe)
spieler = Spieler(name, gold=gold)
spieler.zeige_inventar()
except ValueError:
print("Die Goldmenge ist ungültig.")
int(...) kann einen ValueError auslösen. Unser Spieler aus Teil 7 könnte
aber ebenfalls einen ValueError auslösen, wenn wir seine Validierung
entsprechend erweitern.
Der Handler könnte dann behaupten, die Gold-Eingabe sei fehlerhaft, obwohl der Fehler an einer ganz anderen Stelle entstand.
Besser:
try:
gold = int(eingabe)
except ValueError:
print("Die Goldmenge ist ungültig.")
else:
spieler = Spieler(name, gold=gold)
spieler.zeige_inventar()
Nun behandelt der except-Block nur Exceptions aus der Umwandlung im
try-Block.
Schmale try-Blöcke machen deutlicher, welchen Fehler wir tatsächlich
behandeln wollen.
else: der Erfolgsfall
Ein try-Statement kann nach den except-Blöcken einen else-Block besitzen:
try:
gold = int(eingabe)
except ValueError:
print("Bitte gib eine ganze Zahl ein.")
else:
print(f"Du startest mit {gold} Goldmünzen.")
Der else-Block wird ausgeführt, wenn der try-Block normal beendet wurde,
also insbesondere keine Exception ausgelöst und kein return, break oder
continue ausgeführt wurde.
Der Vorteil gegenüber zusätzlichem Code im try-Block: Exceptions aus dem
else-Block werden nicht von den davorstehenden except-Blöcken
behandelt.
try:
gold = int(eingabe)
except ValueError:
print("Ungültige Zahl.")
else:
spieler = Spieler(name, gold=gold)
Wenn Spieler(...) hier selbst einen ValueError auslöst, landet dieser nicht
im Handler für die Zahleneingabe.
Genau deshalb eignet sich else gut dafür, den eigentlich geschützten Bereich
klein zu halten.
finally: Code beim Verlassen des try-Statements
Ein finally-Block wird beim Verlassen des zugehörigen try-Statements
ausgeführt – im normalen Python-Kontrollfluss unabhängig davon, ob eine
Exception auftrat oder nicht.
try:
wert = int(eingabe)
except ValueError:
print("Keine gültige Zahl.")
finally:
print("Die Eingabe wurde verarbeitet.")
Der finally-Block läuft unter anderem dann, wenn:
- keine Exception auftritt,
- eine Exception behandelt wird,
- eine Exception unbehandelt weitergereicht wird,
- der geschützte Code mit
return,breakodercontinueverlassen wird.
finally wird deshalb häufig für Cleanup verwendet.
Ein klassisches Beispiel ist das Schließen einer Datei:
datei = open("spielstand.txt", encoding="utf-8")
try:
inhalt = datei.read()
finally:
datei.close()
Auch wenn read() fehlschlägt, wird datei.close() beim Verlassen des
try-Statements noch ausgeführt.
Für Dateien ist allerdings ein with-Block fast immer die angenehmere Lösung:
with open("spielstand.txt", encoding="utf-8") as datei:
inhalt = datei.read()
Darum geht es ausführlicher in Teil 9.
Kein Kontrollfluss aus finally
Vermeide return, break oder continue, wenn sie einen finally-Block
verlassen.
Beispielsweise kann ein return dort eine gerade aktive Exception verdrängen:
def problematisch():
try:
1 / 0
finally:
return 42
Statt des ZeroDivisionError liefert die Funktion hier 42.
Seit Python 3.14 warnt der Compiler mit einem SyntaxWarning, wenn
return, break oder continue auf diese Weise einen finally-Block
verlassen.
Unabhängig von der Python-Version ist solcher Code schwer nachvollziehbar und sollte vermieden werden.
Die vollständige Form
try, except, else und finally können miteinander kombiniert werden:
try:
wert = int(eingabe)
except ValueError:
print("Keine gültige Zahl.")
else:
print(f"Gültige Zahl: {wert}")
finally:
print("Verarbeitung abgeschlossen.")
Die Reihenfolge ist fest:
try
except
else
finally
Du brauchst aber nicht immer alle vier Bestandteile.
Ein try benötigt entweder mindestens einen except-Block oder einen
finally-Block. else kann nur zusammen mit except verwendet werden.
Wiederholt nach einer Zahl fragen
Für unseren Dungeon soll eine falsche Eingabe nicht automatisch zu 0 werden.
Wir fragen stattdessen so lange nach, bis ein gültiger Integer eingegeben
wurde:
def frage_ganzzahl(text):
while True:
eingabe = input(text)
try:
wert = int(eingabe)
except ValueError:
print("Bitte gib eine ganze Zahl ein.")
else:
return wert
Bei einer ungültigen Eingabe wird der ValueError behandelt. Danach erreicht
die Funktion das Ende des Schleifendurchlaufs und while True beginnt von vorn.
Ein möglicher Ablauf:
Wie viele Goldmünzen bringst Du mit? viel
Bitte gib eine ganze Zahl ein.
Wie viele Goldmünzen bringst Du mit? fünfzig
Bitte gib eine ganze Zahl ein.
Wie viele Goldmünzen bringst Du mit? 50
Beim Wert 50 läuft int(...) erfolgreich durch und die Funktion gibt den
Integer zurück. Durch return endet damit zugleich die Schleife.
Wertebereiche zusätzlich prüfen
Ein gültiger Integer kann für unser Spiel trotzdem ungeeignet sein:
Wie viele Goldmünzen bringst Du mit? -500
-500 lässt sich problemlos in einen Integer umwandeln. Negative
Start-Goldmünzen wollen wir trotzdem nicht zulassen.
Wir ergänzen deshalb einen Mindestwert:
def frage_ganzzahl(text, minimum=None):
while True:
eingabe = input(text)
try:
wert = int(eingabe)
except ValueError:
print("Bitte gib eine ganze Zahl ein.")
continue
if minimum is not None and wert < minimum:
print(f"Der Wert muss mindestens {minimum} betragen.")
continue
return wert
Verwendung:
gold = frage_ganzzahl(
"Wie viele Goldmünzen bringst Du mit? ",
minimum=0,
)
Hier behandeln zwei verschiedene Sprachmittel zwei verschiedene Situationen:
except ValueErrorreagiert darauf, dass die Umwandlung gar nicht möglich war.if wert < minimumprüft eine fachliche Regel unseres Spiels.
Ein negativer Integer ist kein Fehler für int(...). Er ist lediglich für
unsere Goldmenge nicht erlaubt.
Exceptions selbst auslösen
Exceptions müssen nicht von Python oder einer Built-in-Funktion stammen. Mit
raise können wir selbst eine auslösen:
def setze_gold(spieler, gold):
if gold < 0:
raise ValueError("Gold darf nicht negativ sein.")
spieler.gold = gold
Der Aufruf
setze_gold(spieler, -20)
löst einen ValueError aus:
ValueError: Gold darf nicht negativ sein.
raise unterbricht den normalen Ablauf an dieser Stelle genauso wie eine von
Python ausgelöste Exception.
Das ist nützlich, wenn eine Funktion mit den übergebenen Daten ihren Auftrag nicht sinnvoll erfüllen kann.
Eigene Exception-Klassen
Built-in Exceptions wie ValueError, TypeError oder KeyError reichen für
viele Situationen aus.
Für fachliche Fehler unseres Spiels können eigene Exception Types den Code aber verständlicher machen:
class UngueltigerBefehlError(Exception):
"""Ein eingegebener Befehl ist unbekannt oder unvollständig."""
Mehr braucht eine einfache eigene Exception zunächst nicht.
Wir können sie mit raise auslösen:
raise UngueltigerBefehlError("Bitte gib einen Befehl ein.")
und gezielt behandeln:
try:
verarbeite_befehl(eingabe)
except UngueltigerBefehlError as fehler:
print(fehler)
Nach verbreiteter Python-Konvention enden Namen von Exception-Klassen, die
Fehler darstellen, auf Error.
Eine gemeinsame Basisklasse
Für mehrere fachlich verwandte Exceptions können wir eine eigene gemeinsame Basisklasse definieren:
class DungeonError(Exception):
"""Basisklasse für erwartbare Fehler im Dungeon."""
class UngueltigerBefehlError(DungeonError):
"""Der eingegebene Befehl ist unbekannt oder unvollständig."""
class UnmoeglicheAktionError(DungeonError):
"""Eine bekannte Aktion ist in der aktuellen Situation nicht möglich."""
Nun können wir die Fehler einzeln behandeln:
try:
...
except UngueltigerBefehlError:
...
except UnmoeglicheAktionError:
...
Oder beide gemeinsam:
try:
...
except DungeonError as fehler:
print(fehler)
Ein AttributeError, TypeError oder NameError gehört dagegen nicht zu
DungeonError.
Genau das wollen wir: Erwartbare Probleme aus der Spiellogik lassen sich gemeinsam behandeln, während echte Bugs weiterhin sichtbar bleiben.
Unbekannt oder nur nicht möglich?
Eigene Exception Types können unterschiedliche fachliche Situationen ausdrücken.
Dieser Befehl ist unvollständig:
nimm
Die Aktion ist nicht vollständig beschrieben. Dafür verwenden wir:
raise UngueltigerBefehlError(
"Was möchtest Du nehmen?"
)
Dieser Befehl ist dagegen verständlich:
nimm drachenei
Liegt im aktuellen Raum kein Drachenei, ist der Befehl gültig, aber die Aktion kann dort nicht ausgeführt werden:
raise UnmoeglicheAktionError(
"Hier liegt kein Gegenstand namens drachenei."
)
Diese Trennung wäre für unser kleines Spiel nicht zwingend notwendig. Sie macht aber deutlicher, warum eine Aktion fehlgeschlagen ist und erlaubt später unterschiedliche Reaktionen auf beide Fälle.
Exception Chaining mit raise ... from ...
Manchmal fangen wir eine technische Exception ab und übersetzen sie in einen Fehler, der besser zu unserer Anwendung passt.
def bestimme_ziel(raum, richtung):
try:
return raum.ausgaenge[richtung]
except KeyError as ursache:
raise UnmoeglicheAktionError(
f"Du kannst nicht nach {richtung} gehen."
) from ursache
Intern schlägt ein Dictionary-Zugriff mit KeyError fehl. Für den Rest unseres
Dungeons ist aber die fachliche Aussage interessanter: In dieser Richtung gibt
es keinen Ausgang.
Mit
raise ... from ursache
markieren wir die neue Exception ausdrücklich als Folge der ursprünglichen.
Bleibt sie unbehandelt, zeigt der Traceback sowohl den ursprünglichen KeyError
als auch den daraus entstandenen UnmoeglicheAktionError.
Das kann bei der Fehlersuche sehr hilfreich sein.
Den vorherigen Kontext ausblenden
Soll die vorherige Exception bewusst nicht im Traceback angezeigt werden, geht das mit:
def bestimme_ziel(raum, richtung):
try:
return raum.ausgaenge[richtung]
except KeyError:
raise UnmoeglicheAktionError(
f"Du kannst nicht nach {richtung} gehen."
) from None
from None unterdrückt die Darstellung des vorherigen Exception-Kontexts im
Traceback.
Das kann bei einer bewusst übersetzten, erwartbaren Exception für eine übersichtlichere Ausgabe sorgen. Beim Debugging ist die technische Ursache dagegen oft nützlich, weshalb Du sie nicht reflexartig ausblenden solltest.
EAFP: Ausführen und Fehler behandeln
In Python ist ein Stil namens EAFP verbreitet:
Easier to Ask Forgiveness than Permission
Sinngemäß bedeutet das:
Versuche die gewünschte Operation und behandle die Exception, falls sie nicht funktioniert.
Beim Dictionary-Zugriff kann das so aussehen:
try:
ziel = raum.ausgaenge[richtung]
except KeyError:
print("In dieser Richtung gibt es keinen Ausgang.")
Die Alternative wird LBYL genannt:
Look Before You Leap
Hier prüfen wir die Voraussetzung zuerst:
if richtung in raum.ausgaenge:
ziel = raum.ausgaenge[richtung]
else:
print("In dieser Richtung gibt es keinen Ausgang.")
Beide Varianten sind völlig gültig.
EAFP ist oft angenehm, wenn der erfolgreiche Zugriff der Normalfall ist und die Operation selbst ohnehin eindeutig entscheidet, ob sie funktioniert.
LBYL kann klarer sein, wenn eine Bedingung ein normaler Teil des Programmablaufs ist und wir sie direkt ausdrücken können.
Ein weiterer Unterschied kann bei gemeinsam genutzten oder sich gleichzeitig ändernden Daten wichtig werden: Zwischen einer Vorabprüfung und der eigentlichen Operation kann sich der Zustand ändern. EAFP vermeidet diese Lücke, weil die Operation selbst getestet wird.
Für unseren kleinen Dungeon spielt das praktisch noch keine Rolle.
Pythonic bedeutet jedenfalls nicht, jedes if durch try und except zu
ersetzen.
dict.get(...) als dritte Möglichkeit
Bei einem Dictionary brauchen wir manchmal weder einen try-Block noch eine
Mitgliedschaftsprüfung:
ziel = raum.ausgaenge.get(richtung)
if ziel is None:
print("In dieser Richtung gibt es keinen Ausgang.")
Das funktioniert gut, solange None kein gültiger Wert im Dictionary sein
kann.
Für dasselbe Problem stehen uns damit mehrere Varianten zur Verfügung:
# LBYL
if richtung in raum.ausgaenge:
ziel = raum.ausgaenge[richtung]
# EAFP
try:
ziel = raum.ausgaenge[richtung]
except KeyError:
...
# Default-Wert
ziel = raum.ausgaenge.get(richtung)
Welche davon am verständlichsten ist, hängt vom konkreten Code ab.
Exceptions nicht als beliebige Sprünge verwenden
Exceptions verändern den Kontrollfluss, sind aber kein Ersatz für jede normale Bedingung.
Das hier wäre unnötig kompliziert:
class SpielerLebt(Exception):
pass
try:
if spieler.hp > 0:
raise SpielerLebt
except SpielerLebt:
print("Der Spieler lebt.")
Ein einfaches if sagt viel direkter, worum es geht:
if spieler.hp > 0:
print("Der Spieler lebt.")
Exceptions passen gut zu Situationen, in denen eine Operation ihren normalen Auftrag nicht erfüllen kann.
Ein erwartbarer Zustand wie „der Spieler hat noch Lebenspunkte“ braucht dagegen keine Exception.
assert ist kein Ersatz für Fehlerbehandlung
Mit assert lassen sich interne Annahmen über den Programmzustand ausdrücken:
assert spieler.hp >= 0
Ist die Bedingung falsch, wird ein AssertionError ausgelöst.
Assertions eignen sich beispielsweise für Zustände, die nach der eigenen Programmlogik eigentlich niemals auftreten dürften.
Sie sind nicht für die Validierung von Benutzereingaben gedacht:
# Nicht für Benutzereingaben:
assert gold >= 0
Python kann mit der Option -O ausgeführt werden. Dabei werden
assert-Statements nicht in den ausgeführten Code übernommen.
Für Regeln, die immer geprüft werden müssen, verwendest Du deshalb eine normale
Bedingung und gegebenenfalls raise:
if gold < 0:
raise ValueError("Gold darf nicht negativ sein.")
Ob ein Nutzer gültige Daten eingegeben hat, darf nicht davon abhängen, mit welchen Interpreter-Optionen das Programm gestartet wurde.
Der Dungeon, Schritt 8
Jetzt machen wir den Dungeon aus Teil 7 robuster.
Neu sind unter anderem:
- validierte Eingaben für Name und Gold,
- eigene Exception-Klassen für erwartbare Spielfehler,
- ein zentraler Command Handler,
- EAFP beim Zugriff auf Ausgänge,
- gezielte Behandlung von
DungeonErrorim Game-Loop, Gegenstand.untersuche()aus Übung 4 von Teil 7 ersetzt dieif gegenstand.beschreibung-Abfrage im Game-Loop.
__repr__ und hat_ausgang fallen weg.
Öffne dungeon.py und ersetze den bisherigen Inhalt durch folgenden Code:
# Dungeon – Teil 8: Exceptions und Fehlerbehandlung
class DungeonError(Exception):
"""Basisklasse für erwartbare Fehler im Dungeon."""
class UngueltigerBefehlError(DungeonError):
"""Der eingegebene Befehl ist unbekannt oder unvollständig."""
class UnmoeglicheAktionError(DungeonError):
"""Eine bekannte Aktion ist im aktuellen State nicht möglich."""
class Gegenstand:
"""Ein Gegenstand, der in einem Raum oder Inventar liegen kann."""
def __init__(self, name, beschreibung=""):
self.name = name
self.beschreibung = beschreibung
def __str__(self):
return self.name
def untersuche(self):
if self.beschreibung:
return self.beschreibung
return f"An {self.name} fällt Dir nichts Besonderes auf."
class Spieler:
"""Verwaltet den State und das Verhalten des Spielers."""
def __init__(self, name, max_hp=100, gold=0):
name = name.strip()
if not name:
raise ValueError("Der Name darf nicht leer sein.")
if max_hp <= 0:
raise ValueError("max_hp muss größer als 0 sein.")
if gold < 0:
raise ValueError("gold darf nicht negativ sein.")
self.name = name
self.hp = max_hp
self.max_hp = max_hp
self.gold = gold
self.inventar = []
def nimm(self, gegenstand):
self.inventar.append(gegenstand)
def erleide_schaden(self, menge):
if menge < 0:
raise ValueError("Schaden darf nicht negativ sein.")
self.hp = max(self.hp - menge, 0)
def heile(self, menge):
if menge < 0:
raise ValueError("Heilung darf nicht negativ sein.")
self.hp = min(self.hp + menge, self.max_hp)
def ist_besiegt(self):
return self.hp <= 0
def zeige_inventar(self):
if not self.inventar:
print("Dein Inventar ist leer.")
return
print("Inventar:")
for nummer, gegenstand in enumerate(self.inventar, start=1):
print(f" {nummer}) {gegenstand}")
def __str__(self):
return (
f"{self.name} | "
f"HP: {self.hp}/{self.max_hp} | "
f"Gold: {self.gold} | "
f"Inventar: {len(self.inventar)}"
)
class Raum:
"""Ein Raum mit Beschreibung, Ausgängen und Gegenständen."""
def __init__(
self,
name,
beschreibung,
ausgaenge=None,
gegenstaende=None,
):
self.name = name
self.beschreibung = beschreibung
self.ausgaenge = dict(ausgaenge) if ausgaenge is not None else {}
self.gegenstaende = (
list(gegenstaende)
if gegenstaende is not None
else []
)
def beschreibe(self):
print()
print(f"Ort: {self.name}")
print(self.beschreibung)
print("Ausgänge:")
for nummer, richtung in enumerate(self.ausgaenge, start=1):
print(f" {nummer}) {richtung}")
if self.gegenstaende:
print(
"Hier liegt:",
", ".join(str(g) for g in self.gegenstaende),
)
def zeige_gegenstaende(self):
if not self.gegenstaende:
print("Hier liegt nichts Brauchbares.")
return
print(
"Hier liegt:",
", ".join(str(g) for g in self.gegenstaende),
)
def nimm_gegenstand(self, name):
gesuchter_name = name.strip().lower()
for gegenstand in self.gegenstaende:
if gegenstand.name.lower() == gesuchter_name:
self.gegenstaende.remove(gegenstand)
return gegenstand
raise UnmoeglicheAktionError(
f"Hier liegt kein Gegenstand namens {name}."
)
def ziel(self, richtung):
try:
return self.ausgaenge[richtung]
except KeyError:
raise UnmoeglicheAktionError(
f"Du kannst nicht nach {richtung} gehen."
) from None
def erstelle_raeume():
"""Erstellt die Dungeon-Welt."""
fackel = Gegenstand(
name="Fackel",
beschreibung="Eine rußige, aber noch brauchbare Fackel.",
)
schluessel = Gegenstand(
name="Schlüssel",
beschreibung=(
"Ein schwerer Eisenschlüssel mit rostigen Zähnen."
),
)
return {
"halle": Raum(
name="Halle",
beschreibung=(
"Du stehst in einer dunklen Halle mit moderigem Geruch."
),
ausgaenge={
"norden": "bibliothek",
"osten": "krypta",
},
gegenstaende=[fackel],
),
"bibliothek": Raum(
name="Bibliothek",
beschreibung=(
"Staubige Regale voller zerfledderter Bücher umgeben Dich."
),
ausgaenge={
"sueden": "halle",
},
gegenstaende=[schluessel],
),
"krypta": Raum(
name="Krypta",
beschreibung=(
"Feuchtigkeit glänzt auf den Wänden der alten Krypta."
),
ausgaenge={
"westen": "halle",
},
),
}
def frage_ganzzahl(text, minimum=None):
"""Fragt so lange nach einer Ganzzahl, bis die Eingabe gültig ist."""
while True:
eingabe = input(text)
try:
wert = int(eingabe)
except ValueError:
print("Bitte gib eine ganze Zahl ein.")
continue
if minimum is not None and wert < minimum:
print(f"Der Wert muss mindestens {minimum} betragen.")
continue
return wert
def frage_name(text):
"""Fragt so lange nach, bis ein nicht leerer Name eingegeben wurde."""
while True:
name = input(text).strip()
if name:
return name
print("Der Name darf nicht leer sein.")
def teile_befehl(eingabe):
"""Zerlegt den Input in ein Verb und ein optionales Argument."""
verb, _, argument = eingabe.strip().lower().partition(" ")
if not verb:
raise UngueltigerBefehlError(
"Bitte gib einen Befehl ein."
)
return verb, argument.strip()
def finde_raeume_mit_loot(raeume):
"""Gibt die Namen aller Räume mit Gegenständen zurück."""
return [
raum.name
for raum in raeume.values()
if raum.gegenstaende
]
def verarbeite_befehl(
verb,
argument,
spieler,
raum,
raeume,
besuchte_raeume,
):
"""
Verarbeitet einen Befehl.
Gibt die ID eines neuen Raums oder None zurück.
"""
if verb == "umsehen":
raum.zeige_gegenstaende()
return None
if verb == "nimm":
if not argument:
raise UngueltigerBefehlError(
"Was möchtest Du nehmen?"
)
gegenstand = raum.nimm_gegenstand(argument)
spieler.nimm(gegenstand)
print(f"Du nimmst {gegenstand}.")
print(gegenstand.untersuche())
return None
if verb == "inventar":
spieler.zeige_inventar()
return None
if verb == "status":
print(spieler)
print(f"Besuchte Räume: {len(besuchte_raeume)}")
return None
if verb == "loot":
raeume_mit_loot = finde_raeume_mit_loot(raeume)
text = (
", ".join(raeume_mit_loot)
if raeume_mit_loot
else "(keine)"
)
print("Räume mit Loot:", text)
return None
if verb == "geh":
if not argument:
raise UngueltigerBefehlError(
"In welche Richtung möchtest Du gehen?"
)
return raum.ziel(argument)
if not argument:
try:
return raum.ausgaenge[verb]
except KeyError:
pass
raise UngueltigerBefehlError(
f"Den Befehl '{verb}' verstehe ich nicht."
)
def main():
"""Startet den Dungeon und führt den Game-Loop aus."""
raeume = erstelle_raeume()
print("Du stehst vor dem rostigen Tor eines vergessenen Verlieses.")
print("Ein kalter Luftzug weht Dir entgegen.")
name = frage_name("Wie heißt Du, Abenteurer? ")
gold = frage_ganzzahl(
"Wie viele Goldmünzen bringst Du mit? ",
minimum=0,
)
spieler = Spieler(
name=name,
max_hp=100,
gold=gold,
)
ort = "halle"
besuchte_raeume = set()
raum_anzeigen = True
print()
print(f"Willkommen, {spieler.name}!")
print(
"Befehle: umsehen, nimm <Gegenstand>, inventar, status, "
"loot, geh <Richtung>, ende"
)
while True:
raum = raeume[ort]
if raum_anzeigen:
besuchte_raeume.add(ort)
raum.beschreibe()
raum_anzeigen = False
eingabe = input("\n> ")
try:
verb, argument = teile_befehl(eingabe)
if verb == "ende":
print("Du verlässt das Verlies. Bis zum nächsten Mal.")
break
neuer_ort = verarbeite_befehl(
verb=verb,
argument=argument,
spieler=spieler,
raum=raum,
raeume=raeume,
besuchte_raeume=besuchte_raeume,
)
except DungeonError as fehler:
print(fehler)
else:
if neuer_ort is not None:
ort = neuer_ort
raum_anzeigen = True
if __name__ == "__main__":
main()
Führe das Programm aus:
python dungeon.py
Ein möglicher Durchlauf sieht so aus:
Du stehst vor dem rostigen Tor eines vergessenen Verlieses.
Ein kalter Luftzug weht Dir entgegen.
Wie heißt Du, Abenteurer?
Der Name darf nicht leer sein.
Wie heißt Du, Abenteurer? Karl
Wie viele Goldmünzen bringst Du mit? viel
Bitte gib eine ganze Zahl ein.
Wie viele Goldmünzen bringst Du mit? -10
Der Wert muss mindestens 0 betragen.
Wie viele Goldmünzen bringst Du mit? 50
Willkommen, Karl!
Befehle: umsehen, nimm <Gegenstand>, inventar, status, loot, geh <Richtung>, ende
Ort: Halle
Du stehst in einer dunklen Halle mit moderigem Geruch.
Ausgänge:
1) norden
2) osten
Hier liegt: Fackel
>
Bitte gib einen Befehl ein.
> nimm
Was möchtest Du nehmen?
> nimm drachenei
Hier liegt kein Gegenstand namens drachenei.
> geh unten
Du kannst nicht nach unten gehen.
> nimm fackel
Du nimmst Fackel.
Eine rußige, aber noch brauchbare Fackel.
> norden
Ort: Bibliothek
Staubige Regale voller zerfledderter Bücher umgeben Dich.
Ausgänge:
1) sueden
Hier liegt: Schlüssel
> ende
Du verlässt das Verlies. Bis zum nächsten Mal.
Was der Game-Loop jetzt abfängt
Der Game-Loop behandelt gezielt unsere erwartbaren Dungeon-Fehler:
except DungeonError as fehler:
print(fehler)
Zu dieser Basisklasse gehören:
UngueltigerBefehlError
UnmoeglicheAktionError
Ein leerer Befehl, ein fehlendes Argument oder eine nicht vorhandene Richtung
landen deshalb als DungeonError im Handler und werden als Meldung ausgegeben,
statt in jeder Verzweigung einzeln abgefragt zu werden.
Andere Exceptions werden bewusst nicht abgefangen.
Würden wir beispielsweise versehentlich schreiben:
print(spieler.namme)
entstünde ein AttributeError.
Dieser Programmierfehler gehört nicht zu DungeonError. Er läuft daher durch
unseren Handler hindurch und bleibt als Traceback sichtbar.
Genau diese Trennung wollen wir: Erwartbare Fehler aus der Benutzung behandeln, echte Bugs nicht verstecken.
Der Erfolgsfall steht in else
Im Game-Loop wird der Return Value der Befehlsverarbeitung erst im else-Block
ausgewertet:
try:
neuer_ort = verarbeite_befehl(...)
except DungeonError as fehler:
print(fehler)
else:
if neuer_ort is not None:
ort = neuer_ort
raum_anzeigen = True
neuer_ort wird damit nur verarbeitet, wenn der try-Block normal
abgeschlossen wurde.
Gleichzeitig liegt die Änderung von ort nicht im geschützten Bereich. Würde
dort unerwartet etwas fehlschlagen, würde diese Exception nicht versehentlich
als DungeonError aus der Befehlsverarbeitung behandelt.
Technische Fehler in fachliche Fehler übersetzen
Die Methode ziel(...) verwendet EAFP:
def ziel(self, richtung):
try:
return self.ausgaenge[richtung]
except KeyError:
raise UnmoeglicheAktionError(
f"Du kannst nicht nach {richtung} gehen."
) from None
Intern bedeutet die fehlende Richtung einen KeyError: Der Schlüssel befindet
sich nicht im Dictionary.
Für den Spieler ist diese technische Information uninteressant. Für ihn lautet das Problem:
In dieser Richtung gibt es keinen Ausgang.
Die Methode übersetzt deshalb einen technischen Fehler der Datenstruktur in einen fachlichen Fehler unseres Dungeons.
Der Game-Loop muss dadurch weder KeyError noch die interne Speicherung der
Ausgänge kennen.
Warum wir nicht alles mit Exceptions lösen
frage_ganzzahl(...) kombiniert bewusst Exception Handling mit einer normalen
Bedingung:
try:
wert = int(eingabe)
except ValueError:
...
Die Umwandlung kann fehlschlagen. Dafür ist der ValueError genau das passende
Signal.
Der Mindestwert wird anschließend normal geprüft:
if minimum is not None and wert < minimum:
...
Ein negativer Integer ist schließlich weiterhin ein vollkommen gültiger Integer. Er verstößt lediglich gegen eine Regel unseres Spiels.
Exceptions und Bedingungen haben damit unterschiedliche Aufgaben. Wir wählen das Sprachmittel danach, was im jeweiligen Fall tatsächlich passiert.
Stolperfallen
-
Einen nackten
exceptverwenden: Er fängt auch Exceptions wieKeyboardInterruptundSystemExitab. -
Zu breit mit
except Exceptionfangen: Ein solcher Handler kann viele unerwartete Programmfehler erwischen. Behandle ihn nur, wenn Du dafür einen guten Grund hast und weißt, was anschließend mit der Exception geschehen soll. -
Den
try-Block zu groß machen: Dann kann der Handler eine passende Exception behandeln, die an einer völlig anderen Stelle entstanden ist als gedacht. -
Exceptions lautlos verschlucken:
python
try:
...
except ValueError:
pass
pass ist nicht grundsätzlich falsch. Wenn Du einen Fehler aber einfach
ignorierst, sollte das eine bewusste Entscheidung sein und der State danach
weiterhin eindeutig gültig sein.
- Nach einer Exception einen möglicherweise nicht gesetzten Namen verwenden:
```python try: gold = int(eingabe) except ValueError: print("Ungültig")
print(gold) ```
Schlägt die Umwandlung fehl und wurde gold vorher nicht definiert, führt die
letzte Zeile zu einem NameError.
-
Mehrere Fehlerarten in eine unpassende Meldung pressen: Ein unerwarteter
TypeErrorsollte nicht automatisch als „ungültige Zahleneingabe“ ausgegeben werden. -
return,breakodercontinueausfinallyverwenden: Dadurch kann eine aktive Exception unterdrückt werden. Python 3.14 warnt vor solchen Konstruktionen zusätzlich mit einemSyntaxWarning. -
Exceptions für jede normale Verzweigung verwenden: Ein einfaches
ifist oft die klarere Lösung, wenn lediglich ein erwartbarer Zustand geprüft wird. -
assertfür Benutzereingaben verwenden: Assertions sind für interne Annahmen gedacht und können bei einer mit-Ogestarteten Python-Ausführung entfallen. -
raise ... from Noneüberall verwenden: Damit wird der vorherige Exception-Kontext im Traceback ausgeblendet. Das kann erwünscht sein, nimmt Dir beim Debugging aber Informationen. -
Den State bei fehlgeschlagenen Operationen vergessen: Verändert eine Operation mehrere Objekte und schlägt dazwischen fehl, kann ein halbfertiger State zurückbleiben. Prüfe Voraussetzungen nach Möglichkeit vor irreversiblen Änderungen oder strukturiere die Operation entsprechend.
Übungen
1. Wiederholt nach einer Zahl fragen
Schreibe eine Funktion frage_ganzzahl(text), die so lange nach einer Eingabe
fragt, bis sie erfolgreich in einen Integer umgewandelt werden kann.
Lösung
def frage_ganzzahl(text):
while True:
try:
return int(input(text))
except ValueError:
print("Bitte gib eine ganze Zahl ein.")
2. Einen Mindestwert ergänzen
Erweitere die Funktion um einen Parameter minimum. Kleinere Werte sollen
abgelehnt werden.
Lösung
def frage_ganzzahl(text, minimum=None):
while True:
try:
wert = int(input(text))
except ValueError:
print("Bitte gib eine ganze Zahl ein.")
continue
if minimum is not None and wert < minimum:
print(f"Der Wert muss mindestens {minimum} betragen.")
continue
return wert
gold = frage_ganzzahl(
"Wie viel Gold? ",
minimum=0,
)
3. Einen KeyError übersetzen
Schreibe eine Funktion bestimme_ziel(raum, richtung). Sie soll das Ziel aus
raum.ausgaenge zurückgeben.
Existiert die Richtung nicht, soll stattdessen ein
UnmoeglicheAktionError ausgelöst werden.
Lösung
class UnmoeglicheAktionError(Exception):
pass
def bestimme_ziel(raum, richtung):
try:
return raum.ausgaenge[richtung]
except KeyError:
raise UnmoeglicheAktionError(
f"Du kannst nicht nach {richtung} gehen."
) from None
try:
ort = bestimme_ziel(raum, "unten")
except UnmoeglicheAktionError as fehler:
print(fehler)
4. Unterschiedliche Exceptions behandeln
Gegeben ist ein nummeriertes Inventar:
inventar = ["Fackel", "Schlüssel", "Brot"]
Frage nach einer Nummer. Behandle getrennt:
- eine Eingabe, die kein Integer ist,
- eine Zahl außerhalb des Inventars.
Die gültigen Nummern beginnen bei 1.
Lösung
inventar = ["Fackel", "Schlüssel", "Brot"]
try:
nummer = int(input("Nummer: "))
if nummer < 1:
raise IndexError
gegenstand = inventar[nummer - 1]
except ValueError:
print("Bitte gib eine ganze Zahl ein.")
except IndexError:
print("Unter dieser Nummer gibt es keinen Gegenstand.")
else:
print(f"Ausgewählt: {gegenstand}")
Vertiefung
Wenn eine Exception auftritt, möchtest Du in einem größeren Programm häufig nicht nur eine Meldung anzeigen, sondern Informationen für die spätere Fehlersuche festhalten:
Für die interaktive Untersuchung eines laufenden Programms:
Im nächsten Teil
Unser Dungeon reagiert jetzt kontrolliert auf ungültige Eingaben. Beim Beenden gehen der Player State, das Inventar und der aktuelle Raum jedoch weiterhin verloren.
In Teil 9 arbeiten wir deshalb mit
Dateien, pathlib, with und JSON. Damit können wir den Spielstand
speichern und beim nächsten Start wieder laden.
0 Kommentare
Noch keine Kommentare. Sei der/die Erste!
Anmelden um einen Kommentar zu hinterlassen.