Diese Folge geht ans Eingemachte von C++11: Sie zeigt, wozu Move-Semantik nützlich ist, erklärt Rvalues und Lvalues und deckt auf, was es mit dem doppelten &-Zeichen auf sich hat.
Wenn das Kopieren von Daten zu aufwändig oder unmöglich ist, kann sich der Programmierer damit behelfen, sie stattdessen zu verschieben. Diese einfache Idee liegt der Move-Semantik in C++11 zugrunde. Wer sie geschickt anwendet, bewegt auf diese Weise große Datenmengen schneller und kann sogar Smart Pointer mit der STL versöhnen. Dieser Artikel zeigt, wie das geht.
Großer Kopierer
Listing 1 enthält eine einfache Klasse namens »BigArrayCopy« , die einen Wrapper um ein einfaches C-Array darstellt. Diese Programmzeilen sollen als Grundlage für weitere Optimierungen dienen. Um das C-Array richtig zu verwalten, merkt sich »BigArrayCopy« in den Zeilen 37 und 38 den Verweis auf das C-Array »data_« und dessen Länge »len_« .
Listing 1
Großes Array kopieren
01 #include <algorithm>
02 #include <iostream>
03 #include <vector>
04
05 using std::cout;
06 using std::endl;
07
08 using std::vector;
09
10 class BigArrayCopy{
11
12 public:
13 BigArrayCopy(size_t len): len_(len), data_(new int[len]){}
14
15 BigArrayCopy(const BigArrayCopy& other): len_(other.len_),data_(new int[other.len_]){
16 cout << "copy construction of " << other.len_ << " elements "<< endl;
17 std::copy(other.data_, other.data_ + len_, data_);
18 }
19
20 BigArrayCopy& operator=(const BigArrayCopy& other){
21 cout << "copy assignment of " << other.len_ << " elements "<< endl;
22 if (this != &other){
23 delete[] data_;
24
25 len_ = other.len_;
26 data_ = new int[len_];
27 std::copy(other.data_, other.data_ + len_, data_);
28 }
29 return *this;
30 }
31
32 ~BigArrayCopy(){
33 if (data_ != nullptr) delete[] data_;
34 }
35
36 private:
37 size_t len_;
38 int* data_;
39 };
40
41 int main(){
42
43 cout << endl;
44
45 vector<BigArrayCopy> myVec;
46
47 BigArrayCopy bArray(11111111);
48 BigArrayCopy bArray2(bArray);
49 myVec.push_back(bArray);
50
51 bArray= BigArrayCopy(22222222);
52 myVec.push_back(BigArrayCopy(33333333));
53
54 cout << endl;
55
56 }
Das Interface von »BigArrayCopy« ist minimal. Es besteht aus einem Standardkonstruktor (Zeile 13), einem Kopierkonstruktor (Zeile 15), einem Zuweisungsoperator (Zeile 20) und einem Destruktor (Zeile 32). Im Kopierkonstruktor, der sich durch den Aufruf »BigArrayCopy bArray2(bArray)« anstoßen lässt, setzt der Code die Länge und den Verweis auf das C-Array. Im Anschluss findet durch
std::copy(other.data_, other.data_ + len_,data_)
das Kopieren der Daten von »bArray« nach »bArray2« statt. Einer ähnlichen Struktur folgt der Zuweisungsoperator, der in dem Ausdruck »bArray= BigArrayCopy(22222222)« zur Anwendung kommt. Hier stellt in Zeile 42 die Klausel »if (this != &other)« sicher, dass Quelle und Ziel der Zuweisung verschieden sind. Das ist notwendig, denn Zeile 23 löscht das Ziel der Zuweisung mit »delete[] data_« . Das Ergebnis des Zuweisungsoperators ist das modifizierte Objekt, in Zeile 29 durch »return *this« zurückgegeben. Der Destruktor komplementiert den C-Array-Wrapper »BigArrayCopy« .
STL-tauglich
Mit diesem einfachen Interface erfüllt »BigArrayCopy« alle Anforderungen, damit es sich in einem Container der Standard Template Library (STL) verwenden lässt. Ein kleines C++11-Feature hat sich noch im Destruktor versteckt: »nullptr« ersetzt die bekannte Nullzeigerkonstante »NULL« . Damit gehört das implizite Konvertieren von »NULL« in eine natürliche Zahl der Vergangenheit an.
Wer das Programm ausführt und die Ausgabe betrachtet (Abbildung 1), der erkennt, dass sowohl der Kopierkonstruktor als auch der Zuweisungsoperator zum Einsatz kommen. Darüber hinaus lässt sich »BigArrayCopy« in einem Vektor verwenden. »BigArrayCopy« erfüllt offenbar alle Erwartungen.
Eine Zeile zu viel
Ein scharfer Blick auf Ausgabe und Programmcode (Listing 1) zeigt jedoch, dass »BigArrayCopy« trotzdem weiteres Optimierungspotenzial birgt. Zuerst stellt sich die Frage, woher der letzte Aufruf »copy construction of 11111111 elements« des Kopierkonstruktors in Abbildung 1 kommt. Die ersten vier Ausgaben dagegen entsprechen genau den Ausdrücken in den Zeilen 48 bis 52.
Die Erklärung ist schnell gefunden und schon das klassische C++ bietet Gegenmaßnahmen an. Dem Vektor geht einfach der Speicher aus, sodass er durch den erneuten Aufruf mit »myVec.push_ back(BigArrayCopy(33333333))« reallokiert werden muss. Dies bedingt natürlich auch, dass das Programm seine Elemente kopiert.
Für diesen Anwendungsfall stellt der C++-Vektor die Methode »reserve()« zur Verfügung. Damit lässt sich Speicher reservieren, was ein erneutes Allokieren überflüssig macht. Der schlichte Aufruf »myVec.reserve(2)« unterbindet das überflüssige Kopieren der Vektorelemente.
Tuning-Werkzeuge
Greift der Programmierer zu C++11, stehen für ihn weitere Tuning-Werkzeuge parat. Die entscheidende Beobachtung: Sowohl die Zuweisung »bArray= BigArrayCopy(22222222)« als auch der Aufruf des Kopierkonstruktors »myVec.push_back(BigArrayCopy(33333333))« in den Zeilen 51 und 52 nutzen die temporären Objekte »BigArrayCopy(22222222)« und »BigArrayCopy(33333333)« . Warum sollte man in diesem Fall die Elemente von »BigArrayCopy(22222222)« teuer von der Quelle zum Ziel kopieren, wenn es doch genügen würde, dass »bArray« die Daten einfach entwendet? Dies ist zulässig, da die Quelle der Zuweisung automatisch verfällt.
In Listing 2 ist die Optimierungsstrategie mit Rvalue-Referenzen implementiert, einem neuen Feature von C++11. Neben dem klassischen Kopierkonstruktor in Zeile 15 und dem Zuweisungsoperator in Zeile 20 bietet »BigArray« einen Move-Konstruktor (Zeile 32) und einen Move-Zuweisungsoperator (Zeile 38) an. Die Signatur dieser Move-Methoden unterscheidet sich von ihren klassischen C++-Pendants dadurch, dass sie ihr Argument als Rvalue-Referenz »BigArray&& other« annehmen.
Listing 2
Optimiertes Kopieren
01 #include <algorithm>
02 #include <iostream>
03 #include <vector>
04
05 using std::cout;
06 using std::endl;
07
08 using std::vector;
09
10 class BigArray{
11
12 public:
13 BigArray(size_t len): len_(len), data_(new int[len]){}
14
15 BigArray(const BigArray& other): len_(other.len_),data_(new int[other.len_]){
16 cout << "copy construction of " << other.len_ << " elements "<< endl;
17 std::copy(other.data_, other.data_ + len_, data_);
18 }
19
20 BigArray& operator=(const BigArray& other){
21 cout << "copy assignment of " << other.len_ << " elements "<< endl;
22 if (this != &other){
23 delete[] data_;
24
25 len_ = other.len_;
26 data_ = new int[len_];
27 std::copy(other.data_, other.data_ + len_, data_);
28 }
29 return *this;
30 }
31
32 BigArray(BigArray&& other): len_(other.len_),data_(other.data_){
33 cout << "move construction of " << other.len_ << " elements "<< endl;
34 other.len_= 0;
35 other.data_ = nullptr;
36 }
37
38 BigArray& operator=(BigArray&& other){
39 cout << "move assignment of " << other.len_ << " elements "<< endl;
40 if (this != &other){
41 delete[] data_;
42
43 len_= other.len_;
44 data_= other.data_;
45
46 other.data_ = nullptr;
47 other.len_= 0;
48 }
49 return *this;
50 }
51
52 ~BigArray(){
53 if (data_ != nullptr) delete[] data_;
54 }
55
56 private:
57 size_t len_;
58 int* data_;
59 };
60
61 int main(){
62
63 cout << endl;
64
65 vector<BigArray> myVec;
66 myVec.reserve(2);
67
68 BigArray bArray(11111111);
69 BigArray bArray2(bArray);
70 myVec.push_back(bArray);
71
72 bArray= BigArray(22222222);
73 myVec.push_back(BigArray(33333333));
74
75 cout << endl;
76
77 }
Interessant ist vor allem die Implementierung dieser Move-Methoden. Während der klassische Kopierkonstruktor (Zeile 15) seine Daten mit
std::copy(other.data_, other.data_ + len_,data_)
kopiert, setzt der Move-Konstruktor in Zeile 32 »BigArray(BigArray&& other): len_(other.len_),data_(other.data_)« lediglich die Länge des C-Array und einen Verweis auf die Daten. Dazu weist er in seinem Funktionskörper den Daten der Datenquelle ihren Defaultwert zu. In Abbildung 2 ist dies für die Zuweisung »bArray= BigArray(22222222)« exemplarisch dargestellt. Eine ähnliche Strategie wendet der Move-Zuweisungsoperator an. Der entscheidende Punkt ist, dass C++11 zur Laufzeit Rvalue auf Rvalue-Referenzen und Lvalues auf Lvalue-Referenzen abbildet.
Kopieren überflüssig
Das Ausführen des Programms bringt es an den Tag (Abbildung 3): Wenn möglich, wie im Falle von »bArray= BigArray(22222222)« und »myVec.push_back(BigArray(33333333))« , werden die Daten nicht kopiert, sondern verschoben. Zudem verhindert »myVec.reserve(2)« das überflüssige Kopieren des Vektors.
Rvalue- und Lvalue-Referenzen
Rvalue-Referenzen sind im Unterschied zu Lvalue-Referenzen in C++11 durch zwei Et-Zeichen (&&) gekennzeichnet. So bezeichnet »bigArrayRvalue« in »MyBigArray&& bigArrayRvalue« eine Rvalue-Referenz, »bigArrayLvalue« in »MyBigArray& bigArrayLvalue« hingegen eine klassische Lvalue-Referenz.
Rvalue-Referenzen sind spezielle Verweise. Der Programmierer muss sie bereits als solche initialisieren, nachträglich lassen sie sich nicht auf ein anderes Objekt umbiegen. Im Gegensatz zu Lvalue-Referenzen kann man an sie nur einen Rvalue binden. Rvalues sind:
- Temporäre Objekte
- Objekte ohne Namen
- Objekte, deren Adresse sich nicht bestimmen lässt
Trifft auf Objekte eines dieser Charakteristika zu, liegt ein Rvalue vor. Im Umkehrschluss bedeutet dies, dass Lvalues einen Namen und eine Adresse besitzen. Ein paar Beispiele für Rvalues:
int five= 5;
std::string a= std::string("Rvalue");
std::string b= std::string("R") + std::string("value");std::string c= a + b;
std::string d= std::move(b);
Rvalues stehen auf der rechten Seite einer Zuweisung. So sind in den Beispielen der Wert 5 und der Konstruktoraufruf »std::string(“Rvalue”)« Rvalues, denn weder lässt sich für den Wert 5 die Adresse bestimmen, noch besitzt der Konstruktoraufruf einen Namen. Gleiches gilt für die Addition der Rvalues in »std::string(“R”) + std::string(“value”)« .
Interessanter ist die String-Addition zweier Lvalues in »a + b« . Dieser Ausdruck wird zum Rvalue, da die Addition zweier Lvalues ein temporäres Objekt erzeugt. Als besonderer Anwendungsfall gilt »std::move(b)« . Diese neue C++11-Funktion konvertiert den Lvalue »b« in eine Rvalue-Referenz.
Der letzte Baustein für das Zusammenspiel von Lvalues und Rvalues sowie Lvalue- und Rvalue-Referenzen fehlt noch: Listing 3 zeigt die Values zusammen mit den entsprechenden Referenzen. An »LvalueReference« lässt sich ein Lvalue, an »RvalueReference« ein Rvalue binden. Umgekehrt gilt: An »LvalueReference2« kann kein Rvalue, an »RvalueReference2« kein Lvalue gebunden sein.
Listing 3
Lvalues- versus Rvalue-Referenzen
01 std::string Lvalue("Lvalue");
02 std::string& LvalueReference= Lvalue;
03 std::string& LvalueReference2= std::string("Rvalue"); // Syntax error
04 std::string&& RvalueReference = std::string("Rvalue");
05 std::string&& RvalueReference2 = Lvalue; // Syntax error
06 const std::string& LvalueReference3= std::string("Rvalue");
In C++11 gilt weiterhin die klassische C++-Regel, dass sich eine konstante Lvalue-Referenz durch einen Rvalue initialisieren lässt. Ist für eine Klasse »BigArray« (Listing 2) sowohl der Copy- (Zeile 20) als auch der Move-Zuweisungsoperator (Zeile 38) definiert, verfügt der Move-Zuweisungsoperator, der sein Argument per Rvalue-Referenz annimmt, über die höhere Priorität. Die Regel dahinter ist recht einfach: Ein Rvalue wird im Zweifelsfall an eine Rvalue-Referenz und nicht an eine konstante Lvalue-Referenz gebunden.
Zeitgewinn
Mit der neuen Zeitbibliothek lässt sich der Performanceboost durch Verwendung der Move-Semantik einfach messen (Listing 4). Innerhalb der »main()« -Funktion bestimmt die Zeile 12 den Anfangs- und die Zeile 17 den Endzeitpunkt. Die folgende Zeile
Listing 4
Performancemessung
01 int main(){
02
03 cout << endl;
04
05 vector<BigArray> myVec;
06 myVec.reserve(2);
07
08 BigArray bArray(11111111);
09 BigArray bArray2(bArray);
10 myVec.push_back(bArray);
11
12 auto begin= system_clock::now();
13
14 bArray= BigArray(22222222);
15 myVec.push_back(BigArray(33333333));
16
17 auto end= system_clock::now() - begin;
18 auto timeInSeconds= duration<double>(end).count();
19
20 cout << endl;
21 cout << "time in seconds: " << timeInSeconds << endl;
22 cout << endl;
23
24 }
auto timeInSeconds= duration<double>(end).count()
gibt die Zeit in Sekunden als Double zurück. Das Ergebnis ist beeindruckend. Das Verschieben der Vektoren (Abbildung 4) ist etwa um den Faktor 1000 schneller als das Kopieren (Abbildung 5).
In C++11 unterstützen die Container der Standard Template Library [1] oder auch »std::string« von Haus aus die Move-Semantik. Sogar für maßgeschneiderte Datentypen erzeugt die C++-Laufzeit automatisch den Move-Konstruktor und Move-Zuweisungsoperator, sofern dies möglich ist. Den Performanceschub gibt es inklusive.
Statt Kopieren std::move
Die Move-Semantik lässt sich aber nicht nur als optimiertes Kopieren verstehen. Sie eröffnet auch neue Anwendungsfälle in C++11. Daten, die nicht kopiert werden können, lassen sich zumeist wenigstens verschieben. Dazu gehören Threads, Mutexe, Locks, aber auch Future und Promise [2] als Endpunkte eines Datenkanals. Die neue Funktion »std::move()« erledigt das Verschieben explizit.
Als Beispiel verschiebt Listing 5 einen Lvalue. Was unter der Oberfläche geschieht, ist recht naheliegend: »std::move()« konvertiert »myVec« in »myNewVec(std::move(myVec))« zu einer Rvalue-Referenz. Das hat zur Folge, dass der Move-Konstruktor des Vektors zur Verwendung kommt. Dieser verschiebt die Ressource von »myVec« nach »myNewVec« , sodass »myVec« am Ende ein leerer Vektor ist. Ein weiteres, prominentes Beispiel für einen nicht kopierbaren Datentyp ist der neue Smart Pointer »std::unique_ptr« , denn er überwacht exklusiv die Lebenszeit der ihm anvertrauten Ressource.
Listing 5
Lvalue verschieben
01 std::vector<int> myVec{1,2,3,4,5,6,7,8,9};
02 std::vector<int> myNewVec(std::move(myVec));
Smart Pointer
Die Eigenschaft des »std::unique_ptr« , dass er sich nicht kopieren lässt, verhindert eigentlich seinen Einsatz in der Standard Template Library. Der Grund: Die STL-Container setzen Copy-Semantik um, denn sie kopieren jedes Element zuerst in den Container. Diese Einschränkung kann der Programmierer mittels Move-Semantik umgehen.
Das initiale Kopieren kann er mit »std::move« in den Zeilen 4 und 5 von Listing 6 elegant abwandeln. Der Code schiebt die Elemente explizit auf den Container. Damit lassen sich die Algorithmen der STL-Library auf »std::unique_ptr« anwenden. Als Sortierkriterium dient die Lambda-Funktion in Zeile 6, die den Zeiger auf die natürliche Zahl »*fir < *sec« dereferenziert.
Listing 6
std::unique_ptr verschieben
01 std::unique_ptr<int> unique1(new(int(2)));
02 std::unique_ptr<int> unique2(new(int(1)));
03 std::vector<std::unique_ptr<int> > myInt;
04 myInt.push_back(std::move(unique1));
05 myInt.push_back(std::move(unique2));
06 std::sort(myInt.begin(),myInt.end(),[](std::unique_ptr<int>& fir, std::unique_ptr<int>& sec) { return *fir < *sec; });
In Konstruktoren oder Fabrikfunktionen ist es oft nötig, die Argumente als Referenz anzunehmen und unter Wahrung ihrer Lvalue- oder Rvalue-Eigenschaften identisch weiterzureichen. Dies ist in klassischem C++ aber generisch unmöglich. “Ein bislang ungelöstes Problem in C++” nannten dies Howard E. Hinnant, Bjarne Stroustrup und Bronek Kozicki bereits 2008 in einem Aufsatz [3].
Die C++98-Lösung besteht darin, zwei Funktionen zu schreiben. Eine Funktion nimmt ihr Argument als Lvalue-Referenz, die andere als konstante Lvalue-Referenz an, denn Rvalues lassen sich auch an konstante Lvalue-Referenzen binden. Dieser Ansatz skaliert jedoch nicht, denn bei drei Funktionsargumenten sind schon 23=8 verschiedene Funktionen zu implementieren.
Listing 7 zeigt zwei generische Fabrikfunktionen, die einen Typ »T« und ein Argument »<T>t1« erhalten, ihn im Return-Ausdruck »T(t1)« erzeugen und wieder zurückzugeben. Während »createClassic« in den Zeilen 8 und 14 ihre Argumente per konstantem Lvalue und nicht konstanter Lvalue-Referenz bindet, bindet in Zeile 21 das Funktionstemplate sowohl Lvalues als auch Rvalues. In der Abbildung 6 ist dies schön zu sehen.
Listing 7
Generische Fabrikfunktion mit C++98 und C++11
01 #include <iostream>
02
03 using std::forward;
04
05 using std::cout;
06 using std::endl;
07
08 template <typename T, typename T1>
09 T createClassic(const T1& t1){
10 cout << "const T1& t1" << endl;
11 return T(t1);
12 }
13
14 template <typename T, typename T1>
15 T createClassic(T1& t1){
16 cout << "T1& t1" << endl;
17 return T(t1);
18 }
19
20
21 template <typename T, typename T1>
22 T createNew(T1&& t1){
23 cout << "T1&& t1" << endl;
24 return T(forward<T1>(t1));
25 }
26
27 int main(){
28
29 cout << endl;
30
31 cout << "C++98" << endl;
32 int six= 6;
33 cout << " Rvalue => : " ;
34 int mySix2= createClassic<int>(6);
35 cout << " Lvalue => : ";
36 int mySix= createClassic<int>(six);
37
38 cout << endl;
39
40 cout << "C++11" << endl;
41 int five= 5;
42 cout << " Rvalue => : ";
43 int myFive2= createNew<int>(5);
44 cout << " Lvalue => : ";
45 int myFive= createNew<int>(five);
46
47 cout << endl;
48
49 }
Eine Besonderheit hält die C++11-Implementierung vor. Der Rückgabe-Ausdruck »(forward<T1>(t1))« (Zeile 24) verwendet die neue Funktion »std::forward« . Deren Aufruf ist hier notwendig, denn sonst würde »t1« in jedem Aufruf als Lvalue an den Konstruktor von »T« weitergereicht, denn dieser wird mit einem Argument mit Namen aufgerufen – und alles, was einen Namen besitzt, ist ein Lvalue. »std::forward« sorgt dafür, dass der Konstruktor den wahren Wert von »t1« erhält, so wie ihn auch die Funktion »createNew« erhalten hat. Dies kann ein Lvalue oder ein Rvalue sein. Diese Vorgehensweise nennt man Perfect Forwarding.
Wie geht’s weiter?
Wegen der Fülle an Informationen rund um Rvalue-Referenzen, Move-Semantik und Perfect Forwarding hat dieser Artikel ein neues Feature der Standardbibliothek in Listing 6 nur kurz erläutert, obwohl es deutlich mehr Aufmerksamkeit verdient: die Smart Pointer (Abbildung 7). Viele Entwickler halten die neuen Smart Pointer »std::shared_ptr« , »std::weak_ptr« und »std::unique_ptr« für das wichtigste Feature der neuen Standardbibliothek, denn sie führen automatisches Speichermanagement in C++11 ein. Der nächste Artikel wird sich daher intensiv mit ihnen beschäftigen.
Infos
- STL-Container: http://en.cppreference.com/w/cpp/container
- Rainer Grimm, “Alle im Einklang”: Linux-Magazin 08/12, S. 88
- Howard E. Hinnant, Bjarne Stroustrup and Bronek Kozicki, “A Brief Introduction to Rvalue References”: http://www.artima.com/cppsource/rvalue.html
- Listings zu diesem Artikel: https://www.linux-magazin.de/static/listings/magazin/2012/12/cpp/













