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):
File "dungeon.py", line 203, in <module>
main()
File "dungeon.py", line 145, in main
gold = int(input("Wie viele Goldmünzen bringst Du mit? "))
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
ValueError: invalid literal for int() with base 10: 'viel'
Heute lernen wir, solche Situationen kontrolliert zu behandeln. Der Dungeon soll bei ungültigem Input nicht einfach abbrechen, sondern erklären, was schiefgegangen ist, und erneut nachfragen.
Dafür verwendet Python Exceptions.
Syntaxfehler und Exceptions
Nicht jeder Fehler ist eine Exception, die während des normalen Programmablaufs auftritt.
Bei einem Syntaxfehler kann Python den Quelltext nicht korrekt verstehen:
if hp > 0
print("Du lebst.")
Der Doppelpunkt fehlt. Python kann die Datei deshalb nicht vollständig
kompilieren und meldet einen SyntaxError, bevor das eigentliche Programm
startet.
Eine Exception entsteht dagegen während der Ausführung grundsätzlich gültigen Codes:
gold = int("viel")
Der Funktionsaufruf ist syntaktisch korrekt. Der String "viel" lässt sich aber
nicht in einen Integer umwandeln. Deshalb löst int(...) einen ValueError aus.
Weitere typische Exceptions sind:
| Exception | Typischer Grund |
|---|---|
ValueError |
Der Typ stimmt, aber der Wert ist ungeeignet |
TypeError |
Eine Operation erhält einen unpassenden Typ |
KeyError |
Ein Dictionary-Schlüssel existiert nicht |
IndexError |
Ein Listenindex liegt außerhalb der Liste |
AttributeError |
Ein Objekt besitzt das angesprochene Attribut nicht |
ZeroDivisionError |
Eine Zahl wird durch null geteilt |
FileNotFoundError |
Eine angeforderte Datei existiert nicht |
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
Die genaue Exception ist wichtig, weil wir später gezielt nur die Fehler behandeln, die wir an einer bestimmten Stelle tatsächlich erwarten.
Was geschieht bei einer Exception?
Wird eine Exception ausgelöst, beendet Python zunächst den aktuellen Programmablauf an dieser Stelle.
Betrachten wir:
print("Vorher")
gold = int("viel")
print("Nachher")
Die erste Ausgabe erscheint:
Vorher
Danach löst int("viel") einen ValueError aus. Die zweite print-Anweisung
wird nicht mehr erreicht.
Python sucht nun danach, ob die Exception durch einen passenden
except-Block behandelt wird. Findet es keinen Handler, wandert die Exception
durch die aufrufenden Funktionen nach außen.
Wird sie nirgends behandelt, beendet Python das Programm und zeigt einen Traceback.
Dieser Weg nach außen wird häufig als Propagation oder Stack Unwinding bezeichnet.
Einen Traceback lesen
Ein Traceback ist keine zusätzliche Strafe für einen Fehler, sondern eine Beschreibung des Weges, den das Programm bis zur Exception genommen hat.
Traceback (most recent call last):
File "dungeon.py", line 203, in <module>
main()
File "dungeon.py", line 145, in main
gold = int(input("Wie viele Goldmünzen bringst Du mit? "))
ValueError: invalid literal for int() with base 10: 'viel'
Beginne beim Lesen meistens unten.
Die letzte Zeile enthält:
ValueError: invalid literal for int() with base 10: 'viel'
Dort stehen:
- der Exception Type:
ValueError - die Fehlermeldung
- häufig der problematische Wert
Darüber findest Du die Stelle, an der die Exception ausgelöst wurde:
File "dungeon.py", line 145, in main
Die darüberliegenden Einträge zeigen, welche Funktionen zuvor aufgerufen wurden.
In einem größeren Programm kann ein Traceback viele Ebenen enthalten. Gehe von unten nach oben und suche zuerst nach Dateien und Zeilen, die zu Deinem eigenen Projekt gehören.
try und except
Code, bei dem eine bestimmte Exception auftreten kann, kommt in einen
try-Block:
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 ohne Exception durch, wird der except-Block übersprungen.
Löst int(...) dagegen einen ValueError aus, geschieht Folgendes:
- Der restliche
try-Block wird übersprungen. - Python sucht einen passenden
except-Block. - Der
except ValueError-Block wird ausgeführt. - Danach läuft das Programm hinter dem gesamten Konstrukt 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 stürzt nicht mehr ab.
Nur erwartete Exceptions behandeln
Ein except sollte möglichst genau angeben, welche Exception erwartet wird:
except ValueError:
...
Vermeide dagegen einen nackten except:
try:
...
except:
...
Ein nackter except behandelt nicht nur normale Programmfehler, sondern auch
Exceptions wie KeyboardInterrupt und SystemExit.
Dadurch könnte beispielsweise Strg+C nicht mehr wie erwartet zum Abbruch des
Programms führen.
Auch ein sehr breiter Handler sollte bewusst verwendet werden:
try:
...
except Exception:
...
Exception ist die gemeinsame Basisklasse der meisten Fehler, die in
Anwendungsprogrammen auftreten. Ein solcher Handler kann deshalb auch
Programmierfehler verschlucken, die Du eigentlich bemerken und beheben solltest.
Eine vereinfachte Exception-Hierarchie sieht so aus:
BaseException
├── KeyboardInterrupt
├── SystemExit
├── GeneratorExit
└── Exception
├── ValueError
├── TypeError
├── KeyError
├── IndexError
├── OSError
└── ...
Eigene Anwendungsfehler erben normalerweise von Exception, nicht direkt von
BaseException.
Die Exception als Objekt verwenden
Mit as kannst Du das Exception-Objekt an eine Variable binden:
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 die Fehlermeldung und je nach Exception Type weitere Informationen.
Bei benutzerfreundlichen Meldungen solltest Du nicht immer den internen Originaltext ausgeben. Für einen Spieler ist diese Meldung verständlicher:
except ValueError:
print("Bitte gib eine ganze Zahl ein.")
Beim Debugging oder Logging kann der ursprüngliche Fehlertext dagegen 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 passenden Handler.
Eine nicht passende Exception wird nicht ignoriert, sondern weiter nach außen gereicht.
Das ist wichtig: Würde innerhalb des try-Blocks unerwartet ein TypeError
auftreten, fangen die beiden Handler ihn nicht ab. Der Traceback bleibt sichtbar
und kann untersucht werden.
Mehrere Exception Types gemeinsam behandeln
Sollen mehrere Exceptions 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 die Reaktion für beide Fehler wirklich identisch ist.
Benötigst Du unterschiedliche Meldungen oder unterschiedliche Korrekturen,
verwende getrennte except-Blöcke.
Den try-Block klein halten
In einen try-Block gehört möglichst nur der Code, bei dem die erwartete
Exception tatsächlich auftreten soll.
Ungünstig wäre:
try:
gold = int(eingabe)
spieler = Spieler(name, gold=gold)
spieler.zeige_inventar()
except ValueError:
print("Die Goldmenge ist ungültig.")
Hier könnte nicht nur int(...), sondern auch der Constructor von Spieler
einen ValueError auslösen. Der Handler würde dann möglicherweise eine falsche
Fehlermeldung anzeigen.
Besser:
try:
gold = int(eingabe)
except ValueError:
print("Die Goldmenge ist ungültig.")
else:
spieler = Spieler(name, gold=gold)
spieler.zeige_inventar()
Nun ist eindeutig, dass der except-Block ausschließlich die Umwandlung
behandelt.
Schmale try-Blöcke erleichtern die Fehlersuche und verhindern, dass
unbeabsichtigt andere Exceptions abgefangen werden.
else: der Erfolgsfall
Ein try-Statement kann einen else-Block enthalten:
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 nur ausgeführt, wenn im try-Block keine Exception
aufgetreten ist.
Er ist besonders nützlich, um den geschützten Bereich klein zu halten:
try:
gold = int(eingabe)
except ValueError:
print("Ungültige Zahl.")
else:
spieler = Spieler(name, gold=gold)
Die Erstellung des Spielerobjekts steht außerhalb des eigentlichen
try-Blocks. Falls sie selbst fehlschlägt, wird der Fehler nicht fälschlich als
ungültige Zahl behandelt.
finally: Code, der auf jeden Fall läuft
Ein finally-Block wird beim Verlassen des try-Statements ausgeführt,
unabhängig davon, ob:
- keine Exception aufgetreten ist,
- eine Exception behandelt wurde,
- eine Exception weitergereicht wird,
- die Funktion über
returnverlassen wird, - eine Schleife über
breakodercontinueverlassen wird.
try:
wert = int(eingabe)
except ValueError:
print("Keine gültige Zahl.")
finally:
print("Die Eingabe wurde verarbeitet.")
finally wird 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()
Selbst wenn read() eine Exception auslöst, wird close() ausgeführt.
Für Dateien verwenden wir normalerweise einen with-Block, der diese Aufgabe
übersichtlicher erledigt:
with open("spielstand.txt", encoding="utf-8") as datei:
inhalt = datei.read()
Darum geht es ausführlich in Teil 9geplant.
Vermeide nach Möglichkeit return, break oder continue innerhalb von
finally. Solcher Kontrollfluss kann eine aktive Exception verdecken und macht
das Verhalten schwer nachvollziehbar.
Die vollständige Form
Alle vier Bestandteile 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
Nicht jeder Block ist verpflichtend.
Ein try benötigt mindestens:
- einen oder mehrere
except-Blöcke, - oder einen
finally-Block.
Wiederholt nach einer Zahl fragen
Für unseren Dungeon soll eine falsche Eingabe nicht automatisch zu 0 werden.
Stattdessen fragen wir so lange nach, bis eine gültige Zahl 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 ungültigem Input erreicht die Funktion das return nicht. Die
while-Schleife beginnt erneut.
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.
Wertebereiche zusätzlich prüfen
Eine syntaktisch gültige Zahl kann fachlich trotzdem ungeeignet sein:
Wie viele Goldmünzen bringst Du mit? -500
Deshalb erweitern wir die Funktion um 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,
)
Die Exception behandelt hier die fehlgeschlagene Typumwandlung. Eine normale
if-Bedingung behandelt anschließend die fachliche Regel.
Das ist eine sinnvolle Kombination:
- Exception für eine Operation, die nicht ausgeführt werden konnte
- Bedingung für einen erwartbaren Wertebereich
Exceptions selbst auslösen
Mit raise kannst Du selbst eine Exception auslösen:
def setze_gold(spieler, gold):
if gold < 0:
raise ValueError("Gold darf nicht negativ sein.")
spieler.gold = gold
Verwendung:
setze_gold(spieler, -20)
Das löst aus:
ValueError: Gold darf nicht negativ sein.
raise beendet den aktuellen Ablauf genauso wie eine von Python ausgelöste
Exception.
Du verwendest raise, wenn eine Funktion ihren Auftrag aufgrund ihrer
Argumente oder ihres aktuellen States nicht sinnvoll erfüllen kann.
Eigene Exception-Klassen
Built-in Exceptions wie ValueError und KeyError reichen für viele Fälle aus.
Für fachliche Fehler kann eine eigene Exception verständlicher sein:
class UngueltigerBefehlError(Exception):
"""Ein eingegebener Befehl ist unbekannt oder unvollständig."""
Eine eigene Exception-Klasse benötigt zunächst keinen weiteren Code.
raise UngueltigerBefehlError("Bitte gib einen Befehl ein.")
Sie kann wie jede andere Exception behandelt werden:
try:
verarbeite_befehl(eingabe)
except UngueltigerBefehlError as fehler:
print(fehler)
Exception-Klassen enden nach verbreiteter Python-Konvention mit Error.
Eine gemeinsame Basisklasse
Für mehrere fachliche Exceptions können wir eine 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 lassen sich die Fehler einzeln behandeln:
try:
...
except UngueltigerBefehlError:
...
except UnmoeglicheAktionError:
...
Oder gemeinsam:
try:
...
except DungeonError as fehler:
print(fehler)
Andere Exceptions wie AttributeError, TypeError oder NameError gehören
nicht zu DungeonError. Sie bleiben deshalb sichtbar und werden nicht
versehentlich als harmlose Benutzereingabe behandelt.
Unbekannt oder nur nicht möglich?
Eigene Exception Types können verschiedene Fehlerarten ausdrücken.
Dieser Befehl ist unvollständig:
nimm
Dafür eignet sich:
raise UngueltigerBefehlError(
"Was möchtest Du nehmen?"
)
Dieser Befehl ist verständlich, aber im aktuellen Raum nicht möglich:
nimm drachenei
Dafür eignet sich:
raise UnmoeglicheAktionError(
"Hier liegt kein Gegenstand namens drachenei."
)
Die Trennung ist nicht zwingend, macht die fachliche Bedeutung aber deutlicher und erlaubt später unterschiedliche Reaktionen.
Exception Chaining mit raise ... from ...
Manchmal fangen wir eine technische Exception ab und übersetzen sie in einen fachlichen Fehler.
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
Bleibt die neue Exception unbehandelt, zeigt Python beide Fehler:
- den ursprünglichen
KeyError - den daraus entstandenen
UnmoeglicheAktionError
Das hilft beim Debugging, weil die technische Ursache sichtbar bleibt.
Soll die ursprüngliche Exception bewusst nicht im Traceback erscheinen, kannst Du schreiben:
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 Anzeige des vorherigen Exception-Kontexts.
Für erwartbare Benutzerfehler kann das einen klareren Traceback ergeben. Beim Debugging ist die ursprüngliche Ursache häufig wertvoller.
EAFP: Ausführen und Fehler behandeln
In Python ist ein Stil namens EAFP verbreitet:
Easier to Ask Forgiveness than Permission
Sinngemäß:
Führe die gewünschte Operation aus und behandle den Fehler, falls sie nicht funktioniert.
Beim Zugriff auf einen Dictionary-Schlüssel sieht das so aus:
try:
ziel = raum.ausgaenge[richtung]
except KeyError:
print("In dieser Richtung gibt es keinen Ausgang.")
Die Alternative heißt LBYL:
Look Before You Leap
Dabei wird die Voraussetzung zuerst geprüft:
if richtung in raum.ausgaenge:
ziel = raum.ausgaenge[richtung]
else:
print("In dieser Richtung gibt es keinen Ausgang.")
Beide Varianten sind gültig.
EAFP ist häufig passend, wenn:
- der erfolgreiche Fall der Normalfall ist,
- die Operation selbst zuverlässig entscheidet, ob sie möglich ist,
- eine Vorabprüfung nur denselben Zugriff wiederholen würde,
- zwischen Prüfung und Zugriff eine Veränderung stattfinden könnte.
LBYL kann klarer sein, wenn:
- eine Situation ein normaler Teil des Kontrollflusses ist,
- die Vorbedingung leicht und eindeutig geprüft werden kann,
- verschiedene Bedingungen unterschiedliche Reaktionen benötigen,
- die Operation bei einem Fehler teuer oder problematisch wäre.
Pythonic bedeutet nicht, jedes if durch try und except zu ersetzen.
dict.get(...) als dritte Möglichkeit
Bei einem Dictionary ist manchmal weder ein try-Block noch eine getrennte
Mitgliedschaftsprüfung notwendig:
ziel = raum.ausgaenge.get(richtung)
if ziel is None:
print("In dieser Richtung gibt es keinen Ausgang.")
Das funktioniert, wenn None kein gültiger Zielwert sein kann.
Welche Variante am klarsten ist, hängt vom Kontext ab:
# 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)
Es gibt nicht für jede Situation genau eine richtige Schreibweise.
Exceptions nicht als beliebige Sprünge verwenden
Exceptions können den normalen Kontrollfluss steuern, sollten aber nicht jede einfache Verzweigung ersetzen.
Unnötig wäre:
class SpielerLebt(Exception):
pass
try:
if spieler.hp > 0:
raise SpielerLebt
except SpielerLebt:
print("Der Spieler lebt.")
Klarer ist:
if spieler.hp > 0:
print("Der Spieler lebt.")
Exceptions eignen sich für Situationen, in denen eine Funktion ihren normalen Auftrag nicht erfüllen kann oder eine Grenze zwischen verschiedenen Programmschichten überschritten wird.
assert ist kein Ersatz für Fehlerbehandlung
Mit assert kannst Du interne Annahmen prüfen:
assert spieler.hp >= 0
Schlägt die Annahme fehl, entsteht ein AssertionError.
assert ist jedoch nicht für die Validierung von Benutzereingaben gedacht:
# Nicht für Benutzereingaben:
assert gold >= 0
Assertions können bei optimierter Python-Ausführung deaktiviert werden. Für
fachliche Validierung verwendest Du deshalb eine normale Bedingung und
gegebenenfalls raise:
if gold < 0:
raise ValueError("Gold darf nicht negativ sein.")
Nutze assert für interne Zustände, die nach der Logik des Programms eigentlich
niemals falsch sein dürften.
Der Dungeon, Schritt 8
Wir machen nun den Dungeon aus Teil 7 robuster.
Neu sind:
- eine wiederholte und validierte Zahleneingabe,
- eigene Exception-Klassen,
- ein zentraler Command Handler,
- gezielte Fehler für unvollständige oder unmögliche Befehle,
- EAFP beim Zugriff auf die Ausgänge,
- ein schmaler
try-Block im Game-Loop.
Ö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
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 ausschließlich unsere erwartbaren Dungeon-Fehler:
except DungeonError as fehler:
print(fehler)
Dazu gehören:
UngueltigerBefehlError
UnmoeglicheAktionError
Ein leerer Befehl oder eine nicht vorhandene Richtung beendet das Spiel deshalb nicht mehr.
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 und bleibt deshalb als Traceback sichtbar.
Genau das wollen wir: Erwartbare Benutzerfehler behandeln, echte Bugs nicht verstecken.
Der Erfolgsfall steht in else
Im Game-Loop liegt die Auswertung des Return Values im else-Block:
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 nur verwendet, wenn die Befehlsverarbeitung ohne Exception
abgeschlossen wurde.
Das verhindert zugleich, dass zusätzlicher Code unnötig im geschützten
try-Block steht.
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 handelt es sich um einen fehlenden Dictionary-Schlüssel. Für den Spieler
ist jedoch nicht der KeyError relevant, sondern die Bedeutung:
In dieser Richtung gibt es keinen Ausgang.
Die Methode übersetzt deshalb einen technischen Fehler in einen fachlichen Fehler.
Warum wir nicht alles mit Exceptions lösen
Die Funktion frage_ganzzahl(...) verwendet sowohl eine Exception als auch eine
normale Bedingung:
try:
wert = int(eingabe)
except ValueError:
...
Die Umwandlung kann technisch fehlschlagen. Dafür eignet sich eine Exception.
Der Mindestwert wird dagegen normal geprüft:
if minimum is not None and wert < minimum:
...
Eine negative Ganzzahl ist weiterhin eine erfolgreich eingelesene Ganzzahl. Sie verstößt lediglich gegen unsere fachliche Regel.
Diese Unterscheidung hält den Code verständlich.
Stolperfallen
-
Einen nackten
exceptverwenden: Er fängt auchKeyboardInterrupt,SystemExitund andereBaseException-Unterklassen ab. -
Zu breit mit
except Exceptionfangen: Dadurch können echte Programmierfehler als vermeintlich harmlose Benutzerfehler verschwinden. -
Den
try-Block zu groß machen: Der Handler kann dann eine Exception behandeln, die an einer ganz anderen Stelle entstanden ist. -
Exceptions lautlos verschlucken:
python
try:
...
except ValueError:
pass
Ohne Meldung oder sinnvolle Ersatzhandlung bleibt möglicherweise ein inkonsistenter State zurück.
- Nach einer Exception uninitialisierte Werte verwenden:
```python try: gold = int(eingabe) except ValueError: print("Ungültig")
print(gold) ```
Nach einem Fehler wurde gold hier nicht gesetzt.
-
Mehrere Fehlerarten in eine unpassende Meldung pressen: Ein
TypeErrorsollte nicht automatisch als „ungültige Zahleneingabe“ dargestellt werden, wenn er auch durch einen Bug entstanden sein könnte. -
returninfinallyverwenden: Dadurch kann eine aktive Exception verdeckt werden. -
Exceptions für jede normale Verzweigung verwenden: Ein einfaches
ifist für erwartbare Zustände häufig klarer. -
assertfür Benutzereingaben verwenden: Assertions sind für interne Annahmen gedacht und können deaktiviert werden. -
Die ursprüngliche Ursache immer unterdrücken:
raise ... from Noneerzeugt einen ruhigeren Traceback, entfernt aber hilfreiche Informationen für die Fehlersuche. -
Eine Exception behandeln, ohne den State zu bedenken: Nachdem eine Operation teilweise ausgeführt wurde, kann ein einfaches Weiterlaufen problematisch sein. Änderungen sollten möglichst erst erfolgen, nachdem alle Voraussetzungen erfüllt sind.
Ü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 keine Ganzzahl ist,
- eine Zahl außerhalb des Inventars.
Lösung
inventar = ["Fackel", "Schlüssel", "Brot"]
try:
nummer = int(input("Nummer: "))
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:
Logging statt printgeplant
Für die interaktive Untersuchung eines laufenden Programms:
Debuggen mit pdbgeplant
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 9geplant 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.