C++: To Move or not to Move

von Hubert Schmid vom 2013-06-09

Kompakter und verständlicher Code mit Value-Objekten bei gleichzeitig maximaler Performance – dieses Ziel unterstützt die Move-Semantik aus C++11. Dafür wurde die Sprache an vielen Stellen aufgebohrt und um R‑Value-Referenzen erweitert. Solche umfangreichen Veränderungen bringen allerdings auch ein Problem mit sich: Aufgrund fehlender Erfahrung werden die Erweiterungen teils falsch verwendet und führen zu potentiell höheren Kosten in der Entwicklung.

Bei der Move-Semantik sollte eigentlich alles ganz einfach sein: Code, der vor C++11 korrekt war, ist auch mit C++11 korrekt – nur wesentlich effizienter. Darüber hinaus müssen die Sprachkonstrukte verstanden sein, bevor sie eingesetzt werden. Leider ist Letzteres anscheinend nicht der Fall. Denn ich beobachte zunehmend, wie Move-Semantik falsch verwendet wird. Auf drei typisch falsche Beispiele möchte ich im Weiteren eingehen.

perfect forwarding

Das folgende Funktionstemplate implementiert den Test auf Gleichheit über den operator<, indem geprüft wird, dass keiner der beiden Parameter kleiner als der jeweils andere ist. Dabei wird das sogenannte Perfect Forwarding eingesetzt, das die Parameter so weiterreicht wie sie der equals-Funktion als Argumente übergeben wurden.

template <typename Lhs, typename Rhs> bool equals(Lhs&& lhs, Rhs&& rhs) { return !(std::forward<Lhs>(lhs) < std::forward<Rhs>(rhs)) && !(std::forward<Rhs>(rhs) < std::forward<Lhs>(lhs)); }

Das klingt gut, ist aber falsch. Denn wenn die equals-Funktion mit einem temporären Objekt als ersten Parameter aufgerufen wird, dann wird lhs durch std::forward in eine R‑Value-Referenz umgewandelt. Der operator< könnte daher den Zustand verändern, da scheinbar niemand mehr eine Referenz auf das Objekt hält.

Kurz gesagt: Mit std::forward ist es wie mit std::move: Man kann die Operation auf ein Objekt nur einmal ausführen, denn danach ist das Objekt potentiell weg.

repeated forwarding

Das folgende Beispiel ist ähnlich zum Vorherigen mit zwei wichtigen Unterschieden: Erstens kommt std::forward nur einmal vor. Und zweitens ist Perfect Forwarding notwendig, um überhaupt die richtige Funktion aufrufen zu können.

template <typename Callable, typename ...Args> void repeat(std::size_t count, Callable&& callable, Args&&... args) { while (count-- > 0) { callable(std::forward<Args>(args)...); } }

Das ändert allerdings nichts daran, dass es falsch ist. Darüber hinaus: Es gibt schlicht und einfach keine richtige Implementierung dieses Funktionstemplates. Das mag zunächst überraschen, lässt sich am folgenden Beispiel aber einfach zeigen.

// void foo(std::unique_ptr<bar>); foo(std::make_unique<bar>(/* ... */)); // OK repeat(5, foo, std::make_unique<bar>(/* ... */)); // ERROR

Der erste Aufruf ist korrekt. Der Aufruf des Funktionstemplates mit den entsprechenden Argumenten kann hingegen überhaupt nicht funktionieren, da nur ein bar-Objekt erzeugt wird, das bereits beim ersten Aufruf der Funktion foo verbraucht wird.

Kurz gesagt: Wenn es ohne Perfect Forwarding nicht geht, ist es mit Perfect Forwarding noch lange nicht richtig.

indirect forwarding

Das dritte Beispiel ist ein wenig anders gelagert. Dieses Mal geht es um ein Funktionstemplate, das ein Range erwartet – also ein Objekt, über das in einer for-Schleife iteriert werden kann. Dabei werden die Elemente in eine andere Datenstruktur überführt. Um unnötige Kopien zu vermeiden, setzt der Entwickler std::move ein – abhängig davon, ob der Range als L‑Value- oder R‑Value-Referenz übergeben wird.

template <typename Range> void foobar(Range&& range) { for (auto&& item : range) { if (std::is_rvalue_reference<Range&&>{}) { other.push_back(std::move(item)); } else { other.push_back(item); } } }

Das funktioniert für die meisten Container wie beispielsweise std::vector sehr gut. Im Allgemeinen ist es allerdings falsch, da die Elemente auch von anderer Stelle referenziert werden können. Oder umgekehrt: Es funktioniert nur, wenn der Range die Objekte besitzt. Bisher gibt es allerdings keine einfache Möglichkeit das festzustellen.

Eine sinnvollere Variante des obigen Codes sieht wie folgt aus. Die Move-Operation ist hängt dabei von der Typinferenz in der for-Schleife ab. Das wird zwar in vielen Fällen – wie beispielsweise bei std::vector – nicht greifen. Doch zumindest ist es korrekt.

for (auto&& item : range) { other.push_back(std::forward<decltype(item)>(item)); }

Allgemein lässt sich beobachten, dass std::forward und std::move ein wenig übereifrig eingesetzt werden. Das ist unnötig, da Implementierungen ohne diese Operationen korrekt wären – oder zumindest gar nicht übersetzen würden. Daher: Vorsicht mit expliziten Moves, solange gute Richtlinien für die korrekte Anwendung fehlen.