en / de
AI
Expertisen
Methoden
Dienstleistungen
Referenzen
Jobs & Karriere
Firma
Technologie-Trends TechCast WebCast TechBlog News Events Academy

Neue C++20 Features

by Daniel Wyder 16. Dezember 2024· 10 Min. lesen
C++

Dank der Einführung von C++20 werden einige bereits vorhandene Features verbessert und auch neue hinzugefügt. Nachstehend sind nun einige davon aufgelistet mit jeweiligen Codebeispielen.

C++20 Features

Range-Based for-Loop

Seit C++11 gibt es die Range-basierte for-Schlaufe. Mit ihr kann man nun Elemente eines zusammengehängten Objekts einfacher und sicherer ansprechen. Will man aber das aktuelle Element beispielsweise an einer speziellen Stelle in einem Array speichern, so muss ausserhalb der for-Schleife eine Indexvariable erstellt und mitgeführt werden. Dies ist relativ unschön, weil diese Indexvariable ausserhalb der for-Schleife erstellt werden muss und somit eine längere Lebensdauer als nötig aufweist.
Beispiel der Range-basierten for-Schlaufe ab C++11:
Annahme: arr und arr2 sind fiktive Arrays, welche schon vorher definiert und initialisert wurden.

int i = 0;
for (int v : arr) {
    arr2[++i] = v;
}

Nun wurde mit C++20 eine Lösung dafür gemacht:

for (int i = 0; int v : arr){
    arr2[++i] = v;
}

Somit kann nun die Lebensdauer der Indexvariable auf die Schleife begrenzt werden. Aber, die Indexvariable muss weiterhin im Schleifenkörper inkrementiert werden.

Verwendung von Enums

Seit C++11 kann man Enums wie folgt verwenden:

enum class WeekDay : int {
    Monday,
    Tuesday,
    Wednesday,
    Thursday,
    Friday
}
void showWeekday(WeekDay day){
    switch (day){
        case WeekDay::Monday:
            cout << "Monday" << endl;
            break;
        case WeekDay::Tuesday:
            cout << "Tuesday" << endl;
            break;
        ...
    }
}

Dabei musste jeweils der Typenname des Enums als Identifier mit der Doppelpunkt-Schweibweise mitgegeben werden. Dank C++20 kann man nun den Typnamen weglassen und stattdessen ein using voranstellen:

enum class WeekDay : int {
    Monday,
    Tuesday,
    Wednesday,
    Thursday,
    Friday
}

void showWeekday (WeekDay day) {
    using enum WeekDay;
    switch (day) {
        case Monday:
            cout << "Monday" << endl;
            break;
        case Tuesday:
            cout << "Tuesday" << endl;
            break;
        ...
    }
}

Float, Double und Structs als Parameter bei Template Funktionen

Neu ist es auch möglich, double oder float Werte als Non-Type-Template-Parameter (NTTP) zu verwenden:

template <float f>
void f() {
    cout << f << endl;
}

int main() {

    f<4.5>();
    f<-1.5>();

    return 0;
}

Ausserdem können auch selbstdefinierte Datentypen (Structs) als NTTPs verwendet werden, jedoch dürfen diese nur literale Datentypen sein. Des weiteren ist es zwingend, dass die nicht-statischen Datenelemente Public sind.

struct Test {
    int x;
    constexpr Test(int ix = 0) : x{ ix } {}
    constexpr operator int() const { return x; }
};

template <Test example>
void f() { cout << example << endl; }

int main() {
    constexpr Test example1{ 10 };
    constexpr Test example2{ 28 };
    f<example1>();
    f<example2>();
    return 0;
}

Attribute likely und unlikely

Seit C++20 ist es mithilfe der Attribute likely bzw. unlikely möglich, dem Compiler einen Hinweis zu geben, welche von mehreren Programmalternativen wahrscheinlicher bzw. unwahrscheinlicher ist als die anderen. Die beiden Attribute werden bei Anweisungen oder Sprungmarken verwendet. Typischerweise werden sie bei switch- oder if/else-Anweisungen eingesetzt. Mithilfe dieser Hinweise kann der Compiler unter Umständen effizienteren Code generieren. Eine fehlerhafte oder unsinnige Anwendung dieser Attribute wird je nach Compiler (mit oder ohne Warnung) einfach ignoriert oder als Fehler markiert. Aber aufgepasst: Diese Attribute sollten nur verwendet werden, wenn Performanceprobleme vorhanden sind, welche auf beispielsweise eine if/else-Anweisung zurückzuführen sind.

void showDaysOfMonth(unsigned int month) {
    static constexpr const char* nameOfMonth[]{ "January", "February", "March", "April",
        "May", "June", "July", "August", "September", "October", "November", "December"};

    int nrOfDays;

    switch (month) {
        case 1: 
        case 3: 
        case 5: 
        case 7: 
        case 8: 
        case 10: 
        case 12: 
            nrOfDays = 31; 
            break;
        case 4: 
        case 6: 
        case 9: 
        case 11: 
            nrOfDays = 30; 
            break;
        case 2: 
            nrOfDays = 28; 
            break; 
        // ignoring leap day...
        [[unlikely]] default: nrOfDays = 0; 
        break;
    }

    if (nrOfDays > 0){ 
        [[likely]] cout << nameOfMonth[month - 1] << " has " << nrOfDays << " days." << endl;
    else
        cout << "Unknown month: " << month << endl;
    }
}

int main() {
    for (unsigned int i = 0; i < 15; ++i) {
        showDaysOfMonth(i);
    }
    return 0;
}

Designierte Initialisierer

Die Elemente einer Struktur (bzw. einer Klasse mit ausschließlich öffentlichen Elementen) können seit C++20 bei der statischen Initialisierung durch die Angabe ihres Namens mit einem vorangestellten Punkt explizit initialisiert werden. Der Vorteil dabei ist die bessere Lesbarkeit, was besonders dann wichtig ist, wenn es sich um eine Struktur oder Klasse mit Elementen handelt, deren Bedeutung keine typische Reihenfolge nahelegt. Eine Konsequenz der besseren Lesbarkeit ist auch eine geringere Fehlerwahrscheinlichkeit beim Schreiben des Initialisierungscodes. Alle genannten Elemente werden mit den angegebenen Werten initialisiert. Alle nicht genannten Elemente, die nicht vorbelegt sind, werden mit dem typspezifischen 0-Äquivalent initialisiert. Somit müssen nur die Elemente angegeben werden, deren Initialisierungswert sich von 0 unterscheidet. Dies ist ein weiterer Vorteil, wenn nur wenige Elemente explizit initialisiert werden müssen. Allerdings müssen diese sogenannten „designierten Initialisierer“ in der Reihenfolge der Deklaration der Elemente in der Struktur angegeben werden.

struct Time {
    int hours, minutes, seconds;
    void show() { 
        auto previousFillChar = cout.fill();
        cout << setfill('0') <<
        setw(2) << hours << ":" <<
        setw(2) << minutes << ":" <<
        setw(2) << seconds << endl; 
        cout.fill(previousFillChar);
    }
};

int main() {
    Time alarmTime { .hours = 6 };
    Time wakeupTime{ .seconds = 10 };
    Time lunchTime { .hours = 12, .minutes = 15 };
    Time delayTime1{ .hours = 1, .seconds = 20 };
    Time delayTime2{ .minutes = 15, .seconds = 2 }; 
}

Schlüsselwort requires

Seit C++11 gibt es die Möglichkeit, die Gültigkeit von Bedingungen mithilfe von static_assert zur Übersetzungszeit prüfen zu lassen. Somit können nur Bedingungen geprüft werden, die zu diesem Zeitpunkt auswertbar sind. Da die Prüfung zur Übersetzungszeit erfolgt, kostet dies weder Speicherplatz noch Laufzeit. Wenn die bei static_assert angegebene Bedingung nicht erfüllt ist, wird vom Compiler ein entsprechender Fehler zusammen mit dem String, der als zweiter Parameter angegeben wird, angezeigt. Seit C++14 ist der Stringparameter übrigens optional.

Mithilfe des Schlüsselwortes requires gibt es seit C++20 die Möglichkeit, die Voraussetzungen, die ein Parametertyp erfüllen muss, als Teil der Templatedefinition anzugeben. Das Schlüsselwort requires wird in verschiedenen Formen angewendet. In der hier gezeigten Form muss die Bedingung nach dem Schlüsselwort requires als boolscher Ausdruck, der zur Übersetzungszeit auswertbar sein muss und in runden Klammern eingeschlossen ist, angegeben werden.

template <typename T> requires (sizeof(T) == 4)
void show(T x) {
    cout << x << endl;
}

int main() {
    show('A'); // compile time error!
    show(100); // ok
    show(1.2); // compile time error!
    show(1.2f); // ok
}

Erfüllt ein Typ die angegebenen Voraussetzungen nicht, dann wird der zugehörige Templatecode vom Compiler nicht in Betracht gezogen. Wegen SFINAE („Substitution Failure Is Not An Error“) führt die Nichterfüllung einer Anforderung – anders als bei static_assert – nicht zum Abbruch der Übersetzung, sondern schliesst den zugehören Templatecode nur als mögliche Alternative aus. Somit kann mithilfe des Schlüsselworts requires eine von verschiedenen typabhängigen Codevarianten ausgewählt werden, was mit static_assert nicht möglich wäre. Stehen dem Compiler wie im oben gezeigten Beispiel mehrere Alternativen zur Verfügung, dann wählt er die Variante, die die meisten Einschränkungen erfüllt.

template <typename T>
void show(T x) {
    cout << x << endl;
}

template <typename T> requires (sizeof(T) == 4)
void show(T x) {
    cout << x << " (special version of show() for 4 byte values)" << endl;
}

int main() {
    show('A'); // Output: a
    show(100); // Output: 100 (special version of show() for 4 byte values)
    show(1.2); // Output: 1.2
    show(1.2f); // Output: 1.2 (special version of show() for 4 byte values)
}

Vergleichsoperatoren

Jetzt noch ein kleiner Ausflug zu den Vergleisoperatoren. Sollen die Elemente eines selbstdefinierten Typs auf Gleichheit bzw. Ungleichheit geprüft werden können, dann musste bisher ein entsprechender operator==() bzw. operator!=() bereitgestellt werden. Seit C++20 genügt es, nur den operator==() zu definieren. Falls bei der Prüfung auf Ungleichheit operator!=() nicht existiert, ruft der Compiler automatisch operator==() auf und verwendet das negierte Ergebnis:

class Counter { 
    int v;
    public:
        Counter(int iv = 0) : v{ iv } {}
        void inc() { ++v; }
        void dec() { --v; }
        int get() const { return v; }
        bool operator==(const Counter& rhs) const = default;
};

Wie man im obigen Beispiel sehen kann, ist es seit C++20 mithilfe von =default sogar möglich, die Implementierung von operator==() dem Compiler zu überlassen. Dabei generiert der Compiler einfach einen Algorithmus, der jedes der Datenelemente in der Reihenfolge ihrer Definition in der Klasse aufruft und vergleicht. Sobald eine Ungleichheit erkannt wird, bricht der Algorithmus ab und liefert den Wert false. Natürlich kann der operator==() auch individuell bereitgestellt werden mit einer eigenen Implementation.

Aber, umgekehrt wird dies nicht funktionieren. Sprich, wenn nur der operator!=() definiert wird und dann auf Gleichheit geprüft wird, dann wird der Compiler einen Fehler generieren.

Spaceship-Operator (Three-Way-Comparison-Operator)

Die Vergleisoperatoren <, <=, > und >= können nicht einzeln als default zur Generierung vom Compiler definiert werden. Stattdessen gibt es seit C++20 den sogenannten Spaceship-Operator <=> (auch Three-Way-Comparison-Operator genannt):

class Counter { 
    int v;
    public:
        explicit Counter(int iv = 0) : v{ iv } {}
        void inc() { ++v; }
        void dec() { --v; }
        int get() const { return v; }
        auto operator<=>(const Counter& rhs) const = default;
};

Der vom Compiler generierte Spaceship-Operator unterstützt nicht nur die Größer-/Kleiner-Operationen (>=, >, <, <=), sondern auch die Gleich- und Ungleich Operationen (==, !=). Generell liefert der 3-Wege-Vergleichsoperator einen Wert kleiner 0, wenn der linke Operand kleiner ist als der rechte, den Wert 0, wenn die beiden Operanden gleich sind und sonst einen Wert größer 0. Der Spaceship-Operator kann auch gleich im Programm verwendet werden, wie dieses Beispiel zeigt:

int main() {
    Counter c1{ 1 }, c2{ 2 }; 
    auto cmp = c1 <=> c2;
    cout << (cmp < 0 ? "c1 < c2" : "c1 >= c2") << endl;
}

Wenn man den Wert von cmp auf dem Debugger anschaut, dann wird man sehen, dass dieser vom Typ std::strong_ordering sein wird. Speziell an diesem Typ ist, dass dieser nur Operationen unterstützt, welche einen Vergleich mit der Zahl 0 ausführen.  Das heisst, dass dieser Typ eine Klasse bzw. eine Struktur ist und nicht etwa ein Aufzählungstyp. Dabei gibt es noch zwei andere Typen, welche verwendet werden können: std::weak_ordering und std::partial_ordering.

 

Überblick der Rückgabetypen vom Spaceship-Operator

Nachstehend befindet sich eine Beschreibung der drei verschiedenen Typen und deren Verwendung:

Dabei können die Typen folgende Werte aufweisen:

Ordering Type Values
std::strong_ordering less, equal, equivalent, greater
std::weak_ordering less, equivalent, greater
std::partial_ordering less, equivalent, greater, unordered

Natürlich gibt es auch die Möglichkeit, dass man den Spaceship-Operator selbst implementiert und nicht die Default-Implementierung des Compilers verwendet, wie dieses Beispiel hier zeigen soll:

class Counter { 
    ...
    auto operator<=>(const Counter& rhs) const {
        if (v < rhs.v) {
            return std::strong_ordering::less;
        } 
        else if (v == rhs.v) {
            return std::strong_ordering::equal;
        }
        else {
            return std::strong_ordering::greater;
        } 
    }
};

Jedoch ist dabei zu beachten, dass bei dieser Verwendung der Operatoren operator==() und operator!=() so nicht bereitgestellt werden. Um diese Operatoren auch zur Verfügung zu haben, müssen diese explizit definiert werden (nachstehendes Beispiel zeigt 3 mögliche Varianten dafür, wovon zwei davon aus syntaktischen Gründen auskommentiert sind):

class Counter { 
    ...
    auto operator<=>(const Counter& rhs) const {
        if (v < rhs.v) {
            return std::strong_ordering::less;
        }
        else if (v == rhs.v) {
            return std::strong_ordering::equal;
        }
        else {
            return std::strong_ordering::greater;
        }    
    }
    // bool operator== (const Counter& rhs) const = default; // v1
    // bool operator== (const Counter& rhs) const { return v == rhs.v ; } // v2
    bool operator== (const Counter& rhs) const { return *this <=> rhs == 0; } // v3
};

Auch wenn die Default-Implementierung des Spaceship-Operators einen Wert vom Typ std::strong_ordering liefert, muss sich die selbst geschriebene Implementierung nicht daran halten und kann z.B. etwas vom Typ int liefern. Dabei muss der Operator einen Wert kleiner Null liefern, wenn der linke Operand kleiner ist als der rechte, Null, wenn die beiden gleich sind und einen Wert größer Null in den übrigen Fällen. Das Problem dabei ist allerdings, dass der vom Operator gelieferte Wert dann auch mit anderen Werten als 0 verglichen und somit falsch benutzt werden kann. Deshalb ist es in jedem Fall ratsam, die von der Bibliothek bereitgestellten Returntypen zu benutzen, da Anwendungsfehler dann bereits zum Übersetzungszeitpunkt erkannt werden.

 

Header

Typischerweise gibt es in C++ die sogenannten Header-Dateien, welche Quelltexte enthalten, die dann mit Hilfe der #include-Direktive des Präprozessors in den Quelltext der Übersetzungseinheit per Copy-Paste eingefügt werden. Um zu verhindern, dass der Inhalt einer Header-Datei nicht mehrfach in eine Übersetzungseinheit gelangt, gibt es dafür die Header Guards (#ifndef __CLASS_NAME_H__ ect.).

Leider ist es aber so, dass der Präprozessor jedes mal die ganze Header-Datei einlesen muss um zu sehen, was er einfügen muss und was nicht. Dies führt dazu, dass die Übersetzungszeit verlangsamt wird und, dass dies bei jeder Übersetzung wieder gemacht werden muss, auch wenn sich die Header-Datei nicht ändert.

Modules als Lösung

Um den Prozess zu optimieren wurden daher mit C++20 die Modules eingeführt. Glücklicherweise wurde bei der Einführung dieses Features dafür gesorgt, dass man bereits existierende Header-Dateien relativ einfach in Headermodule umkonventieren kann um die Vorteile der C++20 Modul Unterstützung nutzen zu können.

Um aus einer Headerdatei ein Headermodul zu generieren, muss ihr Inhalt kompiliert werden. Auf diese Weise entsteht eine Art precompiled Headerfile, das als Headermodul mit dem Anwenderprogramm gelinkt werden kann. Dadurch wird der Übersetzungsvorgang beschleunigt, weil so die Headerdateien nicht immer wieder neu eingelesen und verarbeitet werden müssen.

Was genau gemacht werden muss, damit die Headerdatei übersetzt wird, ist abhängig von der jeweiligen Entwicklungsumgebung. Bei Visual Studio (2019) muss in den Eigenschaften der Headerdatei das Element „Item Type“ von „C/C++ header“ auf „C/C++ compiler“ umgestellt werden. Durch den Buildvorgang wird nun der Inhalt der Headerdatei in ein internes Datenformat übersetzt, das mit dem main-Programm gelinkt wird. Um das neu erzeugte Headermodul nutzen zu können, muss nun noch die Präprozessoranweisung #include «Counter.h» durch die Anweisung import «Counter.h» ersetzt werden.

Hier das bereits bekannte Format von Headerdateien:

#ifndef __COUNTER_H__
#define __COUNTER_H__
class Counter { 
    int v;
    public:
        explicit Counter(int iv = 0) : v{ iv } {}
        void inc() { ++v; }
        void dec() { --v; }
        int get() const { return v; }
};
#endif

Und hier ist die neu mögliche Headerdatei als Modul:

export module Counter.Module;

export class Counter {
    int v;
    public:
        explicit Counter(int iv = 0) : v{ iv } {}
        void inc() { ++v; }
        void dec() { --v; }
        int get() const { 
            return v; 
        }
};

Um es nun nutzen zu können, wird es neu importiert. Hier Beispielsweise im main.cpp:

include <iostream>
import "Counter.h";

int main() {
    Counter c;
    for (int i = 0; i < 3; ++i) {
        c.inc();
    }
    std::cout << c.get() << std::endl;
}

Achtung: Dabei wird bei der import Anweisung am Schluss ein Strichpunkt (;) erwartet.

In diesem Beispiel wurden die Deklaration und Definition gleich im Header gemacht. Es ist aber auch möglich, dies zu trennen:

Hier die Klasse Counter im File Counter.h:

export module Counter.Module;
import <iostream>;

export class Counter {
    int v;
    public:
        explicit Counter(int iv = 0) : v{ iv } {}
        void inc();
        void dec();
        int get() const;
};
export void show(Counter c);

Und hier die Implementation im File Counter.cpp:

module Counter.Module;

using std::cout, std::endl;

Counter::Counter(int iv) : v{ iv } {}
void Counter::inc() { ++v; }
void Counter::dec() { --v; }
int Counter::get() const { return v; }
void show(Counter c) { cout << c.get() << endl; }

Dabei ist zu beachten, dass das Implementierungsmodul mit dem Schlüselwort module gefolgt vom Modulnamen beginnen muss. Anschliessend erfolgt dann der eigentliche Code. Ein Implementierungsmodul importiert implizit das zugehörige Interfacemodul. Dadurch stehen dem Implementierungsmodul nicht nur die Deklarationen, sondern auch die Imports des Interfacemoduls zu Verfügung.

Fazit

Mit C++20 kommen viele neue Features hinzu. Ich persönlich finde, dass einige davon durchaus schon früher hätten kommen können (Verbesserung in des Ranged for-Loops oder double/float Parameter für Templates). Was spannend sein wird zu beobachten ist das Feature likely/unlikely. Es gibt bereits in diversen Foren Diskussionen darüber, wie nützlich es ist. Allgemein wird gesagt, dass dieses Feature eher nur anzuwenden ist, wenn man ein Performance-Problem hat und dieses bis auf eine if/else-Anweisung zurückführen kann.

 

 

Kommentare

Schreiben Sie einen Kommentar

Ihre E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert

Newsletter - aktuelle Angebote, exklusive Tipps und spannende Neuigkeiten

 Jetzt anmelden

Copyright © 2025 Noser Engineering AG – Alle Rechte vorbehalten.

NACH OBEN
Privacy Policy Cookie Policy
Zur Webcast Übersicht