constexpr - Den Compiler rechnen lassen

Video

Code

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <iostream>

using namespace std;

constexpr bool DEBUG_ENABLED = false; 

enum class LogLevel
{
    TRACE,
    DEBUG,
    INFO,
    WARNING,
    ERROR
};

constexpr char const *to_string(const LogLevel level)
{
    switch (level)
    {
        case LogLevel::TRACE:
            return "TRACE";
        case LogLevel::DEBUG:
            return "DEBUG";
        case LogLevel::INFO:
            return "INFO";
        case LogLevel::WARNING:
            return "WARNING";
        case LogLevel::ERROR:
            return "ERROR";
    }
}

template <LogLevel level, typename TContent> inline void log(TContent const &content)
{
    if constexpr (DEBUG_ENABLED || level > LogLevel::DEBUG)
    {
        constexpr auto level_string = to_string(level);
        cout << "[" << level_string << "] " << content << endl;
    }
} 

int main()
{
    log<LogLevel::TRACE>("Just a test");
    log<LogLevel::INFO>(12345);
    return 0;
}

Erklärung

constexpr sind eine Möglichkeit, ab C++ 11 (oder so richtig benutzbar ab C++ 14/17) Sachen vom Compiler zur Compilezeit übersetzen zu lassen. Bisher musste man für derartige Sachen auf Dinge wie Template Metaprogramming zurückgreifen. Das war und ist natürlich ein mächtiges Sprachfeature, mit dem sich interessante Probleme lösen lassen, aber für viele Sachen (und für viele Programmierer) werden derartige Programme schnell unübersichtlich. “Klassischer” C++-Code ist oft einfach besser lesbar, als der fast schon funktionale Stil eines rekursiven Templates.

Mit C++ 11 wurden erstmal constexpr-Ausdrücke eingeführt. Das sind (damals noch sehr beschränkte) Funktionen, die der Compiler zur Compilezeit auswerten und deren Rückgabewert er wie eine Compile-Time-Constant verwenden kann. Das Stichwort “Compile Time” ist dabei wichtig: im Gegensatz zu Runtime Constants (die mit dem const-Schlüsselwort, die zwar konstant sind, aber zur Laufzeit initialisiert werden) sind Compile-Time-Constants direkt im Programmcode hinterlegt und können überall da verwendet werden, wo man normalerweise auch ein Literal eines Wertes hinterlegen könnte. Mit C++ 14 wurden viele der Beschränkungen der ursprünglichen constexpr aufgehoben. Die Funktionen dürfen nun aus mehreren Anweisungen bestehen und auch mehr als ein return haben. Schleifen und if-Bedingunen werden möglich, was die Verwendbarkeit des Features deutlich erhöht. Weiterhin zwingend bleibt die Verwendung von constexpr als Input. Wenn der Compiler die constexpr zur Compilezeit auswerten soll, dann müssen natürlich auch alle Eingabewerte zur Compilezeit vorliegen.

Das Beispiel führt constexpr in der Methode to_string() ab Zeile 16 vor. Die Funktion übersetzt zwischen den Werten des LogLevel-enum und ihrer menschenlesbaren Repräsentation als Zeichenkette. Durch die Verwendung des constexpr-Schlüsselwortes wird der Compiler angewiesen, die Auswertung zur Compilezeit zumindest zu prüfen (erzwungen wird sie im Beispiel letztlich durch die Zuweisung an constexpr auto level_string in Zeile 37). Wird die Auswertung zur Compilezeit erfolgreich durchgeführt, dann wird der komplette Funktionsaufruf durch den statischen Rückgabewert ersetzt. Im Video ist das erkennbar durch die direkte Einbettung der Zeichenkette "INFO" in den Assemblercode. Einen Funktionskörper von to_string() sucht man dort hingegen vergeblich. Er wird schlicht nicht mehr gebraucht.

Ein weiteres Feature im Bereich constexpr ist die Einführung von if constexpr mit C++ 17. Diese speziellen if-Anweisungen ermöglichen eine bedingte Kompilierung ähnlich wie Templatespezialisierungen, allerdings flexibler und übersichtlicher in “normalen” Code eingebettet. if constexpr können auf alles zurückgreifen, was seinerseits wieder eine Compile-Time-Constant ist und über diese Bedingungen bilden. Wird die entsprechende Bedingung true, so übersetzt der Compiler den zugehörigen if-Block. Ist die Bedingung false, so unterbleibt das.

Der bedingt übersetzte Code muss ähnlich wie bei Template-Spezialisierungen nur syntaktisch korrekt sein. Ob er semantisch gerade Sinn ergibt (bspw. weil eine aufgerufene Methode am Zieltyp nicht vorhanden ist), ist irrelevant, solange der entsprechende Block nicht kompiliert werden soll. Man kann if constexpr also auch einsetzen, um auf das Vorhandensein von bestimmten Eigenschaften an Typen zu prüfen und abhängig vom Ergebnis bestimmte Aufrufe durchzuführen oder eben nicht.

Im Beispiel entscheided die if constexpr aus, ob eine Logausgabe überhaupt stattfinden soll. Bedingung dafür ist entweder, dass alle Ausgaben getätigt werden sollen (DEBUG_ENABLED also true ist) oder dass die auszugebende Nachricht eine “normale” Nachricht (im Gegensatz zu einer Debug-Nachricht) ist. Ist die entsprechende Prüfung erfolgreich, wir der im if-Block enthaltene Code kompiliert und zur Laufzeit eben auch ausgeführt. Ist die Bedingung unwahr, wird der Code entsprechend nicht eingesetzt. Damit wird die gesamte Methode leer und vom Optimierer aus der Ausgabe entfernt.

Abhängig von DEBUG_ENABLED sind also beide Aufrufe der Funktion (Zeilen 44 und 45) in der Ausgabe enthalten oder nur Zeile 45, und das deutlich lesbarer, als mit Template-Spezialisierungen.