zurück zum Artikel

C++17: Kleinvieh macht auch Mist

Nicolai Josuttis
C++17: Kleinvieh macht auch Mist

Seit Juni ist C++17 Feature-komplett. Das ist ein guter Anlass, die Neuerungen zu betrachten. Dieser Artikel widmet sich zunächst den Sprachmitteln.

Als die Ankündigung für C++17 als das nächste Major Release erfolgte, waren die Erwartungen ähnlich hoch wie bei C++11. Es zeigte sich jedoch, dass die Zeit schlichtweg zu kurz war. C++11 hatte allerdings seit 1998 dreizehn beziehungsweise seit 2003 acht Jahre Zeit zu reifen, C++17 dagegen nur sechs.

Auch bei C++ zeigt sich der Fluch großer Software, die rückwärtskompatibel sein muss und von einer Community gepflegt wird: Die Zeit, bis ein Feature einen Reifegrad hat, mit dem die deutliche Mehrheit der standardisierenden Personen und Organisationen leben kann, ist länger als anfangs erwartet.

Für etliche lang ersehnte (und zum Teil avisierte) neue Features konnten die Macher keinen Konsens erzielen. Concepts, Modules, Reflection, Ranges, Contracts, Default-Vergleichsoperatoren, das Überladen vom Punkt-Operator, Co-Routinen und Transactional Memory haben es nicht geschafft. Teilweise gibt es aber Beta-Standards [1] (sogenannte Technical Specifications), über die Entwickler sie ausprobieren können.

Immerhin gibt es wichtige Bibliotheken wie das Dreigestirn variant, optional, any, eine Filesystem-Bibliothek und die Unterstützung parallel laufender STL-Algorithmen. Diese Themen wird der nächste Teil des zweiteiligen Artikels behandeln.

Der erste Teil konzentriert sich dagegen auf die Neuerungen bei den Sprachmitteln. In Summe findet sich nichts Revolutionäres, aber eine signifikante Anzahl sinnvoller Verbesserungen, die das Programmieren komfortabler machen.

Unter dem Begriff "Structured Bindings" erlaubt C++17, mehrere Objekte gleichzeitig mit den Elements eines Array, einer Struktur oder eines Tuples zu initialisieren. Das macht vor allem die Verwendung mehrerer Rückgabewerte deutlich einfacher.

Das folgende Beispiel zeigt die Initialisierung der zwei int-Variablen x und y mit den Elementen aus einem zurückgelieferten Array:

int[2] f();
auto [ x, y ] = f();

Die Initialisierung von drei Variablen a, b und c mit den drei Elementen eines Tuples, bei denen die Variablen die entsprechenden Datentypen der Tuple-Elemente übernehmen, sieht folgendermaßen aus:

tuple<T1,T2,T3> g();
auto [a,b,c] = g();

Der folgende Code zeigt die Initialisierung zweier Variablen u und v mit den Komponenten einer zurückgelieferten Struktur:

struct MyStruct {
int x;
double y;
};
MyStruct h();
auto [u,v] = h();

Analog zu den Datentypen der MyStruct-Komponenten bekommt u den Typ int und v den Typ double.

Entwickler können dieses Feature nutzen, um mit auto und den üblichen Modifizierungen auf die Weise bequem über alle Schlüssel-Werte-Paare einer Map zu iterieren:

std::map<string, double> mymap;
...
for (const auto& [key,val] : mymap) {
std::cout << key << ": " << val << std::endl;
}

Der Sprachstandard bringt neue Kontrollstrukturen bei den Fallunterscheidungen: Sowohl if als auch switch dürfen jetzt optional eine Initialisierung im Anweisungskopf haben. Das ist vor allem hilfreich, wenn neben dem Test einer Bedingung dessen Initialisierung und Weiterverarbeitung erforderlich ist, wie dieses Beispiel zeigt:

if (status s = check(); s != SUCCESS) {
return s;
}

Vor C++17 sah der Code dazu folgendermaßen aus:

{
status s = check();
if (s != SUCCESS) {
return s;
}
}

Lokale Locks sind damit ebenfalls einfacher. Bisher sah der Code folgendermaßen aus:

{
std::lock_guard<std::mutex> lg(mv);
if (v.empty()) {
v.push_back(initVal);
}
}

Jetzt können die geschweiften Klammern außen wegfallen, da die Initialisierung des Lock im Scope von if erfolgt:

if (std::lock_guard<std::mutex> lg(mv);
v.empty() {
v.push_back(initVal);
}

Wieder hat sich Etliches bei den Berechnungen zur Compile-Zeit getan – für Templates und constexpr.

Für Klassen-Templates gibt es nun ein Feature, das es für Funktions-Templates schon lange gibt: Die Template-Parameter müssen nicht angegeben werden, wenn sie automatisch ermitteln werden können. In diesem Falll heißt das, wenn die Datentypen durch den Aufruf des Konstruktor klar werden. Beispielsweise genügt nun folgende Schreibweise:

std::tuple t(4, 3, 2.5);

Bisher war dafür folgender Code nötig:

std::tuple<int,int,double) t(4, 3, 2.5);

Ebenso funktionieren folgende Formulierungen:

std::lock_guard lg(mx);
std::shared_ptr up{new int(2)};

Damit erübrigen sich viele Hilfsfunktionen, die nur dazu dienten, die Datentypen der Klassen-Templates nicht unbedingt angeben zu müssen. So reicht jetzt pair(2, 4.5) statt make_pair(2, 4.5).

Hilfsfunktionen wie make_pair() sind dadurch aber nicht gänzlich überflüssig, denn der std::pair-Konstruktor führt kein "decay" durch (die Konvertierung von Arrays im Zeiger).

Der Ausdruck

std::pair("hi", "guy");

erzeugt also ein Objekt vom Typ

std::pair<const char[6], const char[7]>

während:

std::make_pair("hi","guy);

nach wie vor ein Objekt vom Typ

std::pair<const char*, const char*>

erstellt. Das kann beim Umgang mit verschiedenen Wertepaaren wichtig sein, da sich dadurch die Datentypen der einzelnen Wertepaare nicht unterscheiden.

Parameter von Value-Templates dürfen neuerdings mit dem Schlüsselwort auto deklariert werden. Es definiert, dass der Template-Parameter ein Objekt eines beliebigen Typs ist (im Gegensatz zu "Variadic Templates", bei denen die Template-Parameter die Datentypen selbst sind):

template <auto N>
class S {
...
};
S<42> s1; // OK: Datentyp von N in S ist int
S<'a'> s2; // OK: Datentyp von N in S ist char

Allerdings sind Gleitkomma-Datentypen als Template-Argumente nach wie vor nicht erlaubt:

S<2.5> s3;  // FEHLER

Partielle Spezialisierungen sind wie folgt möglich:

template <int N> class S<N> {
...
};

Auch hier sind Modifizierungen für auto erlaubt. Die folgende Definition legt fest, dass P ein konstanter Zeiger auf einen beliebigen Typ ist:

<const auto* P> struct S;

Entwickler können das Sprachmittel insbesondere dazu einsetzen, eine heterogene Liste beliebig vieler Template-Werte-Parameter zu definieren:

template <auto... vs>
struct value_list {
...
};

Mit einem kleinen Trick können sie auf die Weise eine homogene Liste von Template-Werte-Parametern definieren, deren gemeinsamer Datentyp sie noch nicht festlegen:

template <auto v1, decltype(v1)... vs>
struct typed_value_list {
...
};

Bei einer Definition, die beliebig viele Template-Argumente abgearbeitet hat, brauchte man bisher immer ein separates Abschlusskriterium:

void print ()
{
}
template <typename T, typename... Types>
void print (const T& firstArg, const Types&... args)
{
std::cout << firstArg << std::endl;
print(args...);
}

In C++17 ist stattdessen folgende Implementierung möglich:

template <typename T, typename... Types>
void print (const T& firstArg, const Types&... args)
{
std::cout << firstArg << std::endl;
if constexpr(sizeof...(args) > 0) {
print(args...);
}
}

if constexpr wird zur Compile-Zeit ausgewertet, sodass der Compiler die dazugehörigen then- oder else-Anweisungen gar nicht erst übersetzt, wenn die Bedingung beim Kompilieren false beziehungsweise true liefert.

Das Feature wird historisch bedingt übrigens "constexpr if" genannt, obwohl sich die Reihenfolge der Schlüsselworte seit dem initialen Vorschlag umgekehrt hat.

Geht es wie im vorherigen Beispiel nur um das Aneinanderreihen einzelner Operationen für alle Elemente einer variabel langen Liste von Template-Parametern, gehen sogenannte "Fold-Expressions" noch einen Schritt weiter: Sie erlauben, alle Parameter einer variablen Liste von Template-Parametern (einem sogenannten "Parameter-Pack") hintereinander mit einem Operator zu verknüpfen.

Das obige Beispiel kann deshalb ab C++17 auch folgendermaßen aussehen:

template <typename... Args>
void print (Args&&... args) {
(std::cout << ... << args) << '\n';
}

Den Ausdruck

(std::cout << ... << args)

expandiert der Compiler zu folgendem Pseudo-Code, bei dem args1, args2 und so weiter für die Argumente von args stehen:

std::cout << args1 << args2 << args3 << ...

Den Aufruf

print(42, x, "hello");

expandiert der Compiler also folgendermaßen:

std::cout << 42 << x << "hello" << '\n';

Diesen Mechnismus können Entwickler für beliebige Operatoren verwenden. Wichtig ist aber die Klammerung um die zu expandierende Operation. Formal wird

( <init> OP ... OP <parameterpack> )

expandiert zu

<init> OP <arg1> OP <arg2> OP ...

wobei die Initialisierung wegfallen kann.

Ein Ausdruck wie (... + args) liefert also die Summe aller Werte einer variablen Liste von Parametern args.

Viele Entwickler stören sich daran, dass sie globale beziehungsweise statische Objekte nur einmal definieren dürfen und sie nur deshalb Source-Dateien benötigen, statt Klassen mit statischen Komponenten komplett im Header zu definieren. Auch wenn es einige Workarounds gibt, galt bisher die grundsätzliche Regel, dass eine Deklaration in MyClass.hpp wie folgende:

struct MyClass{
static const char* prefix;
static int counter;
};

eine Source-Datei (genau eine eigene Übersetzungseinheit) MyClass.cpp benötigt, die beispielsweise folgenden Inhalt haben kann:

const char* MyClass::prefix = "> ";
int MyClass::counter = 0;

Ab C++17 reicht die Verwendung des Schlüsselworts inline innerhalb der Header-Datei:

struct MyClass{
static inline const char* prefix = "> ";
static inline int counter = 0;
};

Damit können Entwickler in einer Header-Datei Datentypen und dazugehörige globale Objekte definieren:

class Monitor {
public:
Monitor() { ... }
void log(const std::string& msg) { ... }
};
inline Monitor globalMonitor;

Das globale Objekt wird von allen Modulen gemeinsam verwendet und bei seiner ersten Verwendung initialisiert.

Die Definition von Namespaces ist ab C++17 geschachtelt möglich. Statt

namespace A {
namespace B {
namespace C {
...
}
}
}

funktioniert nun folgende Schreibweise:

namespace A::B::C {
...
}

Ein kleines Feature sei noch erwähnt, das zwar streng genommen zur Bibliothek gehört, aber im Grunde ein Sprachmittel ist:

Die bisher vorhandene Funktion std::unexpected_exception() (ohne 's' am Ende) wurde durch std::unexpected_exceptions() (mit 's' am Ende) abgelöst. Damit können Entwickler nun endlich sauber formulieren, dass Objekte bei der Behandlung von Exceptions unterscheiden können, ob ein Stack-Unwinding sie "abräumt" oder die Behandlung einer Exception sie verwendet. Im letzteren Fall dürfen sie nämlich deutlich mehr machen (z.B. Exceptions werfen).

Der Trick ist, dass Entwickler nun ermitteln können, ob sie im Destruktor mehr Exceptions haben als im Konstruktor. Damit können sie eine Klasse, die Aufgaben im Destruktor je nach Sachverhalt erfüllen oder abbrechen soll, folgendermaßen sauber programmieren:

class C {
private:
int numInitialExceptions;
public:
C() : numInitialExceptions(std::uncaught_exceptions()) {
}
C (const C&) :
numInitialExceptions(std::uncaught_exceptions()) {
}
...
~C() {
if (numInitialExceptions
== std::uncaught_exceptions()) {
commit(); // Aufruf NICHT wegen Stack-Unwinding
}
else {
rollback(); // Aufruf wegen Stack-Unwinding
}
}
};

Beim Zerstören eines Objekts der Klasse C aufgrund eines unvorhergesehenen Ereignisses, also während der aktuelle Geltungsbereich im Rahmen des Stack-Unwinding verlassen wird, ruft der Code die Methode rollback() auf, weil im Destruktor eine Exception mehr zur Bearbeitung existiert als im Konstruktor.

Erfolgt die Verwendung eines Objekts C jedoch im Rahmen der Behandlung einer Exception (also in einer catch-Klausel), handelt es sich um ein "gewolltes" Objekt, das auch im Rahmen der Behandlung einer Exception seine Arbeit sauber zu Ende bringen kann. Da es im Rahmen von catch erzeugt und zerstört wird, sind die Anzahl der unbehandelten Exceptions im Konstruktor und Destruktor gleich. Es erfolgt somit commit().

Neben der ausführlichen Aufstellung gilt es noch auf ein paar weitere Sprachmittel hinzuweisen:

Obwohl die großen neuen Sprachmittel fehlen, die das Programmieren in C++ grundsätzlich ändern, kann man von einer guten Weiterentwicklung sprechen. Unter dem Motto "Kleinvieh macht auch Mist" geht in C++17 einiges mehr oder bequemer als vorher. Das alleine kann sehr positive Effekte haben – als Beispiel aus der Vergangenheit seien zwei kleine Änderungen von C++11 genannt: auto und range-based for-Schleifen. Zumal in C++17 noch Etliches an Bibliotheken hinzukommt, mit denen sich der zweite Teil der Artikelminiserie beschäftigt.

Dass sich die neuen Sprachmittel in Grenzen halten, hat auch sein Gutes: Es sind keine oder nur wenige grundsätzliche Umbauten in Compilern erforderlich. Damit können die Werkzeughersteller die Integration von C++17 schnell umsetzen. Die meisten Features sind in dem ein oder anderen Compiler bereits in Arbeit oder experimentell vorhanden.

Nicolai Josuttis [2]
ist seit dem ersten C++-Standard an der Standardisierung von C++ aktiv beteiligt und hat mehrere Bücher zu der Programmiersprache geschrieben (darunter das weltweite Standardwerk "The C++ Standard Library"). Er gibt Schulungen zum Umstieg auf modernes C++ wie C++11, C++14 oder jetzt auch C++17.
(rme [3])


URL dieses Artikels:
https://www.heise.de/-3324790

Links in diesem Artikel:
[1] https://isocpp.org/std/status
[2] mailto:nico@josuttis.de
[3] mailto:rme@ix.de