Zwei neue Schlüsselwörter in C++20: consteval und constinit

Modernes C++ Rainer Grimm  –  170 Kommentare

Mit C++20 erhalten wir zwei neue Schlüsselwörter: consteval und constinit. Das erste erzeugt eine Funktion, die zur Compilezeit ausgeführt wird, das zweite sichert zu, dass eine Variable zur Compilezeit initialisiert wird.

Beim Lesen meiner kurzen Einleitung zu consteval und constinit mag der Eindruck entstehen, dass beide Spezifizierer sehr ähnlich zu constexpr sind. Diese Beobachtung trifft zu. Bevor ich aber die Schlüsselwörter consteval, constinit, constexpr und das gute alte const genauer unter die Lupe nehmen, möchte ich consteval und constinit vorstellen.

consteval

consteval int sqr(int n) {
return n * n;
}

consteval erzeugt eine sogenannte immediate-Funktion. Jeder Aufruf einer solchen Funktion erzeugt eine Compilezeit-Konstante. Dies lässt sich auch einfacher ausdrücken. Eine consteval- (immediate-)Funktion wird zur Compilezeit ausgeführt.

consteval kann nicht auf Destruktoren oder Funktionen angewandt werden, die Speicher allokieren oder freigeben. In einer Deklaration lässt sich nur maximal eines der Schlüsselwörter consteval, constexpr oder constinit einsetzen. Eine immediate-Funktion (consteval) ist implizit inline und muss dieselben Anforderungen wie eine constexpr-Funktion erfüllen.

Die Anforderungen an eine constexpr-Funktion in C++14 und damit eine consteval-Funktion sind die folgenden: Eine constexpr-Funktion kann

  • bedingte Sprung- und Iterationsanweisungen enthalten.
  • mehrere Anweisungen umfassen.
  • constexpr-Funktionen aufrufen. Eine consteval-Funktion kann eine constexpr-Funktion aufrufen. Eine constexpr-Funktion kann aber nicht eine consteval-Funktion aufrufen.
  • fundamentale Datentypen verwenden, die mit einem konstanten Ausdruck zu initialisieren sind.

constexpr-Funktionen können keine statischen oder thread_local-Daten verwenden. Auch ist ein try-Block oder eine goto-Anweisungen nicht möglich. Das folgende Programm constevalSqr.cpp stellt die consteval-Funktion sqr vor.

// constevalSqr.cpp

#include <iostream>

consteval int sqr(int n) {
return n * n;
}

int main() {

std::cout << "sqr(5): " << sqr(5) << std::endl; // (1)

const int a = 5; // (2)
std::cout << "sqr(a): " << sqr(a) << std::endl;

int b = 5; // (3)
// std::cout << "sqr(b): " << sqr(b) << std::endl; ERROR

}

5 ist ein konstanter Ausdruck und lässt sich damit als Argument der Funktion sqr (1) einsetzen. Dasselbe gilt die für Variable a (2). Eine Konstante wie a lässt sich in einem konstanten Ausdruck verwenden, wenn sie mit einem konstanten Ausdruck initialisiert wird. b (3) ist kein konstanter Ausdruck. Konsequenterweise ist der Aufruf sqr(5) nicht gültig.

Dank des neuen GCC11-Compilers und dem Compiler Explorer kann ich das Programm ausführen.

constinit

constinit kann auf Variablen mit statischer Speicherdauer (static storage duration) oder Thread-Speicherdauer (thread storage duration) angewandt werden.

  • Globale oder statische Variablen oder statische Mitglieder einer Klasse besitzen statische Speicherdauer. Diese Objekte werden allokiert, wenn das Programm startet, und deallokiert, wenn das Programm endet.
  • thread_local-Variablen besitzen Thread-Speicherdauer. Thread-lokale Daten werden bei Bedarf für jeden Thread erzeugt. Sie gehören exklusiv einem Thread, werden bei ihrer ersten Verwendung erzeugt und ihre Lebenszeit ist an die Lebenszeit ihres Threads gebunden. Gerne werden Thread-lokale Daten auch Thread-lokaler Speicher genannt.

constinit sichert für diese Variablen (statische Speicherdauer und Thread-Speicherdauer) zu, dass sie zur Compilezeit initialisiert werden:

// constinitSqr.cpp

#include <iostream>

consteval int sqr(int n) {
return n * n;
}

constexpr auto res1 = sqr(5);
constinit auto res2 = sqr(5);

int main() {

std::cout << "sqr(5): " << res1 << std::endl;
std::cout << "sqr(5): " << res2 << std::endl;

constinit thread_local auto res3 = sqr(5);
std::cout << "sqr(5): " << res3 << std::endl;

}

res1 und res2 besitzen statische Speicherdauer. res3 besitzt Thread-Speicherdauer.

Nun ist es an der Zeit, auf die Unterschiede von const, constexpr, consteval und constinit genauer einzugehen. Diese Unterschiede werde ich anhand des Ausführens einer Funktion und des Initialisierens einer Variable vorstellen.

Ausführen einer Funktion

Das Programm consteval.cpp besitzt drei Versionen der square-Funktion:

// consteval.cpp

#include <iostream>

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

consteval int sqrCompileTime(int n) {
return n * n;
}

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

int main() {

// constexpr int prod1 = sqrRunTime(100); ERROR (1)
constexpr int prod2 = sqrCompileTime(100);
constexpr int prod3 = sqrRunOrCompileTime(100);

int x = 100;

int prod4 = sqrRunTime(x);
// int prod5 = sqrCompileTime(x); ERROR (2)
int prod6 = sqrRunOrCompileTime(x);

}

Die Namen deuten es bereits an: Die gewöhnliche Funktion sqrRunTime wird zur Laufzeit ausgeführt; die consteval-Funktion sqrCompileTime zur Compilezeit; die constexpr Funktion sqrRunOrCompileTime zur Laufzeit oder Compilezeit. Konsequenterweise führt der Aufruf der Funktion sqrRunTime (1) zur Compilezeit zu einem Fehler. Ein Fehler ergibt sich, wenn ein nichtkonstaner Ausdruck als Argument der Funktion sqrCompileTime (2) verwendet wird.

Der Unterschied zwischen constexpr-Funktion sqrRunOrCompileTime und der consteval-Funktion sqrCompileTime ist, dass die erste nur dann zur Compilezeit ausgeführt werden muss, wenn das ihr Aufrufkontext erfordert:

static_assert(sqrRunOrCompileTime(100) == 100);                   // compile-time (1)
int arrayNewWithConstExpressioFunction[sqrRunOrCompileTime(100)]; // compile-time (1)
constexpr int prod = sqrRunOrCompileTime(100); // compile-time (1)

int a = 100;
int runTime = sqrRunOrCompileTime(a); // run-time (2)

int runTimeOrCompiletime = sqrRunOrCompileTime(100); // run-time or compile-time (3)

int allwaysCompileTime = sqrCompileTime(100); // compile-time (4)

Die ersten drei Zeilen (1) fordern die Ausführung zur Compilezeit. Zeile (2) lässt sich nur zur Laufzeit ausführen. a ist kein konstanter Ausdruck. Die kritische Zeile ist die Zeile (3). Die Funktion kann zur Compilezeit oder zur Laufzeit ausgeführt werden. Ob sie zur Compilezeit oder Laufzeit ausgeführt wird, hängt von dem verwendeten Compiler und dem Optimierungsstufen des Programms ab. Die Beobachtung gilt nicht für die Zeile (4). Eine consteval-Funktion wird immer zur Compilezeit ausgeführt.

Initialisierung einer Variable

In dem folgenden Programm constexprConstinit.cpp vergleiche ich const, constexpr und constint:

// constexprConstinit.cpp

#include <iostream>

constexpr int constexprVal = 1000;
constinit int constinitVal = 1000;

int incrementMe(int val){ return ++val;}

int main() {

auto val = 1000;
const auto res = incrementMe(val); // (1)
std::cout << "res: " << res << std::endl;

// std::cout << "res: " << ++res << std::endl; ERROR (2)
// std::cout << "++constexprVal++: " << ++constexprVal << std::endl; ERROR (2)
std::cout << "++constinitVal++: " << ++constinitVal << std::endl; // (3)

constexpr auto localConstexpr = 1000; // (4)
// constinit auto localConstinit = 1000; ERROR

}

Lediglich die const-Variable (1) wird zur Laufzeit initialisiert. constexpr- und constinit-Variablen werden zur Compilezeit initialisiert. constinit (3) impliziert nicht die Konstantheit der Variable wie im Falle von const (2) oder constexpr (2). Eine als constexpr (4) oder const (1) deklarierte Variable kann als eine lokale Variable erzeugt werden. Das gilt aber nicht für eine constinit-Variable.

Wie geht's weiter?

Die Initialisierung von statischen Variablen in verschieden Übersetzungseinheiten besitzt ein ernsthaftes Problem: Es ist nicht definiert, in welcher Reihenfolge statische Variablen initialisiert werden, deren Initialisierung voneinander abhängt. Um es kurz zu machen: In meinem nächsten Artikel geht es um das Static Initialization Order Fiasco und wie sich dies dank constinit in Wohlgefallen auflöst.