Das Vererben von Ressourcen

Content

Angenommen, wir verlagern das Erzeugen eines IntegerSequence-Objekts in eine Funktion und geben die fertige Sequenz über den Return-Wert zurück. Dann erscheint die Umkopier-Aktion unvermeidlich. Folgendes Beispiel demonstriert dies, indem IntegerSequence signalisiert, welche der Methoden aufgerufen wird:

#ifndef INTEGER_SEQUENCE_HPP
#define INTEGER_SEQUENCE_HPP

#include <cassert>
#include <cstdlib>
#include <new>
#include <iostream>

class IntegerSequence {
   public:
      IntegerSequence() : data{nullptr}, size{0}, allocated{0} {
	 std::cout << "IntegerSequence: default constructor" << std::endl;
      }
      IntegerSequence(const IntegerSequence& other) :
	    data{
	       other.data?
		  static_cast<int*>(std::malloc(other.allocated * sizeof(int)))
	       :
		  nullptr
	    }, size{other.size}, allocated{other.allocated} {
	 if (size > 0 && !data) {
	    throw std::bad_alloc();
	 }
	 for (std::size_t i = 0; i < size; ++i) {
	    data[i] = other.data[i];
	 }
	 std::cout << "IntegerSequence: copy constructor copying "
	    << size << " elements" << std::endl;
      }
      ~IntegerSequence() {
	 std::cout << "IntegerSequence: destructor" << std::endl;
	 std::free(data);
      }
      IntegerSequence& operator=(const IntegerSequence& other) {
	 if (other.size > allocated) {
	    int* newdata = static_cast<int*>(std::realloc(data,
	       other.allocated * sizeof(int)));
	    if (!newdata) {
	       throw std::bad_alloc();
	    }
	    data = newdata; allocated = other.allocated;
	 }
	 size = other.size;
	 for (std::size_t i = 0; i < size; ++i) {
	    data[i] = other.data[i];
	 }
	 std::cout << "IntegerSequence: assignment operator copying "
	    << size << " elements" << std::endl;
	 return *this;
      }

      void add(int value) {
	 if (size == allocated) {
	    std::size_t newsize = allocated * 2 + 8;
	    int* newdata = static_cast<int*>(std::realloc(data,
	       newsize * sizeof(int)));
	    if (!newdata) {
	       throw std::bad_alloc();
	    }
	    data = newdata;
	    allocated = newsize;
	 }
	 data[size++] = value;
      }
      std::size_t length() const {
	 return size;
      }
      int& operator()(std::size_t index) {
	 assert(index < size);
	 return data[index];
      }
      const int& operator()(std::size_t index) const {
	 assert(index < size);
	 return data[index];
      }
   private:
      int* data;
      std::size_t size;
      std::size_t allocated;
};

#endif
#include <cstdlib>
#include <iostream>
#include "IntegerSequence.hpp"

void print(const IntegerSequence& is) {
   for (std::size_t i = 0; i < is.length(); ++i) {
      std::cout << " " << is(i);
   }
   std::cout << std::endl;
}

IntegerSequence gen_sequence() {
   IntegerSequence iseq;
   for (std::size_t i = 0; i < 10; ++i) {
      iseq.add(i);
   }
   return iseq;
}

int main() {
   IntegerSequence iseq;
   iseq = gen_sequence();
   std::cout << "iseq: "; print(iseq);
}
theon$ g++ -Wall -o test_is test_is.cpp
theon$ ./test_is
IntegerSequence: default constructor
IntegerSequence: default constructor
IntegerSequence: assignment operator copying 10 elements
IntegerSequence: destructor
iseq:  0 1 2 3 4 5 6 7 8 9
IntegerSequence: destructor
theon$ 

Wie zu sehen ist, wird zuerst iseq in main mit dem default constructor aufgebaut, dann wird die Funktion aufgerufen, bei der ein lokales iseq angelegt und befüllt wird. Nach der Rückkehr wird dann mit dem Zuweisungs-Operator der Inhalt umkopiert und danach der Rückgabewert abgebaut.

Copy elision

Bevor wir fragen, wie sich das vermeiden lässt, lohnt es sich, folgenden Fall zu betrachten:

theon$ diff -U 2 test_is.cpp test_is2.cpp
--- test_is.cpp	Wed May  9 16:07:20 2018
+++ test_is2.cpp	Wed May  9 16:14:23 2018
@@ -19,6 +19,5 @@
 
 int main() {
-   IntegerSequence iseq;
-   iseq = gen_sequence();
+   IntegerSequence iseq = gen_sequence();
    std::cout << "iseq: "; print(iseq);
 }
theon$ g++ -Wall -o test_is2 test_is2.cpp
theon$ ./test_is2
IntegerSequence: default constructor
iseq:  0 1 2 3 4 5 6 7 8 9
IntegerSequence: destructor
theon$ 

Hier sehen wir, dass nur eine einzige Sequenz erzeugt und abgebaut wird. Das Umkopieren entfällt. Dieses Phänomen nennt sich in C++ copy elision, d.h. in besonderen Umständen darf der Übersetzer die Sache vereinfachen. Im zweiten Fall ist dies möglich, weil das zu main lokale iseq-Objekt mit dem Return-Wert konstruiert wird.

Wenn eine Funktion ein nicht-triviales Objekt zurückgibt, muss vom Aufrufer die hierfür benötigte Speicherfläche auf dem Stack reserviert werden, denn das Objekt wird ja noch temporär benötigt, wenn der Aufruf der Funktion abgeschlossen wird. Implementierungstechnisch kann man sich das so vorstellen, dass der Aufrufer der Funktion implizit einen weiteren Parameter mit der Adresse des Return-Werts übergibt.

Wenn beim Aufrufer ein Objekt mit dem Return-Wert konstruiert wird, dann kann der Übersetzer sogleich die Adresse des neuen Objekts übergeben, womit der Kopieraufwand eingespart werden kann. Und wenn bei der Umsetzung der Funktion der Übersetzer sieht, dass nur eine einzige lokale Variable für den Return-Wert verwendet wird, dann für die lokale Variable von vornherein die Speicherfläche für den Return-Wert genommen werden. Letzteres wird mit NRVO bezeichnet (named return value optimization). Wenn beides zusammenkommt, entfällt das Kopieren vollständig (zero copy overhead).

Das gehört zu den wenigen Fällen, bei der die Anwendung einer Optimierung sich bei den möglichen Seiteneffekten beobachten lässt (wie hier beispielsweise durch die Debug-Ausgaben). Der C++-Standard lässt dies in eine Reihe benannter Fälle ausdrücklich zu. Da der Übersetzer die Freiheit hat, diese Optimierung anzuwenden oder darauf zu verzichten, kann das Programm sich bei verschiedenen Übersetzern unterschiedlich verhalten. So verzichtet beispielsweise der mit CC aufzurufende Oracle-Studio-Übersetzer auf diese Optimierung:

theon$ CC -std=c++11 -o test_is2 test_is2.cpp
theon$ ./test_is2
IntegerSequence: default constructor
IntegerSequence: copy constructor copying 10 elements
IntegerSequence: destructor
iseq:  0 1 2 3 4 5 6 7 8 9
IntegerSequence: destructor
theon$ 

Das Verschieben von Ressourcen

Kehren wir zum ersten Fall zurück, bei dem copy elision nicht zur Anwendung kommt, da das Zielobjekt bereits existiert. Hier haben wir ein temporäres Objekt auf der rechten Seite der Zuweisung:

   IntegerSequence iseq;
   iseq = gen_sequence();

Da das Objekt auf der rechten Seite der Zuweisung unmittelbar danach abgebaut wird, stellt sich die Frage, ob wir das nicht anders handhaben können. D.h. können wir “sterbende” Objekte als solche erkennen und in diesem Fall das Kopieren vermeiden?

Beginnend mit C++11 wurden sogenannte rvalue references eingeführt, d.h. Referenzen auf ein temporäres Objekt auf der rechten Seite. Sie ähneln syntaktisch den sogenannten lvalue references. Während letztere mit einem einfachen & gekennzeichnet werden, ist beim declarator für eine rvalue reference && anzugeben.

Der Handel sieht so aus: Wenn wir eine rvalue reference für ein dem Tode geweihtes Objekt erhalten, dürfen wir es nach Herzenslust “ausplündern”, müssen es aber in einem aufgeräumten Zustand zurücklassen, so dass es noch korrekt abgebaut werden kann.

So könnte ein Zuweisungs-Operator aussehen, der eine rvalue reference akzeptiert:

IntegerSequence& operator=(IntegerSequence&& other) {
   std::swap(data, other.data);
   std::swap(size, other.size);
   std::swap(allocated, other.allocated);
   std::cout << "IntegerSequence: move assignment taking "
      << size << " elements" << std::endl;
   return *this;
}

Zu beachten ist, dass die rvalue reference natürlich nicht mit const ausgezeichnet wird, da wir selbstverständlich darauf schreibend zugreifen möchten. Der einfachste Ansatz beim verschiebenden Zuweisungs-Operator besteht in dem Austausch aller Komponenten. Der alte Inhalt unseres Objekts landet dann bei “dem Tode geweihten” Objekt, das unmittelbar danach abgebaut werden wird. Die Funktion std::swap steht über #include <utility> zur Verfügung und erhält zwei lvalue references des gleichen Typs und tauscht diese gegeneinander aus. Diese Funktion ist für alle elementaren Typen und zahlreiche Typen der Standard-Bibliothek vordefiniert. Sie kann ergänzend für eigene Typen definiert werden.

So sieht dann ein Testlauf aus:

theon$ g++ -o test_is test_is.cpp
theon$ ./test_is
IntegerSequence: default constructor
IntegerSequence: default constructor
IntegerSequence: move assignment taking 10 elements
IntegerSequence: destructor
iseq:  0 1 2 3 4 5 6 7 8 9
IntegerSequence: destructor
theon$ 

Aufgabe

Ergänzen Sie die Vorlage um einen Konstruktor, der ähnlich wie der Zuweisungs-Operator eine rvalue reference akzeptiert und die Inhalte dann übernimmt (move constructor). Ergänzen Sie das Testprogramm dahingehend, dass alle Varianten zum Zuge kommen, d.h. der klassische Kopierkonstruktor neben dem move constructor und die klassische Zuweisung neben dem move assignment.

Vorlage

#ifndef INTEGER_SEQUENCE_HPP
#define INTEGER_SEQUENCE_HPP

#include <cassert>
#include <cstdlib>
#include <new>
#include <iostream>

class IntegerSequence {
   public:
      IntegerSequence() : data{nullptr}, size{0}, allocated{0} {
	 std::cout << "IntegerSequence: default constructor" << std::endl;
      }
      IntegerSequence(const IntegerSequence& other) :
	    data{
	       other.data?
		  static_cast<int*>(std::malloc(other.allocated * sizeof(int)))
	       :
		  nullptr
	    }, size{other.size}, allocated{other.allocated} {
	 if (size > 0 && !data) {
	    throw std::bad_alloc();
	 }
	 for (std::size_t i = 0; i < size; ++i) {
	    data[i] = other.data[i];
	 }
	 std::cout << "IntegerSequence: copy constructor copying "
	    << size << " elements" << std::endl;
      }
      ~IntegerSequence() {
	 std::cout << "IntegerSequence: destructor" << std::endl;
	 std::free(data);
      }
      IntegerSequence& operator=(const IntegerSequence& other) {
	 if (other.size > allocated) {
	    int* newdata = static_cast<int*>(std::realloc(data,
	       other.allocated * sizeof(int)));
	    if (!newdata) {
	       throw std::bad_alloc();
	    }
	    data = newdata; allocated = other.allocated;
	 }
	 size = other.size;
	 for (std::size_t i = 0; i < size; ++i) {
	    data[i] = other.data[i];
	 }
	 std::cout << "IntegerSequence: assignment operator copying "
	    << size << " elements" << std::endl;
	 return *this;
      }
      IntegerSequence& operator=(IntegerSequence&& other) {
	 std::swap(data, other.data);
	 std::swap(size, other.size);
	 std::swap(allocated, other.allocated);
	 std::cout << "IntegerSequence: move assignment taking "
	    << size << " elements" << std::endl;
	 return *this;
      }

      void add(int value) {
	 if (size == allocated) {
	    std::size_t newsize = allocated * 2 + 8;
	    int* newdata = static_cast<int*>(std::realloc(data,
	       newsize * sizeof(int)));
	    if (!newdata) {
	       throw std::bad_alloc();
	    }
	    data = newdata;
	    allocated = newsize;
	 }
	 data[size++] = value;
      }
      std::size_t length() const {
	 return size;
      }
      int& operator()(std::size_t index) {
	 assert(index < size);
	 return data[index];
      }
      const int& operator()(std::size_t index) const {
	 assert(index < size);
	 return data[index];
      }
   private:
      int* data;
      std::size_t size;
      std::size_t allocated;
};

#endif
#include <cstdlib>
#include <iostream>
#include "IntegerSequence.hpp"

void print(const IntegerSequence& is) {
   for (std::size_t i = 0; i < is.length(); ++i) {
      std::cout << " " << is(i);
   }
   std::cout << std::endl;
}

IntegerSequence gen_sequence() {
   IntegerSequence iseq;
   for (std::size_t i = 0; i < 10; ++i) {
      iseq.add(i);
   }
   return iseq;
}

int main() {
   IntegerSequence iseq;
   iseq = gen_sequence();
   std::cout << "iseq: "; print(iseq);
}