C++11 und die Sache mit der Move-Semantik

von Hubert Schmid vom 2012-03-18

Die Move-Semantik gehört mit Sicherheit zu den bedeutendsten Erweiterungen von C++11. In vielen Artikeln wird sie primär als Optimierung der bereits existierenden Kopiersemantik dargestellt. Meiner Meinung nach wird dadurch allerdings nicht das volle Potential der Move-Semantik deutlich. Daher versuche ich in diesem Artikel, die aus meiner Sicht wichtigsten Anwendungsfälle der Move-Semantik aufzuzeigen.

Was ist Move-Semantik?

Am Einfachsten lässt sich die Move-Semantik über die Copy-Semantik erklären. Unter Letzterer versteht man die Copy-Konstruktoren und Copy-Zuweisungsoperatoren, mit denen jeder C++‑Entwickler vertraut sein sollte. In C++11 gibt es nun Entsprechungen davon für die Move-Semantik, die Move-Konstruktoren und Move-Zuweisungsoperatoren genannt werden. Im folgenden Beispiel sind für eine Klasse alle vier Operationen deklariert.

class string { // ... public: // copy constructor string(const string& rhs); // move constructor string(string&& rhs) noexcept; // copy assignment operator auto operator=(const string& rhs) & -> string&; // move assignment operator auto operator=(string&& rhs) & noexcept -> string&; };

Die Move-Semantik steht in engem Zusammenhang zu den R‑Value-Referenzen, die sich durch && von den normalen Referenzen unterscheiden. Einfach ausgedrückt sind die Copy- und Move-Operationen Überladungen des Konstruktors beziehungsweise des Zuweisungsoperators, und beim Aufruf wird die Operation ausgeführt, deren Parametertypen besser zu den verwendeten Argumenten passen.

Der wichtigste Unterschied zwischen den einander entsprechenden Operationen liegt hingegen im Verhalten: Die Copy-Operation erzeugt in der Regel eine tiefe Kopie des übergebenen Arguments ohne dieses zu verändern. Die entsprechende Move-Operation hat im Wesentlichen die gleiche Aufgabe, allerdings darf dabei das Argument weitgehend beliebig verändert werden. Es muss eigentlich nur beachtet werden, dass das referenzierte Objekt sich nach der Operation wiederum in einem konsistenten Zustand befindet.

Die Move-Operationen sind in der Regel sehr effizient. Denn im Gegensatz zu den Copy-Operationen muss lediglich eine flache Kopie gemacht werden, wobei gleichzeitig das Argument in eine Art Leer- beziehungsweise Initialzustand versetzt wird.

Erstens: Move als Optimierung von Copy

Eine Besonderheit der Move-Semantik ist, dass ganz ohne Änderung des Quelltexts an vielen Stellen Move-Operationen verwendet werden, an denen bisher Copy-Operationen verwendet wurden. Dafür reicht es aus, den Quelltext neu zu übersetzen. Das typische Beispiel für eine Stelle, an der diese Optimierung greift, sieht so aus:

// function returns non-trivial structure by value auto split(const std::string& value, char separator) -> std::vector<std::string>; void foobar(const std::string& line) { // uses move constructor std::vector<std::string> tokens = split(line, ';'); // uses move assignment operator tokens = split(line, ';'); }

Interessant dabei ist nicht nur, dass existierender Code performanter wird, sondern insbesondere auch, dass man nun bedenkenlos fast alle Typen als Rückgabewert verwenden kann. Das ist allerdings nicht ganz neu. Auch vor C++11 gab es einige Mechanismen um Kopien bei Rückgabetypen zu vermeiden. Dazu gehören insbesondere die verschiedenen Arten der Return-Value-Optimization (RVO), und die Move-Assignment lässt sich auch mit Hilfe von swap nachbilden.

void foobar(const std::string& line) { // RVO avoids copy operations std::vector<std::string> tokens = split(line, ';'); // emulate move assignment with swap (in C++03) split(line, ';').swap(tokens); }

Zweitens: Move für nicht kopierbare Klassen

Die folgende Hilfsfunktion soll die übergebene Funktion callable parallel in mehreren Threads ausführen und sich beenden, sobald jeder einzelne Thread sich beendet hat.

void execute(std::size_t concurrency, void callable()) { std::vector<std::thread> threads; for (std::size_t i = 0; i != concurrency; ++i) { // NOTE: compilation error in C++03 threads.push_back(std::thread(callable)); } for (std::size_t i = 0; i != concurrency; ++i) { threads[i].join(); } }

In C++03 lässt sich dieser Code nicht übersetzen. Der Compiler wird vermutlich bemängeln, dass an zwei Stellen versucht wird das Thread-Objekt zu kopieren, obwohl es nicht kopierbar ist. Die erste Stelle ist beim Einfügen des übergebenen Objekts, das durch Copy-Konstruktion aus dem Parameter erzeugt wird. Die zweite Stelle liegt tiefer vergraben: Wenn der bisher allokierte Speicherbereich nicht mehr ausreicht und vergrößert werden muss, dann werden die bereits existierenden Elemente im neuen Speicherbereich durch Copy-Konstruktion erzeugt und anschließend im alten Speicherbereich zerstört.

Das Ärgerliche an dieser Situation ist, dass der Programmierer die Thread-Objekte – aus fachlicher Sicht – gar nicht kopieren will. Er will lediglich ein paar Threads erzeugen und in einen Container stecken. Um das zu erreichen muss er in C++03 zu einem Workaround greifen. Anstatt die Thread-Objekte direkt zu speichern, speichert er lediglich intelligente Zeiger und verwendet als Container std::vector<std::shared_ptr<std::thread>>.

Die Move-Semantik aus C++11 löst dieses Problem. Der oben angegebene Code funktioniert ohne irgendwelche Änderungen vornehmen zu müssen. An beiden fraglichen Stellen wird nun – sofern möglich – die Move-Konstruktion verwendet. Move ist also nicht nur eine Optimierung von Copy, sondern ermöglicht Funktionalität für Klassen, die überhaupt nicht kopierbar sind.

Exkurs: Der obige Code lässt sich zwar mir C++11 übersetzen. Allerdings würde ich die Funktion in C++11 anders schreiben. Die wichtigste Änderung liegt in der Verwendung der Funktion emplace_back statt push_back. Das sieht zunächst nur nach einer Optimierung aus. Tatsächlich wird dadurch aber die Reihenfolge zweier kritischer Operationen umgedreht. Im ursprünglichen Code wird zunächst ein Thread-Objekt erzeugt und dann erst der Platz im Container geschaffen. Im folgenden Code wird hingegen zunächst der Platz geschaffen und erst anschließend der Thread erzeugt und gestartet. Die letztere Reihenfolge ist ein wenig besser, weil im Fehlerfall der Rollback einfacher ist.

template <typename Callable> void execute(std::size_t concurrency, Callable&& callable) { std::vector<std::thread> threads; for (std::size_t i = 0; i != concurrency; ++i) { threads.emplace_back(callable); } for (auto&& thread : threads) { thread.join(); } }

Drittens: Systematische Wahl zwischen Move und Copy

In den beiden vorherigen Abschnitten wurde das Quellobjekt nach dem Move nicht mehr verwendet. In den gewählten Beispielen waren diese Objekte nicht einmal sichtbar, da es sich lediglich um temporäre Zwischenobjekte handelte. In diesem Abschnitt geht es hingegen um die bewusste und gezielte Wahl zwischen Move und Copy – insbesondere wenn das Quellobjekt der Operation auch danach noch benutzt wird.

std::vector<std::string> lines; std::string line; while (getline(std::cin, line)) { lines.emplace_back(std::move(line)); }

Ich versuche es an diesem einfachen Beispiel zu erklären. Die paar gezeigten Anweisungen lesen die Eingabe von std::cin zeilenweise ein und speichern sie in einem Container. Die Funktion std::move sorgt dafür, dass die Variable line beim Einfügen wie eine R‑Value-Referenz behandelt und damit zum Quellobjekt einer Move-Operation wird. Das Besondere an diesem Code ist, dass das selbe Objekt in der nächsten Schleifeniteration wieder verwendet wird, um die Eingabe zu speichern.

Die Variable line wird also sozusagen als lokaler Zwischenspeicher benutzt, um die Daten schrittweise von der Standardeingabe in den Container zu schieben – ohne auch nur eine einzige Kopie anzufertigen.

Ich habe noch ein weiteres Beispiel, falls das Vorgehende nicht überzeugen konnte. Anders ist insbesondere, dass auf die fragliche Variable auch nach dem std::move noch lesend zugegriffen wird. Das beabsichtigte Verhalten wird in diesem speziellen Fall von der Standardbibliothek leider nicht garantiert, aber das Beispiel sollte zumindest die dahinter liegende Idee verdeutlichen. Weiter gehe ich an dieser Stelle nicht darauf ein.

std::unordered_set<std::string> seen; std::string line; while (getline(std::cin, line)) { bool inserted = seen.insert(std::move(line)).second; if (!inserted) { std::cout << line << '\n'; } }

Eine Anmerkung noch: Es ist nicht selbstverständlich, dass ein Objekt noch benutzt werden kann, nachdem es Quelle einer Move-Operation war. Der Standard sichert das allerdings für alle Typen aus der Standardbibliothek zu. Und ich empfehle das auch für eigene Typen zu berücksichtigen.

Fazit

Wichtig ist mir die Aussage, dass Move viel mehr als eine Optimierung für Rückgabetypen ist. Ich habe dafür ein paar Beispiele gezeigt, und meine Erwartung ist, dass sich Entwickler für das Potential öffnen, dass diese C++11-Erweiterung schafft.