Variablen, Gültigkeitsbereich & Lebensdauer
Lerne die vier Speicherdauern kennen, warum Gültigkeitsbereich und Lebensdauer verschiedene Dinge sind, welche Fallstricke bei der Initialisierung lauern und was es mit dem Static Initialization Order Fiasco auf sich hat.
Gültigkeitsbereich und Lebensdauer sind nicht dasselbe
Eines der häufigsten Missverständnisse in C++ ist, Gültigkeitsbereich und Lebensdauer gleichzusetzen. Die beiden hängen zusammen, sind aber verschiedene Konzepte:
Gültigkeitsbereich ist eine Eigenschaft zur Kompilierzeit — er bestimmt, wo ein Name im Quelltext sichtbar ist. Eine Variable, die innerhalb eines Blocks {} deklariert wird, ist nur in diesem Block und in verschachtelten Blöcken sichtbar.
Lebensdauer ist eine Eigenschaft zur Laufzeit — sie bestimmt, wann ein Objekt im Speicher existiert. Die Lebensdauer eines Objekts beginnt, wenn sein Konstruktor abgeschlossen ist, und endet, wenn sein Destruktor startet.
In vielen Fällen fallen Gültigkeitsbereich und Lebensdauer zusammen: Eine lokale Variable ist in ihrem Block sichtbar und lebt bis zum Ende des Blocks. Sie können aber auseinanderlaufen. Eine static-Variable in einer Funktion ist nur innerhalb dieser Funktion sichtbar (begrenzter Gültigkeitsbereich), lebt aber für die gesamte Programmlaufzeit (unbegrenzte Lebensdauer). Ein dynamisch allokiertes Objekt hat eine Lebensdauer, die über den Gültigkeitsbereich des Zeigers hinausreicht, der es erzeugt hat.
Die vier Speicherdauern
C++ definiert vier Speicherdauern, die festlegen, wann Objekte erzeugt und zerstört werden:
#include <iostream>
#include <string>
// Static storage duration — lives for the entire program
int global_count = 0; // Initialized before main()
static int file_local = 42; // Same, but only visible in this file
void demonstrate_storage() {
// Automatic storage duration — created on entry, destroyed on exit
int local = 10; // Born here, dies at closing brace
// Static storage duration (local scope)
static int call_count = 0; // Initialized ONCE, persists across calls
++call_count;
std::cout << "Call #" << call_count << '\n';
// Thread-local storage duration (C++11)
// thread_local int per_thread = 0; // One copy per thread, lives until thread ends
}
int main() {
demonstrate_storage(); // Call #1
demonstrate_storage(); // Call #2 — call_count is still alive!
demonstrate_storage(); // Call #3
// Dynamic storage duration — you control the lifetime
int* heap_int = new int{99}; // Born here
std::cout << *heap_int << '\n';
delete heap_int; // Dies here — you must not forget!
heap_int = nullptr; // Good practice: nullify after delete
// In modern C++, prefer smart pointers over raw new/delete
// auto ptr = std::make_unique<int>(99); // Covered in later modules
}Klicke auf Ausführen, um dieses Beispiel zu kompilieren und auszuführen.
Initialisierungsformen und Nullinitialisierung
C++ kennt drei grundlegende Initialisierungskonzepte:
Standardinitialisierung — die Variable wird ohne Initialisierer konstruiert. Bei eingebauten Typen (int, double usw.) bedeutet das, dass der Wert unbestimmt ist (ihn zu lesen ist undefiniertes Verhalten!). Bei Klassentypen wird der Standardkonstruktor aufgerufen.
Wertinitialisierung — ausgelöst durch leere runde Klammern T() oder leere geschweifte Klammern T{}. Bei eingebauten Typen wird dabei nullinitialisiert: Ganzzahlen werden 0, Zeiger werden nullptr, Wahrheitswerte werden false. Bei Klassentypen wird der Standardkonstruktor aufgerufen (und Member werden wertinitialisiert, sofern kein Konstruktor sie initialisiert).
Nullinitialisierung — eine eigene Phase, die für statische und thread-lokale Variablen vor jeder anderen Initialisierung abläuft. Alle statischen Variablen werden nullinitialisiert, bevor das Programm startet.
Deshalb ist static int x; garantiert 0, ein lokales int x; aber nicht — die lokale Variable wird standardinitialisiert (unbestimmt), während die statische Variable zuerst die Nullinitialisierung durchläuft.
Initialisierung in der Praxis
Zu verstehen, wann eine Variable null und wann sie unbestimmt ist, ist entscheidend, um Fehler zu vermeiden:
#include <iostream>
struct Point {
int x;
int y;
};
static int global_int; // Zero-initialized → 0
static Point global_point; // Zero-initialized → {0, 0}
int main() {
int a; // Default-initialized → INDETERMINATE (UB to read!)
int b{}; // Value-initialized → 0
int c = int(); // Value-initialized → 0
int d = {}; // Value-initialized → 0
Point p1; // Default-initialized → members are INDETERMINATE
Point p2{}; // Value-initialized → {0, 0}
Point p3{1, 2}; // Aggregate-initialized → {1, 2}
// Array initialization
int arr1[5]; // Default-initialized → INDETERMINATE values!
int arr2[5]{}; // Value-initialized → {0, 0, 0, 0, 0}
int arr3[5]{1, 2}; // Partially initialized → {1, 2, 0, 0, 0}
std::cout << "global_int: " << global_int << '\n'; // Safe: 0
std::cout << "b: " << b << '\n'; // Safe: 0
// std::cout << a; // DANGER: UB — reading uninitialized variable!
std::cout << "p2: (" << p2.x << ", " << p2.y << ")\n"; // Safe: (0, 0)
std::cout << "p3: (" << p3.x << ", " << p3.y << ")\n"; // Safe: (1, 2)
}Klicke auf Ausführen, um dieses Beispiel zu kompilieren und auszuführen.
Reihenfolge der Zerstörung: LIFO
Objekte mit automatischer Speicherdauer (lokale Variablen) werden in umgekehrter Reihenfolge ihrer Konstruktion zerstört — Last In, First Out (LIFO). Das ist nicht bloß Konvention; der Standard garantiert es.
Das ist enorm wichtig, wenn Objekte voneinander abhängen. Wenn Objekt b auf Objekt a verweist, musst du a vor b deklarieren. Dann wird b zuerst zerstört (während a noch lebt) und a danach. Wäre die Reihenfolge umgekehrt, könnte der Destruktor von b versuchen, ein bereits totes a zu benutzen — undefiniertes Verhalten.
Statische Variablen werden nach der Rückkehr aus main() ebenfalls in umgekehrter Reihenfolge ihrer Konstruktion zerstört. Globale Variablen, die in derselben Übersetzungseinheit definiert sind, werden von oben nach unten konstruiert und von unten nach oben zerstört. Über Übersetzungseinheiten hinweg ist die Konstruktionsreihenfolge globaler Variablen jedoch nicht spezifiziert — das ist das Static Initialization Order Fiasco.
Das Static Initialization Order Fiasco ist einer der berüchtigtsten Fallstricke in C++. Wenn du globale oder statische Variablen in verschiedenen Übersetzungseinheiten (verschiedenen .cpp-Dateien) hast, legt der C++-Standard die Reihenfolge ihrer Initialisierung nicht fest. Hängt eine globale Variable von einer anderen aus einer anderen Übersetzungseinheit ab, kann sie benutzt werden, bevor sie konstruiert wurde.
Die klassische Lösung ist das Idiom Construct on First Use: Ersetze die globale Variable durch eine Funktion, die eine Referenz auf eine lokale statische Variable zurückgibt. Lokale statische Variablen werden garantiert beim ersten Aufruf der Funktion initialisiert (und das ist seit C++11 thread-sicher).
- Die Initialisierungsreihenfolge globaler Variablen über Übersetzungseinheiten hinweg ist nicht spezifiziert
- Hängt die globale Variable A (in file1.cpp) von der globalen Variable B (in file2.cpp) ab, existiert B womöglich noch nicht, wenn A konstruiert wird
- Lösung: das Idiom Construct on First Use — verpacke globale Variablen in Funktionen mit lokalen statischen Variablen
- Die Initialisierung lokaler statischer Variablen ist seit C++11 thread-sicher (bekannt als 'magic statics')
Das Idiom Construct on First Use
Dieses Muster löst das Static Initialization Order Fiasco, indem es die Konstruktion bis zur ersten Verwendung aufschiebt:
#include <string>
#include <iostream>
// BAD: global depends on another global — order is unspecified across files
// std::string bad_prefix = "[LOG] "; // What if this is used before construction?
// GOOD: Construct on First Use idiom
std::string& log_prefix() {
static std::string prefix = "[LOG] "; // Constructed on first call, thread-safe
return prefix;
}
std::string& app_name() {
static std::string name = "MyApp";
return name;
}
void log(const std::string& message) {
// Safe: log_prefix() and app_name() are guaranteed to be initialized
std::cout << log_prefix() << app_name() << ": " << message << '\n';
}
int main() {
log("Starting up");
log("Ready");
}Klicke auf Ausführen, um dieses Beispiel zu kompilieren und auszuführen.
- Gültigkeitsbereich (wo ein Name sichtbar ist) und Lebensdauer (wann ein Objekt existiert) sind verschiedene Konzepte, die auseinanderlaufen können
- C++ hat vier Speicherdauern: automatisch (Stack), statisch (Programmlaufzeit), thread-lokal und dynamisch (Heap)
- Standardinitialisierte eingebaute Typen haben unbestimmte Werte — sie zu lesen ist undefiniertes Verhalten
- Die Wertinitialisierung mit {} garantiert bei eingebauten Typen eine Nullinitialisierung
- Lokale Variablen werden in LIFO-Reihenfolge zerstört — deklariere Abhängigkeiten vor denen, die sie benutzen
- Verwende das Idiom Construct on First Use, um das Static Initialization Order Fiasco über Übersetzungseinheiten hinweg zu vermeiden
Quiz — Teste dein Wissen
(15 XP)1. Welchen Wert hat ein lokales `int x;` (ohne Initialisierer) in C++?
2. Was ist das Static Initialization Order Fiasco?
3. Worin unterscheidet sich `static int count = 0;` innerhalb einer Funktion von `int count = 0;`?