Zum Inhalt springen

7. Exceptions

Zu Zen-Modus wechseln

Programme laufen nicht nur im Idealfall. Benutzer tippen Text, wo Zahlen erwartet werden. Dateien fehlen, sind schreibgeschützt oder haben das falsche Format. Netzwerke fallen aus. Ein professionelles Programm unterscheidet sich von einem Übungsskript vor allem darin, wie es mit solchen Situationen umgeht: Es stürzt nicht ab und rechnet nicht stillschweigend mit falschen Werten weiter, sondern erkennt den Fehler, reagiert angemessen und informiert verständlich.

Der Mechanismus dafür heißt in Python (wie in den meisten modernen Sprachen) Exception: ein Objekt, das einen Fehlerzustand beschreibt und den normalen Programmablauf unterbricht, bis es behandelt wird.

Der naheliegende Ansatz zur Fehlermeldung sind spezielle Rückgabewerte:

def parse_frame_number(text):
"""Return the frame number, or -1 if the input is invalid."""
if not text.isdigit():
return -1
return int(text)

Dieses Muster hat drei strukturelle Schwächen:

  1. Der Fehlerwert kollidiert mit echten Werten. Was, wenn -1 (oder 0, oder None) irgendwann ein gültiges Ergebnis ist? Für jede Funktion muss ein eigener, unverwechselbarer Fehlerwert erfunden und dokumentiert werden.
  2. Die Prüfung ist freiwillig. Vergisst der Aufrufer das if result == -1, rechnet das Programm mit -1 als Framenummer weiter. Der Fehler fällt erst viel später auf, weit weg von seiner Ursache, oder gar nicht.
  3. Fehlercodes müssen durchgereicht werden. Ruft a() die Funktion b(), die c() ruft, und tritt der Fehler in c() auf, müssen b() und a() den Fehlerwert Ebene für Ebene weiterreichen. Der eigentliche Programmcode versinkt in Weiterleitungslogik.

Exceptions lösen alle drei Punkte: Sie sind vom normalen Rückgabeweg getrennt, sie lassen sich nicht versehentlich ignorieren (ein unbehandelter Fehler beendet das Programm sichtbar statt still falsch weiterzulaufen), und sie durchqueren beliebig viele Aufrufebenen von selbst, bis sich jemand zuständig erklärt.

Unbehandelte Exceptions kennt jeder, der Python benutzt:

>>> int("abc")
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
ValueError: invalid literal for int() with base 10: 'abc'

Der Traceback ist ein Untersuchungsbericht und wird von unten nach oben gelesen: Die letzte Zeile nennt den Typ des Fehlers (ValueError) und die Beschreibung. Die Zeilen darüber zeigen den Weg dorthin, also die Kette der Funktionsaufrufe von der äußersten Ebene bis zur Zeile, in der der Fehler auftrat. Bei tief verschachtelten Programmen ist die unterste Datei-und-Zeilen-Angabe der Ort des Geschehens.

Die Fähigkeit, einen Traceback ruhig zu lesen, statt ihn als Katastrophenmeldung zu überfliegen, ist eine Kernkompetenz der Fehlersuche.

Mit try/except wird ein Codeabschnitt unter Beobachtung gestellt:

text = input("Frame number: ")
try:
frames = int(text)
print(f"frame {frames} selected")
except ValueError:
print(f"'{text}' is not a valid number.")

Ablauf: Python führt den try-Block aus. Tritt dabei ein ValueError auf, springt die Ausführung sofort in den except-Block; der Rest des try-Blocks wird übersprungen. Tritt kein Fehler auf, wird der except-Block übersprungen. In beiden Fällen läuft das Programm danach normal weiter.

Der except-Klausel wird der Exception-Typ mitgegeben, der behandelt werden soll. Mehrere Typen können getrennt behandelt werden:

values = {"width": "1920"}
try:
width = int(values["heigth"]) # typo in the key
except KeyError as error:
print(f"missing entry: {error}")
except ValueError as error:
print(f"not a number: {error}")

Der Zusatz as error bindet das Exception-Objekt an einen Namen; seine Textdarstellung enthält die Detailinformation. Wichtig ist, was hier nicht steht:

# anti-pattern: silences every error, including bugs
try:
width = int(values["heigth"])
except:
pass

Ein nacktes except: (oder except Exception:) fängt alles, auch Fehler, die mit dem erwarteten Problem nichts zu tun haben: Tippfehler im eigenen Code, fehlgeschlagene Importe, sogar den Programmabbruch mit Strg+C. Kombiniert mit pass entsteht die schlimmste Sorte Programm: eines, das Fehler verschluckt und mit unsinnigen Zuständen weiterläuft. Die Regel lautet daher: So spezifisch wie möglich fangen, und nur Fehler, mit denen an dieser Stelle sinnvoll umgegangen werden kann. Alles andere darf weiterfliegen; ein sichtbarer Absturz ist besser als ein unsichtbar falsches Ergebnis.

Die vollständige Anweisung hat vier Teile:

try:
frames = int(text)
except ValueError:
print("invalid input")
else:
print(f"frame {frames} selected") # runs only if NO exception occurred
finally:
print("done") # runs ALWAYS, error or not

else nimmt den Erfolgsfall auf und hält den try-Block dadurch minimal: Nur die Zeile, die tatsächlich fehlschlagen kann, steht unter Beobachtung. finally läuft in jedem Fall, auch wenn die Exception nicht gefangen wurde, und ist für Aufräumarbeiten gedacht (Datei schließen, Verbindung trennen, temporäre Daten löschen). else und finally sind optional und deutlich seltener als das Grundpaar try/except.

Exceptions sind Klassen, und sie bilden eine Vererbungshierarchie; die Konzepte aus Kapitel 5 gelten hier unmittelbar. Ein Ausschnitt der eingebauten Hierarchie:

classDiagram
    BaseException <|-- KeyboardInterrupt
    BaseException <|-- Exception
    Exception <|-- ValueError
    Exception <|-- TypeError
    Exception <|-- LookupError
    LookupError <|-- KeyError
    LookupError <|-- IndexError
    Exception <|-- OSError
    OSError <|-- FileNotFoundError
    OSError <|-- PermissionError
    Exception <|-- ZeroDivisionError

Die für den Alltag wichtigsten Typen:

Exceptiontritt auf bei
ValueErrorrichtiger Typ, unbrauchbarer Wert: int("abc")
TypeErrorfalscher Typ: len(42)
KeyErrorfehlender Dictionary-Schlüssel
IndexErrorListenindex außerhalb des Bereichs
FileNotFoundErrorDatei existiert nicht
PermissionErrorfehlende Zugriffsrechte
ZeroDivisionErrorDivision durch 0

Ein except fängt den angegebenen Typ und alle seine Subklassen; das ist isinstance-Logik. except LookupError: behandelt also KeyError und IndexError gemeinsam, except OSError: alle Datei- und Systemfehler. Daran zeigt sich auch, warum except Exception: so grob ist: Es ist die Wurzel fast der gesamten Hierarchie.

Eigener Code meldet Fehler mit raise. Das gehört überall dorthin, wo eine Funktion mit ungültigen Eingaben nichts Sinnvolles anfangen kann:

def frames_to_timecode(frames, fps=25):
"""Convert an absolute frame number to a timecode HH:MM:SS:FF."""
if frames < 0:
raise ValueError(f"frame number must be >= 0, got {frames}")
if fps <= 0:
raise ValueError(f"fps must be positive, got {fps}")
total_seconds = frames // fps
return (f"{total_seconds // 3600:02d}:{(total_seconds % 3600) // 60:02d}:"
f"{total_seconds % 60:02d}:{frames % fps:02d}")

Damit ändert die Funktion ihren Charakter: Statt bei negativem Input stillschweigend einen absurden Timecode zu berechnen, verweigert sie die Arbeit laut und mit Begründung. Der Fehlertext nennt die Erwartung und den tatsächlichen Wert; wer den Traceback liest, weiß sofort, was schiefging. Solche frühen, präzisen Prüfungen an den Grenzen einer Funktion nennt man Eingangsvalidierung; sie verwandeln späte, rätselhafte Folgefehler in frühe, klare Meldungen.

Auch die Properties aus Kapitel 4 bekommen damit ihre saubere Form:

class Account:
# ...
@balance.setter
def balance(self, new_balance):
if new_balance < 0:
raise ValueError("balance must not be negative")
self._balance = new_balance

Das provisorische print von damals ist ersetzt: Der Setter erzwingt die Regel jetzt, statt nur an sie zu erinnern.

Die eingebauten Typen decken technische Fehler ab. Für fachliche Fehler definiert man eigene Exception-Klassen, und zwar durch Vererbung von Exception:

class InsufficientFundsError(Exception):
"""Raised when a withdrawal exceeds the account balance."""
class Account:
def __init__(self, owner, balance=0):
self.owner = owner
self._balance = balance
def withdraw(self, amount):
if amount > self._balance:
raise InsufficientFundsError(
f"withdrawal of {amount} exceeds balance of {self._balance}"
)
self._balance -= amount

Der Klassenkörper bleibt meist leer (der Docstring genügt); der Wert liegt im Namen. Der Aufrufer kann fachliche Fehler jetzt getrennt von technischen behandeln:

try:
account.withdraw(500)
except InsufficientFundsError:
print("Not enough money in the account.")

Für größere Programme lohnt eine kleine eigene Hierarchie, etwa eine gemeinsame Basisklasse pro Modul:

class MediaLibraryError(Exception):
"""Base class for all media library errors."""
class UnknownFormatError(MediaLibraryError):
pass
class CorruptFileError(MediaLibraryError):
pass

Wer alles aus der Mediathek fangen will, schreibt except MediaLibraryError:; wer nur kaputte Dateien behandelt, fängt CorruptFileError. Das ist Vererbung und Polymorphismus, angewendet auf Fehlerbehandlung.

Der Lehrplan verlangt ausdrücklich das Abfangen von Eingabefehlern. Das Standardmuster dafür ist die Validierungsschleife: fragen, prüfen, bei Fehlern erklären und erneut fragen:

def ask_int(prompt, minimum, maximum):
"""Ask until the user enters an integer within [minimum, maximum]."""
while True:
text = input(prompt)
try:
value = int(text)
except ValueError:
print(f"'{text}' is not a whole number. Please try again.")
continue
if not minimum <= value <= maximum:
print(f"Please enter a value between {minimum} and {maximum}.")
continue
return value
age = ask_int("Your age: ", 10, 120)

Beachtenswert an dieser Funktion:

  • Die Schleife endet ausschließlich über das return im Erfolgsfall. Der Benutzer kommt erst weiter, wenn die Eingabe gültig ist, und das Programm dahinter kann sich auf den Wert verlassen.
  • Die Fehlermeldungen sind für Menschen formuliert: Sie sagen, was falsch war und was stattdessen erwartet wird. "Error: invalid input" erfüllt keines der beiden Kriterien. Für ein Publikum, das später Benutzeroberflächen gestaltet, gilt: Fehlermeldungen sind Teil des Interfaces, nicht technisches Beiwerk.
  • Die Funktion ist wiederverwendbar für jede ganzzahlige Eingabe mit Bereichsgrenzen. Solche Eingabehilfen sind der Grundstock jeder Konsolenanwendung und kehren bei der GUI-Entwicklung (Kapitel 14) in Form von Feldvalidierung wieder.

Die schwierigste Frage der Fehlerbehandlung ist nicht wie, sondern wo. Die Antwort ergibt sich aus einer einzigen Überlegung: Gefangen wird dort, wo eine sinnvolle Reaktion möglich ist.

  • Eine Hilfsfunktion tief im Programm kann meist nicht sinnvoll reagieren. Sie weiß nicht, ob sie in einem Konsolenprogramm, einer GUI oder einem Test läuft; ein print an dieser Stelle wäre eine Anmaßung. Sie soll den Fehler weiterfliegen lassen (oder mit raise einen fachlich präziseren auslösen).
  • Die äußere Ebene, etwa das Hauptprogramm oder der Klick-Handler einer GUI, kennt den Kontext: Sie kann den Benutzer informieren, den Vorgang abbrechen, einen neuen Versuch anbieten oder den Fehler protokollieren. Dort gehört das except hin.

Als Konsequenz enthalten gut strukturierte Programme deutlich weniger try-Blöcke, als Anfänger vermuten: viele Funktionen, die Fehler nur auslösen oder passieren lassen, und wenige, zentrale Stellen, die sie behandeln.

Lernergebnisse: Was Sie nach diesem Kapitel können sollten

Abschnitt betitelt „Lernergebnisse: Was Sie nach diesem Kapitel können sollten“

Nach Abschluss dieses Kapitels sollten Schülerinnen und Schüler in der Lage sein:

  • Erklären: warum Exceptions Fehlercodes überlegen sind und wie ein Traceback gelesen wird.
  • Anwenden: Fehler mit try/except (inklusive else und finally) gezielt fangen und typspezifisch behandeln.
  • Nennen: die wichtigsten eingebauten Exception-Typen und ihre Stellung in der Vererbungshierarchie nennen.
  • Anwenden: Eingaben an Funktionsgrenzen validieren und mit raise präzise Fehler auslösen.
  • Entwerfen: eigene Exception-Klassen (auch als kleine Hierarchie) definieren und einsetzen.
  • Anwenden: Benutzereingaben mit Validierungsschleifen robust machen und Fehlermeldungen benutzergerecht formulieren.
  • Beurteilen: entscheiden, auf welcher Programmebene ein Fehler behandelt und wo er weitergereicht wird.
  • Aufgabe 14 - Robust gegen alles