Move-Semantik in C++11 – Teil 2

Einleitung

In Teil 1 dieser kleinen Blog-Serie hatten wir uns angeschaut, wie wir in modernem C++ Move-Semantik implementieren und diese verwenden. In diesem Beitrag werden wir uns damit befassen, was genau bei dieser neuen Semantik geschieht, ein erfahrener C++-Programmierer hat schließlich (berechtigterweise) viele Fragen nach dem Lesen des ersten Teils:

„Was macht dieses &&?“

Das && symbolisiert eine neue Art von Referenz, eine rvalue-Referenz. Schon immer hat der C++-Standard zwischen lvalues und rvalues unterschieden:

  • vereinfacht betrachtet ist jedes Objekt, das eine Adresse hat, auf die das Programm zugreifen kann, ein lvalue – also jedes Objekt, das auf der linken Seite einer Zuweisung steht. const-Variablen, Array-Elemente, Funktionsaufrufe, die eine lvalue-Referenz zurückliefern, Bitfelder, Unions und Membervariablen sind in diese Definition mit eingeschlossen.
  • alles andere ist ein rvalue.

Neu in C++11 ist jedoch, dass uns Referenzen zur Verfügung stehen, mit deren Hilfe wir zwischen diesen Wertkategorien unterscheiden können:

  • T&: Eine lvalue-Referenz. Sie erlaubt es uns, ein Objekt zu verändern, erhält ein Objekt aber nie am Leben.
  • const T&: Eine const-lvalue-Referenz. Sie erlaubt es uns nicht, das Objekt, auf das sie zeigt zu modifizieren, erhält das referenzierte Objekt jedoch in manchen Fällen am Leben.

Diese beiden Kategorien kennen wir bereits aus C++03; neu jedoch sind:

  • T&&: Eine rvalue-Referenz. Sie erlaubt es uns, das Objekt, auf das sie zeigt, zu verändern und zeigt grundsätzlich auf Objekte, deren Lebenszeit fast vorbei ist. Sie erhält das Objekt unter manchen Umständen am Leben.
  • const T&&: Eine const-rvalue-Referenz, die es uns nicht ermöglicht, das Objekt zu verändern. Sie ist aus Entwicklersicht vollkommen nutzlos, weil jede Move-Operation es erfordert, dass wir das ursprüngliche Objekt verändern können, und dies bei einer const-Referenz nicht geht. Der Compiler erzeugt diese Art von Referenz trotzdem manchmal bei der Code-Transformation, deswegen sollten wir, wenn wir Code für die anderen drei Kategorien schreiben, auch solchen für diese vierte Kategorie implementieren.

Die Regel, dass Objekte mit Adresse lvalues sind, hat eine auf den ersten Blick überraschende Konsequenz: ein Objekt, das via rvalue-Referenz an eine Funktion übergeben wird, ist trotzdem wieder ein lvalue. Das hat den Grund, dass der Parameter von der Innensicht der Funktion aus betrachtet ja eine Adresse hat.

„Was macht dieses std::move()?“

Das my_copy aus unserer Move-Semantik-Klasse ist ein lvalue, d. h. eine rvalue-Referenz kann an dieses Objekt nicht binden. Was aber, wenn wir genau dies wollen? In diesem Fall kommt std::move() ins Spiel. Anders, als man intuitiv vermuten würde, ist es nicht dazu da Objekte zu verschieben, sondern es ist lediglich ein Cast, der einen lvalue in eine rvalue-Referenz umwandelt. Eine mögliche Implementierung von std::move() schaut folgendermaßen aus:

template<typename T>
typename std::remove_reference_t<T>&& move(T&& arg) noexcept {
  return static_cast<typename std::remove_reference_t<T>&&>(arg);
}

„Wieso sind die Move-Operationen als noexcept markiert?“

noexcept ist ein Hinweis an den Compiler, der anzeigt, dass eine Funktion nie eine Exception werfen wird, die für vom Benutzer geschriebenen Code sichtbar wird. In der Praxis bedeutet dies, dass eine Exception, die eine so markierte Funktion verlässt, zum Programmabbruch führt.

Move-Operationen sollten grundsätzlich als noexcept markiert werden: es gibt schlicht und ergreifend nichts, was bei einem Move schiefgehen kann. Dem Compiler erspart es dieser Ansatz, Code zu erzeugen, der eine Ausnahmebehandlung ermöglicht. Dies führt zu effizienterem Laufzeitverhalten.

„Wie sollten Schnittstellen nun aussehen?“

In Bezug auf die Frage, wie sinnvolle Schnittstellen aussehen, hat sich durch die Einführung von rvalue-Referenzen in den C++-Standard einiges geändert. Vor C++11 war vollkommen klar, dass es in den meisten Fällen richtig ist, Objekte als const-Referenzen zu übergeben:

class Foo final {
public:
  Foo(const std::string& foo) : m_foo(foo) {}
};

Dies galt unabhängig davon ob, wie hier, das übergebene Objekt in eine Membervariable kopiert werden soll oder ob es lediglich an eine andere Funktion übergeben wird.

Seit C++11 stehen uns folgende Alternativen zur Verfügung:

Übergabe per Kopie, Zuweisung per Move

class Foo final {
public:
  Foo(std::string foo) : m_foo(std::move(foo)) {}
};

Hier wird bei der Parameterübergabe eine Kopie des Strings angelegt, diese wird mittels std::move() zu einem rvalue gecastet und dieser dann per Move-Konstruktor an die Member-Variable zugewiesen. Obwohl wir hier by value übergeben, haben wir es immer noch mit derselben Anzahl an Kopieroperationen zu tun wie in der ersten Variante.

Unsere Schnittstelle bringt dem Anwender unserer API gegenüber aber nun zum Ausdruck, was tatsächlich gemeint ist: eine Objektkopie soll angelegt werden, die dann unabhängig vom ursprünglichen Objekt existiert.

Übergabe als rvalue-Referenz, Zuweisung als Kopie

Wir hätten natürlich auch auf die Idee kommen können, das Objekt als rvalue-Referenz zu übergeben:

class Foo final {
public:
  Foo(std::string&& foo) : m_foo(foo) {}
};

So schränken wir jedoch den Benutzer unserer Schnittstelle vielleicht unnötig ein, schließlich können jetzt nur noch rvalues und keine lvalues mehr übergeben werden. Die Operation, die ausgeführt wird, ist vom Effekt her aber identisch: in der Initialisierungsliste haben wir es wieder mit einem lvalue zu tun, d. h. die Zuweisung an die Member-Variable erfolgt als Kopie, und wir gewinnen nichts dadurch, dass wir hier eine rvalue-Referenz verwenden. Diese Variante können wir also gleich wieder vergessen.

Übergabe als rvalue-Referenz, Zuweisung durch Move

Eine sinnvollere Implementierung der Übergabe als rvalue-Referenz würde folgendermaßen aussehen:

class Foo final {
public:
  Foo(std::string&& foo) : m_foo(std::move(foo)) {}
};

Dies bedeutet dann aber, dass das ursprüngliche Objekt, auf das foo zeigt, invalidiert wird. Im Gegenzug wird in diesem Fall aber fast gar nichts kopiert.

Übergabe als Forwarding Reference

Vor allem bei der Implementierung von Softwarebibliotheken ist es sinnvoll, alle Wertkategorieren an den Schnittstellen zu überladen – bei der Implementierung von Bibliothekscode müssen wir schließlich damit rechnen, dass Clientcode die Bibliothek auf ihren performance-kritischen Pfaden verwendet und Move-Semantik als Optimierung deswegen lohnt:

void do_something(std::string& s) { … }
void do_something(const std::string& s) { … }
void do_something(std::string&& s) { … }
void do_something(const std::string&& s) { … }

Für uns als Bibliotheksentwickler bedeutet dies jedoch einen enormen Schreibaufwand. Schließlich haben wir es oft mit Schnittstellen zu tun, die mehr als einen Parameter in Form einer Referenz akzeptieren, und wir müssen in solchen Fällen ja alle Kombinationen von Übergabesemantiken anbieten.

Glücklicherweise hat C++ eine Lösung für dieses Dilemma: das Konzept der „Forwarding Reference“, die sich je nach Kontext wie eine lvalue- oder rvalue-Referenz verhält. Das folgende Beispiel ist gleichwertig zum vorhergehenden:

template<typename T> void do_something(T&& s) { … }

Eine Referenz ist in C++ eine Forwarding-Referenz genau dann, wenn sie der Form T&& genügt und der Typ von T per Typinferenz festgelegt wird. Das heißt, dass weder const T& noch T& noch const T&& noch std::vector<T>&&  eine Forwarding-Referenz sind, selbst dann nicht, wenn der Compiler den Typ von T inferiert hat.

Auch eine Forwarding Reference ist innerhalb der Funktion jedoch wieder ein lvalue, d. h. um sie korrekt an eine Funktion weiterzugeben, muss hier ein Cast stattfinden. Dieser muss aber kontextsensitiv erfolgen, je nachdem, ob s als lvalue-Referenz oder als rvalue übergeben wurde. Zu diesem Zweck stellt die C++-Standardbibliothek uns die Funktion std::forward() zur Verfügung:

template<typename T> void do_something(T&& s) {
  do_something_else(std::forward(s));
}

Je nachdem ob s ein lvalue oder rvalue ist, belässt std::forward() den lvalue oder castet nach rvalue-Referenz.

Schluss

Wir haben nun ein recht gutes Verständnis davon, wie wir Move-Semantik implementieren und verwenden. Außerdem haben wir uns angeschaut, auf welche Fallstricke wir dabei beachten müssen.

Es gibt allerdings ein paar Fragen, die wir noch nicht beantwortet haben: Wann müssen wir std::move() explizit hinschreiben? Wann sollten wir trotz der Existenz von Move-Semantik als const-Referenz übergeben? Und schließlich: Ist der Aufwand, den wir hier treiben, überhaupt gerechtfertigt? Oder verschwenden wir hier nur Zeit und Geld unserer Kunden?

Mit dieser Frage wollen wir uns in Teil 3 dieser Artikelserie befassen.

 

Mehr Informationen zu den Themen Software Engineering und Medical Devices findest du in unseren Kompetenzfeldern.