C++: Konstruktoren und Destruktoren

Ein Konstruktor legt fest, welche Aktionen beim Erzeugen eines Objektes ausgeführt werden und sind daher ein zentraler Bestandteil einer Klasse. Wie Methoden können sie überladen werden. Die Eigenschaften von Konstruktoren (und des Destruktors, der beim Löschen eines Objektes aufgerufen wird), einige spezielle Konstruktoren (Default-Konstruktor, Kopier-Konstruktor) sowie das Delegations-Prinzip werden hier vorgestellt.

Inhaltsverzeichnis

Einordnung des Artikels

In den bisherigen Kapiteln über C++: Fortgeschrittene Syntax wurde ein kleines Statistik-Projekt entwickelt, insbesondere wurde in C++: Grundlagen der objektorientierten Programmierung (OOP) eine objekt-orientierte Variante entworfen. Daran werden nun die mit Konstruktoren und Destruktoren verbundenen Konzepte erläutert.

Einführung

Für die Klasse RandomGenerator wurde in dem Statistik-Projekt bereits ein Konstruktor angeboten, nämlich der Konstruktor, der in einer Initialisierungs-Liste die beiden Datenelemente min und max der Klasse RandomGenerator setzt:

RandomGenerator::RandomGenerator(int minimum, int maximum) : min {minimum}, max {maximum}
{
}

Es wurde auch diskutiert, dass dieser Konstruktor gleichwertig ist zu dem — leichter verständlichen — Konstruktor, der die Zuweisungen nutzt:

RandomGenerator::RandomGenerator(int minimum, int maximum)
{
    min = minimum;
    max = maximum;
}

Beachtenswert ist insbesondere, dass der Konstruktor auf die privaten Datenelemente min und max zugreifen darf; es ist nicht nötig, dazu die öffentlichen Setter- und Getter-Methoden zu verwenden (wie bei einem Zugriff von außerhalb der Klasse).

Dieses Beispiel soll in den folgenden Unterabschnitten weiterentwickelt werden.

Überladen von Konstruktoren und das Delegations-Prinzip

Überladen von Konstruktoren

Konstruktoren ähneln in vielerlei Hinsicht Funktionen, insbesondere können sie auch überladen werden. Wie bei Funktionen können weitere Konstruktoren angeboten werden, die sich in der Signatur (also der Parameterliste) unterscheiden müssen.

Beispiel:

Ein weiterer Konstruktor soll für die Klasse RandomGenerator zusätzlich zum bereits bekannten Konstruktor

RandomGenerator(int minimum, int maximum);

testen, ob die beiden Zahlen min und max tatsächlich min < max erfüllen. Genauer:

1. Man kann im neuen Konstruktor eine Boolsche Variable validate setzen.

2. Die Überprüfung der Eingabewerte beinhaltet:

Die Deklaration des neuen Konstruktors erfolgt wieder in RandomGenerator.h:

// Konstruktor zum Setzen von min und max und zusätzlicher Überprüfung der Eingabe
RandomGenerator(int minimum, int maximum, bool validate);

Die Implementierung befindet sich in RandomGenerator.cpp. Der folgende Quelltext erfüllt zwar die oben beschriebenen Anforderungen, ist aber in sehr schlechtem Stil geschrieben:

// Nicht nachahmen!! Der Quelltext enthält leicht vermeidbare Wiederholungen!

// Konstruktor zum Setzen von min und max und zusätzlicher Überprüfung der Eingabe
RandomGenerator::RandomGenerator(int minimum, int maximum, bool validate)
{
    if (!validate)          // keine Überprüfung von minimum und maximum
    {
        min = minimum;
        max = maximum;
    }
    else                // Überprüfung von minimum und maximum
    {
        if (minimum < maximum)
        {
            min = minimum;
            max = maximum;
        }
        else
            if (minimum == maximum)
            {
                cout << "Achtung: minimum == maximum!" << endl;
                min = minimum;
                max = maximum;
            }
            else
            {
                cout << "Achtung: Sie haben minimum > maximum eingegeben!\nDie Zahlen werden richtig sortiert!" << endl;
                max = minimum;
                min = maximum;
            }
    }
}

Werden beide Konstruktoren gleichzeitig angeboten, spricht man davon, dass der Konstruktor überladen ist. Damit gewinnt das Programm an Flexibilität, da man die Konstruktoren so vorbereiten kann, wie später die Objekte der Klasse erzeugt werden sollen.

Dennoch ist das Beispiel nicht nachahmenswert. Warum soll der Konstruktor nicht so aufgebaut werden? Aus zwei Gründen:

Dass man Quelltext-Wiederholungen vermeiden soll, wurde schon für Funktionen erklärt, dies gilt natürlich auch für Konstruktoren. Besser wäre es daher, die sich wiederholenden Quelltexte in eine private Funktion auszulagern und diese Funktion aufzurufen.

Es gibt sogar eine noch bessere Lösung, die darüber hinausgeht: In der Initialisierungs-Liste eines Konstruktors kann ein anderer Konstruktor aufgerufen werden; man nennt dieses Prinzip Delegation, da Aufgaben weitergereicht oder delegiert werden.

Die Delegation

Eine verbesserte Version der obigen Konstruktoren, die die Delegation nutzt, könnte dann so aussehen:

In der Datei mit den Deklarationen stehen die beiden Konstruktoren und eine private Methode init(), die den sich wiederholenden Quelltext beschreibt; die Präprozessor-Direktiven und die restlichen Datenelemente und Methoden sind nicht gezeigt:

// RandomGenerator.h

// includes usw.

class RandomGenerator
{
public:
    
    // Konstruktor zum Setzen von min und max
    RandomGenerator(int minimum, int maximum);

    // Konstruktor zum Setzen von min und max und zusätzlicher Überprüfung der Eingabe
    RandomGenerator(int minimum, int maximum, bool validate);
    
    // weitere Methoden
    
private:

    void init(int minimum, int maximum);
    
    // ...
};

Wie später die Delegation genutzt wird, ist an der Deklaration noch nicht erkennbar.

Die Implementierung zeigt:

// RandomGenerator.cpp

RandomGenerator::RandomGenerator(int minimum, int maximum) : RandomGenerator(minimum, maximum, false)           // Delegation
{
}

// Konstruktor zum Setzen von min und max und zusätzlicher Überprüfung der Eingabe
RandomGenerator::RandomGenerator(int minimum, int maximum, bool validate)
{
    if (!validate)          // keine Überprüfung von minimum und maximum
    {
        init(minimum, maximum);
    }
    else                    // Überprüfung von minimum und maximum
    {
        cout << "Überprüfung" << endl;
        if (minimum < maximum)
        {
            init(minimum, maximum);
        }
        else
            if (minimum == maximum)
            {
                cout << "Achtung: minimum == maximum!" << endl;
                init(minimum, maximum);
            }
            else
            {
                cout << "Achtung: Sie haben minimum > maximum eingegeben!\nDie Zahlen werden richtig sortiert!" << endl;
                init(maximum, minimum);          // Achtung: Vertauschung der Reihenfolge
            }
    }
}

void RandomGenerator::init(int minimum, int maximum)
{
    min = minimum;
    max = maximum;
}

Zur Erklärung:

  1. Die Delegation ist in Zeile 3 erkennbar: wird der einfache Konstruktor RandomGenerator(int minimum, int maximum) aufgerufen, wird validate gleich false gesetzt und damit der Konstruktor RandomGenerator(int minimum, int maximum, false) aufgerufen.
  2. Der Körper des einfachen Konstruktors kann leer bleiben, da alle Aktionen nach dem Weiterreichen erledigt werden (Zeile 4 und 5).
  3. Im Konstruktor RandomGenerator(int minimum, int maximum, bool validate) werden die sich wiederholenden Quelltexte in die private Methode init() ausgelagert, die in Zeile 12, 19, 25, 30) aufgerufen wird.

Der Vorteil des Delegations-Prinzips liegt weniger darin, dass man weniger Schreibarbeit hat. Viel wichtiger ist, dass der Quelltext leichter wartbar wird: Die Aktionen, die in der alten Version im Konstruktor RandomGenerator(minimum, maximum) enthalten waren, sind jetzt in der privaten Methode init() enthalten. Sollte sich die Anforderung an den Konstruktor RandomGenerator(minimum, maximum) einmal ändern, so muss nur der Quelltext von init() angepasst werden. In der nicht nachahmenswerten Version des Konstruktors RandomGenerator(minimum, maximum, bool validate) müsste man alle Stellen aufsuchen, die den Aktionen von init() entsprechen und diese abändern. Vergisst man dabei eine Stelle, werden andere Aktionen ausgeführt, obwohl dies nicht gewünscht ist.

Tip: Wenn Sie Konstruktoren überladen müssen, versuchen Sie die Delegation und private Methoden zu nutzen, um Quelltext-Wiederholungen zu vermeiden. Bei nachträglichen Änderungen des Quelltextes sollten dann die abzuändernden Stellen leicht auffindbar sein.

Gegenbeispiel: Falsche Anwendung der Delegation

Die nicht nachahmenswerte Version des Konstruktors von oben

// Konstruktor zum Setzen von min und max und zusätzlicher Überprüfung der Eingabe
RandomGenerator::RandomGenerator(int minimum, int maximum, bool validate)

verführt dazu, das Delegations-Prinzip falsch einzusetzen.

Das folgende Beispiel zeigt eine Version der beiden Konstruktoren, die auf den ersten Blick identisch ist zur Version, die die Delegation nutzt. Der Konstruktor erfüllt aber nicht die Aufgabe, die man von ihm erwartet.

Gegenbeispiel: Falscher Einsatz des Delegations-Prinzips

Deklaration der Konstruktoren:

// Konstruktor zum Setzen von min und max
// Es wird versucht, mit Hilfe der Delegation Aufgaben an diesen Konstruktor weiterzuleiten
RandomGenerator(int minimum, int maximum);

// Konstruktor zum Setzen von min und max und zusätzlicher Überprüfung der Eingabe
RandomGenerator(int minimum, int maximum, bool validate);

Implementierung der Konstruktoren:

// NICHT NACHAHMEN: Die Konstruktoren arbeiten nicht wie erwartet!!
// Konstruktor mit Initialisierungs-Liste
// Es wird versucht, mit Hilfe der Delegation Aufgaben an diesen Konstruktor weiterzuleiten
RandomGenerator::RandomGenerator(int minimum, int maximum) : min {minimum}, max {maximum}
{
}

// Konstruktor zum Setzen von min und max und zusätzlicher Überprüfung der Eingabe
RandomGenerator::RandomGenerator(int minimum, int maximum, bool validate)
{
    if (!validate)          // keine Überprüfung von minimum und maximum
    {
        RandomGenerator(minimum, maximum);                  // Versuch der Weiterleitung! Warum geht das nicht??
    }
    else                    // Überprüfung von minimum und maximum
    {
        cout << "Überprüfung" << endl;
        if (minimum < maximum)
        {
            RandomGenerator(minimum, maximum);              // Versuch der Weiterleitung (siehe oben)
        }
        else
            if (minimum == maximum)
            {
                cout << "Achtung: minimum == maximum!" << endl;
                RandomGenerator(minimum, maximum);          // Versuch der Weiterleitung (siehe oben)
            }
            else
            {
                cout << "Achtung: Sie haben minimum > maximum eingegeben!\nDie Zahlen werden richtig sortiert!" << endl;
                RandomGenerator(maximum, minimum);          // Achtung: Vertauschung der Reihenfolge
            }
    }
}

In obigem Gegenbeispiel wird versucht, vom Konstruktor mit drei Argumenten die Aufgaben an den Konstruktor mit zwei Argumenten weiterzureichen. Behauptet wird, dass dies nicht der Fall ist — aber warum?

Man muss sich dazu klarmachen, dass der Konstruktor

RandomGenerator(int minimum, int maximum, bool validate)

etwa folgendermaßen (aus der main()-Methode) aufgerufen wird:

RandomGenerator rg(1, 6, true);         // Erzeugen eines Würfels; mit Überprüfung, ob min < max

Diese Programmzeile soll also dafür sorgen, dass das Objekt rg mit Hilfe des Konstruktors (mit drei Argumenten) erzeugt wird. Man kann jetzt leicht nachvollziehen, welcher Fall vorliegt (nämlich validate == true und minimum < maximum) und daher wird Zeile 20 ausgeführt (in der Implementierung der Konstruktoren):

RandomGenerator(minimum, maximum);

Aber was geschieht in Zeile 20? (Und genauso in den Zeilen 13, 26, 31 mit dem Aufruf dieses Konstruktors.) Es wird ein neues Objekt vom Typ RandomGenerator mit Hilfe des Konstruktors mit zwei Argumenten erzeugt. Das heißt die Erzeugung des Objektes rg löst die Erzeugung eines weiteren Objektes aus; dieses liegt zwar irgendwo im Speicher, kann aber nicht durch rg angesprochen werden.

Obige Implementierung der Konstruktoren ist syntaktisch völlig korrekt, die Delegation arbeitet hier aber nicht so wie gewünscht.

Dieses Beispiel zeigt somit auch, warum die Delegation nur in der Initialisierungs-Liste verwendet werden kann.

Der Standard-Konstruktor (Default-Konstruktor)

Die Klasse RandomGenerator besitzt jetzt zwei Konstruktoren, die das Delegations-Prinzip nutzen; ihre Deklarationen lauten:

// Konstruktor zum Setzen von min und max
// Delegation: der Konstruktor ruft den anderen Konstruktor mit validate == false auf
RandomGenerator(int minimum, int maximum);

// Konstruktor zum Setzen von min und max und zusätzlicher Überprüfung der Eingabe
RandomGenerator(int minimum, int maximum, bool validate);

Nach obigen Diskussionen sollte klar sein, warum gerade diese Konstruktoren angeboten werden.

Oftmals wird auch ein zusätzlicher Konstruktor angeboten, der weder Eingabewerte noch irgendwelche auszuführenden Aktionen besitzt. Seine Implementierung würde hier also lauten:

// Default-Konstruktor
RandomGenerator::RandomGenerator()
{
}

Dieser Konstruktor wird meist als der Standard-Konstruktor oder Default-Konstruktor einer Klasse bezeichnet. Er ist so einfach, das es schon wieder schwer zu verstehen ist, warum dieser Konstruktor benötigt werden soll.

Der Default-Konstruktor hat mehrere besondere Eigenschaften:

1. Besitzt eine Klasse keinen Konstruktor, wird der Default-Konstruktor automatisch vom Compiler erzeugt und kann aufgerufen werden.

2. Sobald eine Klasse (mindestens) einen selbtgeschriebenen Konstruktor hat, wird der Default-Konstruktor nicht vom Compiler erzeugt. Wenn man ihn benötigt, muss man ihn selbst implementieren.

3. Der Aufruf des Default-Konstruktors erfolgt ohne runde Klammern (anschließend werden für das neu erzeugte Objekt die Datenelemente min und max gesetzt):

RandomGenerator defaultGenerator;           // Aufruf des Default-Konstruktors
defaultGenerator.setMin(0);                 // Verwendung des erzeugten Objektes
defaultGenerator.setMax(1);                 // min und max werden selbst gesetzt (nicht im Konstruktor)

Da Konstruktoren ähnlich wie Funktionen aufgerufen werden, hätte man vermutlich erwartet:

// Falscher Gebrauch des Default-Konstruktors
RandomGenerator defaultGenerator();         // Compiler-Fehler

Hier stellt sich sofort die Frage: Wozu soll ein Default-Konstruktor eigentlich gut sein, wenn er keine Aktion ausführt? Ein Konstruktor ist doch dazu da ein Objekt zu erzeugen und wie kann dies ohne irgendwelche Aktionen geschehen?

Wird die Frage so gestellt, offenbart sie ein tiefes Missverständnis über die Bedeutung des Konstruktors, die allerdings durch seinen Namen suggeriert wird. Der Aufruf eines Konstruktors (egal ob der Default-Konstruktor oder ein anderer selbstdefinierter Konstruktor aufgerufen wird) besteht immer aus zwei Phasen:

1. Es wird Speicherplatz reserviert für das zu erzeugende Objekt. Da der Compiler die Klasse kennt, weiß er, welche Datenelemente (insbesondere deren Datentypen) die Klasse besitzt und kann diese Aufgabe erledigen.

2. Anschließend wird der Konstruktor aufgerufen. Welcher dies ist, erkennt der Compiler an der Parameterliste des Aufrufs; falls kein Konstruktor vorhanden ist, wird der Default-Konstruktor erzeugt und ausgeführt. Im folgenden Beispiel wird einmal der Konstruktor mit drei Argumenten und dann der Default-Konstruktor (sofern er implementiert ist) aufgerufen:

RandomGenerator rg(1, 6, true);
RandomGenerator defaultGenerator;

Die erste Phase kann nicht von außen beeinflusst werden; nur die Aktionen der zweiten Phase können vorgeschrieben werden.

Mit dem Aufruf des Default-Konstruktors wird also ein Objekt im Speicher vorbereitet, das man über seinen Namen ansprechen kann und das später alle Eigenschaften der Klasse tragen kann, es muss aber erst durch weitere Befehle konfiguriert werden.

Der Zuweisungs-Operator und der Kopier-Konstruktor

Zur Wiederholung: Eigenschaften von fundamentalen Datentypen

Werden zwei Variablen a und b eines fundamentalen Datentyps angelegt, eine initialisiert und der anderen zugewiesen, so sollte klar sein, was passiert. Ebenso wenn eine Referenz auf eine Variable erzeugt wird, etwa wie im folgenden Beispiel:

int a {1};
int b {a};
int & c {a};            // Referenz:    int & c {1};    wäre ein Compiler-Fehler    

cout << "Werte: " << endl;
cout << b << endl;          // 1
cout << c << endl;          // 1

cout << "Gleichheit: " << endl;
cout << (a == b) << endl;       // true
cout << (a == c) << endl;       // true

a = 2;

cout << "Werte: " << endl;
cout << b << endl;          // 1
cout << c << endl;          // 2


cout << "Gleichheit: " << endl;
cout << (a == b) << endl;           // false
cout << (a == c) << endl;           // true

c = 1;

cout << "a = " << a << endl;        // a = 1
cout << "c = " << c << endl;        // c = 1
cout << (a == c) << endl;           // true

Zur Erklärung:

  1. Da b eine Variable ist und keine Referenz auf eine int-Variable, hat die Veränderung von a keinen Einfluss auf b (siehe Zeile 6, 13 und 16).
  2. Werden zwei Variablen a und b auf Gleichheit geprüft, werden die Inhalte des Speichers verglichen (siehe Zeile 10 und 21).
  3. Die Variable c ist als Referenz auf die Variable a definiert (siehe Zeile 3). Man beachte, dass c (als Referenz) mit einer Variable initialisiert werden muss; die Initialisierung mit einem Zahlenwert erzeugt einen Compiler-Fehler.
  4. Die Variable c stimmt zunächst mit a überein (Zeile 7). Wird a verändert (Zeile 13), ändert auch c seinen Wert (Zeile 17).
  5. Gleichheit zwischen a und c besteht immer (Zeile 11 und 22).
  6. Die Beziehung zwischen a und c ist symmetrisch: Ändert man c, wird auch a verändert (Zeile 24 bis 28).

Der Zuweisungs-Operator bei selbstdefinierten Datentypen

Das zuletzt untersuchte Verhalten von fundamentalen Datentypen sollte klar sein. Aber wie verhalten sich selbstdefinierte Datentypen? Wie Variablen von fundamentalen Datentypen oder wie Referenzen? Was passiert bei einer Zuweisung mit Objekten eines selbstdefinierten Datentyps?

Dass Antworten auf diese Fragen nicht so selbstverständlich sind, zeigt folgendes Beispiel:

Beispiel: Kopieren eines Ordners

Die Ordner eines Dateisystems sind baumartig angeordnet:

Wird nun die Kopie eines Ordners erzeugt, muss man zwischen einer flachen und einer tiefen Kopie unterscheiden:

Und warum soll der Nutzer nicht die Möglichkeit haben, selber zu definieren, was bei einer Kopie übertragen wird, zum Beispiel alle Unterordner bis zu einer gewünschten Ordnung.

Um zu klären, wie sich selbstdefinierte Datentypen bei Zuweisungen verhalten, wird obiges Beispiel mit int-Variablen auf selbstdefinierte Datentypen übertragen. Im Statistik-Projekt wurde zum Beispiel die Klasse RandomGenerator definiert, die als Datenelemente min und max besitzt. Was passiert jetzt, wenn man ein Objekt dieser Klasse erzeugt, die beiden Datenelemente setzt und dann dieses Objekt einem anderen zuweist, wie in:

RandomGenerator rg(1, 6);
RandomGenerator dice = rg;

Sind damit auch die Datenelemente min und max für das Objekt dice gesetzt? Ist dice tatsächlich ein neues Objekt oder eine Referenz auf rg? Was passiert also bei einer Veränderung der Datenelemente in rg mit denen in dice?

RandomGenerator rg(1, 6);
RandomGenerator dice = rg;

cout << rg.getMax() << endl;              // 6
cout << dice.getMax() << endl;            // 6

rg.setMax(12);

cout << rg.getMax() << endl;              // 12
cout << dice.getMax() << endl;            // 6

Man sieht an diesem Beispiel:

  1. Durch die Zuweisung in Zeile 2 wird tatsächlich eine tiefe Kopie des Objektes rg erzeugt; denn sonst hätte dice.getMax() einen unbestimmten Wert (Zeile 5).
  2. Die Veränderung, die in Zeile 7 am Objekt rg vorgenommen wird, hat keine Auswirkung auf das Objekt dice; die Zuweisung hat also ein neues Objekt dice als Kopie des alten Objektes rg erzeugt; es wurde nicht nur eine Referenz auf rg erzeugt.

Die Erklärung für dieses Verhalten erfordert etwas Hintergrundwissen: In C++ wird bei der Zuweisung (wie in Zeile 2) für selbstdefinierte Datentypen der sogenannte Kopier-Konstruktor aufgerufen; dieser erzeugt eine Kopie des Objektes auf der rechten Seite der Zuweisung nach folgenden Regeln:

  1. Es wird ein neues Objekt erzeugt.
  2. Die Datenelemente des ursprünglichen Objektes werden kopiert, genauer:
    • haben die Datenelemente fundamentale Datentypen, werden sie kopiert (wie oben bei den fundamentalen Datentypen diskutiert),
    • haben sie selbstdefinierte Datentypen, wird wiederum deren Kopier-Konstruktor aufgerufen (Rekursion!).

Man kann dies leicht überprüfen, indem man Zeile 2 oben ersetzt: anstelle der Zuweisung mit dem Zuweisungs-Operator wird der Kopier-Konstruktor aufgerufen. Alle anderen Anweisungen bleiben gleich:

RandomGenerator rg(1, 6);
RandomGenerator dice(rg);

cout << rg.getMax() << endl;              // 6
cout << dice.getMax() << endl;            // 6

rg.setMax(12);

cout << rg.getMax() << endl;              // 12
cout << dice.getMax() << endl;            // 6

Der Konstruktor mit einem Argument vom Datentyp RandomGenerator ist in der Klasse RandomGenerator nicht definiert; es ist der Kopier-Konstruktor, der automatisch erzeugt wird und der wahlweise mit dem Zuweisungs-Operator oder explizit als Konstruktor aufgerufen werden kann.

Überschreiben des Kopier-Konstruktors

Soll der Kopier-Konstruktor andere Aufgaben erfüllen als der automatisch erzeugte Kopier-Konstruktor, reicht es den geeigneten Kopier-Konstruktor selber zu definieren, man sagt der Kopier-Konstruktor wird überschrieben. Er muss dann nur die richtige Signatur besitzen, um in der Anweisung

RandomGenerator dice(rg);

aufgerufen zu werden.

Im vorliegenden Beispiel bietet sich folgender Kopier-Konstruktor an (zuerst die Deklaration, dann die Implementierung):

// Deklaration des Kopier-Konstruktors in RandomGenerator.h
RandomGenerator(const RandomGenerator & rgToCopy);
// Implementierung des Kopier-Konstruktors in RandomGenerator.cpp
RandomGenerator::RandomGenerator(const RandomGenerator & rgToCopy)
{
    cout << "Kopier-Konstruktor" << endl;
    min = rgToCopy.min;
    max = rgToCopy.max;
}

Zur Erklärung:

  1. Generell muss der Eingabewert vom Datentyp RandomGenerator sein. Da dieses Objekt aber sehr großen Speicherplatz beanspruchen kann, ist es günstiger eine Referenz auf das zu kopierende RandomGenerator-Objekt zu übergeben.
  2. Und da dieses Objekt nicht verändert werden soll, wird es als const gekennzeichnet.
  3. In der Implementierung werden dann die beiden Datenelemente min und max von RandomGenerator gesetzt (siehe Zeile 5 und 6 der Implementierung).
  4. Um zu demonstrieren, dass tatsächlich der selbstdefinierte (und nicht der automatisch erzeugte) Kopier-Konstruktor aufgerufen wird, wird die Konsolen-Ausgabe (Zeile 4) eingefügt.

Der Test (also aufruf des Kopier-Konstruktors wie oben oder der Zuweisung RandomGenerator dice = rg; ) zeigt, dass tatsächlich der selbstdefinierte Kopier-Konstruktor aufgerufen wird und dass das erzeugte Objekt identische Eigenschaften hat wie beim automatisch erzeugten Kopier-Konstruktor.

Damit hat man die Möglichkeit, selbst zu bestimmen, welche Datenelemente beim Kopieren eines Objektes übernommen und welche nicht gesetzt werden (tiefe oder flache Kopie).

Der Kopier-Konstruktor wird nicht nur bei Zuweisungen aufgerufen, er wird auch verwendet, wenn ein Funktions-Aufruf mit call by value realisiert wird.

Aufgabe:

Testen Sie, ob der Kopier-Konstruktor tatsächlich bei einem Funktions-Aufruf mit call by value aufgerufen wird.

Und dass er bei call by reference nicht aufgerufen wird.

Der Destruktor

Der Destruktor ist das Gegenstück zum Konstruktor. Und so wie der Konstruktor nicht das Objekt erzeugt, sondern aufgerufen wird, nachdem das Objekt erzeugt wurde, ist auch der Destruktor nicht für die Zerstörung eines Objektes zuständig. Vielmehr wird der Destruktor aufgerufen, wenn der Gültigkeitsbereich eines Objektes verlassen wird. Der Compiler sorgt dafür, dass der Aufruf des Destruktors zum richtigen Zeitpunkt geschieht — nämlich unmittelbar bevor der Speicherplatz des Objektes freigegeben wird. Letzteres wird wieder von Compiler erledigt und kann nicht beeinflusst werden.

Deklaration und Implementierung (hier ohne Befehle) des Destruktors für die Klasse RandomGenerator sehen wie folgt aus:

// Deklaration in RandomGenerator.h
::~RandomGenerator();

// Implementierung in RandomGenerator.cpp
RandomGenerator::~RandomGenerator()
{
}

Das Symbol ~ soll an das bitweise Komplement erinnern, da der Destruktor das Komplement zum Konstruktor ist.

Ein expliziter Aufruf des Destruktors ist möglich, führt aber nicht dazu, dass das Objekt zerstört wird, es werden einfach die Befehle des Destruktors ausgeführt:

rg.~RandomGenerator();       // Aufruf des Destruktors

Es ist gar nicht so leicht, sich ein Szenario vorzustellen, in dem man den Destruktor mit sinnvollen Aktionen ausstatten kann — wenn das Objekt sowieso zerstört wird, warum soll man dann noch etwas damit tun?

Bei den bisher behandelten Programmier-Techniken benötigt man den Destruktor nicht. Allerdings hat man in C++ auch die Möglichkeit, selber über den Speicherplatz zu verfügen — und dann ist der Einsatz von Destruktoren geboten.

Was ist damit gemeint? Die Klasse std::vector ist ein Beispiel für eine Klasse, die in der Lage ist, den Speicherplatz dynamisch (also während der Programm-Ausführung) zu verwalten: werden an ein vector-Objekt Elemente angehängt oder beseitigt, wird der Speicher automatisch angepasst. Man kann sich solche Klassen mit dynamischer Speicherplatz-Verwaltung auch selber programmieren. Umgekehrt ist man dann aber auch selber dafür verantwortlich, den Speicherplatz wieder freizugeben, falls ein Objekt zerstört wird. Macht man dies nicht, kann leicht ein Speicher-Leck (memory leak) entstehen.