Move-Semantik in C++11 verstehen

Keine Zeit? Hier die Kurzversion in 30 Sekunden.
- Eine rvalue-Referenz (
T&&) bindet an temporäre Objekte. Darüber kann ein Move-Konstruktor Ressourcen übernehmen, statt sie zu kopieren.std::moveverschiebt nichts, es ist nur ein Cast auf eine rvalue-Referenz.- Move-Konstruktoren sollten
noexceptsein, sonst kopiertstd::vectorbeim Vergrößern lieber.- Regel der Fünf: Wer Destruktor, Kopierkonstruktor oder Kopierzuweisung selbst schreibt, muss auch an Move-Konstruktor und Move-Zuweisung denken.
- GCC 4.7 und Visual C++ 2012 beherrschen das, der klassische bcc32 im C++Builder noch nicht.
Im letzten Jahr habe ich die Move-Semantik angekündigt, jetzt reiche ich sie nach. Sie ist für mich die wichtigste Neuerung von C++11, die nicht sofort ins Auge fällt, denn auto und Lambdas sieht man im Code, Move-Semantik dagegen nur in der Laufzeit. Und sie ist anfangs verwirrend, weil mehrere Begriffe zusammenkommen. Ich gehe sie deshalb in der Reihenfolge durch, in der sie aufeinander aufbauen.
lvalue und rvalue
Ein lvalue ist etwas, das einen Namen hat und dessen Adresse man nehmen kann, also eine Variable. Ein rvalue ist ein Wert ohne Namen, der im nächsten Moment wieder verschwindet: das Ergebnis von a + b, ein Literal oder das, was eine Funktion per Wert zurückgibt. Die Faustregel reicht für den Alltag. In C++03 gab es für beides nur die gewöhnliche Referenz, und eine const T& konnte auch einen rvalue aufnehmen. Der Haken: Man konnte nicht unterscheiden, ob ein Objekt noch gebraucht wird oder ohnehin gleich zerstört wird. Im zweiten Fall wäre Kopieren Verschwendung.
C++11 führt dafür die rvalue-Referenz T&& ein. Sie bindet an temporäre Objekte, und eine Funktion, die ein T&& entgegennimmt, darf den Inhalt ausräumen, weil niemand sonst ihn mehr sieht.
Ein Puffer mit Move-Konstruktor
Am besten sieht man den Unterschied an einer Klasse, die Speicher verwaltet:
#include <algorithm>
#include <cstddef>
#include <utility>
class Puffer
{
public:
explicit Puffer(std::size_t n = 0)
: daten_(n ? new char[n] : nullptr), groesse_(n) {}
~Puffer() { delete[] daten_; }
// Kopieren: neuer Speicher, Inhalt duplizieren
Puffer(const Puffer& o)
: daten_(o.groesse_ ? new char[o.groesse_] : nullptr), groesse_(o.groesse_)
{
std::copy(o.daten_, o.daten_ + groesse_, daten_);
}
Puffer& operator=(const Puffer& o)
{
if (this != &o) {
char* neu = o.groesse_ ? new char[o.groesse_] : nullptr;
std::copy(o.daten_, o.daten_ + o.groesse_, neu);
delete[] daten_;
daten_ = neu;
groesse_ = o.groesse_;
}
return *this;
}
// Verschieben: Zeiger übernehmen, Quelle leer zurücklassen
Puffer(Puffer&& o) noexcept
: daten_(o.daten_), groesse_(o.groesse_)
{
o.daten_ = nullptr;
o.groesse_ = 0;
}
Puffer& operator=(Puffer&& o) noexcept
{
if (this != &o) {
delete[] daten_;
daten_ = o.daten_;
groesse_ = o.groesse_;
o.daten_ = nullptr;
o.groesse_ = 0;
}
return *this;
}
std::size_t groesse() const { return groesse_; }
private:
char* daten_;
std::size_t groesse_;
};Der Move-Konstruktor kopiert nur den Zeiger und die Größe, danach wird die Quelle auf einen gültigen, leeren Zustand gesetzt. Das ist wichtig: Der Destruktor des Originals läuft später trotzdem, und mit nullptr ist sein delete[] harmlos. Statt eines Megabyte-Puffers wandern zwei Wörter. Die Move-Zuweisung räumt zusätzlich den bisherigen Inhalt des Ziels weg.
std::move ist nur ein Cast
Der Name führt in die Irre. std::move(x) verschiebt gar nichts, es wandelt x in eine rvalue-Referenz um und sagt damit: Aus diesem Objekt darf man Ressourcen herausnehmen. Das Verschieben macht erst der Move-Konstruktor, der dadurch ausgewählt wird:
Puffer a(1024);
Puffer b(std::move(a)); // Move-Konstruktor, a ist jetzt leer
Puffer c(b); // Kopierkonstruktor, b bleibt unverändertNach dem std::move darf man a nur noch zuweisen oder zerstören. Ich behandle es wie ein Objekt, das abgegeben wurde.
Rückgabe per Wert und RVO
Eine Funktion, die einen Puffer zurückgibt, ist der klassische Anwendungsfall:
Puffer erzeuge()
{
Puffer p(4096);
return p;
}
Puffer x = erzeuge();Hier ist kein std::move nötig, im Gegenteil: return std::move(p); wäre schädlich, weil es die Optimierung verhindern kann. Die meisten Compiler lassen die Kopie ohnehin ganz weg und konstruieren das Objekt direkt beim Aufrufer (Return Value Optimization). Wo das nicht greift, wird ein lokales Objekt beim return automatisch als rvalue behandelt, also verschoben statt kopiert. Rückgabe per Wert ist damit auch bei großen Objekten wieder eine vertretbare Wahl, was in C++03 oft nicht so war.
noexcept, std::vector und die Regel der Fünf
Wächst ein std::vector, muss er seine Elemente in neuen Speicher bringen. Der Standard verlangt, dass er dabei nur verschiebt, wenn der Move-Konstruktor keine Ausnahme werfen kann; sonst kopiert er, damit der Container bei einer Ausnahme in einem konsistenten Zustand bleibt. Deshalb steht oben noexcept. Wer es vergisst, bekommt korrekten Code, der aber still zu langsam ist. Das ist die Art Fehler, die man erst bei einem Profiler sieht.
Das Zusammenspiel sieht man am einfachsten an einem Container voller Puffer:
std::vector<Puffer> liste;
liste.push_back(Puffer(256)); // temporäres Objekt, wird verschoben
liste.push_back(Puffer(512)); // beim Vergrößern werden die alten Elemente verschobenOhne Move-Konstruktor würde jeder dieser Schritte alle Daten neu allokieren und kopieren. Gerade bei Containern mit vielen größeren Elementen macht das einen spürbaren Unterschied. Damit das zuverlässig klappt, hilft eine Merkregel. Sie ist die Erweiterung einer alten, die viele aus C++03 kennen, und ich habe sie mir inzwischen fest angewöhnt, weil sich Fehler an dieser Stelle schlecht finden lassen.
Aus der alten Regel der Drei (Destruktor, Kopierkonstruktor, Kopierzuweisung) wird in C++11 eine Regel der Fünf: Schreibt man eine dieser Funktionen selbst, braucht man in der Regel alle fünf, also dazu Move-Konstruktor und Move-Zuweisung. Wer nur die Kopierfunktionen definiert, bekommt keine Move-Operationen automatisch, und der Code kopiert weiter. Die meisten Klassen sollten gar keine dieser Funktionen schreiben müssen, wenn sie ihre Ressourcen in std::vector oder std::unique_ptr halten.
Compiler-Stand
GCC 4.7 (mit -std=c++11) und Visual C++ 2012 verstehen rvalue-Referenzen und std::move. Eine Einschränkung beim Visual C++ 2012: noexcept kennt er noch nicht, und die Move-Funktionen werden nicht automatisch erzeugt, man muss sie von Hand schreiben. Der klassische bcc32 im C++Builder kann Move-Semantik noch nicht. Wer dort arbeitet, sieht den Gewinn erst in anderen Projekten oder mit einem anderen Compiler. Ich schreibe Bibliothekscode, der später portiert werden soll, trotzdem schon jetzt mit sauberer Besitzerlogik, weil sich das später leicht umstellen lässt.
Christian Ackermann
Softwareentwickler (C++, C, Java, C#) aus der Oberpfalz, bloggt seit 2001 über Linux, Technik und Kochen.