Vor zehn Jahren erblickte diese Artikelreihe unter dem Titel “Modernes C++ in der Praxis” das Licht der Welt. Ein guter Grund der Frage nachzugehen, welche Wege die Sprache im letzten Jahrzehnt genommen hat.
Bevor wir uns der Frage widmen, wie modernes C++ in den letzten Jahren gereift ist, gilt es zu klären, was denn Legacy C++ und modernes C++ unterscheidet? Als Legacy C++ bezeichnet man C++ vor C++11. Das umfasst den ersten C++-Standard C++98 sowie C++03, eine kleine Erweiterung. Das beantwortet auch schon beinahe den zweiten Teil der Frage: Unter modernem C++ versteht man C++11, C++14 und C++17. Für C++20 hat sich noch kein Begriff in der C++-Community eingebürgert, es bleibt in diesem Schema außen vor. Der einzige Konsens lautet, dass mit C++20 eine neue Ära beginnt.
Wie lässt sich die Evolution von Legacy C++ zu modernem C++ am besten veranschaulichen? Diese Frage sollen die zehn Leitsätze für moderne C++-Entwicklung aus Abbildung 1 auf den Punkt bringen.
Implizite Typkonvertierung
Implizite Typkonvertierungen sind ein häufiger Grund für undefiniertes Programmverhalten. Das Programm kann abstürzen oder ein falsches beziehungsweise im schlimmsten Fall nur vermeintlich richtiges Ergebnis liefern.
Doch wann treten implizite oder heimliche Typkonvertierungen auf? Unschönerweise viel zu oft. So lässt sich eine Int-Variable mit einem Double-Wert initialisieren, was die Nachkommastellen abschneidet: »int i(3.14)«. Ein weiteres Beispiel liefern die Null-Pointer-Konstanten »0« und »NULL«, die sich beide in eine natürliche Zahl konvertieren lassen. Ähnliches gilt für Aufzählungen: Der Wert »red« aus »enum Color{red, blue, green}« konvertiert “heimlich” in die ganze Zahl 0.
Um diese Probleme zu vermeiden, bietet modernes C++ mehrere Hilfen an. So soll man zum Beispiel Variablen immer mit geschweiften Klammern initialisieren (»int i{3.14}«) oder Konvertierungskonstruktoren als »explicit« deklarieren. In beiden Fällen meldet sich der Compiler mit einer unmissverständlichen Fehlermeldung, falls es zu einer Konvertierung mit Verlust der Datengenauigkeit (Narrowing Conversion) oder einer impliziten Konvertierung kommt.
Programmiere deklarativ
Deklaratives Programmieren steht für einen Programmierstil, bei dem der Entwickler lediglich seine Absicht ausdrückt und das Verhalten des Systems spezifiziert – die C++-Laufzeit setzt das Gewünschte um. Die C++11 Schlüsselwörter »default« und »delete« erlauben es, Methoden vom Compiler anzufordern oder sie zu unterdrücken. In ähnlicher Weise stellt das Schlüsselwort »override« sicher, dass eine Member-Funktion eine virtuelle Member-Funktion einer Basisklasse überschreibt. Das Gegenteil bewirkt »final«; damit lässt sich eine virtuelle Methode nicht überschreiben.
Beim Einsatz des Schlüsselworts »override« stellt der Compiler sicher, dass sowohl die Parameter als auch der Rückgabetyp und die »const«-Zusicherungen der Member-Funktion eingehalten werden. Ist die zu überschreibende Member-Funktion nicht virtuell, moniert dies der Compiler ebenfalls.
Listing 1 bringt diesen Vertrag zwischen dem Compiler und dem Programmierer auf den Punkt. So ist die Member-Funktion »func1()« (Zeile 2) nicht virtuell. Die Member-Funktion »func2()« besitzt einen »double«-Parameter, »func3()« ist nicht »const« und »func4()« hat den falschen Rückgabetyp. Da die Member-Funktion »h()« als »final« deklariert ist, lässt sie sich in der Klasse »Derived« nicht überschreiben. Das Überladen für den Parametertyp »double« (Zeile 15) klappt hingegen.
Listing 1
override und final
class Base {
void func1();
virtual void func2(float);
virtual void func3() const;
virtual long func4(int);
virtual void h(int) final;
};
class Derived: public Base {
virtual void func1() override; // ERROR
virtual void func2(double) override; // ERROR
virtual void func3() override; // ERROR
virtual int func4(int) override; // ERROR
virtual long func4(int) override; // OK
virtual void h(int); // ERROR
virtual void h(double); // OK
};
Automatische Optimierungen
Mit der Move-Semantik und konstanten Ausdrücken besitzt modernes C++ eine mächtige Stellschraube, um die Performance einer Anwendung deutlich zu verbessern. Während die Move-Semantik es erlaubt, Objekte billig zu verschieben statt teuer zu kopieren, lassen sich als »constexpr« deklarierte Funktionen bereits zur Compile-Zeit ausführen.
Dementsprechend besteht die Aufgabe des Implementierers einer Bibliothek nun darin, die Algorithmen und Funktionen so umzusetzen, dass der Compiler automatisch die schnelle Move-Semantik auswählt oder der Anwender den konstanten Ausdruck bereits zur Compile-Zeit berechnen kann. Die Implementierung des »std::swap«-Algorithmus in Listing 2 mit Copy- und Move-Semantik verdeutlicht die entscheidenden Unterschiede. Während die erste Variante des »std::swap«-Algorithmus die Copy-Semantik (Zeile 4 bis 8) verwendet, setzt die zweite Implementierung auf Move-Semantik (Zeile 11 bis 14), die sie durch die Methode »std::move« explizit vom Compiler anfordert.
Listing 2
std::swap
std::vector<int> a, b;
swap(a,b);
template <typename T>
void swap(T& a, T& b) {
T tmp(a);
a= b;
b= tmp;
}
template <typename T>
void swap(T& a, T& b) {
T tmp(std::move(a));
a= std::move(b);
b= std::move(tmp);
}
So weit, so gut. Bei beiden Algorithmen handelt es sich um Funktions-Templates, die beliebige Argumente annehmen können, im konkreten Fall die zwei Vektoren »a« und »b«, die getauscht werden. Um die Implikationen der beiden Semantiken zu verstehen, hilft ein genauer Vergleich der Zeile 5 (»T tmp(a)«) mit der entsprechenden Zeile 10 (»T tmp(std::move(a)«).
Der Ausdruck »T tmp(a)« bewirkt, dass das System Speicher für »tmp« und jedes Element von »tmp« alloziert. Dann wird jedes Element von »a« nach »tmp« kopiert und zu guter Letzt der Speicher für »tmp« und jedes Element von »tmp« freigegeben. Hingegen bewirkt der Ausdruck »T tmp(std::move(a))«, dass »tmp« auf die Daten von »a« verweist und seine Größe richtig initialisiert wird. Diese Beobachtungen gelten freilich auch für die Zeilenpaare 6 und 11 oder 7 und 12.
Move-Semantik zahlt sich für Datenstrukturen aus, die einen Verweis ihrer Daten auf dem Heap besitzen. Das gilt aber nicht nur für den »std::vector«, sondern auch für alle anderen Container der Standard Template Library inklusive »std::string«. Nur »std::array« legt seine Daten zur Übersetzungszeit des Programms an.
Doch was heißt es, dass sich die Move-Semantik auszahlt? Zweierlei: Zum einen ist ein einfaches Verbiegen eines Zeigers eine Operation mit konstanter Zeitdauer. Zum anderen kann beim Verbiegen eines Zeigers nichts schiefgehen. Beim Anfordern und Freigeben von Speicher wäre das anders.
Verbreitete Hybris
Es ist eine sehr verbreitete Hybris unter C++-Entwicklern, schlauer als der Compiler sein zu wollen. Das trifft auf die explizite Typisierung der Variablen ebenso zu wie auf die Kosten einer Abstraktion, etwa mit Smart Pointern.
Das Halbwissen vieler Programmierer gründet sich zwar auf jahrzehntelange Erfahrung, gilt aber nach einem Upgrade des C++-Standards oder auch des C++-Compilers nicht mehr. Die modernen Optimierer leisten großartige Arbeit. Daher hat der Merksatz von Donald Knuth nichts von seiner Aktualität verloren: “Premature optimization is the root of all evil (or at least most of it) in programming” [1].
Exemplarisch soll der Smart Pointer »std::unique_ptr« in Listing 3 belegen, dass automatische Speicherverwaltung in Zeit- und Ressourcenverbrauch der expliziten Speicherverwaltung in nichts nachsteht. Das Programm ist denkbar einfach strukturiert. Die For-Schleife ab Zeile 7 führt 100 Millionen Speicherallokationen und -deallokationen aus. Das geschieht in verschiedenen, zunächst auskommentierten Varianten. Zuerst kommen Raw-Pointer zum Einsatz, dann in den Zeilen 10 und 11 ein »std::unique_ptr«. Dabei geben die Smart Pointer ihren Speicher automatisch wieder frei. Es fragt sich allerdings, was dieser Mehrwert kostet.
Listing 3
Smart Pointer
#include <chrono>
#include <iostream>
#include <memory>
static const long long numInt= 100000000;
int main(){
auto start = std::chrono::system_clock::now();
for (long long i = 0 ; i < numInt; ++i){
int* tmp(new int(i));
delete tmp;
// std::unique_ptr<int> tmp(new int(i));
// std::unique_ptr<int> tmp = std::make_unique<int>(i);
}
std::chrono::duration<double> dur = std::chrono::system_clock::now() - start;
std::cout << dur.count() << std::endl;
}
Als Compiler kam ein aktueller GCC mit maximaler Optimierung zum Einsatz; die Zahlen in der Tabelle “Laufzeiten” zeigen den Mittelwert von zehn Programmläufen. Sie sprechen eine deutliche Sprache: Im Wesentlichen sind alle Programmdurchläufe gleich schnell.
|
Zeigertyp |
Dauer |
|---|---|
|
»new/delete« |
3,22 s |
|
»std::unique_ptr« |
3,18 s |
|
»std::make_unique« |
3,17 s |
Das große Bild
“Because we can”: Das Zitat von Herb Sutter [2] auf der CppCon 2015 bringt die Mentalität vieler C++-Entwickler auf den Punkt. Ein sehr anspruchsvolles Feature in C++ setzt der Programmierer oft nur deshalb ein, weil es vorhanden ist. In diesem Zusammenhang bleiben aber wichtigere Fragen zu klären: Rechtfertigt der vermeintliche Mehrwert des Features den Aufwand, es zu verwenden? Wie stark leidet die Verständlichkeit des Codes unter dem Feature? Und: Lässt sich das Feature sicher einsetzen?
Wem das zu abstrakt war, den verweise ich gern auf atomare Operationen in C++ [3]. Durch Optimierung im Mikrobereich lässt sich durch den Bruch der sequenziellen Konsistenz das letzte Quäntchen Performance aus einem Codeabschnitt pressen. Dabei geht jedoch allzu leicht der Blick auf das große Bild und insbesondere auf die Performance im Ganzen verloren. Genau diesen Aspekt stellt der Artikel “Crash nach Flash” [4] dar und zeigt viele Irrwege falscher Optimierung deutlich auf.
Undefiniertes Verhalten
Undefiniertes Verhalten nimmt in C und C++ viele Gestalten an. Daher ist es umso wichtiger zu wissen, ob eine Variable initialisiert wird, wie lange sie gültig bleibt oder auch, in welcher Reihenfolge Funktionsargumente ausgewertet werden. Viele Entwickler nehmen an, dass die Reihenfolge der Auswertung von Funktionsargumenten von links nach rechts erfolgt.
Falsch! Hier gibt es keine Garantien; Listing 4 deckt die Illusion auf. Übersetzt man das Programm mit einem aktuellen GNU-C-Compiler und einem Clang-Compiler, zeigt sich: GCC evaluiert seine Funktionsargument von links nach recht, der Clang-Compiler dagegen von rechts nach links.
Listing 4
Auswertungsreihenfolge
#include <iostream>
void func(int fir, int sec){
std::cout << "(" << fir << "," << sec << ")" << std::endl;
}
int main(){
int i = 0;
func(i++, i++);
}
Bessere Lesbarkeit
Quellcode wird deutlich öfter gelesen als geschrieben. Daher sollte man ihn für den Leser optimieren. Mit Lambda-Funktionen, der Range-basierten For-Schleife oder auch der automatischen Typableitung mit »auto« lässt sich modernes C++ viel leichter verdauen. Alle aufgezählten Features gehören zu C++11.
Es wäre jedoch irrig zu glauben, dass modernes C++ den Code per se lesbar macht – Überraschungen kann es auch hier geben (Listing 5). Gute Namen sind wohl die wichtigste Regel in der Softwareentwicklung. Das trifft auch auf Variablen zu, bei denen man deshalb ähnlich aussehende Namen vermeiden sollte.
Listing 5
Ähnliche Namen
if (readable(i1 + l1 + ol + o1 + o0 + ol + o1 + I0 + l0)) surprise();
Deklariert man zwei Variablen in einem Rutsch, führt das leicht zur Verwirrung. Die klassische Frage, die sich wohl viele Softwareentwickler bei der zweiten Zeile aus Listing 6 stellen, lautet: Handelt es sich bei »p2« um eine Variable vom Datentyp »char« oder um einen Zeiger auf ein »char«? Wer »p« und »p2« in zwei separaten Zeilen deklariert, vermeidet jeden Diskussionsbedarf (letzte zwei Zeilen).
Listing 6
Variablen-Deklaration
// verwirrend char* p, p2; // klar char* p; char p2;
Lass dir helfen
C++ bringt reichlich Werkzeuge mit, die helfen, ein wohldefiniertes Programm zu schreiben. So lässt sich der Code zur Compile-Zeit mithilfe des Schlüsselworts »static_assert« und der Funktionen der Type-Traits-Bibliothek auf seine Richtigkeit prüfen. Die automatische Typableitung mit »auto« stellt zur Übersetzungszeit des Programms sicher, dass der richtige Typ zur Verfügung steht. Hier endet die Geschichte aber bei Weitem noch nicht. Statische Codeanalysewerkzeuge wie Clang-Tidy [5] oder auch schon das Übersetzen des Quellcodes mit einem anderen Compiler geben ohne großen Aufwand wertvolle Einsichten.
Die Type-Traits-Bibliothek stellt die Funktion »std::is_integral<type>::value« bereit, die zur Compile-Zeit prüft, ob es sich bei »T« um einen integralen Datentyp handelt. Damit lässt sich der Algorithmus zur Berechnung des größten gemeinsam Teilers zweier Zahlen in Listing 7 typsicher implementieren. Das Übersetzen des Programms scheitert mit einer einfach zu lesenden Fehlermeldung.
Listing 7
Typsichere Berechnung
#include <iostream>
#include <type_traits>
template <typename T>
T gcd(T a, T b){
static_assert(std::is_integral<T>::value, "T should be an integral type!");
if( b == 0 ){ return a; }
else{
return gcd(b, a % b);
}
}
int main(){
std::cout << std::endl;
std::cout << gcd(3.5, 4.0) << std::endl;
std::cout << std::endl;
}
Der »gcd«-Algorithmus in Listing 7 basiert auf C++11. Verlassen wir den Bereich von modernem C++ und verwenden C++20, lässt er sich noch deutlich angenehmer formulieren. In Listing 8 kommt dazu das Concept »std::integral« zum Einsatz, das prüft, ob beide Argumente integrale Datentypen sind.
Listing 8
Mit Concepts
#include <concepts>
#include <iostream>
std::integral auto gcd( std::integral auto a, std::integral auto b){
if( b == 0 ) return a;
else return gcd(b, a % b);/
}
int main(){
std::cout << std::endl;
std::cout << gcd(3.5, 4.0) << std::endl;
std::cout << std::endl;
}
Der »gcd«-Algorithmus in Listing 8 ist mächtiger als der in Listing 7. Der auf Concepts basierende »gcd«-Algorithmus fordert, dass die Datentypen beider Funktionsargumente integral sein müssen, aber nicht identisch. Das Concept »std::integral« in Zeile 3 erfordert das Einbinden der Header-Datei »concepts«.
Kenne deine Bibliotheken
In C++ darf mittlerweile folgende Annahme gelten: Wer eine explizite For-Schleife schreibt, um die Elemente seines Containers zu modifizieren, der kennt die gut 100 Algorithmen der Standard Template Library (STL) nicht. Positiv ausgedrückt: Die Kenntnis der Algorithmen der STL, die sich mit Lambda-Funktionen an die eigenen Bedürfnisse anpassen lassen, zählt zu den Schlüsselfähigkeiten jedes professionellen C++-Softwareentwicklers. Das trifft noch stärker auf C++17 zu, denn mit dieser Sprachvariante lassen sich die Algorithmen der STL mithilfe der Execution Policy sequenziell, parallel oder vektorisiert ausführen – ein Mehrwert, den eine hart kodierte For-Schleife nicht besitzt.
Strebe nach Einfachheit
Die Liste der Features, die den Einsatz von C++ erleichtern, ist lang. Neben der automatischen Typableitung mit »auto« und der Range-basierten For-Schleife, die sich auf jeden Container der STL einschließlich des Strings anwenden lässt, darf an dieser Stelle auf keinen Fall die vereinheitlichte Initialisierung mit geschweiften Klammern fehlen. Die einfache Regel lautet: Initialisiere alles, was es zu initialisieren gilt, mit geschweiften Klammern. Dabei lassen sich einzelne Variablen oder auch ganze Container in einem Rutsch vorbelegen.
Listing 9 initialisiert einen »std::vector« mit vier Werten und gibt ihn aus. Während man in Legacy C++ jedes Element auf den Vektor schieben muss (Zeile 5 bis 8), erlaubt modernes C++ dank der vereinheitlichten Initialisierung, alle Element in einem Rutsch auf den Vektor zu befördern (Zeile 16). Kommt wie in diesem Beispiel C++17 zum Einsatz, kann der Compiler automatisch den Typ des »std::vector« aus dessen Initialisierungselementen bestimmen.
Listing 9
Initialisierung und Ausgabe
#include <iostream>
#include <vector>
int main() {
std::vector<int> legacyVector;
legacyVector.push_back(1);
legacyVector.push_back(9);
legacyVector.push_back(9);
legacyVector.push_back(8);
std::vector<int>::iterator endIterator = legacyVector.end();
std::vector<int>::iterator vecIt;
for(vecIt = legacyVector.begin(); vecIt != endIterator; ++vecIt) {
std::cout << *vecIt; // 1998
}
std::cout << '\n';
std::vector modernVector{2, 0, 1, 7};
for(auto v : modernVector) std::cout << v; // 2017
}
Noch deutlicher wird der Unterschied zwischen traditionellem und modernem C++, sollen die Vektorelemente ausgegeben werden. In Legacy C++ muss man dazu mit einer expliziten Schleife über die Elemente des Vektors iterieren (Zeile 9 bis 13). Anstelle der expliziten For-Schleife kommt in modernem C++ eine Range-basierte For-Schleife zum Einsatz, diese implizit über die Elemente iteriert. Der Einfachheit halber stellen die Zeilen 12 und 17 die Ausgabe des Programms direkt im Beispiel dar.
Freilich bleibt dieser Artikel unvollständig, denn er stellt nur einige Features von modernem C++ exemplarisch vor und stellt sie Beispielen in Legacy C++ gegenüber. Dennoch hilft er, Bjarne Stroustrups Zitat zur modernem C++ ins richtige Licht zu rücken: “C++11 feels like a new language.”
Ausblick
Nach diesem Jubiläumsartikel wird sich die nächste Folge dieser Artikelserie zu modernem C++ wieder mit dem täglichen Brot eines Softwareentwicklers beschäftigen: den Regeln der C++ Core Guidelines rund um Templates. Dabei geht es um die richtige Parametrisierung der Algorithmen der Standard Template Library. Zudem stellen wir Templates als Lösung vor, um Funktionen und Klassen für konkrete Datentypen zu spezialisieren. (jcb/jlu)
Infos
- “The fallacy of premature optimization”: https://ubiquity.acm.org/article.cfm?id=1513451
- Herb Sutter (CppCon 2015): https://cppcon2015.sched.com/event/41a3/writing-good-c14-by-default
- Atomare Operation in C++: https://en.cppreference.com/w/cpp/atomic
- C++-Rezepte (Teil 36): Rainer Grimm, “Crash nach Flash”, LM 10/2017, S. 76, https://www.lm-online.de/39664
- Clang-Tidy: https://clang.llvm.org/extra/clang-tidy






