1. Fehler als Szenario und Vertrag
Wenn ein Anfänger das Wort „Fehler“ hört, stellt er sich oft roten Text in der Konsole und das Gefühl „jetzt werde ich angeschrien“ vor. Tatsächlich ist ein Fehler ein Szenario, das Sie vorausplanen müssen. Und zwar nicht nur das Szenario „der Benutzer hat Unsinn eingegeben“, sondern auch das Szenario „etwas ging mitten in der Operation schief“, wenn ein Teil der Aktionen bereits durchgeführt wurde und ein anderer Teil noch nicht.
Im C++-Standard wird viel Aufmerksamkeit darauf verwendet, wann das Programm durch terminate beendet werden muss und wie solche Situationen formuliert werden — dazu entstehen sogar separate Diskussionen und Änderungen an den Formulierungen. Das ist ein guter Hinweis: die Grenzen von Fehlern und „was nach einem Ausfall garantiert ist“ sind kein Kleinkram, sondern Teil des Fundaments der Sprache.
Die nützlichste Gewohnheit in diesem Kurs klingt etwas pedantisch, funktioniert aber wie eine Schutzweste: jede öffentliche Methode sollte eine Geschichte „vor → während → nachher“ haben. Noch besser ist es, wenn diese Geschichte nicht nur Ihr Wunschdenken ist, sondern durch kleine Tests abgesichert wird.
Methodenvertrag: vor, nach und bei Ausnahme
Beim Schreiben einer Methode ist es nützlich, drei Ebenen im Kopf zu behalten:
Präbedingung (vor dem Aufruf): was wahr sein muss, damit die Methode Sinn macht.
Postbedingung (nach Erfolg): was garantiert wahr ist, wenn die Methode erfolgreich abgeschlossen wurde.
Garantie bei Ausnahme: was wahr bleibt, wenn die Methode nicht abgeschlossen wurde (Ausnahme geworfen).
Und hier beginnt das erwachsene Leben. Denn „wenn eine Ausnahme geworfen wurde“ ist nicht einfach „Pech gehabt“. Das ist ein separater Vertragszweig.
Um die Vorlesung nicht in ein Glossar zu verwandeln, fassen wir es in einer kleinen Tabelle zusammen.
| Vertrags-Ebene | Frage | Beispiel für add_task |
|---|---|---|
| Präbedingung | „Darf man das tun?“ | title ist nicht leer |
| Postbedingung | „Was ist herausgekommen?“ | in der Liste erschien eine Aufgabe mit neuer id, next_id_ wurde erhöht |
| Bei Ausnahme | „Was blieb übrig?“ | die Liste wurde entweder nicht verändert (strong) oder sie könnte teilweise verändert sein (basic) |
Die starke Garantie lautet meistens: „entweder alles erfolgreich oder wie zuvor“. Und das ist extrem praktisch für das Gehirn: für den aufrufenden Code ist das Leben einfacher.
2. Invarianten und Objektkorrektheit
Eine Invariante ist eine Regel, die während der gesamten Lebenszeit eines Objekts wahr bleiben muss (außer in sehr kurzen „internen“ Momenten innerhalb einer Methode, wenn Sie die Grenzen genau kontrollieren). Für einen Anfänger wirkt das wie „übertriebene Philosophie“, aber in der Praxis sind Invarianten der schnellste Weg herauszufinden, was genau kaputtgegangen ist, wenn bereits alles brennt.
Kommen wir zu einer Festigung der Idee anhand einer kleinen Klasse aus unserer Lern-Konsolenanwendung. Angenommen, wir haben einen Mini-Aufgabenmanager: wir fügen Aufgaben hinzu, markieren sie als erledigt, löschen sie. Nichts Komplexes — aber ideal, um das Denken über Fehler zu trainieren.
Datenmodell: Task und TaskList
Lassen Sie uns ein einfaches Modell machen:
#include <string>
struct Task {
int id = 0;
std::string title;
bool done = false;
};
Jetzt der Container:
#include <vector>
class TaskList {
public:
void add_task(std::string title);
void mark_done(int id);
void remove_task(int id);
private:
std::vector<Task> tasks_;
int next_id_ = 1; // nächste id, die wir vergeben
};
Formulieren der Invarianten in Worten
Jetzt sprechen wir die Invarianten von TaskList explizit in natürlicher Sprache aus. Das klingt wie etwas, das man in die Dokumentation schreiben könnte:
- next_id_ ist immer positiv.
- Jede Aufgabe hat eine id > 0.
- Die ids der Aufgaben sind eindeutig.
- Das title einer Aufgabe ist nicht leer (falls das die Regel der Anwendung ist).
Beachten Sie: diese Regeln sind nicht über Ausnahmen. Sie betreffen die Korrektheit des Zustands. Ausnahmen machen das Leben nur komplizierter, weil sie eine Methode an einem unerwarteten Punkt unterbrechen können — und wenn Sie das Objekt „in Teilen“ aktualisieren, sind Invarianten leicht zu verletzen.
3. Praxis: Methoden schreiben, die schwer zu brechen sind
Jetzt implementieren wir die Methoden von TaskList so, dass:
- Eingaben vor der Zustandsänderung geprüft werden;
- Änderungen über Vorbereitung und Commit vorgenommen werden;
- nach jeder öffentlichen Methode das Objekt korrekt bleibt.
Kleiner „Lügendetektor“: validate_invariant()
Wir schreiben eine private Funktion, die die Invarianten prüft. In einem echten Projekt würde man sie nicht immer eingeschaltet lassen, aber als Lernwerkzeug ist das Gold wert.
#include <cassert>
#include <string>
#include <vector>
class TaskList {
public:
void add_task(std::string title);
private:
void validate_invariant() const {
assert(next_id_ > 0);
for (std::size_t i = 0; i < tasks_.size(); ++i) {
assert(tasks_[i].id > 0);
assert(!tasks_[i].title.empty());
for (std::size_t j = i + 1; j < tasks_.size(); ++j) {
assert(tasks_[i].id != tasks_[j].id);
}
}
}
private:
std::vector<Task> tasks_;
int next_id_ = 1;
};
Ja, hier gibt es eine doppelte Schleife, und ja, das ist nicht der schnellste Weg. Aber das ist eine Invariantprüfung, kein Produktionsalgorithmus. Wir lernen jetzt, richtig zu denken, nicht einen Mikrosekunden-Wettbewerb zu gewinnen.
add_task: erst prüfen, dann vorbereiten, dann committen
#include <stdexcept>
#include <utility>
void TaskList::add_task(std::string title) {
if (title.empty()) {
throw std::invalid_argument("Task title must not be empty");
}
TaskList tmp = *this; // Vorbereitung des neuen Zustands (kann eine Ausnahme werfen)
tmp.tasks_.push_back(Task{tmp.next_id_, std::move(title), false});
++tmp.next_id_;
// Commit in einem Schritt
tasks_.swap(tmp.tasks_);
std::swap(next_id_, tmp.next_id_);
validate_invariant();
}
Beachten Sie, wie hier automatisch ein Rollback entsteht. Wenn das Kopieren von *this nach tmp oder push_back plötzlich eine Ausnahme wirft, dann wurde this noch nicht verändert. Das ist der Stil, in dem das Leben am einfachsten ist: riskante Operationen passieren an einem temporären Objekt.
Übrigens: die Tatsache, dass im Standard viel darüber diskutiert wird, welche Operationen noexcept sein sollten und wo man fest versprechen kann „wir werfen nicht“, betrifft ebenfalls den Vertrag und die Vorhersagbarkeit. Beispielsweise werden Anforderungen an noexcept sogar für niedrigstufige Dinge wie operator delete in neuen Standards diskutiert.
4. Randfälle testen
In diesem Kurs steigen wir noch nicht in vollständige Unit-Tests mit Bibliotheken ein (das kommt separat). Aber es gibt etwas sehr Praktisches, das Sie jetzt schon tun können: manuelle Mini-Checks der Randfälle.
Der Sinn von „edge cases“ ist, dass die meisten Fehler nicht im „normalen“ Szenario leben, sondern an Stellen wie:
- leerer String anstelle eines Namens,
- id nicht gefunden,
- sehr langer String,
- wiederholter Aufruf einer Operation,
- Ausnahme mitten in der Zustandsänderung.
Wichtig: Randfälle sind kein „Checkliste-Formular“. Das ist ein Weg zu prüfen, ob Ihr Vertrag „vor/nach“ wirklich der Realität standhält.
Mini-Teststand zum Prüfen: ausprobieren und Ergebnis ausgeben
Schreiben wir eine Funktion, die add_task aufruft und ausgibt, was passiert ist.
#include <exception>
#include <iostream>
#include <string>
void try_add(TaskList& list, const std::string& title) {
try {
list.add_task(title);
std::cout << "Added task: '" << title << "'\n"; // Added task: '...'
} catch (const std::exception& e) {
std::cout << "Add failed for '" << title << "': " << e.what() << "\n";
}
}
Und in main verwenden:
#include <iostream>
int main() {
TaskList list;
try_add(list, "Buy milk"); // Added task: 'Buy milk'
try_add(list, ""); // Add failed for '': Task title must not be empty
std::cout << "Program is alive.\n"; // Program is alive.
}
Das sind keine „industrielevel Tests“. Aber das ist bereits Gedankentests: Sie prüfen bewusst, dass ein Fehler das Objekt nicht in einen seltsamen Zustand versetzt und die Anwendung nicht vollständig zerstört.
Ausnahme mitten in der Operation
Die unangenehmste Fehlerklasse ist, wenn eine Operation mitten unterbrochen wird und das Objekt in einem Halb-Zustand bleibt. Wir haben den ganzen Tag dagegen mit Commit/Rollback gearbeitet — jetzt ist es Zeit zu prüfen, ob wir wirklich gewonnen haben oder nur schön geredet haben.
Wir simulieren die Situation: „in der Methode ist etwas schiefgelaufen“, z. B. eine Unterfunktion wirft plötzlich eine Ausnahme.
Funktion, die „manchmal kaputtgeht“:
#include <stdexcept>
void maybe_fail(bool fail) {
if (fail) {
throw std::runtime_error("Simulated failure");
}
}
Methode, die zuerst vorbereitet, dann (möglicherweise) abstürzt und erst danach committet:
#include <utility>
void add_task_with_failure(TaskList& list, std::string title, bool fail) {
TaskList tmp = list; // Vorbereitung
tmp.add_task(std::move(title)); // kann selbst eine Ausnahme werfen
maybe_fail(fail); // simuliert einen Ausfall NACH der Logik
list = std::move(tmp); // Commit durch Zuweisung
}
Ja, hier gibt es einen Punkt: wir rufen tmp.add_task auf, welches bereits innerhalb von tmp einen Commit macht. Für list ist das jedoch weiterhin sicher: solange wir nicht list = ... gemacht haben, wurde das Originalobjekt nicht verändert.
Der Sinn dieses Beispiels liegt nicht im „perfekten Design“, sondern in der Schulung der Reflexe: jede potenziell ausnahmewerfende Operation sollte vor dem Commit stattfinden.
Übrigens werden in Standarddiskussionen sogar das Verhalten und die Behandlung von Ausnahmen in einigen Algorithmen explizit geklärt und überarbeitet, weil das ein wichtiger Teil des Verhaltensvertrags eines Programms ist.
5. Design und Klassifizierung von Fehlern
Invarianten als Wartungswerkzeug
Jetzt kommt eine Idee, die anfangs zu simpel erscheint: wenn Sie explizit formulieren, was eine Methode bewahren muss, beginnen Sie automatisch, anders zu schreiben.
Beispielsweise betrachten wir remove_task(id). Präbedingung: id muss existieren (oder wir entscheiden, dass das sonst ein Fehler ist). Postbedingung: es gibt keine Aufgabe mit dieser id mehr, und die anderen haben ihre ids nicht geändert. Garantie bei Ausnahme: wenn etwas schiefgeht, ist der Zustand entweder unverändert (strong) oder weiterhin gültig (basic).
Und hier taucht ein interessanter Punkt auf: Löschen aus std::vector kann Elemente verschieben, Move-Operationen auslösen usw. Also selbst eine „einfache Löschoperation“ ist ein Szenario, bei dem man bei Ausnahmen wissen muss, was man verspricht.
Wir werden in dieser Vorlesung nicht in die Garantiedetails jedes Containers und jeder Operation eintauchen (das ist ein großes eigenes Thema). Unser Ziel ist es, die richtigen Fragen zu stellen und Zustandsaktualisierungen so zu gestalten, dass in Ihrem Kopf keine „Magie“ entsteht.
Benutzerfehler vs. Systemausfall
Ein häufiger Schmerzpunkt bei Anfängern: „Wo wirft man eine Ausnahme, und wo ignoriert man sie stillschweigend?“. Ehrlich gesagt ist das keine Syntaxfrage, sondern gesunder Menschenverstand und Vertragsdenken.
Es hilft normalerweise, Probleme gedanklich in zwei Typen zu trennen.
Typ-1-Probleme — erwartet und extern: der Nutzer hat einen leeren Namen eingegeben, eine negative id übergeben oder einen nicht vorhandenen Befehl angegeben. Das ist normal, Teil des Programmlaufs. Hier sollte der Fehler verständlich sein und in der Regel das Objekt nicht zerstören.
Typ-2-Probleme — unerwartet und intern: „kein Speicher mehr“, „Eingabeapparat hat versagt“, „interne Invariante verletzt“. Das ist näher an „wir haben diesen Zustand nicht erwartet“, und hier wirft man die Ausnahme meist weiter nach oben oder (an seltenen Stellen) beendet man das Programm.
Der Punkt ist, dass derselbe Mechanismus (Ausnahmen) beides signalisieren kann. Aber die Herangehensweise an das Design des Objektzustands bleibt gleich: das Objekt darf nicht zur „halben Gurke“ werden.
6. Typische Fehler
Fehler Nr. 1: Invarianten „leben im Kopf“, nicht im Code.
Ein Anfänger ist oft sicher: „natürlich ist next_id_ immer positiv“. Klar — bis zur ersten Änderung nach einem Monat. Sobald ein zweites, drittes oder viertes Methode hinzukommt und Sie Änderungen an mehreren Stellen committen, ist Ihr Gehirn kein verlässlicher Aufbewahrungsort für Regeln mehr. Eine einfache Disziplin hilft: formulieren Sie Invarianten in Worten und haben Sie zumindest ein paar Prüfungen (z. B. validate_invariant() im Debug-Modus).
Fehler Nr. 2: Eingabeprüfung nach Zustandsänderung.
Das ist das klassische Muster „erst zugewiesen, dann nachgedacht“. Infolgedessen bleibt selbst eine korrekt geworfene Ausnahme im Objekt eine Änderung, obwohl die Operation nicht abgeschlossen wurde. Das lässt sich fast immer gleich behandeln: validieren Sie Eingaben vor Änderungen oder arbeiten Sie mit einem temporären Objekt und committen Sie am Ende.
Fehler Nr. 3: Commit „in Stücken“.
Wenn Sie zuerst ein Feld ändern, dann ein zweites, dann ein drittes, erzeugen Sie selbst ein Zeitfenster, in dem das Objekt temporär inkorrekt ist. Trifft in diesem Fenster eine Ausnahme ein, haben Sie einen Halb-Update-Zustand. Viel einfacher und verlässlicher ist es, den Commit in einem kurzen, atomaren Schritt durchzuführen (oft über swap/Zuweisung eines temporären Objekts), damit der Moment „Objekt hat sich geändert“ eindeutig ist.
Fehler Nr. 4: Es werden nur „Happy-Path“-Szenarien getestet.
Der Code „Aufgabe hinzufügen“ funktioniert fast immer mit dem String "Buy milk". Aber echte Bugs treten bei leerem String, wiederholtem Löschen, nicht vorhandener id, sehr langem Text oder Ausnahme in der Mitte einer Operation auf. Wenn Sie wenigstens gelegentlich solche Fälle bewusst durchspielen (auch manuell), beschleunigen Sie Ihr Entwicklertempo erheblich — und sparen sich zukünftige Stunden mit Debugging.
GO TO FULL VERSION