Neue Attribute mit C++20

Modernes C++ Rainer Grimm  –  96 Kommentare

Mit C++20 erhalten wir die neuen und verbesserten Attribute [[nodiscard("reason")]], [[likely]], [[unlikely]] und [[no_unique_address]]. Insbesondere [[nodiscard("reason")]] erlaubt es, die Intention eines Interfaces deutlicher auf den Punkt zu bringen.

Dank Attributen lässt sich die Absicht des Codes deklarativ ausdrücken.

Neue Attribute

Während des Schreibens dieses Artikels bin ich zu einem großen Fan von [[nodiscard("reason")]] geworden. Daher beginne ich damit. Bereits seit C++17 gibt es das Attribut [[nodiscard]]. Mit C++20 wurde es um die Möglichkeit erweitert, eine Nachricht hinzuzufügen. Unglücklicherweise habe ich [[nodiscard]] in den letzten Jahren ignoriert. Diese Scharte möchte ich jetzt auswetzen und mit dem folgenden Programm starten:

// withoutNodiscard.cpp

#include <utility>

struct MyType {

MyType(int, bool) {}

};

template <typename T, typename ... Args>
T* create(Args&& ... args){
return new T(std::forward<Args>(args)...);
}

enum class ErrorCode {
Okay,
Warning,
Critical,
Fatal
};

ErrorCode errorProneFunction() { return ErrorCode::Fatal; }

int main() {

int* val = create<int>(5);
delete val;

create<int>(5); // (1)

errorProneFunction(); // (2)

MyType(5, true); // (3)

}

Dank Perfect Forwarding und Parameter Packs erlaubt es die Fabrikfunktion create, jeden Konstruktor aufzurufen und ein Heap-allokiertes Objekt zurückzugeben.

Das Programm hat einige Unzulänglichkeiten. Zuerst einmal verursacht die Zeile (1) ein Speicherleck, denn das auf dem Heap erzeugte Objekt wird nicht destruiert. Darüber hinaus wird der Fehlercode der Funktion errorProneFunction (2) nicht geprüft. Zuletzt erzeugt der Konstruktoraufruf MyType(5, true) eine temporäre Variable, die sofort wieder gelöscht wird. Das ist zumindest Verschwendung von Ressouren.

Nun kommt aber [[nodiscard]] ins Spiel. Es lässt sich in Funktions-, Aufzähler- und Klassendeklaration verwenden. Der Compiler soll eine Warnung ausgeben, falls du den Rückgabewert einer als "nodiscard" deklarierten Funktion ignorierst, die ihren Wert per Copy zurückgibt. Dasselbe gilt für eine Funktion, die eine als "nodiscard" erklärte Aufzählung oder eine Klasse per Copy zurückgibt. Das gilt aber nicht, falls eine Konvertierung nach void angewandt wird.

Was heißt das nun? Im folgenden Beispiel setze ich die C++17-Synax des Attributes [[nodiscard]] ein:

// nodiscard.cpp

#include <utility>

struct MyType {

MyType(int, bool) {}

};

template <typename T, typename ... Args>
[[nodiscard]]
T* create(Args&& ... args){
return new T(std::forward<Args>(args)...);
}

enum class [[nodiscard]] ErrorCode {
Okay,
Warning,
Critical,
Fatal
};

ErrorCode errorProneFunction() { return ErrorCode::Fatal; }

int main() {

int* val = create<int>(5);
delete val;

create<int>(5); // (1)

errorProneFunction(); // (2)

MyType(5, true); // (3)

}

Die Fabrikfunktion create und die enum ErrorCode sind als [[nodiscard]] deklariert. Konsequenterweise erzeugen die Aufrufe (1) und (2) eine Warnung.

Das ist schon deutlich besser, einige Unzulänglichkeiten bestehen aber immer noch. [[nodiscard]] lässt sich nicht auf Konstruktoren, die natürlich nichts zurückgeben, anwenden. Daher wird der temporäre Wert MyType(5, true) ohne Warnung erzeugt. Darüber hinaus sind mir die Fehlermeldungen zu allgemein. Als Anwender der Funktionen möchte ich wissen, warum das Verwerfen des Werts ein Problem darstellt.

Beide Unzulänglichkeiten lassen sich mit C++20 lösen. Konstruktoren können als [[nodiscard]] deklariert werden und der Warnung lässt sich eine zusätzliche Nachricht hinzufügen:

// nodiscardString.cpp

#include <utility>

struct MyType {

[[nodiscard("Implicit destroying of temporary MyInt.")]] MyType(int, bool) {}

};

template <typename T, typename ... Args>
[[nodiscard("You have a memory leak.")]]
T* create(Args&& ... args){
return new T(std::forward<Args>(args)...);
}

enum class [[nodiscard("Don't ignore the error code.")]] ErrorCode {
Okay,
Warning,
Critical,
Fatal
};

ErrorCode errorProneFunction() { return ErrorCode::Fatal; }

int main() {

int* val = create<int>(5);
delete val;

create<int>(5); // (1)

errorProneFunction(); // (2)

MyType(5, true); // (3)

}

Nun erhalten Anwender der Funktion eine spezifische Warnung. Hier ist die Ausgabe des Microsoft-Compilers:

Viele Funktionen in C++ können vom [[nodiscard]]-Attribut profitieren. Wenn du zum Beispiel den Rückgabewert von std::async nicht verwendest, wird aus einem asynchronen Aufruf ein synchroner. Was in einem separaten Thread ausgeführt werden soll, wird daher zu einem blockierenden Funktionsaufruf. Mehr Details zum überraschenden Verhalten von std::async bietet mein Artikel "Besondere Futures mit std::async".

Bei meiner Recherche zur [[nodiscard]]-Syntax auf cppreference.com fiel mir auf, dass die Überladungen von std::async mit C++20 verändert wurden. Hier ist exemplarisch eine der Überladungen:

template< class Function, class... Args>
[[nodiscard]]
std::future<std::invoke_result_t<std::decay_t<Function>,
std::decay_t<Args>...>>
async( Function&& f, Args&&... args );

std::future als Rückgabetyp des Promise std::async ist als [[nodiscard]] deklariert.

Die nächsten zwei neue Attribute [[likely]] und [[unlikely]] beschäftigen sich mit der Optimierung.

[[likely]] und [[unlikely]]

Das Proposal P0479R5 zu "likelyl" und "unlikey" ist das kürzeste, das ich kenne. Es besteht fast nur aus einer Anmerkung, die ich zitieren möchte: "The use of the likely attribute is intended to allow implementations to optimize for the case where paths of execution including it are arbitrarily more likely than any alternative path of execution that does not include such an attribute on a statement or label. The use of the unlikely attribute is intended to allow implementations to optimize for the case where paths of execution including it are arbitrarily more unlikely than any alternative path of execution that does not include such an attribute on a statement or label. A path of execution includes a label if and only if it contains a jump to that label. Excessive usage of either of these attributes is liable to result in performance degradation."

Beide Attribute erlauben es, dem Compiler einen Hinweis zu geben, welcher Ausführungspfad mit höherer Wahrscheinlichkeit ausgeführt wird:

for(size_t i=0; i < v.size(); ++i){
if (v[i] < 0) [[likely]] sum -= sqrt(-v[i]);
else sum += sqrt(v[i]);
}

Die Geschichte zur Optimierung mit den neuen Attributen endet hier aber noch nicht. Dank [[no_unique_address]] lässt sich der Addressraum optimieren.

[[no_unique_address]]

[[no_unique_address]] drückt aus, dass dieses Mitglied einer Klasse keine Adresse benötigt, die sich von allen anderen nichtstatischen Mitgliedern der Klasse unterscheidet. Falls dieses Mitglied ein Empty Type ist, kann der Compiler konsequenterweise seinen Speicherplatz wegoptimieren.

Das folgende Beispiel stellt [[no_unique_address]] genauer vor:

// uniqueAddress.cpp

#include <iostream>

struct Empty {};

struct NoUniqueAddress {
int d{};
Empty e{};
};

struct UniqueAddress {
int d{};
[[no_unique_address]] Empty e{}; // (1)
};

int main() {

std::cout << std::endl;

std::cout << std::boolalpha;

std::cout << "sizeof(int) == sizeof(NoUniqueAddress): " // (2)
<< (sizeof(int) == sizeof(NoUniqueAddress)) << std::endl;

std::cout << "sizeof(int) == sizeof(UniqueAddress): " // (3)
<< (sizeof(int) == sizeof(UniqueAddress)) << std::endl;

std::cout << std::endl;

NoUniqueAddress NoUnique;

std::cout << "&NoUnique.d: " << &NoUnique.d << std::endl; // (4)
std::cout << "&NoUnique.e: " << &NoUnique.e << std::endl; // (4)

std::cout << std::endl;

UniqueAddress unique;

std::cout << "&unique.d: " << &unique.d << std::endl; // (5)
std::cout << "&unique.e: " << &unique.e << std::endl; // (5)

std::cout << std::endl;

}

(1) wendet das neue Attribut [[no_unique_address]] an. Die Größe der Klasse NoUniqueAddress unterscheidet sich vom Datentyp int (2). Das gilt aber nicht für die Klasse UniqueAddress (3). Die Mitglieder d und e der Klasse NoUniqueAddress (4) besitzen verschiedene Adressen. Das gilt wiederum nicht für die Mitglieder der Klasse UniqueAddress (5).

Wie geht's weiter?

Der volatile-Spezifizier steht für eines der dunkelsten Ecken in C++. Daher wird seine Semantik in C++20 deutlich eingeschränkt.