1. Was UB ist und warum es gefaehrlich ist
Wenn Sie schon einmal einen Bug vom Typ „manchmal ist alles in Ordnung, manchmal bricht alles zusammen“ erlebt haben, dann sind Sie mit dem heutigen Thema emotional bereits vertraut. Bei einem gewoehnlichen logischen Fehler arbeitet das Programm vorhersehbar, liefert aber einfach ein falsches Ergebnis aus (zum Beispiel wurde der Durchschnitt falsch berechnet). UB dagegen ist eine Situation, in der Sie die Regeln der Sprache C++ verletzt haben, und danach verspricht die Sprache ueberhaupt nichts mehr: Das Programm kann „zufaellig funktionieren“, es kann abstuerzen, Unsinn ausgeben oder sogar korrekt aussehen — bis zum ersten Release.
UB ist nicht deshalb wichtig, weil es „eine seltene Horrorgeschichte fuer Wettbewerbsprogrammierer“ waere. Es ist wichtig, weil gerade UB das Debugging einem Kriminalfall aehnlich macht: Die Folge ist sichtbar, aber die Ursache kann weit entfernt liegen und sich auf verschiedenen Rechnern, Compilern und mit unterschiedlichen Einstellungen verschieden zeigen.
Warum UB schlimmer ist als „ein gewoehnlicher Bug“
Bei einem logischen Fehler bleiben die Garantien der Sprache erhalten: Ausdruecke werden nach Regeln ausgewertet, Speicher bleibt Speicher, Container bleiben Container. Ja, Sie konnten Zahlen falsch addieren — aber das Verhalten bleibt reproduzierbar.
Bei UB verletzen Sie den Vertrag der Sprache. Und genau das ist der entscheidende Unterschied: Nach UB schulden Ihnen Compiler und Laufzeitumgebung nichts mehr.
Praktische Definition von Undefined Behavior
Stellen Sie sich vor, C++ sei ein Brettspiel mit sehr strengen Regeln. Solange Sie nach den Regeln spielen, garantiert das Spiel, dass der Wuerfel Werte von 1–6 hat, Karten immer gleich gelesen werden und der Sieg fair gezaehlt wird. UB ist der Moment, in dem Sie den Wuerfel nehmen, ihn in zwei Haelften schneiden und sagen: „Jetzt habe ich eine 9 gewuerfelt.“ Danach muss Ihnen niemand mehr erklaeren, was geschieht: Sie sind aus dem Vertrag des Spiels ausgestiegen.
Formal verwendet der C++-Standard an solchen Stellen eine sehr direkte Formulierung: „the behavior is undefined“ („das Verhalten ist undefiniert“).
Eine wichtige praktische Folge: UB bedeutet nicht „das Programm stuerzt auf jeden Fall ab“. UB bedeutet: „Die Sprache gibt keine Garantien mehr.“ Und ohne Garantien darf der Compiler Optimierungen durchfuehren, die Ihre Erwartungen zerstoeren, weil Sie selbst (implizit) versprochen haben: „So etwas mache ich nicht.“
2. Warum UB „manchmal funktioniert“
Fuer Einsteiger klingt es logisch: „Wenn eine Codezeile falsch ist, muss sie immer auf dieselbe Weise kaputtgehen.“ Aber C++ ist eine Sprache, in der der Compiler den Code sehr aktiv fuer Geschwindigkeit umbaut, besonders im Release. Genau hier wird UB toxisch: Der Compiler optimiert das Programm in der Annahme, dass UB nicht auftritt (weil ein korrektes Programm es nicht erzeugen darf).
Unten ist ein einfaches Schema, wie sich das im echten Leben meist anfuehlt:
flowchart TD
A[Es gibt einen Fehler im Code] --> B{Ist es ein logischer Fehler?}
B -->|Ja| C[Falsches Ergebnis, aber stabiles Verhalten]
B -->|Nein, das ist UB| D[Verhalten ist nicht zugesichert]
D --> E[In Debug 'funktioniert es']
D --> F[In Release bricht es]
D --> G[Es bricht 'nicht dort', wo die Ursache liegt]
D --> H[Auf einem anderen Compiler zeigt es sich anders]
Bei UB tritt oft ein „Geistereffekt“ auf: Sie aendern an einer Stelle std::cout (nur eine Ausgabe!), und ploetzlich „verschwindet“ der Absturz. Nicht, weil Sie das Problem behoben haetten, sondern weil sich das Layout von Code oder Speicher geaendert hat und sich UB jetzt anders zeigt.
3. Typische UB-Quellen ohne new/delete und Zeiger
Die gute Nachricht: Um UB zu bekommen, brauchen Sie weder new noch Zeiger noch „furchterregende Systemtricks“. Die schlechte Nachricht: Es reichen die grundlegendsten Operationen, die wir bereits aktiv benutzen.
Heute betrachten wir vier der haeufigsten Quellen, die in Lernprogrammen (und in echter Software) wirklich vorkommen: Zugriff ausserhalb der Grenzen, Division durch null, signed overflow und ungueltige Shifts.
Zur Orientierung — eine kompakte Tabelle „was passiert ist → warum das UB ist“:
| Situation | Beispiel | Warum UB |
|---|---|---|
| Zugriff ausserhalb der Grenzen eines Arrays/Vektors | |
Sie greifen auf Speicher zu, der kein Element des Containers ist |
| Division einer Ganzzahl durch 0 | |
die Operation verletzt die Anforderungen der Sprache |
| Ueberlauf von int | |
signed overflow ist in C++ UB |
| Ungueltiger Shift | |
fuer Shifts gelten strenge Anforderungen an die Argumente |
Jetzt gehen wir jeden Punkt an kleinen Beispielen durch. Wichtig: Die gefaehrlichen Zeilen werden so „eingesperrt“, dass sie standardmaessig nicht ausgefuehrt werden. Wir wollen nicht, dass sich Ihr Programm unter dem Deckmantel von Statistik in einen Zufallszahlengenerator verwandelt.
Zugriff ausserhalb der Grenzen von std::vector
Oft begegnet ein Studierender diesem Problem zum ersten Mal so: Er sieht size() und denkt „aha, das ist das letzte Element“. Aber size() ist die Anzahl der Elemente, und Indizes beginnen bei null. Daher ist der letzte Index size() - 1, und auch das nur dann, wenn der Container nicht leer ist.
#include <iostream>
#include <vector>
int main() {
std::vector<int> v{10, 20, 30};
std::size_t i = v.size(); // i == 3, aber gueltige Indizes: 0..2
#if 0
std::cout << v[i] << '\n'; // UB: out-of-bounds-Zugriff bei operator[]
#endif
std::cout << v[0] << '\n'; // 10
}
Das Heimtueckischste hier ist, dass operator[] die Grenzen nicht pruefen muss. Er ist schnell — und vertraut Ihnen. Wenn Sie sich irren, lesen Sie „fremden“ Speicher, und danach sind alle moeglichen Spezialeffekte denkbar.
Division durch null bei Ganzzahltypen
Division durch null bei Ganzzahltypen ist nicht nur „die Mathematik sagt, das darf nicht sein“. In C++ ist das konkret eine Verletzung der Anforderungen der Operation. In manchen Umgebungen bekommen Sie einen Programmabbruch, in anderen ein seltsames Ergebnis, und in wieder anderen kann der Optimierer den Code sogar so umordnen, dass Sie die Folgen an einer ganz anderen Stelle suchen.
#include <iostream>
int main() {
int a = 10;
int b = 0;
#if 0
int c = a / b; // UB: Division durch null (bei Ganzzahltypen)
std::cout << c << '\n';
#endif
std::cout << a << '\n'; // 10
}
Die praktische Gewohnheit ist hier einfach: Wenn der Divisor potenziell null sein kann, muss das explizit per Bedingung behandelt werden (oder durch die Logik garantiert sein, sodass null unmoeglich ist).
Signed overflow
Viele erwarten ein Verhalten „wie bei einem 8-Bit-Rechner“: Es war das Maximum, dann wird es das Minimum. In C++ ist der Ueberlauf eines signed-Typs (int, long long, sofern er signed ist) jedoch Undefined Behavior. Man darf also keinen Code schreiben, der auf einen solchen Ueberlauf „setzt“.
#include <iostream>
#include <limits>
int main() {
int x = std::numeric_limits<int>::max();
#if 0
int y = x + 1; // UB: signed overflow
std::cout << y << '\n';
#endif
std::cout << x << '\n'; // 2147483647 (typischerweise, aber das haengt von int ab)
}
Wenn Sie sich dabei ertappen zu denken „da kommt doch nur +1 dazu, was soll schon passieren?“ — genau das ist der klassische Weg zu UB. Besonders wenn dieses „nur +1“ in einer Schleife mit einer Million Iterationen steckt.
Ungueltige Bitverschiebungen
Shifts sehen aus wie „mit zwei multiplizieren im binaeren System“, aber fuer sie gelten sehr strenge Regeln: Der Shift darf nicht negativ sein und nicht groesser oder gleich der Breite des Typs sein (bei einem 32-Bit-int darf man also nicht um 32 oder mehr Stellen verschieben). Verletzt man das, entsteht UB.
#include <iostream>
int main() {
int x = 1;
int shift = -1;
#if 0
int y = x << shift; // UB: negativer Shift
std::cout << y << '\n';
#endif
std::cout << x << '\n'; // 1
}
Shifts tauchen oft in Masken, Flags und bei der Kodierung von Zustaenden auf. Selbst wenn Sie keinen „Low-Level-Code“ schreiben, koennen Sie ihnen in ganz normaler Logik begegnen.
4. Praktisches Beispiel: StudyLog ohne UB
Damit UB keine Abstraktion bleibt, binden wir es an etwas Lebensnahes. Stellen wir uns vor, wir haben eine Lern-Konsolenanwendung StudyLog: Sie speichert eine Liste von Lernsitzungen (Thema + wie viele Minuten gelernt wurde) und berechnet danach einfache Statistiken. Solche Dinge haben wir bereits frueher gebaut: struct, std::vector, Funktionen und saubere Eingabe — alles schon bekannt.
Beginnen wir mit dem Datenmodell:
#include <string>
struct StudySession {
std::string topic;
int minutes = 0;
};
Jetzt bauen wir eine Funktion „ein Element sicher per Index holen“. Genau hier taucht das Risiko des Zugriffs ausserhalb der Grenzen auf. Ausnahmen behandeln wir noch nicht als eigenes Thema — es reicht uns, UB nicht zuzulassen.
#include <vector>
#include <optional>
std::optional<StudySession> GetSession(const std::vector<StudySession>& sessions,
std::size_t index) {
if (index >= sessions.size()) {
return std::nullopt;
}
return sessions[index];
}
Achten Sie auf den Gedanken dahinter: Wir versuchen nicht, das Element „fast richtig“ zu lesen. Entweder wir geben einen Wert zurueck oder wir sagen ehrlich „es gibt ihn nicht“. Das senkt die Wahrscheinlichkeit von UB drastisch, weil das Problem vor dem gefaehrlichen Zugriff abgefangen wird.
Jetzt der Durchschnittswert. Hier lauert schon eine andere Falle: Division durch null, wenn die Liste leer ist.
#include <vector>
double AverageMinutes(const std::vector<StudySession>& sessions) {
if (sessions.empty()) {
return 0.0; // Vereinbarung: leer -> Durchschnitt 0
}
int total = 0;
for (const auto& s : sessions) {
total += s.minutes;
}
return static_cast<double>(total) / sessions.size();
}
Wichtig ist hier, dass wir den leeren Container explizit behandeln. Hatten wir einfach total / sessions.size() ohne Pruefung geschrieben, haetten wir bei size() == 0 UB erhalten.
Es bleibt noch eine raffiniertere Sache: der Ueberlauf von total. Angenommen, jemand laedt Daten ueber mehrere Jahre, und die Anzahl der Minuten wird sehr gross. Bei kleinen Datenmengen faellt das nicht auf, und spaeter „zerbricht“ ploetzlich alles im Release — eine klassische UB-Geschichte.
Eine der einfachen Moeglichkeiten, das Risiko zu verkleinern, besteht darin, in einem breiteren Typ zu summieren:
#include <vector>
long long TotalMinutes(const std::vector<StudySession>& sessions) {
long long total = 0;
for (const auto& s : sessions) {
total += static_cast<long long>(s.minutes);
}
return total;
}
Das ist keine „magische Schutzmassnahme gegen alles“, aber fuer Lernaufgaben schon deutlich vernuenftiger, als alles in einem int aufzusummieren und auf das Beste zu hoffen.
Und schliesslich noch ein kleines Bonusbeispiel zu Shifts. Angenommen, Sie moechten Flags dafuer speichern, „was genau gemacht wurde“: gelesen, Aufgaben geloest, eine Vorlesung angeschaut. Das kann man mit einer Bitmaske kodieren, aber man darf weder dem Benutzer noch dem Code erlauben, um „irgendetwas“ zu verschieben.
#include <cstdint>
std::uint32_t MakeFlag(int bit) {
if (bit < 0 || bit >= 32) {
return 0u; // ungueltiges Bit -> wir geben 0 zurueck, ohne UB
}
return 1u << bit;
}
Der Sinn all dieser Ausschnitte ist derselbe: UB taucht meistens dort auf, wo eine Operation Vorbedingungen hat und wir sie weder geprueft noch garantiert haben.
5. Wie man UB von einem gewoehnlichen Fehler unterscheidet
Es gibt einen unangenehmen Punkt: UB kommt selten mit einem Schild „Guten Tag, ich bin UB“. Gewoehnlich sehen Sie nur Symptome. Und wenn Sie lernen, sie zu erkennen, denken Sie schneller in die richtige Richtung: Zuerst schliessen wir UB aus, dann suchen wir nach einem logischen Fehler.
Ein gewoehnlicher logischer Fehler verhaelt sich stabil: Zum Beispiel berechnen Sie den Durchschnitt immer um 1 falsch. UB verhaelt sich dagegen so, als „haette es ein Eigenleben“: Heute funktioniert es, morgen nicht, nach dem Hinzufuegen von std::cout ist es ploetzlich „repariert“, und im Release stuerzt alles ab.
Und hier erinnern wir uns an die Arbeit mit dem Debugger: Der call stack ist nuetzlich, weil sich das Problem an einer Stelle zeigen kann, die Ursache aber woanders liegt. UB „bricht“ besonders gern spaeter: Sie sind aus den Grenzen eines Vektors gelaufen, haben Speicher beschaedigt und stuerten erst 200 Zeilen spaeter in einer voellig anderen Funktion ab.
UB diagnostizieren: wozu Sanitizer gut sind
Wenn Sie UB vermuten, moechten Sie ein Werkzeug, das sagt: „Hier haben Sie eine verbotene Aktion ausgefuehrt.“ Bei einem gewoehnlichen Start kann das Programm schweigen und weiterlaufen — genau das macht UB so gefaehrlich. Deshalb gibt es diagnostische Build- und Laufzeitmodi, die waehrend der Ausfuehrung zusaetzliche Pruefungen einbauen.
In der Welt von C++ sind solche Werkzeuge oft Sanitizer. Auf der Idee-Ebene tun sie etwas Einfaches: Der Compiler „instrumentiert“ das Programm, fuegt rund um gefaehrliche Stellen Pruefungen hinzu und gibt bei einer Verletzung der Bedingungen einen Bericht aus.
Hier reicht es, sich einen Gedanken zu merken: Sanitizer heilen den Code nicht, sie helfen, UB aufzuspuren — moeglichst nahe an der Stelle, an der es entstanden ist. Wie man ASan/UBSan konkret einschaltet und Berichte liest, ist dagegen Stoff fuer eigene Vorlesungen.
6. Typische Fehler
Fehler Nr. 1: „Wenn es einmal funktioniert hat, dann darf man das so machen.“
Das ist eine der teuersten Gewohnheiten in C++. UB kann sich bei kleinen Datenmengen, im Debug, auf Ihrem Rechner und bis zur ersten Aenderung am Compiler „gar nicht zeigen“. Wenn der Code eine Vorbedingung der Operation verletzt (Grenzen, Divisor, Wertebereich des Typs), bleibt er falsch — selbst dann, wenn er heute „normal“ aussieht.
Fehler Nr. 2: Verwechslung von size() mit dem „letzten Index“.
size() ist die Anzahl der Elemente, nicht der Index. Bei der Indizierung sind Werte von 0 bis size()-1 einschliesslich zulaessig, wenn der Container nicht leer ist. Der Fehler v[v.size()] wirkt klein, kann in seinen Folgen aber katastrophal sein, weil operator[] die Grenzen nicht pruefen muss.
Fehler Nr. 3: Division durch null in der Hoffnung, „das wird schon irgendwie verarbeitet“.
In der Ganzzahlarithmetik ist Division durch null nicht einfach ein „Rechenfehler“, sondern UB. Man darf das nicht dem Zufall ueberlassen. Der Divisor muss geprueft oder durch die Programmlogik garantiert werden, sonst bauen Sie ein Haus auf Sand.
Fehler Nr. 4: Arithmetik an den Grenzen von int, als waere Ueberlauf einfach nur „es ist uebergelaufen“.
In C++ ist signed overflow UB, daher ist Code, der auf „zyklischen Ueberlauf“ setzt, logisch inkorrekt. Wenn das Risiko grosser Werte besteht, verwenden Sie fuer Zwischenberechnungen einen breiteren Typ und behalten Sie die Wertebereiche im Kopf.
Fehler Nr. 5: Bitverschiebungen ohne Kontrolle der Argumente.
Shifts verlangen einen korrekten Bereich: Man darf nicht um eine negative Zahl und nicht zu weit verschieben. Wenn shift aus den Daten kommt (oder berechnet wird), muss es begrenzt werden. Sonst wirkt der Fehler zufaellig: „Gestern ging es, heute druckt es nur Nullen.“
GO TO FULL VERSION