C++ Core Guidelines: Die Nuller-, Fünfer- oder Sechserregel

Modernes C++  –  7 Kommentare

In diesem Artikel geht es um die Nuller-, Fünfer- oder Sechserregel, die Unterschiede zwischen Referenz- und Copy-Semantik und ein weiteres, sehr verwandtes Thema: tiefes versus flaches Kopieren.

Um ganz genau zu sein. C++ besitzt circa 50 Regeln für den Lebenszyklus eines Objekts. Heute werde ich mir die drei sehr wichtigen Regeln zu den Default-Operatoren anschauen. Zu jeder Regel gibt es dazu den Link, sodass die Leser die Details gegebenenfalls genauer nachlesen können.

C++ bietet sechs Default-Operatoren, manchmal werden sie auch spezielle Funktionen genannt, die den Lebenszyklus eines Objekts regeln. Konsequenterweise beginnt dieser Artikel mit den sechs Operatoren.

  • ein Default Konstruktor: X()
  • ein Copy-Konstruktor: X(const X&)
  • ein Copy-Zuweisungsoperator: operator=(const X&)
  • ein Move-Konstruktor: X(X&&)
  • ein Move-Zuweisungsoperator: operator=(X&&)
  • ein Destruktor: ~X()

Die Default-Operatoren sind stark verwandt. Das heißt, falls man einen implementiert oder ihn mit =delete unterbindest, sollte man sich zu den anderen fünf Operatoren Gedanken machen. Das Wort "implementiert" mag ein wenig verwirren. Es bedeutet für den Default-Konstruktor, das man ihn definieren oder vom Compiler anfordern kann.

X(){};          // explicitly defined
X() = default; // requested from the compiler

Diese Aussage trifft natürlich auch für die weiteren fünf Default-Operatoren zu.

Ich habe noch eine allgemeine Bemerkung, bevor ich über die sechs Default-Operatoren schreibe. C++ bietet Value- und nicht Referenz-Semantik für seine Datentypen an. Die beste Erläuterung zu beiden Begriffen konnte ich hier finden.

  • Value-Semantik: Value (oder “Copy”)-Semantik bedeutet, dass eine Zuweisung den Wert zuweist und nicht nur den Zeiger auf diesen.
  • Referenz-Semantik: Bei der Referenz-Semantik besteht Zuweisung aus einem kopieren der Zeiger (und Referenzen).

Hier sind die ersten drei Regeln.

Die Default-Operatoren Regeln

C.20: If you can avoid defining any default operations, do

Diese Regel ist auch unter dem Namen Nullerregel "the rule of zero" bekannt. Das bedeutet, falls eine Klasse keine Default-Operatoren benötigt, da bereits alle Mitglieder diese besitzen, muss man auch keine definieren.

struct Named_map {
public:
// ... no default operations declared ...
private:
string name;
map<int, int> rep;
};

Named_map nm; // default construct
Named_map nm2 {nm}; // copy construct

Der Aufruf des Default- und des Copy-Konstruktors ist in diesem Fall möglich, da beide bereits für std::string und std::map vorhanden sind.

C.21: If you define or =delete any default operation, define or =delete them all

Da wir alle sechs zu implementieren oder mit =delete zu unterbinden haben, ist diese Regel unter dem Name "the rule of five" bekannt. Die Zahl fünf ist in diesem Zusammenhang schräg, aber die Regel ist es nicht. Die sechs Operatoren sollten als logische Einheit betrachtet werden. Daher ist die Wahrscheinlichkeit sehr hoch, dass man nichtintuitive Datentypen erhältt, wenn man dieser Regel nicht folgt. Hier ist ein Beispiel aus den Guidelines.

struct M2 {   // bad: incomplete set of default operations
public:
// ...
// ... no copy or move operations ...
~M2() { delete[] rep; }
private:
pair<int, int>* rep; // zero-terminated set of pairs
};

void use()
{
M2 x;
M2 y;
// ...
x = y; // the default assignment
// ...
}

Wann bricht die Klasse M2 die Erwartungshaltung? Zuerst einmal wird im Destruktor rep gelöscht, obwohl er nicht initialisiert wurde. Zum zweiten und das ist ein viel ernsteres Problem, kopiert der Copy-Zuweisungsoperator (x = y) in der letzten Zeile alle Mitglieder von M2. Das bedeutet insbesondere, das der Zeiger rep kopiert wird. Daher wird der Destruktor für x und y aufgerufen und wir erhalten undefiniertes Verhalten, da das Objekt zweimal gelöscht wurde.

C.22: Make default operations consistent

Diese Regel schließt direkt an die vorherige Regel an. Falls man die Default-Operatoren mit unterschiedlicher Semantik implementiert, werden die Anwender mit maximaler Verwirrung reagieren. Zur Verdeutlichung habe ich die Klasse Strange implementiert. Um das Veriwrrungspotenzial auf den Punkt zu bringen, enthält sie einen Zeiger auf int.

// strange.cpp (https://github.com/RainerGrimm/ModernesCppSource)

#include <iostream>

struct Strange{

Strange(): p(new int(2011)){}

// deep copy
Strange(const Strange& a) : p(new int(*(a.p))){} // (1)

// shallow copy
Strange& operator=(const Strange& a){ // (2)
p = a.p;
return *this;
}

int* p;

};

int main(){

std::cout << std::endl;

std::cout << "Deep copy" << std::endl;

Strange s1;
Strange s2(s1); // (3)

std::cout << "s1.p: " << s1.p << "; *(s1.p): " << *(s1.p) << std::endl;
std::cout << "s2.p: " << s2.p << "; *(s2.p): " << *(s2.p) << std::endl;

std::cout << "*(s2.p) = 2017" << std::endl;
*(s2.p) = 2017; // (4)

std::cout << "s1.p: " << s1.p << "; *(s1.p): " << *(s1.p) << std::endl;
std::cout << "s2.p: " << s2.p << "; *(s2.p): " << *(s2.p) << std::endl;

std::cout << std::endl;

std::cout << "Shallow copy" << std::endl;

Strange s3;
s3 = s1; // (5)

std::cout << "s1.p: " << s1.p << "; *(s1.p): " << *(s1.p) << std::endl;
std::cout << "s3.p: " << s3.p << "; *(s3.p): " << *(s3.p) << std::endl;


std::cout << "*(s3.p) = 2017" << std::endl;
*(s3.p) = 2017; // (6)

std::cout << "s1.p: " << s1.p << "; *(s1.p): " << *(s1.p) << std::endl;
std::cout << "s3.p: " << s3.p << "; *(s3.p): " << *(s3.p) << std::endl;

std::cout << std::endl;

std::cout << "delete s1.p" << std::endl;
delete s1.p; // (7)

std::cout << "s2.p: " << s2.p << "; *(s2.p): " << *(s2.p) << std::endl;
std::cout << "s3.p: " << s3.p << "; *(s3.p): " << *(s3.p) << std::endl;

std::cout << std::endl;

}

Die Klasse Strange besitzt einen Copy-Konstruktor (1) und einen Copy-Zuweisungsoperator (2). Der Copy-Konstruktor wendet tiefes Kopieren ("deep copy") und der Copy-Zuweisungsoperator flaches Kopieren ("shallow copy") an. Fast immer will man tiefes Kopieren (Value-Semantik) für die Datentypen; vermutlich will man nie, dass die Datentypen unterschiedliche Semantik für diese beiden Operationen besitzen.

Der Unterschied ist, das tiefes Kopieren zwei neue, getrennte Objekte erzeugt (p(new int(*(a.p)), während flaches Kopieren (p = a.p) nur die Zeiger kopiert. Lass uns mit dem Strange Datentypen spielen. Hier ist die Ausgabe des Programms.

Ich verwende in Ausdruck (3) den Copy-Konstruktor, um s2 zu erzeugen. Die Ausgabe der Adresse des Zeigers und das Verändern des Werts von s2 zeigt (4), s1 und s2 sind vollkommen unabhängige Objekte. Das gilt aber nicht für s1 und s3. Die Copy-Zuweisung in dem Ausdruck (5) erzeugt eine flache Kopie. Das Ergebnis ist, dass das Verändern des Zeiger s3.p (6) auch den Zeiger s1.p betrifft. Das gilt natürlich auch für den Wert, auf den sie verweisen.

Der Spaß beginnt, wenn ich den Zeiger s1.p lösche (7). Aufgrund der tiefen Kopie besitzt dies Löschen keine Auswirkung auf s2.p, aber der Wert von s3.p wird zum Nullzeiger. Um noch genauer zu sein: Anschließendes Dereferenzieren eines Nullzeigers wie in dem abschließenden Ausdruck (*s3.p) stellt undefiniertes Verhalten dar-

Wie geht's weiter?

Die Geschichte der C++ Core Guidelines zum Lebenszyklus von Objekten geht weiter. Im nächsten Artikel dreht sich alles um Destruktoren.

Hintergrundwissen