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.
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.
Anzeige
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.
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 // ... };
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.
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.
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 // ... };
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.
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.
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.
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.
Wie geht's weiter?
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.
Weitere Informationen
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.