C++ Core Guidelines: Regeln für das Kopieren und Verschieben

Die Regeln für das Kopieren und Verschieben von Objekten sind ziemlich offensichtlich. Bevor ich sie aber vorstelle, ist auf die letzten zwei Regeln für Konstruktoren einzugehen. Sie beschäftigen sich mit dem Delegieren und Vererben von Konstruktoren.

In Pocket speichern vorlesen Druckansicht 3 Kommentare lesen
Lesezeit: 9 Min.
Von
  • Rainer Grimm
Inhaltsverzeichnis

Die Regeln für das Kopieren und Verschieben von Objekten sind ziemlich offensichtlich. Bevor ich sie aber vorstelle, muss ich erst auf die letzten zwei verbleibenden Regeln für Konstruktoren eingehen. Diese beschäftigen sich mit dem Delegieren und Vererben von Konstruktoren.

Hier sind die zwei verbleibenden Regeln:

Seit C++11 kann ein Konstruktor seine Arbeit an einen anderen Konstruktor der gleichen Klasse delegieren. Das ist die moderne Variante, gemeinsame Aktionen aller Konstruktoren in einen Konstruktor zu verschieben. In C++-Code vor C++11 kam dazu typischerweise eine Methode mit dem Name init zum Einsatz.

class Degree{
public:
Degree(int deg){ // (1)
degree= deg % 360;
if (degree < 0) degree += 360;
}

Degree(): Degree(0){} // (2)

Degree(double deg): Degree(static_cast<int>(ceil(deg))){} // (3)

private:
int degree;
};

Die Konstruktoren (2) und (3) delegieren ihre Aufgabe an den Konstruktor (1), der seine Argumente prüft. Konstruktoren rekursiv aufzurufen stellt undefiniertes Verhalten dar.

Falls du Konstruktoren der Basis-Klasse in der abgeleiteten Klasse wiederverwenden kannst, tue es. Falls du sie nicht wiederverwendest, verletzt du das DRY-Prinzip (Don't Repeat Yourself).

class Rec {
// ... data and lots of nice constructors ...
};

class Oper : public Rec {
using Rec::Rec;
// ... no data members ...
// ... lots of nice utility functions ...
};

struct Rec2 : public Rec {
int x;
using Rec::Rec;
};

Rec2 r {"foo", 7};
int val = r.x; // uninitialized (1)

Beim Vererben von Konstruktoren lauert immer eine Gefahr. Falls die abgeleitete Klasse wie Rec2 eigene Datenmitglieder besitzt, werden diese nicht initialisiert (1).

Dieser Abschnitt beginnt mit einer Meta-Regel: Values Types, auch bekannt als Datentypen, die sich wie ints verhalten, sollten kopierbar sein, aber Interfaces in Klassenhierarchien nicht. Die letzte Regel C.67 bezieht sich auf diese Meta-Regel.

Hier sind die acht Regeln:

Die ersten sechs Regeln fürs Kopieren und Verschieben von Objekten bestehen aus drei sehr ähnlichen Paaren; daher werde ich sie zusammen darstellen.

  • C.60 und C.63 fordert, dass der Copy- (Move-) Zuweisungsoperator nicht virtuell sein und seine Argumente mit einer nicht konstanten Referenz zurückgeben soll. Es besteht nur ein Unterschied, wie er sein Parameter annehmen soll.
    • Die Copy-Zuweisung soll ihre Parameter als konstante Lvalue-Referenz (&) annehmen, denn dadurch kann die Quelle der Zuweisung nicht verändert werden.
    • Die Move-Zuweisung soll ihre Parameter als nicht-konstante Rvalue-Referenz (&&) annehmen, denn die Quelle der Zuweisung wird bei der Operation verändert.
    • Beide Regeln beschreiben genau das Muster, dem die Zuweisungsoperatoren der Standard Template Library folgen. Hier ist ein vereinfachender Blick auf std::vector:
vector& operator=( const vector& other );     
vector& operator=( vector&& other ); // (since C++11, until C++17)
vector& operator=( vector&& other ) noexcept // since C++17)
  • C.61 und C.64 sagen aus, das eine Kopier- (Verschiebe-) Operation tatsächlichen kopieren (verschieben) soll. Das ist die erwartete Semantik für a = b.
    • Im Falle des Kopierens bedeutet dies, dass nach dem Kopieren von a und b (a = b) gelten muss, dass beide Werte identisch sind: (a == b).
    • Kopieren kann tief (deep) oder flach (shallow) sein. Tiefes Kopieren bedeutet, dass nach dem Kopieren zwei unabhängige Objekte vorhanden sind (Value Semantic). Flaches Kopieren bedeutet, dass sich a und b nach dem Kopieren ein gemeinsames Objekt teilen (Reference Semantic).
    • Die Regel C.64 fordert weiter, dass das moved-from-Objekt danach in einem gültigen Zustand sein soll. Oft ist dies der Default-Zustand der Quelle. Der C++-Standard verlangt, das ein moved-from-Objekt danach in einem unspezifizierten, aber gültigen Zustand sein muss.
  • C.62 und C.65 sind sich einig: Eine Copy- (Move-) Zuweisung sollte vor Selbstzuweisung schützen. Ein Aufruf x = x soll den Wert von x nicht ändern.
    • Copy- (Move-) Zuweisung schützt für die Container der Standard Template Library, std::string und die fundamentalen Datentypen vor Selbstzuweisung; daher schützt der automatisch erzeugte Copy- (Move-) Zuweisungsoperator in diesem Fall auch vor Selbstzuweisung. Die gleiche Aussage gilt für einen automatisch erzeugten Copy- (Move-) Zuweisungsoperator, der nur Datentypen verwendet, die sicher vor Selbstzuweisung sind.
class Foo {
string s;
int i;
public:
Foo& Foo::operator=(const Foo& a){
s = a.s;
i = a.i;
return *this;
}
Foo& Foo::operator=(Foo&& a) noexcept {
s = std::move(a.s);
i = a.i;
return *this;
}
// ....
}

In dem Codebeispiel ist kein Test auf Selbstzuweisung notwendig. Hier kommt hingegen eine Version des Datentyps Foo, der überflüssige (teure) Checks in (1) und (2) auf Selbstzuweisung durchführt.

class Foo {
string s;
int i;
public:
Foo& Foo::operator=(const Foo& a){
if (this == &a) return *this; // (1)
s = a.s;
i = a.i;
return *this;
}
Foo& Foo::operator=(Foo&& a) noexcept { // (2)
if (this == &a) return *this;
s = std::move(a.s);
i = a.i;
return *this;
}
// ....
};

Verschiebeoperationen sollten keine Ausnahme werfen; daher solltest du sie als noexcept deklarieren. Der Move-Konstruktor und -Zuweisungsoperator lässt sich so implementieren, dass er keine Ausnahme werfen kann.

Dies ist das Muster, dass die Verschiebeoperatoren der Standard Template Library umsetzen. Hier ist zum Beispiel std::vector.

template<typename T>
class Vector {
// ...
Vector(Vector&& a) noexcept :elem{a.elem}, sz{a.sz} { a.sz = 0; a.elem = nullptr; }
Vector& operator=(Vector&& a) noexcept { elem = a.elem; sz = a.sz; a.sz = 0; a.elem = nullptr; }
// ...
public:
T* elem;
int sz;
};

Die letzte Regel verdient mehr Aufmerksamkeit.

Der Hauptgrund für diese Regel ist, dass kein "slicing" stattfinden soll. Slicing ist eine der Gefahren in C++, vor der mich meine Kollegen immer gewarnt haben. Auch Wikipedia hat einen Artikel zu Object Slicing verfasst.

Slicing passiert dann, wenn ein Objekt einer abgeleiteten Klasse einem Objekt einer Basisklasse per Copy zugewiesen wird.

struct Base { int base_; };

struct Derived : Base { int derived_; };

int main(){
Derived d;
Base b = d; // slicing, only the Base parts of (d) are copied
}

In diesem Beispiel werden die Copy-Operatoren der Basisklasse verwendet. Das bedeutet natürlich, dass nur die Bestandteile der Basisklasse von d kopiert werden.

Mit der OO-Brille betrachtet, ist eine (is-a) Instanz von Derived auch eine Instanz von Base. Das bedeutet, immer wenn du eine Instanz von Base benötigst, kann du auch eine von Derived verwenden. Hier ist Vorsicht geboten. Falls du eine Instanz von Base per Kopie (value-semantic) annimmst, erhältst du nur die Base-Anteile einer Instanz von Derived.

void needBase(Base b){ .... };

Derived der;
needBase(der); // slicing kicks in

Dies ist die Rettung, die die Guidelines empfehlen: Die Basisklasse soll kopieren nicht unterstützen und eine virtuelle clone-Funktion anbieten, falls kopieren notwendig ist. Die Guidelines verdeutlichen die Regel mit einem Beispiel:

class B { // GOOD: base class suppresses copying
B(const B&) = delete;
B& operator=(const B&) = delete;
virtual unique_ptr<B> clone() { return /* B object */; }
// ...
};

class D : public B {
string more_data; // add a data member
unique_ptr<B> clone() override { return /* D object */; }
// ...
};

auto d = make_unique<D>();
auto b = d.clone(); // ok, deep clone

Die clone-Methode gibt das neu erzeugte Objekt in einem std::unique_ptr zurück. Damit gehen die Besitzverhältnisse direkt an den Aufrufer über. Die clone-Methode ist unter den Namen Fabrikmethode deutlich bekannter. Eine Fabrikmethode ist eines der Muster aus dem Buch "Design Pattern: Elements of Reusable Object-Oriented Software", das sich mit dem Erzeugen von Objekten beschäftigt.

Ein paar Regeln für Default-Operationen gibt es noch. Diese betreffen Vergleiche, aber auch die Operationen swap und hash und werden Inhalt des nächsten Artikels sein.

  • Delegieren und Vererben von Konstruktoren: Schönes Objekt (freier Artikel für das Linux-Magazin)

()