C++: To Move or not to Move
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.