Mit der Konstruktion beginnt, mit der Destruktion endet der Lebenszyklus eines Objekts. Da trifft es sich gut, dass Entwickler die Zutaten für diesen wichtigen Prozess in C++ sehr exakt aussuchen dürfen.
In C++ definiert der Entwickler eine Klasse wahlweise mit den Schlüsselwörtern »struct« oder »class«. Die Unterschiede sind minimal, aber dennoch wichtig. Der Grund: Eine »struct« macht alle Klassenmitglieder standardmäßig öffentlich (»public«), bei einer »class« hingegen bleiben sie »private«.
Einen weiteren feinen Unterschied gibt es, der Vollständigkeit halber, beim Vererben: Während eine »struct« mit ihrem Erbe an die Öffentlichkeit geht (»public«), verteilt »class« das Erbe nur im privaten Umfeld (»private«).
Dem Entwickler stellt sich nun natürlich die Frage: Wann soll er eine »struct«, wann eine »class« verwenden, um eine Klasse zu definieren? Die Regel C.2 [1] der C++ Core Guidelines gibt darauf eine plausible Antwort: “Benutze »class«, wenn die Klasse eine Invariante hat; benutze »struct«, wenn die Data Members jeden Wert annehmen können.”
Listing 1
struct versus class
01 struct Pair { // the members can vary independently
02 string name;
03 int volume;
04 };
05
06 class Date {
07 public:
08 // validate that {yy, mm, dd} is a valid date and initialize
09 Date(int yy, int mm, char dd);
10 // [...]
11 private:
12 int y;
13 int m;
14 char d; // day
15 };
Nächste Frage: Was ist eine Invariante? Dabei handelt es sich um eine logische Bedingung, die der Entwickler typischerweise im Konstruktor etabliert. Listing 1 zeigt ein einfaches Beispiel. Die Klasse »Date« besitzt die Invarianten »y«, »m« sowie »d«, die der Konstruktor initialisiert. Er prüft auch, welche Werte für Jahr, Monat und Tag zulässig sind.
Der Datentyp »Pair« bringt hingegen keine Invariante mit. Programmierer dürfen hier alle möglichen Werte für »name« und »volume« verwenden. Konsequenterweise sind die Data Members öffentlich (»public«), und es kommt eine Struktur (»struct«) zum Einsatz.
Nach dieser kleiner Aufwärmrunde geht es jetzt aber zur Sache. Es gilt, Objekte zu erzeugen.
Konstruktoren
Die Aufgabe eines Konstruktors besteht darin, vollkommen initialisierte Objekte zu erzeugen. Was erst mal trivial klingt, erweist sich in der Praxis jedoch als Herausforderung.
Zuerst muss der Entwickler die Frage beantworten, wer den Konstruktor definieren soll. Genügt es, einen von der C++-Laufzeit erzeugten Konstruktor zu verwenden, oder ist es nötig, selbst Hand anzulegen? Die Antwort auf diese Frage liefert die Regel C.40 [2]: “Definiere einen Konstruktor, wenn eine Klasse eine Invariante hat.” Die Aufgabe eines Konstruktors besteht also darin, die Invariante für das Objekt zu etablieren. Doch das ist nur ein Teil der Antwort.
Eine weitere Frage lautet: Wann benötigt eine Klasse einen Default-Konstruktor? Dabei handelt es sich um einen speziellen Konstruktor, den das Programm ohne Argumente aufrufen kann. Um diese Frage zu beantworten, hilft ein Blick auf die Klasse »Date« aus Listing 1. Was ist ein vernünftiger Defaultwert für ein Datum? Es gibt keinen. Daher ergibt ein Default-Konstruktor an dieser Stelle keinen Sinn. Anders verhält es sich mit dem »BankAccount« in Listing 2. Für ein Bankkonto gilt ein leeres Konto als ein sinnvoller Startwert.
Listing 2
Ein einfaches Bankkonto
01 class BankAccount{
02 public:
03 BankAccount(double amt = 0.0): balance(amt){}
04 private:
05 double balance;
06 [...]
07 };
08
09 BankAccount acc1;
10 BankAccount acc2(5.5);
Ein Konstruktor soll vollständig initialisierte Objekte zurückgeben, was der Konstruktor in Listing 2 auch tut. Das aber klappt mit modernem C++ auch noch deutlich eleganter. Regel C.45 [3] erklärt das Konzept: “Deklariere keinen Default-Konstruktor, der allein Data Members initialisiert. Benutze stattdessen klasseninterne Member Initializer.”
Seit C++11 sollen Entwickler alle Klassenmitglieder direkt in der Klasse initialisieren. Das vereinfacht die Klassenschnittstellen deutlich und macht es schwieriger, sie falsch zu verwenden.
Listing 3
Direktes Initialisieren der Klassenmitglieder
01 #include <iostream>
02
03 class Widget{
04 public:
05 Widget(): width(640), height(480), frame(false), visible(true) {}
06 explicit Widget(int w): width(w), height(getHeight(w)), frame(false), visible(true){}
07 Widget(int w, int h): width(w), height(h), frame(false), visible(true){}
08
09 void show(){ std::cout << std::boolalpha << width << "x" << height
10 << ", frame: " << frame << ", visible: " << visible
11 << std::endl;
12 }
13 private:
14 int getHeight(int w){ return w*3/4; }
15 int width;
16 int height;
17 bool frame;
18 bool visible;
19 };
20
21 class WidgetImpro{
22 public:
23 WidgetImpro(){}
24 explicit WidgetImpro(int w): width(w), height(getHeight(w)){}
25 WidgetImpro(int w, int h): width(w), height(h){}
26
27 void show(){ std::cout << std::boolalpha << width << "x" << height
28 << ", frame: " << frame << ", visible: " << visible
29 << std::endl;
30 }
31
32 private:
33 int getHeight(int w){ return w * 3 / 4; }
34 int width = 640;
35 int height = 480;
36 bool frame = false;
37 bool visible = true;
38 };
39
40
41 int main(){
42
43 std::cout << std::endl;
44
45 Widget wVGA;
46 Widget wSVGA(800);
47 Widget wHD(1280, 720);
48
49 wVGA.show();
50 wSVGA.show();
51 wHD.show();
52
53 std::cout << std::endl;
54
55 WidgetImpro wImproVGA;
56 WidgetImpro wImproSVGA(800);
57 WidgetImpro wImproHD(1280, 720);
58
59 wImproVGA.show();
60 wImproSVGA.show();
61 wImproHD.show();
62
63 std::cout << std::endl;
64
65 }
Listing 3 hinterlegt Regel C.45 mit einem Beispiel. Wie Abbildung 1 zeigt, verhalten sich die beiden Klassen »Widget« und »WidgetImpro« identisch. Auch die anderen Konstruktoren (Zeilen 24 und 25) profitieren von der vereinfachten Initialisierung im Klassenkörper (Zeilen 34 bis 37). Setzt der Programmierer den Wert eines Klassenmitglieds im Konstruktor (wie in Zeile 24), besitzt er eine höhere Priorität als der Defaultwert im Klassenkörper. Genauso bietet es sich an, die Initialisierung im Klassenkörper zu starten:
- Der Entwickler bestimmt das Standardverhalten jedes Klassenmitglieds in der Klasse.
- Abweichungen vom Standardverhalten reicht er an Konstruktoren weiter.
Damit gewährleistet er, dass er keine Instanz einer Klasse unvollständig initialisiert, wenn er einen neuen Konstruktor nachträglich ergänzt. Zudem benötigen die Konstruktoren weniger Argumente und er muss sie zum Teil auch nicht implementieren.

Abbildung 1: Direktes Initialisieren der Klassenmitglieder: Die Klassen »Widget« und »WidgetImpro« verhalten sich gleich.
Nur der Vollständigkeit halber: Der Konstruktor in Zeile 24 ist als »explicit« ausgezeichnet. Das entspricht dem Wortlaut der Regel C.46 [4]: “Erkläre nur mit einem Argument deklarierte Konstruktoren standardmäßig als »explicit«.” Kennzeichnet der Programmierer einen Konstruktor nicht als »explicit«, wendet die C++-Laufzeit gegebenenfalls implizite Typkonvertierungen an. Als Konsequenz wäre der Aufruf in Listing 4 gültig und würde einen String der Länge 10 erzeugen.
Listing 4
Implizite Konvertierungen
01 class String {
02 public:
03 explicit String(int); // explicit
04 // String(int); // implicit
05 };
06
07 String s = 10; // error because of explicit
08
Die Nuller- oder Sechser- und die Fünferregel
Die Nuller- und die Sechserregel (C.20 und C.21) besagen, dass ein Entwickler entweder alle oder keine der folgenden speziellen Methoden implementieren soll: Default-Konstruktor, Copy- und Move-Konstruktor, Copy- und Move-Zuweisungsoperator und Destruktor. Doch wo bleibt die Fünferregel (C.22)? Einige Entwickler schließen den Default-Konstruktor nicht in diese Regel ein. Der Übersichtlichkeit halber zeigt Abbildung 2 die Regeln C.20, C.21 und C.22.
Der Grund für die Nullerregel ist simpel. Ist eine Klasse hinreichend einfach, erzeugt der Compiler automatisch alle der sechs wichtigen Methoden. Als hinreichend einfach gilt die Klasse, wenn sie nur Mitglieder und Basisklassen besitzt, die die sechs Operationen automatisch unterstützen.
Dieses Verhalten lässt sich zum Beispiel am Copy-Konstruktor erklären. Generiert der Compiler den Copy-Konstruktor automatisch, delegiert er den Copy-Aufruf direkt an alle Mitglieder und Basisklassen weiter. Eine analoge Delegationsstrategie verfolgt er bei den fünf weiteren speziellen Methoden.
Eine Klasse ist auch dann hinreichend einfach, wenn skalare Datentypen wie Wahrheitswerte, Zahlen und Fließkommazahlen zum Einsatz kommen. Das ist auch der Fall, wenn die Klasse Container aus der Standard-Template-Bibliothek verwendet, etwa einen »std::vector« oder eine »std::map«.
Komplizierter wird es, wenn die Klasse Zeiger oder Referenzen im Gepäck hat. Verpackt sie einen Zeiger in einem »std::shared_ptr«, klappt es mit den automatisch erzeugten Methoden, da »std::shared_ptr« kopierbar sind. Wickelt sie den Zeiger hingegen in einen »std::unique_ptr«, lassen sich Instanzen der Klasse nicht mehr kopieren. Abhilfe schafft dann der Referenzwrapper »std::reference_wrapper« [5]. In diesem verpackt der Programmierer eine Referenz, die kopierbar ist.
Helfen die ganzen Automatismen nicht weiter, ist Arbeit angesagt. In diesem Fall muss der Entwickler meist alle sechs besonderen Methoden implementieren. Oder er fordert diese, falls dies möglich ist, mit »default« vom Compiler an. In diesem Fall gibt es keine einfache Faustregel mehr.
Destruktoren
Damit kommt der Artikel bereits zum Ende des Lebenszyklus eines Objekts. Die ersten beiden dafür zuständigen Regeln, C.30 und C.31, erweisen sich als leicht verständlich (Abbildung 3). Der Entwickler sollte sie dennoch beim Klassenentwurf berücksichtigen.
Eine Klasse besitzt gegebenenfalls Ressourcen wie Speicher, Locks oder Sockets. Diese fordert der Entwickler über den Konstruktor an und gibt sie im Destruktor wieder frei. Landet das Objekt dann noch auf dem Stack, räumt es der Compiler automatisch ab. Dieses bewährte Idiom in C++ ist unter dem Namen RAII [6] bekannt.
Als deutlich interessanter entpuppt sich die Regel C.35: “Der Destructor einer Basisklasse sollte entweder öffentlich und virtuell oder geschützt und nicht-virtuell sein.” Gegen sie verstoßen Entwickler, offen gesagt, häufig. Die Regel gewinnt vor allem dann an Gewicht, wenn eine Klasse virtuelle Methoden besitzt. Dies tritt in zwei Fällen ein.
“public” und virtueller Destruktor
Verfügt eine Klasse über einen öffentlichen (»public«) und virtuellen Destruktor, lässt sich eine Instanz einer abgeleiteten Klasse durch einen Zeiger auf die Basisklasse löschen. Dasselbe gilt für Referenzen. Listing 5 stellt das Verhalten eines öffentlichen, aber nicht-virtuellen Destruktors vor.
Listing 5
Ein nicht-virtueller Destruktor
01 struct Base { // no virtual destructor
02 virtual void f(){};
03 };
04
05 struct Derived : Base {
06 string s {"a resource needing cleanup"};
07 ~D() { /* ... do some cleanup ... */ }
08 };
09
10 [...]
11
12 Base* b = new Derived();
13 delete b;
Das Unheil nimmt seinen Lauf. Der Compiler erzeugt automatisch für »Base« einen nicht-virtuellen Destruktor. Das Löschen eines Objekts vom Typ »Derived« über einen Zeiger auf die Basisklasse »Base« führt aber zu undefiniertem Verhalten, falls der Destruktor der Basisklasse nicht-virtuell ist.
Geschützter und nicht-virtueller Destruktor
Als einfacher erweist es sich, wenn der Destruktor der Basisklasse geschützt (»protected«) ist. In diesem Fall darf C++ eine Instanz einer abgeleiteten Klasse nicht über einen Zeiger auf die Basisklasse löschen. Daher muss der Zeiger nicht-virtuell sein.
Der Punkt zu den Datentypen (keine Zeiger und Referenzen) lässt sich jedoch noch ein wenig weiter ausreizen. Die Faustregeln lauten in diesem Fall: Ist der Destruktor einer Klasse »Base« privat, lässt sich der Datentyp nicht verwenden. Ist der Destruktor einer Klasse »Base« geschützt (»protected«), lässt sich zwar »Derived« von »Base« ableiten – der Entwickler darf aber nur Instanzen vom Typ »Derived« verwenden.
Listing 6
Private und geschützte Destruktoren der Basisklasse
01 struct Base{
02 protected:
03 ~Base() = default;
04 };
05
06 struct Derived: Base{};
07
08 int main(){
09 Base b; // Error: Base::~Base is protected within this context
10 Derived d;
11 }
Die letzten beiden Punkte demonstriert Listing 6 eindrucksvoll. Den Versuch, die Klasse »Base« zu erzeugen, quittiert der Compiler prompt mit einer Fehlermeldung.
Wie geht’s weiter?
Objektorientiertes Design besteht vor allem darin, das Interface und die Implementierung voneinander zu trennen. Damit gerät auch schon das Thema des kommenden Artikels in den Fokus: Klassenhierarchien.
Infos
-
Regel C.2: http://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rc-struct
-
Regel C.40: http://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rc-ctor
-
Regel C.45: http://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rc-default
-
Regel C.46: https://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines#Rc-explicit
-
Referenzwrapper: https://en.cppreference.com/w/cpp/utility/functional/reference_wrapper
-
RAII: https://en.wikipedia.org/wiki/Resource_acquisition_is_initialization








