Menu

Undefiniertes Verhalten in C++: Was es ist und wie man es vermeidet

Undefiniertes Verhalten (UB) ist Code, für den der C++-Standard keinerlei Regeln festlegt – er kann abstürzen, Daten beschädigen oder scheinbar funktionieren. Lerne die häufigen Ursachen, warum "es lief problemlos" nichts beweist und welche Werkzeuge UB aufspüren.

Diese Seite enthält ausführbare Editoren - bearbeiten, ausführen und Ausgabe sofort sehen.

Was "undefiniertes Verhalten" wirklich bedeutet

Die vorige Seite zeigte, wie try/catch die Fehler behandelt, die dein Programm definiert und absichtlich auslöst. Undefiniertes Verhalten ist das Gegenteil: Es ist die Menge an Operationen, denen der C++-Standard sich weigert, überhaupt eine Bedeutung zu geben. Es gibt keine Ausnahme zum Abfangen, keinen Fehlercode, keine Garantie für einen Absturz. Der Compiler darf annehmen, dass UB nie auftritt, und tun, was er will, wenn es doch auftritt.

Genau diese Freiheit macht UB so gefährlich. Dieselbe fehlerhafte Zeile könnte auf deinem Laptop die "richtige" Antwort ausgeben, auf einem Server Müll zurückgeben und vom Optimierer bei -O2 komplett gelöscht werden. UB ist nicht "Verhalten, das wir nicht dokumentieren", sondern "Verhalten, über das die Sprache nichts verspricht". Deine Aufgabe ist es, es von vornherein nie zu schreiben.

int arr[3] = {1, 2, 3};
int x = arr[5];   // undefiniertes Verhalten: Lesen ueber das Ende des Arrays hinaus

Hier gibt es keinen Compilerfehler, und bei vielen Läufen wird er dir stillschweigend eine verirrte Ganzzahl liefern. Dieser scheinbare Erfolg ist die Falle.

Lesen oder Schreiben außerhalb der Grenzen

Die häufigste Form von UB ist, Speicher anzufassen, der dir nicht gehört. Eingebaute Arrays und std::vector::operator[] führen keine Grenzprüfung durch – ein Index hinter dem Ende (oder ein negativer) ist sofort UB, ob du liest oder schreibst.

Der Fehler, auf den du achten musst, ist <= dort, wo du < meintest: Wenn i == v.size(), indizierst du ein Element hinter dem letzten, was UB ist. Bevorzuge ein bereichsbasiertes for (zuvor behandelt), wenn du den Index nicht brauchst, da es nicht über das Ende hinauslaufen kann. Wenn du tatsächlich von Hand indizierst und ein Sicherheitsnetz möchtest, wirft v.at(i) eine std::out_of_range-Ausnahme, statt den Speicher stillschweigend zu beschädigen:

Nutze at(), während du einen Fehler jagst; wechsle in heißen Schleifen zurück zu [], sobald du nachgewiesen hast, dass die Indizes gültig sind.

Hängende Zeiger und Use-after-free

Ein Zeiger oder eine Referenz, die das Objekt überlebt, auf das sie zeigt, ist hängend (dangling). Sie zu verwenden ist UB – der Speicher kann wiederverwendet, freigegeben worden sein oder nie existiert haben. Das ist die Falle, die dir intelligente Zeiger (aus dem vorigen Kapitel) zu vermeiden helfen, aber rohe Zeiger lassen dich noch immer hineinfallen.

Die schärfste Variante ist, die Adresse einer lokalen Variablen zurückzugeben. Die lokale Variable stirbt, wenn die Funktion zurückkehrt, sodass der Aufrufer mit einem Zeiger auf nichts dasteht:

int* makeNumber() {
    int n = 42;
    return &n;   // gibt die Adresse einer lokalen Variablen zurueck - nach dem return weg
}
// Das Dereferenzieren des Ergebnisses ist undefiniertes Verhalten.

Dasselbe passiert nach einem delete oder wenn ein vector neu alloziert und die Iteratoren oder Zeiger in ihn hinein ungültig macht:

int* p = new int(5);
delete p;
cout << *p;   // use-after-free: undefiniertes Verhalten

vector<int> v = {1, 2, 3};
int* first = &v[0];
v.push_back(4);   // kann neu allozieren - 'first' haengt jetzt
cout << *first;   // undefiniertes Verhalten

Die Schutzmaßnahmen sind die, die du bereits kennst: Halte Objekte so lange am Leben, wie irgendein Zeiger sie braucht, bevorzuge Referenzen und intelligente Zeiger gegenüber rohen besitzenden Zeigern, und hole Zeiger/Iteratoren nach jeder Operation, die einen Container vergrößern kann, erneut.

Nicht initialisierte Variablen und vorzeichenbehafteter Überlauf

Eine Variable zu lesen, bevor du ihr einen Wert gegeben hast, ist bei eingebauten Typen UB – es gibt keine voreingestellte 0. Die Variable enthält die Bits, die ohnehin schon in diesem Speicher lagen, und der Optimierer darf annehmen, dass du sie nie nicht initialisiert liest.

Wäre sum als bloßes int sum; deklariert worden, würde jedes sum += i zuerst einen unbestimmten Wert lesen: UB, und ein berüchtigt schwieriger Fehler, weil es oft aussieht, als funktioniere es. Mach die Initialisierung zur Gewohnheit: int x = 0; oder int x{};.

Ein weiterer stiller Übeltäter ist der vorzeichenbehaftete Ganzzahlüberlauf. Einen vorzeichenbehafteten int über sein Maximum hinaus zu treiben, ist UB (vorzeichenlose Typen laufen vorhersehbar über; vorzeichenbehaftete nicht):

int big = 2147483647;   // INT_MAX bei einem 32-Bit-int
int oops = big + 1;     // vorzeichenbehafteter Ueberlauf: undefiniertes Verhalten

Verlass dich nicht darauf, dass er "zu einer negativen Zahl umläuft" – der Compiler darf annehmen, dass ein Überlauf nicht auftreten kann, und auf dieser Basis optimieren. Wenn du einen definierten Umlauf brauchst, verwende einen vorzeichenlosen Typ oder prüfe die Grenzen vor der Addition.

UB mit Sanitizern und Warnungen aufspüren

Du kannst dir über UB keine Sicherheit erarbeiten, denn ein erfolgreicher Lauf garantiert nichts. Was funktioniert, ist, UB zur Laufzeit mit den Sanitizern des Compilers (verfügbar in GCC und Clang) laut zu machen.

// AddressSanitizer: ausserhalb der Grenzen, use-after-free, Lecks
g++ -fsanitize=address -g -O1 main.cpp -o app && ./app

// UndefinedBehaviorSanitizer: vorzeichenbehafteter Ueberlauf, Null-Deref, ungueltige Casts
g++ -fsanitize=undefined -g main.cpp -o app && ./app

Führe deine bestehenden Tests mit diesen Flags aus, und der Lesezugriff außerhalb der Grenzen, das Use-after-free oder der vorzeichenbehaftete Überlauf, der "problemlos lief", verwandelt sich in einen präzisen Bericht, der Datei und Zeile nennt. Kombiniere sie mit -Wall -Wextra, damit der Compiler auch verdächtigen Code (wie ein wahrscheinlich nicht initialisiertes Lesen) markiert, noch bevor du das Programm ausführst.

==1234==ERROR: AddressSanitizer: heap-use-after-free on address 0x...
READ of size 4 at 0x... thread T0
    #0 main.cpp:7 in main

Behandle jeden Sanitizer-Bericht als einen Fehler, der zwingend behoben werden muss, nicht als eine Warnung zum Ignorieren – er sagt dir, dass der Standard über diese Zeile nichts verspricht.

Fazit

Undefiniertes Verhalten ist der Teil von C++, in dem die Sicherheitsgeländer wegfallen: Zugriff außerhalb der Grenzen, hängende Zeiger, Use-after-free, nicht initialisierte Lesevorgänge und vorzeichenbehafteter Überlauf erzeugen alle Code ohne definierte Bedeutung, und "es lief problemlos" ist nie ein Beweis dafür, dass er korrekt ist. Der Weg, sicher zu bleiben, ist defensiv zu programmieren (initialisiere jede Variable, respektiere die Container-Grenzen, lass intelligente Zeiger deinen Heap-Speicher besitzen) und dann mit -fsanitize=address, -fsanitize=undefined und -Wall -Wextra zu überprüfen, damit stilles UB zu einem lauten, behebbaren Bericht wird.

Damit endet das Kapitel Fehler und Debugging. Zwischen Ausnahmen, try/catch und einer gesunden Angst vor UB hast du nun die Werkzeuge, um C++ zu schreiben, das laut und absichtlich fehlschlägt statt still und versehentlich.

Häufig gestellte Fragen

Was ist undefiniertes Verhalten in C++?

Undefiniertes Verhalten (UB) ist jede Operation, der der C++-Standard ausdrücklich kein definiertes Ergebnis zuweist – zum Beispiel das Lesen über das Ende eines Arrays hinaus oder das Dereferenzieren eines hängenden Zeigers. Der Compiler darf alles tun: abstürzen, Müll zurückgeben, den Code wegoptimieren oder heute scheinbar funktionieren und nach einer Neukompilierung kaputtgehen. Es ist ein Fehler in deinem Programm, kein Feature der Sprache.

Warum funktioniert mein C++-Programm, obwohl es undefiniertes Verhalten hat?

"Es lief problemlos" beweist nichts über UB. Der Standard gibt in keine Richtung eine Garantie, also kann ein UB-Fehler heute auf deinem Rechner mit deinem Compiler das erwartete Ergebnis liefern und dann bei einer anderen Optimierungsstufe, Plattform oder Compiler-Version abstürzen. Betrachte einen erfolgreichen Lauf niemals als Beweis dafür, dass UB harmlos ist – nutze einen Sanitizer, um es tatsächlich aufzuspüren.

Wie spürt man undefiniertes Verhalten in C++ auf?

Kompiliere mit Sanitizern: -fsanitize=address (AddressSanitizer) findet Lese-/Schreibzugriffe außerhalb der Grenzen und Use-after-free, und -fsanitize=undefined (UndefinedBehaviorSanitizer) markiert vorzeichenbehafteten Überlauf, Null-Dereferenzierungen und ungültige Casts. Schalte Warnungen ein (-Wall -Wextra) und führe deine Tests mit diesen Flags aus – sie verwandeln stilles UB in einen klaren Laufzeitbericht.

Coddy programming languages illustration

Lerne mit Coddy zu programmieren

LOS GEHT'S