C++ Core Guidelines: Regeln für Konstruktoren

Modernes C++  –  1 Kommentare
Anzeige

Der Lebenszyklus jedes Objekts beginnt mit seiner Erzeugung. Somit beschäftigt sich dieser Artikel mit den dreizehn fundamentalsten Regeln für Objekte: denen für Konstruktoren.

Dreizehn Regeln sind eindeutig zu viel für einen Artikel. In diesem Artikel geht es nur um die ersten elf Regeln. Warum nicht nur zehn Regeln? Die elfte Regel ist einfach zu interessant. Die verbleibenden zwei Regeln spare ich mir für den nächsten Artikel auf. Hier sind alle Regeln kurz und bündig.

Anzeige
C++ Core Guidelines: Regeln für Konstruktoren

Auf die Suche in Breite folgt in bekannter Manier die Suche in die Tiefe. Weitere Details lassen sich einfach mit den Links auf die Regeln nachlesen.

Eine Invariante ist eine Charakteristik eines Objekts, die für seinen ganzen Lebenszyklus gelten soll. Der Platz, um eine Invariante zu etablieren, ist der Konstruktor. Eine Invariante kann ein gültiges Datum sein.

class Date {  // a Date represents a valid date
// in the January 1, 1900 to December 31, 2100 range
Date(int dd, int mm, int yy)
:d{dd}, m{mm}, y{yy}
{
if (!is_valid(d, m, y)) throw Bad_date{}; // enforce invariant
}
// ...
private:
int d, m, y;
};

Diese Regel schlägt eine ähnlichen Ton an wie ihr Vorgänger. Es gilt, dass es die Aufgabe eines Konstruktors ist, ein fertig initialisiertes Objekt zu erzeugen. Besitzt eine Klasse eine init Methode, gehen die Probleme typischerweise bereits los.

class X1 {
FILE* f; // call init() before any other function
// ...
public:
X1() {}
void init(); // initialize f
void read(); // read from f
// ...
};

void f()
{
X1 file;
file.read(); // crash or bad read!
// ...
file.init(); // too late
// ...
}

Der Anwender kann irrtümlicherweise read befor init aufrufen oder schlicht den Aufruf von init vergessen.

Entsprechend zur vorherigen Regel gilt: Wirf eine Ausnahme, falls du kein gültiges Objekt erzeugen kannst. Da gibt es nicht viel hinzuzufügen. Falls ein ungültiges Objekt verwendet wird, muss vor jeder Verwendung des Objekts seinen Zustand geprüft werden. Wenn das nicht fehleranfällig ist? Hier ist ein Beispiel aus den Guidelines.

class X3 {     // bad: the constructor leaves a non-valid object behind
FILE* f;
bool valid;
// ...
public:
X3(const string& name)
:f{fopen(name.c_str(), "r")}, valid{false}
{
if (f) valid = true;
// ...
}

bool is_valid() { return valid; }
void read(); // read from f
// ...
};

void f()
{
X3 file {"Heraclides"};
file.read(); // crash or bad read!
// ...
if (file.is_valid()) {
file.read();
// ...
}
else {
// ... handle error ...
}
// ...
}

Ein Value Type ist ein Datentyp, der sich wie ein int verhält. Ein Value Type ist einem Regular Type sehr ähnlich. Ich habe in dem Artikel zu Concrete Types über Value Types und Regular Types geschrieben. Falls ein Datentyp einen Default-Konstruktor besitzt, lässt es sich deutlich einfacher mit ihm arbeiten. Viele Konstruktoren der STL-Container verlassen sich darauf, dass ein Datentyp einen Default-Konstruktor besitzt. Zum Beispiel der Wert eines geordneten assoziativen Containers wie std::map. Falls alle Mitglieder einer Klasse einen Default-Konstruktor besitzen, erzeugt der Compiler automatisch einen Default-Konstruktor für diese Klasse.

Fehlerbehandlung ist einfacher mit Default-Konstruktoren, die keine Ausnahme werfen können. Die Guidelines bieten ein einfaches Beispiel an.

template<typename T>
// elem is nullptr or elem points to space-elem element allocated using new
class Vector1 {
public:
// sets the representation to {nullptr, nullptr, nullptr}; doesn't throw
Vector1() noexcept {}
Vector1(int n) :elem{new T[n]}, space{elem + n}, last{elem} {}
// ...
private:
own<T*> elem = nullptr;
T* space = nullptr;
T* last = nullptr;
};

Dies ist eines meiner Lieblingsfeatures aus C++11. Wenn du Klassenmitglieder direkt im Klassenkörper initialisierst, wird das Schreiben von Konstruktoren deutlich einfacher und manchmal sogar überflüssig. Die Klasse X1 definiert seine Mitglieder in der klassischen Weise (bevor C++11) und die Klasse X2 in der vorzuziehenden Weise. Ein schöner Seiteneffekt der Klasse X2 ist es, dass der Compiler automatisch den Konstruktor für die Klasse X2 erzeugt.

class X1 { // BAD: doesn't use member initializers
string s;
int i;
public:
X1() :s{"default"}, i{1} { }
// ...
};

class X2 {
string s = "default";
int i = 1;
public:
// use compiler-generated default constructor
// ...
};

Die Anwendung dieser Regel ist sehr wichtig und schützt vor bösen Überraschungen. Konstruktoren, die nur ein Argument annehmen, werden gerne auch Konvertierungs-Konstruktor genannt, da sie das Argument in eine Instanz der Klasse konvertieren. Falls solch einen Konvertierungs-Konstruktor nicht als explizit deklariert ist, lauert immer der Gefahr der impliziten Typkonvertierung. Das Codeschnipsel macht es richtig.

class String {
public:
explicit String(int); // explicit
// String(int); // implicit
};

String s = 10; // error because of explicit

Die implizite Konvertierung von int nach String ist in diesem Beipiel nicht möglich, da der Konstruktor als explizit deklariert wurde. Falls statt des expliziten Konstruktors der auskommentierte, implizite Konstruktor zum Einsatz käme, würde die letzte Zeile einen String der Länge 10 erzeugen.

Klassenmitglieder werden in der Reihenfolge ihre Deklaration initialisiert. Falls sie in einer anderen Reihenfolge in dem Konstruktor-Initialisierer initialisiert werden, mag das Verhalten den einen oder anderen überraschen.

class Foo {
int m1;
int m2;
public:
Foo(int x) :m2{x}, m1{++x} { } // BAD: misleading initializer order
// ...
};

Foo x(1); // surprise: x.m1 == x.m2 == 2

Wenn Klassenmitglieder direkt im Klassenkörper initialisiert werden, wird das Schreiben von Konstruktoren deutlich einfacher. Zusätzlich kannst du auf die Art nicht vergessen, ein Klassenmitglied zu initialisieren.

class X {   // BAD
int i;
string s;
int j;
public:
X() :i{666}, s{"qqq"} { } // j is uninitialized
X(int ii) :i{ii} {} // s is "" and j is uninitialized
// ...
};

class X2 {
int i {666};
string s {"qqq"};
int j {0};
public:
X2() = default; // all members are initialized to their defaults
X2(int ii) :i{ii} {} // s and j initialized to their defaults (1)
// ...
};

Während die Initialisierung von Klassenmitglieder im Klassenkörper das Default-Verhalten für die Objekt einer Klasse etabliert, erlaubt der Konstruktor (1) diese Default-Verhalten zu variieren.

Diese Regel ist schon lang in der Anwendung. Die offensichtlichsten Gründe für Initialisierung gegenüber Zuweisung sind: Du kannst nicht vergessen, einen Wert zu initialisieren und ihn daher uninitialisiert verwenden und die Initialisierung ist meistens schneller, aber nicht langsamer als die Zuweisung.

class B {   // BAD
string s1;
public:
B() { s1 = "Hello, "; } // BAD: default constructor followed by assignment
// ...
};

Der Aufruf einer virtuellen Funktion aus dem Konstruktor verhält sich besonders. Um den Anwender zu schützen, wird der virtuelle Aufruf im Konstruktor unterbunden, da die abgeleiteten Klassen zu diesem Zeitpunkt noch nicht erzeugt sind.

Daher wird in diesem Beispiel die Base-Variante der virtuellen Funktion f aufgerufen.

// virtualConstructor.cpp

#include <iostream>

struct Base{
Base(){
f();
}
virtual void f(){
std::cout << "Base called" << std::endl;
}
};

struct Derived: Base{
virtual void f(){
std::cout << "Derived called" << std::endl;
}
};

int main(){

std::cout << std::endl;

Derived d;

std::cout << std::endl;

};

Hier ist die Ausgabe des Programms:

C++ Core Guidelines: Regeln für Konstruktoren

Jetzt werde ich eine Fabrikmethode implementieren, um virtuelles Verhalten während der Objektinitialisierung zu erhalten. Um mit den Besitzverhältnissen richtig umzugehen, sollte die Fabrikmethode einen Smart Pointer wie std::unique_ptr oder std::shared_ptr zurückgeben. Als Startpunkt meiner Implementierung kommt das vorherige Beispiel zum Einsatz. Damit nur Objekte vom Typ Derived erzeugt werden können, setze ich den Konstruktor von Base auf protected.

// virtualInitialisation.cpp

#include <iostream>
#include <memory>

class Base{
protected:
Base() = default;
public:
virtual void f(){ // (1)
std::cout << "Base called" << std::endl;
}
template<class T>
static std::unique_ptr<T> CreateMe(){ // (2)
auto uniq = std::make_unique<T>();
uniq->f(); // (3)
return uniq;
}
virtual ~Base() = default; // (4)
};

struct Derived: Base{
virtual void f(){
std::cout << "Derived called" << std::endl;
}
};


int main(){

std::cout << std::endl;

std::unique_ptr<Base> base = Derived::CreateMe<Derived>(); // (5)

std::cout << std::endl;

};

Als letzter Schritt der Initialisierung soll die virtuelle Funktion f (1) aufgerufen werden. (2) ist die Fabrikmethode. Die Fabrikmethode ruft f auf, nachdem sie einen std::unqiue_ptr erzeugt hat und gibt diesen zurück. Wenn Derived von Base abgeleitet ist, dann ist std::unique_ptr<Derived> implizit nach std::unique_ptr<Base> konvertierbar. So erhalten wir virtuelles Verhalten während der Initialiserung.

C++ Core Guidelines: Regeln für Konstruktoren

Es gibt eine Gefahr beim Einsatz dieser Technik. Falls base seine Gültigkeit verliert, muss sichergestellt sein, dass der Destruktor von Derived aufgerufen wird. Das ist der Grund für den virtuellen Destruktor von Base (4). Wäre der Destruktor nicht virtuell, würde undefiniertes Verhalten resultieren. Seltsam: Wenn ich einen std::shared_ptr anstelle eines std::unique_ptr in der Fabrikmethode verwendet hätte, wäre der virtuelle Destruktor von Base nicht notwendig gewesen.

Sorry, aber der Artikel wurde ein wenig länglich. Aber ich fand gerade die letzte Regel (C.50) sehr interessant. Daher habe ich mehr dazu geschrieben als üblich. Im nächsten Artikel werde ich die Regeln für Konstruktoren abschließen und dann gibt es schon die Regeln für das Kopieren und Verschieben von Objekten.

  • Source Code: Den Source Code zu den ausführbaren Dateien gibt es auf meinem GitHub-Account: ModernesCppSource
  • Aktuelles pdf-Päckchen: Alle 4-6 Wochen veröffentliche ich nach einer Abstimmung ein Päckchen zu meinen bisherigen Artikeln. Diese Päckchen enthält alle Artikel, den Source Code und eine minimale cmake-Datei zu dem gewünschten Thema. Wie der Download funktioniert, habe ich im Artikel "Das neue pdf-Päckchen ist fertig: Embedded: Hohe Sicherheitsanforderungen" beschrieben.
Anzeige