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):
  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:

  1. Der restliche try-Block wird übersprungen.
  2. Python sucht einen passenden except-Block.
  3. Der except ValueError-Block wird ausgeführt.
  4. 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 return verlassen wird,
  • eine Schleife über break oder continue verlassen 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:

  1. den ursprünglichen KeyError
  2. 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 except verwenden: Er fängt auch KeyboardInterrupt, SystemExit und andere BaseException-Unterklassen ab.

  • Zu breit mit except Exception fangen: 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 TypeError sollte nicht automatisch als „ungültige Zahleneingabe“ dargestellt werden, wenn er auch durch einen Bug entstanden sein könnte.

  • return in finally verwenden: Dadurch kann eine aktive Exception verdeckt werden.

  • Exceptions für jede normale Verzweigung verwenden: Ein einfaches if ist für erwartbare Zustände häufig klarer.

  • assert für Benutzereingaben verwenden: Assertions sind für interne Annahmen gedacht und können deaktiviert werden.

  • Die ursprüngliche Ursache immer unterdrücken: raise ... from None erzeugt 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.")
`return` wird nur erreicht, wenn `int(...)` keine Exception auslöst. Nach einem `ValueError` beginnt die Schleife erneut.

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 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}")
Für einen vollständigen Einsatz müsste zusätzlich geprüft werden, dass `nummer` mindestens `1` beträgt. Negative Listenindizes sind in Python gültig und würden sonst vom Ende der Liste zählen.

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.

Weiterlesen

0 Kommentare

Noch keine Kommentare. Sei der/die Erste!