Move-Semantik in C++11

Einleitung

Vor einiger Zeit habe ich mit dem Method Park-Automotive-Team einen kleinen Workshop zu einem der wichtigsten Sprachmerkmale durchgeführt, die uns C++11 beschert hat: Move-Semantik. Die Inhalte dieses Workshops möchte ich euch heute in Form einer dreiteiligen Blog-Artikel-Serie nahebringen. Denn auch mehr als ein Jahrzehnt nach seiner Einführung habe ich noch immer das Gefühl, dass dieses wichtige Werkzeug vielen C++-Programmierern entweder unbekannt ist oder aber Angstzustände und Verwirrung hervorruft.

Copy-Semantik

In jeder Programmiersprache ist das Kopieren ganzer Objekte eine teure Angelegenheit. So kann ein Objekt Array-Membervariablen umfassen oder der Einstiegspunkt einer tief und weit verzweigten, baumartigen Hierarchie sein. In solchen Fällen ist es im Allgemeinen erstrebenswert eine „Deep Copy“ – also das Kopieren der gesamten Hierarchie – zu vermeiden. Dies bedeutet jedoch stets Handarbeit in C++98 und C++03.

Schauen wir uns einmal anhand eines Beispiels an, was in C++03 typischerweise notwendig ist, wenn wir Copy-Semantik in einer nicht-trivialen Klasse korrekt implementieren wollen („rule of three“):

  • ein Destruktor, der sich um die Aufräumarbeiten bei der Objektzerstörung kümmert,
  • ein Copy-Konstruktor, der aufgerufen wird, wenn das Objekt kopiert wird,
  • der zugehörige Zuweisungsoperator.

Implementiert sieht das dann etwa so aus:

class My03Class;
void swap(My03Class& first, My03Class& second);

class My03Class {
public:
  explicit My03Class(size_t size) // Konstruktor
    : m_content(new char[size])
    , m_size(size) {}

  ~My03Class() {
    delete[] m_content;
  }

  My03Class(const My03Class& other) // Copy-Konstruktor
    : m_content(new char[other.size])
      , m_size(size) {
      std::copy(other.m_content,
                other.m_content + other.m_size, m_content);
  }

  My03Class& operator=(const My03Class& other) { // Zuweisungsoperator
    if (this == &other) { return *this; }

    My03Class my_copy(other);
    swap(*this, my_copy);

    return *this;
  }

private:
  char* m_content;
  size_t m_size;

  friend void swap(My03Class& first, My03Class& second);
};

void swap(My03Class& first, My03Class& second) {
  std::swap(first.m_content, second.m_content);
  std::swap(first.m_size, second.m_size);
}

Eine C++-spezifische Besonderheit, die wir hier beachten müssen, ist die swap()-Operation im Zuweisungsoperator: diese stellt die Invariante sicher, dass alle beteiligten Objekte in einem konsistenten Zustand bleiben. Falls nämlich während der Konstruktion der Objektkopie eine Exception fliegt (z. B., weil die Speicheranforderung fehlschlägt), bleibt das Objekt auf der linken Seite des Zuweisungsoperators unverändert. Dieses Idiom ist unter dem Namen Copy-and-Swap bekannt.

Die Implementierung ist so zwar korrekt; in Fällen, in denen das Ursprungsobjekt zugewiesen wird, selbst aber danach nicht mehr verwendet wird, wird aber unnötigerweise eine vollständige Objektkopie angelegt:

My03Class o1(250000);
My03Class o2(500000);
o2 = o1; // Copy-Assignment
... // o1 wird nicht mehr verwendet,
... // Die Kopie wurde aber trotzdem erstellt.

Move-Semantik

Die neu in C++11 eingeführte Move-Semantik bietet uns heutzutage die sprachlichen Mittel, um solche unnötigen Kopien systematisch zu vermeiden. Eigentlich ist sie aber falsch benannt: Move-Semantik hat nichts damit zu tun, Dinge zu verschieben. Was diese neue Semantik tatsächlich erreicht ist, dass das Eigentum an einem Speicherbereich von einem Objekt an ein anderes übergeben werden kann, und zwar genau dann, wenn dies erwünscht ist.

Um diese neue Semantik zu implementieren, benötigen wir jetzt zwei weitere Funktionen in unserer Klasse: einen Move-Konstruktor und einen Move-Assignment-Operator:

class My11Class;
void move_to(My11Class&& from, My11Class& to) noexcept;

class My11Class final {
public:
  explicit My11Class(size_t size)
  : m_content(new char[size])
  , m_size(size) {}

  ~My11Class() {
    delete[] m_content;
  }

  My11Class(My11Class&& other) noexcept { // Move-Konstruktor
    move_to(std::move(other), *this);
  }

  My11Class& operator=(My11Class&& other) noexcept { // Move-Assignment-Operator
    if (this == &other) { return *this; }

    move_to(std::move(other), *this);

    return *this;
  }

  My11Class(const My11Class& other)
  : m_content(new char[other.m_size])
  , m_size(other.m_size) {
    std::copy(other.m_content,
              other.m_content + other.m_size,
              m_content);
  }

  My11Class& operator=(const My11Class& other) {
    if (this == &other) { return *this; }

    My11Class my_copy{other};
    *this = std::move(my_copy);

    return *this;
  }

private:
  char* m_content = nullptr;
  size_t m_size   = 0U;

  friend void move_to(My11Class&& from, My11Class& to) noexcept;
};

inline void move_to(My11Class&& from, My11Class& to) noexcept {
  // Aufräumarbeiten:
  delete[] to.m_content;

  // Übergabe des Eigentums am Objektinhalt:
  to.m_content = from.m_content;
  to.m_size    = from.m_size;

  // Definierten Zustand des Quellobjekts herstellen:
  from.m_content = nullptr;
  from.m_size    = 0U;
}

Die eigentliche Arbeit erledigt hier die move_to()-Funktion:

  1. Löschen des Inhalts des Zielobjekts,
  2. Setzen des m_content-Zeigers auf den Inhalt des Quellobjekts,
  3. anschließend die Überführung des Quellobjekts in einen nicht näher definierten, aber gültigen Zustand. Dieser letzte Teil ist wichtig, da auf dem Quellobjekt ja noch irgendwann der Destruktor ausgeführt werden muss. Aus dem selben Grund ist es wichtig, m_content mit einem Default-Wert zu versehen; ein delete[] muss auch für ein noch nicht verwendetes Objekt definiertes Verhalten aufweisen.

Das Copy-and-Swap-Idiom haben wir hier durch ein gleichwertiges Copy-and-Move ersetzt.

Die Verwendung dieser neuen Operationen sieht dann zum Beispiel so aus:

int main() {
  MyClass o1{25U};
  MyClass o2{o1};  // Kopie.
  MyClass o3 = o2; // noch eine Kopie.

  /*
   * hässlicher Code, würde man so nicht schreiben,
   * sondern über direkte Initialisierung lösen:
   */
  MyClass o4 = MyClass{35U}; // keine Kopie, Move-Assignment.
  MyClass o5{MyClass{25U}};  // Move-Konstruktor.

  MyClass o6{std::move(o1)}; // Move-Konstruktor.
}

Die Konstruktion von o2 erfolgt über den Copy-Konstruktor, die Zuweisung von o3 an o2 über den Copy-Assignment-Operator. Das an o4 zugewiesene Objekt ist temporär, liegt also als rvalue vor, weswegen der Move-Assignment-Operator zum Zuge kommt. Dito der Move-Konstrukor bei o5. Bei o6 kommt ebenfalls der Move-Konstruktor zum Zug, weil wir o1 als rvalue übergeben. Anschließend befindet o1 sich in einem zerstörbaren, aber ansonsten unbrauchbaren Zustand.

Anders jedoch als in der Programmiersprache Rust, die Move-Semantik mit Hilfe eines linearen Typsystems zum Übersetzungszeitpunkt erzwingt, wird der Compiler uns in C++ nicht davon abhalten, o1 weiterhin zu verwenden. Als Softwareentwickler müssen wir hier selbst Sorge dafür tragen, dass o1 nur noch zerstört und nicht mehr anderweitig verwendet wird.

Schluss

Wie wir Move-Semantik implementieren müssen und grundlegend verwenden können, wissen wir jetzt. Der erfahrene C++03-Programmierer wird jedoch berechtigterweise noch eine Menge weitere Fragen haben. Deswegen werden wir uns in den folgenden Teilen dieser Blog-Artikel-Serie damit auseinandersetzen, was genau geschieht, wenn wir diese neue Semantik einsetzen.

 

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