Zusammenfassung
Ein Programm, das ein falsches Ergebnis liefert, korrigiert man nicht, indem man aufs Geratewohl Zeilen ändert, sondern indem man ermittelt. Diese Lektion stellt das Debugging als experimentelle Methode vor: das Fehlverhalten reproduzieren, eine Hypothese über seine Ursache aufstellen, ein Experiment entwerfen, das sie bestätigt oder verwirft, und den Ort des Fehlers eingrenzen; die Bisektion findet ihn in einer Anzahl von Schritten, die wie der Logarithmus der Programmgröße wächst. Sie entwickelt das Instrument (das Kompilieren mit -g und ohne Optimierung, die Konfiguration von Visual Studio Code auf den drei Systemen und eine echte GDB-Sitzung, die zeigt, was der Editor im Hintergrund tut), einfache und bedingte Haltepunkte, die Schritte, den Aufrufstapel und die Untersuchung des Zustands, mit einem zentralen Beispiel: dem Median, der nur bei einer geraden Anzahl von Werten versagt. Sie schließt mit den Strategien, die mehr wert sind als das Werkzeug (den Fall reduzieren, den letzten korrekten Punkt suchen, Invarianten mit assert prüfen, den Sanitizer-Bericht lesen), und mit drei Fällen: einem Off-by-one-Fehler, einer Rekursion, die nicht terminiert, und einer ungültig gewordenen Referenz.
Lernziele
- Die Kompilierung mit Debuginformationen (
-g, ohne Optimierung) konfigurieren und den Debugger aus Visual Studio Code auf dem eigenen System starten. - Einfache und bedingte Haltepunkte, die schrittweise Ausführung (Einzelschritt, Prozedurschritt, Ausführen bis Rücksprung) und die Untersuchung von Variablen, von Ausdrücken und des Aufrufstapels verwenden.
- Eine systematische Strategie (reproduzieren, reduzieren, per Bisektion eingrenzen, Hypothesen aufstellen und prüfen) anwenden, um einen gegebenen Fehler zu lokalisieren, und dabei jede Hypothese dokumentieren.
- Den Debugger mit den Sanitizern und den Tests kombinieren, um einen Speicherfehler anhand seines Berichts zu lokalisieren.
Debuggen heißt ermitteln
Der Fehler und das Fehlverhalten
Das Fehlverhalten ist das, was man beobachtet: ein falsches Ergebnis, ein Programm, das nicht terminiert, ein Sanitizer-Bericht. Der Fehler (oder Defekt) ist die Ursache: eine Zeile, eine Bedingung oder eine Entwurfsentscheidung, die nicht der Spezifikation entspricht. Das Fehlverhalten sieht man; den Fehler muss man finden, und er kann weit davon entfernt liegen. Daraus folgt, dass eine Korrektur dort, wo man das Fehlverhalten sieht, etwa indem man vom Ergebnis 2 abzieht, weil „immer 2 zu viel herauskommt“, das Symptom korrigiert und die Ursache bestehen lässt, die mit anderen Daten zurückkehren wird.
Wenn dem so ist, ist Debuggen eine Untersuchung der Ursache, und seine Methode ist die der experimentellen Wissenschaften:
- Beobachten und reproduzieren. Das Fehlverhalten genau beschreiben (mit welcher Eingabe, was erwartet wurde, was herauskam) und erreichen, dass es auftritt, wann immer man will. Ein Fehler, der sich nicht reproduzieren lässt, kann nicht untersucht werden, und man kann auch nicht prüfen, ob er behoben wurde.
- Eine Hypothese aufstellen. Eine Aussage über die Ursache, die sich als falsch erweisen kann: „die Sortierung sortiert nicht“, „der Index des zweiten mittleren Werts ist um eins zu groß“.
- Ein Experiment entwerfen. Einen Punkt im Programm wählen und vorhersagen, welcher Zustand dort zu sehen sein müsste, wenn die Hypothese wahr ist, und welcher, wenn sie falsch ist.
- Folgern und eingrenzen. Die Hypothese verwerfen oder bestätigen; in beiden Fällen wird das verdächtige Intervall kleiner. Man wiederholt das, bis das Intervall eine einzige Zeile ist.
Allerdings verlangt die Methode Disziplin: nur eine Sache auf einmal ändern, jede Hypothese mit ihrem Ergebnis notieren und den Code nicht anrühren, solange man nicht weiß, was man sucht. Zeilen aufs Geratewohl zu ändern, bis die Ausgabe gut aussieht, kann das Fehlverhalten verdecken, ohne den Fehler zu beseitigen.
Der Median einer Liste von Werten ist der mittlere Wert der sortierten Liste, wenn die Anzahl ungerade ist, und der Durchschnitt der beiden mittleren, wenn sie gerade ist. Die Lektion Testen hat gezeigt, dass Tests aufdecken, wann eine Funktion versagt, aber nicht, wo ihr Fehler liegt. Die folgende Funktion besteht den Test mit fünf Werten und nicht mit vier.
#include <algorithm>#include <cstddef>#include <print>#include <vector>// Median einer Liste von Werten.// Vorbedingung: values ist nicht leer.// Nachbedingung: gibt den mittleren Wert der sortierten Liste zurück oder den Durchschnitt der beiden mittleren.double median(std::vector<double> values){ std::ranges::sort(values); const std::size_t n{values.size()}; const std::size_t middle{n / 2}; if (n % 2 == 1) { return values[middle]; } return (values[middle] + values[middle + 1]) / 2.0;}int main(){ const std::vector<double> odd{7.0, 1.0, 5.0, 3.0, 9.0}; const std::vector<double> even{7.0, 1.0, 5.0, 3.0}; std::println("Median von {}: {}", odd, median(odd)); std::println("Median von {}: {}", even, median(even));}Median von [7, 1, 5, 3, 9]: 5Median von [7, 1, 5, 3]: 6Die Beobachtung ist genau: Mit , sortiert , wurde erwartet und 6 erhalten; mit fünf Werten ist das Ergebnis korrekt. Es kommen mindestens drei Hypothesen in Frage: (H1) die Sortierung sortiert nicht; (H2) die Indizes der beiden mittleren Werte sind falsch; (H3) die Summe oder die Division ist falsch geschrieben. Jede sagt für Zeile 17, bevor sie ausgeführt wird, etwas anderes voraus: Ist H1 wahr, so ist values nicht sortiert; ist es H2, so sind values[middle] und values[middle + 1] nicht 3 und 5; ist es H3, so sind die beiden Werte 3 und 5, und trotzdem ergibt die Rechnung 6. Dass der ungerade Fall funktioniert, macht H1 unwahrscheinlich, aber „unwahrscheinlich“ ist nicht „verworfen“; das Experiment mit dem Debugger besteht darin, das Programm in Zeile 17 anzuhalten und nachzusehen.
Die Bisektion
Wenn es keine klare Hypothese gibt, bleibt eine Methode, die nicht versagt: die Bisektion. Man nimmt an, dass der Zustand des Programms am Anfang korrekt und am Ende falsch ist und dass sich an jedem Zwischenpunkt entscheiden lässt, ob er korrekt ist (durch Vergleich mit einem von Hand berechneten Wert oder durch Prüfen einer Invariante). Man untersucht den Zustand in der Mitte des verdächtigen Intervalls: Ist er korrekt, liegt der Fehler in der zweiten Hälfte; andernfalls in der ersten. Es ist die binäre Suche der Lektion Suchen und Sortieren, angewandt auf die Zeilen eines Programms (oder auf die Durchläufe einer Schleife oder auf die Versionen einer Datei).
Die Abbildung verfolgt die Bisektion von sechzehn Zeilen mit dem Fehler in Zeile 11; jeder Strich ist ein Experiment über den Zustand nach dieser Zeile: korrekt (✓) oder falsch (✗).
Beweis
Am Anfang sind alle sechzehn Zeilen verdächtig: Man weiß, dass der Zustand vor Zeile 1 korrekt und nach Zeile 16 falsch ist.
Nach Zeile 8 ist der Zustand korrekt: Die Zeilen 1 bis 8 sind entlastet.
Nach Zeile 12 ist der Zustand bereits falsch: Der Fehler liegt zwischen Zeile 9 und Zeile 12.
Nach Zeile 10 korrekt: Es bleiben die Zeilen 11 und 12.
Nach Zeile 11 falsch: Der letzte korrekte und der erste falsche Punkt sind Nachbarn, und der Fehler liegt in Zeile 11.
Die Zählung ist die der binären Suche: Jedes Experiment halbiert das Intervall, sodass Experimente für Zeilen genügen. Für ein Programm mit tausend Zeilen genügen zehn, denn . Daher der Vorteil der Methode: Ihre Kosten wachsen wie der Logarithmus der Größe, und sie verlangt nicht, das ganze Programm zu verstehen, sondern nur, an einem Punkt entscheiden zu können, ob der Zustand der gebotene ist. Diese Entscheidung ist jedoch der schwierige Teil: Spezifikationen und Invarianten sind das Kriterium, nach dem jeder Zwischenzustand beurteilt wird.
Inhalt wird geladen…
Abgeschlossen
Melde dich an, um deinen Fortschritt zu speichern