Es gibt sehr unterschiedliche Arten von Fehlverhalten, die ein Programm zeigen kann, aber man kann allen davon systematisch begegnen.
Arten von Fehlern in der Softwareentwicklung¶
Syntaktische Fehler
Der Interpreter weist uns auf diese Fehler hin, wenn wir nicht versehentlich gültigen Code geschrieben haben oder wenn der fehlerhafte Code nicht vom Interpreter erreicht wird (in der Ausführungskonfiguration). Dagegen helfen weitere statische Analysewerkzeuge wie Linter (die den Code lesen, ohne ihn auszuführen, und mögliche Fehlerquellen aufzählen), die oft auch in IDEs integriert sind.
Laufzeitfehler
Wie die Division durch 0, Zugriff auf einen Nullpointer, Voller Speicher etc. Dabei hilft uns Exception Handling mit
raiseundtry..except. In diesen Fällen gibt es eine ‘Stack Trace’ aus der sich ablesen lässt, welcher Code genau akut zu diesem Fehler führte. Das ist noch nicht unbedingt die Stelle Code, die man ändern möchte um den Fehler zu beheben, aber immerhin die erste Anlaufstelle, um zu verstehen, was genau passiert ist. Wir modellieren, was passiert ist, und vergleichen das mit unserem mentalen Modell, was passieren sollte, und finden so die Stelle, die wir wirklich ändern wollen (wo unser mentales Modell nicht mit der Wirklichkeit übereinstimmt; entweder wir ändern unser mentales Modell oder den Code).
Semantische Fehler (Logikfehler)
Beispiel: Wenn man z.B.
x = y + zgeschrieben hat, aberx = y * zdie richtige Rechnung wäre (um einen bestimmten Algorithmus zu implementieren). Das Programm läuft ohne Fehler, aber die Ausgabe entspricht nicht den Erwartungen. Hier kann man ebenfalls systematisch die Stelle im Code finden, ab der die Erwartungen (unser mentales Modell) nicht mehr der Realität entsprechen. Häufig lassen sich kleinere Methoden isoliert testen, um solche Fehler gering zu halten.
Umgebungsfehler (‘also bei mir klappt alles’)
Abhängig von installierten Paket(version)en, Betriebssystem, Zustand der Internetverbindung, Rechte im Dateisystem, API Limits, kann sich ein Programm recht unterschiedlich verhalten. Anwendungen, die in sehr heterogenen Umgebungen eingesetzt werden, müssen daher aufwändig in diesen Umgebungen getestet werden. Dazu sind Continuous Integration und Containerization nützlich.
Nicht-deterministische Fehler (‘Heisenbugs’)
Fehler, die sehr fragil von bestimmten Laufzeitparametern abhängen, oft race conditions in Code mit mehreren Threads oder Netzwerkverbindungen. Da
print-Statements oft das Timing stark verändern, sind diese Bugs schwer zu debuggen. Häufig ist eine Entkopplung und Vereinfachung von Systemen der einzige Weg, um systematisch race conditions zu vermeiden. Auch intensives Logging mit präzisen Timestamps kann hilfreich sein, um solchen Fehlern auf den Grund zu gehen.
Requirements-Fehler
Das Programm tut, was es soll, laut Entwickler; der Nutzer/Auftraggeber hatte sich aber etwas anderes vorgestellt - die Anforderungen sind nicht korrekt umgesetzt. Während es wenig formale Werkzeuge gibt, diese Sorte Fehler zu vermeiden, ist es eine wichtige Fehlerkategorie in der Praxis. In der Regel hat man jede Menge implizite Annahmen, was ‘offensichtlich’ ist. Es kann hilfreich sein, das offensichtliche explizit zu machen.
Bedienfehler
Das Programm tut, was vorgesehen ist laut Entwickler; der Nutzer hat das Interface / die Eingabedaten aber nicht so verwendet, wie vorgesehen und dadurch treten Fehler oder fehlerhafte Ausgaben auf. Wenn bei einem Bedienfehler kryptische Fehlermeldungen auftreten, so ist dies eine Version von Requirements-Fehler, denn fast immer (außer bei kurzen Skripten) möchte man Nutzereingaben bzw. Nutzerverhalten so verarbeiten, dass alle möglichen (auch die sinnlosen) Eingaben/Eingabedaten zu gültigen Programmzuständen führen. Dieser Fehlerklasse kann man frühzeitig entgegen wirken, indem man Assertions über Nutzereingaben in den Code schreibt (die dann ggf. aufgefangen werden, um dem Nutzer entsprechendes Feedback zu geben, wenn der ‘Vertrag’ nicht eingehalten wurde). Traditionell ist der Bedienfehler auch als Layer-8-Fehler (PEBKAC) bekannt.
Man könnte hier noch mehr aufzählen, z.B. Varianten von schlecht organisiertem Code, was wiederum eigene Fehlerquellen/-arten mit sich bringt, aber das bringt uns hier nicht so viel.
Debugger helfen im Wesentlichen bei Fehlerklasse 2, dem Laufzeitfehler. Häufig präsentieren sich auch die anderen Fehler auf die ein oder andere Weise als Laufzeitfehler, sodass wir sehr davon profitieren, wenn wir diese systematisch beheben können. Vereinfacht gesagt beruht Debugging auf der wissenschaftlichen Methode: Wir stellen Hypothesen auf und versuchen, diese zu falsifizieren. Dabei werden unsere Hypothesen immer besser/enger. Ein Debugger ist nur ein technisches Hilfsmittel, um schneller Hypothesen zu falsifizieren.
Debugging mit assert und print¶
Eine typische Hypothese beim Debugging hat z.B. die Form
``Der Inhalt der Variable x in Zeile 12 ist der leere String’’
das können wir leicht testen, indem wir in Zeile 12 die Zeile
assert x == ""einfügen, und betrachten ob es einen AssertionError gibt.
Wenn wir in einer komplexen Umgebung arbeiten, kann es leider sein, dass so ein AssertionError aufgefangen wird, und vielleicht wollen wir auch noch mehr über die Natur des Problems wissen; daher könnten wir auch den Wert von x ausgeben in Zeile 12:
print("Hoping x is the empty string, it is", x)Noch schöner wäre es, diese Information direkt an eine Assertion zu knüpfen - der gegebene String wird in den AssertionError verpackt.
assert x == "", "We were all expecting x to be empty by now..."Debugging mit print ist für kurze Skripte genau das richtige, aber für größere Anwendungen ist es deutlich komfortabler mit logging zu arbeiten (das ist wie Java’s Log4j). Es sollten dann eigentlich keine prints mehr zum Debugging verwendet werden.
Breakpoints und pdb¶
Mit print-Debugging kommt man leicht dazu, dass man das Programm laufen lässt, bis zu der Stelle an der man gerade etwas mit print ausgibt um zu sehen, ob die Erwartungen erfüllt werden. Dann studiert man die Ausgabe und möchte evtl. noch mehr Variablen betrachten. Dazu ändert man den Code und lässt das Programm erneut laufen - sehr ineffizient.
Schöner ist es, an der Stelle im Programm anzuhalten, damit man den Code vor dem Fehler nicht mehrfach wieder ausführen muss. Dazu schreiben wir nur einmalig breakpoint() in den Code. Sobald der Interpreter dahin kommt, springt pdb an, der (interaktive) Python Debugger. Hier kann man die Werte aller Variablen inspizieren, und z.B. Zeile für Zeile durch den Code gehen.
In pdb gibt help eine Übersicht über die verfügbaren Befehle. So liefert z.B. bt eine Backtrace/Stack trace.
Traceback¶
Während in Java die stack traces mit dem Fehler anfangen und dann der Stack weiter (nach unten) wächst, ist es in Python umgekehrt, und der Fehler steht als letztes im Traceback (chronologisch, most recent call last).
Häufig ist der konkrete Fehler, der ganz unten steht, innerhalb einer Bibliothek, deren Code man gar nicht unbedingt so gut kennt. Anstatt nun den Code der Bibliothek zu lesen, sucht man zunächst nach der Schnittstelle, also dem Code, der die Bibliothek aufruft -- den hier ist mutmaßlich eine Annahme verletzt (zumindest wenn wir erstmal davon ausgehen, dass der Fehler bei uns liegt und nicht bei der externen Bibliothek).
Beispiel: Indexfehler¶
Wir debuggen die folgende Methode:
def find_peaks(values):
peaks = []
for i in range(len(values)):
current_val = values[i]
next_val = values[i+1] # <--- Oh!
if current_val > next_val:
peaks.append(current_val)
return peaks
Diesen Code können wir ausführen, es wird ohne Probleme eine Methode find_peaks deklariert. Soweit, so gut. Wir rufen den Code auf:
data = [10, 50, 30, 20, 40]
print(find_peaks(data))Traceback (most recent call last):
File "<stdin>", line 1, in <module>
File "<stdin>", line 5, in find_peaks
IndexError: list index out of rangeZeile 5 ist die mit # <--- Oh! markierte Zeile.
Der IndexError kann sich nur auf den Ausdruck values[i+1] beziehen, d.h. es gibt innerhalb der for-Schleife irgendwann ein i sodass i+1 außerhalb des halboffenen Intervalls liegt. Das ist äquivalent dazu, dass i+1 >= len(values), also i >= len(values) - 1.
Da die Schleife durch range(len(values)) iteriert, nimmt i die ganzzahligen Werte von 0 bis len(values)-1 einschließlich an. Für genau den letzten Wert gilt i = len(values) - 1, dabei tritt der Fehler auf.
Anstatt das selbst logisch zu analysieren, können wir natürlich an der fraglichen Stelle einfach print(i) aufrufen und somit sehr schnell empirisch den Fehler finden. Wenn wir uns nun vorstellen, dass wir nicht so leicht auf die Idee kämen, dass i das Problem ist, würden wir vielleicht mit breakpoint() als erste Zeile im Schleifenkörper arbeiten und dann in Ruhe alle lokalen Variablen inspizieren. In Ruhe von Hand mehrfach durch die Schleife gehen ist nicht so effizient, daher wäre vielleicht ein conditional breakpoint nützlich, etwa mit der Bedingung i >= len(values)-2, wenn man schon kapiert hat, dass das Problem gegen Ende der Schleife auftritt. Entweder bietet unsere IDE conditional breakpoints an, oder wir schreiben if(condition): breakpoint().
Der beste Fix für den hier vorgestellten Fehler ist eigentlich nicht, den Index zu korrigieren, bis zu dem iteriert wird - es ist, die Iteration semantisch sorgfältiger zu beschreiben; hier wäre es vorteilhafter, mit pairwise (oder einfacher zip) zu arbeiten und auf explizite Indices vollständig zu verzichten.
Fehlermeldungen in Python 3.14¶
In Python 3.9 wurde ein neuer Parser eingeführt, mit dessen Hilfe in Python 3.10, 3.11 und 3.12 kleinere Verbesserungen bei den Fehlermeldungen erreicht werden konnten. In 3.13 sind die Formatierungen (auf mehreren Zeilen) verbessert worden. In Python 3.14 wurden besondere Vorkehrungen getroffen für SyntaxError, ValueError und TypeError, sodass es gerade für Anfänger leichter ist, die Fehler zu beheben.
Daher ist es möglicherweise eine gute Idee, direkt mit Python 3.14 zu arbeiten, wenn man sich noch mit der Syntax schwer tut.