Zum Inhalt springen

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, break oder continue verlassen 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 ValueError reagiert darauf, dass die Umwandlung gar nicht möglich war.
  • if wert < minimum prü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 DungeonError im Game-Loop,
  • Gegenstand.untersuche() aus Übung 4 von Teil 7 ersetzt die if 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 except verwenden: Er fängt auch Exceptions wie KeyboardInterrupt und SystemExit ab.

  • Zu breit mit except Exception fangen: 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 TypeError sollte nicht automatisch als „ungültige Zahleneingabe“ ausgegeben werden.

  • return, break oder continue aus finally verwenden: Dadurch kann eine aktive Exception unterdrückt werden. Python 3.14 warnt vor solchen Konstruktionen zusätzlich mit einem SyntaxWarning.

  • Exceptions für jede normale Verzweigung verwenden: Ein einfaches if ist oft die klarere Lösung, wenn lediglich ein erwartbarer Zustand geprüft wird.

  • assert für Benutzereingaben verwenden: Assertions sind für interne Annahmen gedacht und können bei einer mit -O gestarteten 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.")
`return` wird nur erreicht, wenn `int(...)` erfolgreich ist. Nach einem `ValueError` endet der aktuelle Schleifendurchlauf und `while True` fragt erneut nach.

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
Verwendung:
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
Verwendung:
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}")
Die zusätzliche Prüfung auf `nummer < 1` ist nötig, weil Python negative Listenindizes erlaubt. Auch die Eingabe `0` würde nach `nummer - 1` sonst zu Index `-1` und damit zum letzten Gegenstand führen.

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 print

Für die interaktive Untersuchung eines laufenden Programms:

Debuggen mit pdb

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.

Weiterlesen

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!