Die Lösung des Static Initialization Order Fiasco mit C++20

Modernes C++  –  94 Kommentare

Dem Wortlaut der FAQ von isocppp.org folgend, ist das Static Initialization Order Fiasco "a subtle way to crash your program". Die FAQ geht noch weiter: "The static initialization order problem is a very subtle and commonly misunderstood aspect of C++." Genau mit diesem subtilen und missverstandenen Aspekt von C++ befasst sich mein heutiger Artikel.

Bevor ich beginne, möchte ich einen kurzen Disclaimer loswerden. In diesem Artikel geht es um Variablen mit statischer Speicherdauer und ihren Abhängigkeiten. Sie können globale oder statische Variablen beziehungsweise statische Klassenmitglieder sein. Der Einfachheit halber werde ich sie statische Variablen nennen. Abhängigkeiten statischer Variablen in verschiedenen Übersetzungseinheiten sind im Allgemeinen ein Geschmäckle (code smell) und sollten die Grundlage für die Refaktorierung des Codes sein. Wenn du daher deinen Code refaktorierst, erübrigt sich für dich der Rest des Artikels.

Static Initialization Order Fiasco

Statische Variablen in einer Übersetzungseinheit werden in ihrer Definitionsreihenfolge initialisiert. Im Gegensatz dazu besitzt die Initialisierungsreihenfolge von statischen Variablen zwischen Übersetzungseinheiten ein großes Problem. Wenn eine statische Variable staticA in einer Übersetzungseinheit definiert wird, deren Initialisierung von der Initialisierung einer statischen Variable staticB in einer anderen Übersetzungseinheit abhängt, endet das im Static Initialization Order Fiasco. Das Programm ist ill-formed, denn es definiert nicht, welche statische Variable zuerst zur Laufzeit (dynamisch) initialisiert wird.

Bevor ich mich der Lösung dieses Problems widme, möchte ich erst das Static Initialization Order Fiasco in Aktion vorstellen.

Eine 50:50-Chance auf Korrektheit

Warum ist die Initialisierung einer statischen Variable besonders? Sie besteht aus zwei Schritten: einem statischen und einem dynamischen Schritt. Wenn eine statische Variable nicht zur Compilezeit const-initialisiert werden kann, wird sie vorerst null-initialisiert. Zur Laufzeit werden dann die statischen Variablen initialisiert, die zur Compilezeit null-initialisiert wurden:

// sourceSIOF1.cpp

int quad(int n) {
return n * n;
}

auto staticA = quad(5);
// mainSOIF1.cpp

#include <iostream>

extern int staticA;
auto staticB = staticA;

int main() {

std::cout << std::endl;

std::cout << "staticB: " << staticB << std::endl;

std::cout << std::endl;

}

Zeile (1) erklärt eine statische Variable staticA. Die Initialisierung von staticB hängt von der Initialisierung von staticA. staticB wird zur Compilezeit null-initialisiert und zur Laufzeit dynamisch initialisiert. Das Problem ist, dass es keine Garantie gibt, ob zuerst staticA oder staticB initialisiert wird, da staticA und staticB zu verschiedenen Übersetzungseinheiten gehören. Nun hast du eine 50:50-Chance, dass staticB 0 oder 25 ist.

Um meine Beobachtungen zu verdeutlichen, habe ich die Linkreihenfolge der Objektdateien geändert. Damit ändert sich auch der Wert von staticB!

Was für ein Fiasko! Das Ergebnis des Programms hängt von der Linkreihenfolge der Objektdateien ab. Wie lässt sich das Problem lösen, wenn wir nicht auf C++20 zurückgreifen können?

Verzögerte Initialisierung einer statischen Variable in einem lokalen Bereich

Statische Variable in einem lokalen Bereich werden erst erzeugt, wenn sie benötigt werden. Der lokale Bereich meint im Wesentlichen, dass die statische Variable innerhalb geschweifter Klammern verwendet wird. Diese verzögerte Initialisierung (lazy initialization) ist eine Garantie von C++98. Mit C+11 werden statische Variable in einem lokalen Bereich darüber hinaus noch Thread-sicher initialisiert. Das Thread-sichere Meyers' Singleton basiert auf dieser Garantie. Ich habe bereits einen Artikel "Thread-sicheres Initialisieren eines Singletons" geschrieben.

Die verzögerte Initialisierung kann als Lösung für das Static Initialization Order Fiasco verwendet werden:

// sourceSIOF2.cpp

int quad(int n) {
return n * n;
}

int& staticA() {

static auto staticA = quad(5); // (1)
return staticA;

}
// mainSOIF2.cpp

#include <iostream>

int& staticA(); // (2)

auto staticB = staticA(); // (3)

int main() {

std::cout << std::endl;

std::cout << "staticB: " << staticB << std::endl;

std::cout << std::endl;

}

staticA ist eine statische Variable in einem lokalen Bereich (1). Die Zeile (2) erklärt die Funktion staticA, die zum Einsatz kommt, um die statische Variable staticB zu initialisieren. Der lokale Bereich von staticA sichert zu, dass staticA genau dann von der Laufzeit erzeugt und initialisiert wird, wenn diese das erste Mal genutzt wird. In diesem Fall besitzt die Linkreihenfolge keine Auswirkung auf den Wert von staticB.

Zum Abschluss möchte ich noch das Static Initialization Order Fiasco mithilfe von C++20 lösen.

Compilezeit-Initialisierung einer statischen Variable

Ich werde constinit auf staticA anwenden. Ersteres sichert zu, dass staticA zur Compilezeit initialisiert wird:

// sourceSIOF3.cpp

constexpr int quad(int n) {
return n * n;
}

constinit auto staticA = quad(5); // (2)
// mainSOIF3.cpp

#include <iostream>

extern constinit int staticA; // (1)

auto staticB = staticA;

int main() {

std::cout << std::endl;

std::cout << "staticB: " << staticB << std::endl;

std::cout << std::endl;

}

(1) deklariert die Variable staticA. Diese (2) wird zur Compilezeit initialisiert. Eine kleine Beobachtung finde ich noch interessant. constexpr anstelle von constinit in (1) zu verwenden, ist nicht gültig, da constexpr eine Definition und nicht nur eine Deklaration benötigt.

Dank des Clang-10-Compilers kann ich das Programm ausführen:

Wie im Fall der verzögerten Initialisierung durch statische Variablen in einem lokalen Bereich ist der Wert von staticB immer 25.

Wie geht' s weiter?

C++20 besitzt einige kleine Verbesserungen rund um Templates und Lambdas. Genau darüber werde ich in meinem nächsten Artikel schreiben.