Zum Inhalt springen

C++-Programmierung/ Eigene Datentypen definieren/ Erstellen und Zerstören

Aus Wikibooks


In C++-Klassen gibt es zwei besondere Arten von Methoden: Konstruktoren und den Destruktor. Ein Konstruktor wird beim Anlegen eines Objektes ausgeführt, der Destruktor vor der „Zerstörung“ desselben. Der Name des Konstruktors ist immer gleich dem Klassennamen, der Destruktor entspricht ebenfalls dem Klassennamen, jedoch mit einer führenden Tilde (~).

Konstruktoren und Destruktoren haben keinen Rückgabetyp, auch nicht void. Der Konstruktor kann nicht als Methode aufgerufen werden, beim Destruktor ist dies hingegen möglich (aber nur selten nötig, dazu später mehr).

Konstruktor

[Bearbeiten]

Jede Klasse hat einen oder mehrere Konstruktoren. Ein solcher Konstruktor dient zur Initialisierung eines Objektes. Im Folgenden wird eine Klasse Bruch angelegt, die über den Konstruktor die Membervariablen zaehler_ und nenner_ initialisiert.

class Bruch{
public:
    Bruch(int z, int n):
        zaehler_(z), // Initialisierung von zaehler_
        nenner_(n)   // Initialisierung von nenner_
        {}

private:
    int zaehler_;
    int nenner_;
};

int main(){
    Bruch objekt(7, 10); // Initialisierung von objekt
}

Wie Sie sehen, ist der Methodenrumpf von Bruch::Bruch leer. Die Initialisierung findet in den beiden Zeilen über dem Rumpf statt. Nach dem Prototyp wird ein Doppelpunkt geschrieben, darauf folgt eine Liste der Werte, die initialisiert werden sollen. Vielleicht erinnern Sie sich noch, dass es zur Initialisierung zwei syntaktische Varianten gibt:

int wert(8);
int wert = 8;

Innerhalb der Initialisierungsliste ist nur die erste Variante zulässig. Auch in der Hauptfunktion main() findet eine Initialisierung statt. Hier könnte die zweite Variante verwendet werden, jedoch besitzt der Konstruktor zwei Parameter, daher ist die Variante mit dem Gleichheitszeichen nicht sofort offensichtlich. Dieses Thema wird am Ende des Kapitels genauer besprochen, da zum Verständnis dessen ein wenig Hintergrundwissen nötig ist, das im Verlauf des Kapitels vermittelt wird.

Hinweis

Beachten Sie, dass die Initialisierung der Variablen einer Klasse in der Reihenfolge erfolgt, in der sie in der Klasse deklariert wurden. Die Initialisierungsreihenfolge ist unabhängig von der Reihenfolge, in der sie in der Initialisierungsliste angegeben wurden. Viele Compiler warnen, wenn man so etwas macht, da derartiger Code möglicherweise nicht das tut, was man erwartet.

Natürlich ist es wie bei Funktionen möglich und in der Regel zu empfehlen, die Methodendeklaration von der Methodendefinition zu trennen. Die Definition sieht genau so aus, wie bei einer normalen Funktion. Der einzige auffällige Unterschied besteht darin, dass dem Methodennamen der Klassenname vorangestellt wird, getrennt durch den Bereichsoperator (::).

class Bruch{
public:
    Bruch(int z, int n);     // Deklaration

private:
    int zaehler_;
    int nenner_;
};

Bruch::Bruch(int z, int n): // Definition
    zaehler_(z),            // Initialisierung von zaehler_
    nenner_(n)              // Initialisierung von nenner_
    {}

int main(){
    Bruch objekt(7, 10);     // Der Bruch 7/10 oder auch 0.7
}

Wie bereits erwähnt, hat der Konstruktor keinen Rückgabetyp, daher wird auch in der Definition keiner angegeben. Bei den Basisdatentypen ist es bezüglich der Performance übrigens egal, ob Sie diese initialisieren oder zuweisen. Sie könnten also den gleichen Effekt erzielen, wenn Sie statt der Initialisierungsliste eine Zuweisung im Funktionsrumpf benutzen. Allerdings gilt dies wirklich nur für die Basisdatentypen, bei komplexen Datentypen ist die Initialisierung oft deutlich schneller. Außerdem können konstante Variablen ausschließlich über die Initialisierungsliste einen Wert erhalten. Nun aber noch mal ein einfaches Beispiel, in dem der Funktionsrumpf des Konstruktors zur Anfangswertzuweisung dient:

class Bruch{
public:
    Bruch(int z, int n); // Deklaration

private:
    int zaehler_;
    int nenner_;
};

Bruch::Bruch(int z, int n){  // Definition
    zaehler_ = z; // Zuweisung
    nenner_  = n; // Zuweisung
}

Wie Sie sehen, entfällt der Doppelpunkt, wenn Sie die Initialisierungsliste nicht nutzen. Natürlich können Sie auch Initialisierungsliste und Funktionsrumpf parallel benutzen.

Irgendwann werden Sie sicher einmal in die Verlegenheit kommen, ein Array als Membervariable innerhalb Ihrer Klasse zu deklarieren. Leider gibt es keine Möglichkeit, Arrays zu initialisieren, sie müssen immer im Konstruktorrumpf mittels Zuweisung ihren Anfangswert erhalten. Entsprechend ist es auch nicht möglich, ein Memberarray mit konstanten Daten zu erstellen. Wenn Sie also zwingend eine Initialisierung benötigen, müssen Sie wohl oder übel Einzelvariablen erstellen oder auf eine array-ähnliche Klasse zurückgreifen. std::vector aus der Standard-Headerdatei vector ist meist eine gute Alternative. Um Performanceeinbußen zu verhindern, müssten Sie das std::vector-Objekt zunächst mittels der Methode reserve(n) anweisen, für n Elemente Speicher zu alloziieren und anschließend die Elemente mittels der Methode push_back(element) hinzufügen. Beides passiert natürlich im Konstruktorrumpf, in der Initialisierungsliste müssen Sie keine Angaben machen.

Defaultparameter

[Bearbeiten]

Sie können einem Konstruktor ebenso Defaultparameter vorgeben, wie einer gewöhnlichen Funktion. Die syntaktischen Regeln sind identisch.

class Bruch{
public:
    Bruch(int z, int n = 1); // Deklaration mit Defaultwert

private:
    int zaehler_;
    int nenner_;
};

Bruch::Bruch(int z, int n):  // Definition
    zaehler_(z),
    nenner_(n)
    {}

Mehrere Konstruktoren

[Bearbeiten]

Auch das Überladen des Konstruktors funktioniert wie das Überladen einer Funktion. Deklarieren Sie mehrere Konstruktoren innerhalb der Klasse und schreiben für jeden eine Definition:

class Bruch{
public:
    Bruch(int z);        // Deklaration
    Bruch(int z, int n); // Deklaration

private:
    int zaehler_;
    int nenner_;
};

Bruch::Bruch(int z):        // Definition
    zaehler_(z),
    nenner_(1)
    {}

Bruch::Bruch(int z, int n): // Definition
    zaehler_(z),
    nenner_(n)
    {}

Wenn mehrere Konstruktoren das gleiche tun, ist es oft sinnvoll, diese gleichen Teile in eine eigene Methode (üblicherweise mit dem Namen init()) zu schreiben. Das ist kürzer, übersichtlicher, meist schneller und hilft auch noch bei der Vermeidung von Fehlern. Denn wenn Sie den Code nachträglich ändern, dann müssten Sie diese Änderungen für jeden Konstruktor vornehmen. Nutzen Sie eine init()-Methode, müssen Sie den Code nur in dieser ändern.

class A{
public:
    A(double x);
    A(int x);

private:
    void init();

    int x_;
    int y_;
};

A::A(double x):
    x_(x) {
    init();
    }

A::A(int x):
    x_(x) {
    init();
    }

void A::init(){
    y_ = 7000;
}

Dieses Beispiel ist zugegebenermaßen ziemlich sinnfrei, aber das Prinzip der init()-Funktion wird deutlich. Wenn Sie sich nun irgendwann entscheiden, y_ den Anfangswert 3333 zuzuweisen, dann müssen Sie dies nur in der init()-Funktion ändern. Die Konstruktoren bleiben unverändert.

Standardkonstruktor

[Bearbeiten]

Als Standardkonstruktor bezeichnet man einen Konstruktor, der keine Parameter erwartet. Ein Standardkonstruktor für unsere Bruch-Klasse könnte folgendermaßen aussehen:

class Bruch{
public:
    Bruch();      // Deklaration Standardkonstruktor

private:
    int zaehler_;
    int nenner_;
};

Bruch::Bruch():   // Definition Standardkonstruktor
    zaehler_(0),
    nenner_(1)
    {}

Natürlich könnten wir in diesem Beispiel den Konstruktor überladen, um neben dem Standardkonstruktor auch die Möglichkeiten zur Initialisierung mit beliebigen Werten zu haben. Es bietet sich hier jedoch an, dafür Defaultparameter zu benutzen. Das folgende kleine Beispiel erlaubt die Initialisierung mit einer ganzen Zahl und mit einer gebrochenen Zahl (also zwei Ganzzahlen: Zähler und Nenner). Außerdem wird auch gleich noch der Standardkonstruktor bereitgestellt, welcher den Bruch mit 0 initialisiert, also den Zähler auf 0 und den Nenner auf 1 setzt.

class Bruch{
public:
    // Erlaubt 3 verschiedene Aufrufmöglichkeiten, darunter die des Standardkonstruktors
    Bruch(int z = 0, int n = 1);

private:
    int zaehler_;
    int nenner_;
};

Bruch::Bruch(int z, int n): // Definition des Konstruktors
    zaehler_(z),
    nenner_(n)
    {}

Im Kapitel „Leere Klassen?“ werden Sie noch einiges mehr über den Standardkonstruktor erfahren.

Kopierkonstruktor

[Bearbeiten]

Neben dem Standardkonstruktor gibt es einen weiteren speziellen Konstruktor, den Kopierkonstruktor: er erstellt ein Objekt anhand eines bereits vorhandenen Objektes. Der Parameter des Kopierkonstruktors ist also immer eine Referenz auf ein konstantes Objekt derselben Klasse. Der Kopierkonstruktor unserer Bruch-Klasse hat folgende Deklaration:

Bruch::Bruch(Bruch const& bruch_objekt);

Beachten Sie, dass ein Kopierkonstruktor immer eine Referenz auf ein Objekt (meist ein konstantes Objekt) übernimmt. Würde an dieser Stelle eine „call-by-value“-Übergabe stehen, müsste für die Objektübergabe an den Kopierkonstruktor ja selbst das Objekt zunächst (mittels Kopierkonstruktor) kopiert werden. Dies führt zu einer Endlosrekursion beim Kompilieren und ist somit verboten.

Wenn wir keinen eigenen Kopierkonstruktor schreiben, erstellt der Compiler einen für uns. Dieser implizite Kopierkonstruktor initialisiert alle Membervariablen mit den entsprechenden Werten der Membervariablen im übergebenen Objekt. Für den Moment ist diese vom Compiler erzeugte Variante ausreichend. Später werden Sie Gründe kennenlernen, die es notwendig machen, einen eigenen Kopierkonstruktor zu schreiben. Wenn Sie die Nutzung des Kopierkonstruktors verbieten wollen, dann schreiben Sie seine Deklaration in den private-Bereich der Klassendeklaration. Das bewirkt einen Kompilierfehler, wenn eine nicht-Klassen-Funktion versucht den Kopierkonstruktor aufzurufen.

Neben dem Kopierkonstruktor erzeugt der Compiler übrigens auch noch eine Kopierzuweisung, auch zu dieser werden Sie später noch mehr erfahren. Auch diese können Sie verbieten, indem Sie sie im private deklarieren. Wie dies geht, erfahren Sie im Kapitel zu Operatorüberladung.

Destruktor

[Bearbeiten]

Im Gegensatz zum Konstruktor, gibt es beim Destruktor immer nur einen pro Klasse. Das liegt daran, dass ein Destruktor keine Parameter übergeben bekommt. Sein Aufruf erfolgt in der Regel implizit durch den Compiler bei der Zerstörung eines Objektes. Für den Anfang werden Sie mit dem Destruktor wahrscheinlich nicht viel anfangen können, da es mit den Mitteln, die Sie bis jetzt kennen, kaum nötig werden kann, dass bei der Zerstörung eines Objektes Aufräumarbeiten ausgeführt werden. Das folgende kleine Beispiel enthält einen Destruktor, der einfach gar nichts tut:

class A{
public:
    ~A(); // Deklaration Destruktor
};

A::~A(){  // Definition Destruktor
    // Hier könnten "Aufräumarbeiten" ausgeführt werden
}

Wenn Sie den Abschnitt über Speicherverwaltung gelesen haben, werden Sie wissen, wie nützlich der Destruktor ist. Im Moment reicht es, wenn Sie mal von ihm gehört haben. Auch über den Destruktor werden Sie im Kapitel „Leere Klassen?“ weitere Informationen erhalten.

Beispiel mit Ausgabe

[Bearbeiten]

Um noch einmal deutlich zu machen, an welchen Stellen Konstruktor und Destruktor aufgerufen werden, geben wir einfach innerhalb der Methoden eine Nachricht aus:

#include <iostream>

class A{
public:
    A();          // Deklaration Konstruktor
    ~A();         // Deklaration Destruktor

    void print(); // Deklaration einer Methode
};

A::A(){           // Definition Konstruktor
    std::cout << "Innerhalb des Konstruktors" << std::endl;
}

A::~A(){          // Definition Destruktor
    std::cout << "Innerhalb des Destruktors" << std::endl;
}

void A::print(){  // Definition der print()-Methode
    std::cout << "Innerhalb der Methode print()" << std::endl;
}

int main(){
    A objekt;       // Anlegen des Objekts == Konstruktoraufruf
    objekt.print(); // Aufruf der Methode print()
    return 0;
}                   // Ende von main(), objekt wird zerstört == Destruktoraufruf
Ausgabe:
Innerhalb des Konstruktors
Innerhalb der Methode print()
Innerhalb des Destruktors

Explizite Konstruktoren

[Bearbeiten]

Bevor wir den Begriff des expliziten Konstruktors klären, soll kurz auf die oben angesprochene zweite syntaktische Möglichkeit zum Konstruktoraufruf eingegangen werden.

class Bruch{
public:
    Bruch(int z = 0, int n = 1);
// ...
};

int main(){
    Bruch objekt1 = Bruch(7, 10); // Bruch objekt1(7, 10);
    Bruch objekt2 = Bruch(7);     // Bruch objekt2(7);
    Bruch objekt3 = Bruch();      // Bruch objekt3;
}

Diese Variante, einen Konstruktor aufzurufen, ist absolut gleichwertig zu der, die im Kommentar steht. Beachten Sie an dieser Stelle, dass hier keineswegs ein Kopierkonstruktor aufgerufen wird, es handelt sich tatsächlich nur um eine andere syntaktische Möglichkeit. Im folgenden Beispiel wird eine Klasse String definiert, welche über 2 Konstruktoren verfügt:

#include <iostream>

class String{
public:
    String(char const* zeichenkette);
    String(int anzahl, char zeichen = '*');

    char feld[80];
};

String::String(char const* zeichenkette){
    char* i = feld;
    while(*zeichenkette != '\0'){
        *i++ = *zeichenkette++;
    }
    *i = '\0';
}

String::String(int anzahl, char zeichen){
    for(int i = 0; i < anzahl; ++i)
        feld[i] = zeichen;
    feld[anzahl] = '\0';
}

int main(){
    String str1 = "Ein Text";
    String str2 = String(5, 'Z');
    String str3 = 'A';

    std::cout << str1.feld << std::endl;
    std::cout << str2.feld << std::endl;
    std::cout << str3.feld << std::endl;
}
Ausgabe:
Ein Text
ZZZZZ
*****************************************************************

Auf die Implementierung der Konstruktoren soll an dieser Stelle nicht eingegangen werden und natürlich wäre feld in einer echten Klasse privat. Der erste Konstruktor erzeugt ein Objekt anhand einer Zeichenkette. Der zweite Konstruktor kann mit einem oder mit zwei Parametern aufgerufen werden. Er erzeugt einen String, der aus anzahl zeichen besteht.

Sicher wird Ihnen auffallen, dass für die Initialisierung von str1 und str3 zwar das Gleichheitszeichen verwendet wird, dahinter jedoch kein Objekt der Klasse String folgt. Für diese syntaktische Möglichkeit muss der Compiler eine sogenannte implizite Konvertierung durchführen. Falls die Klasse über einen Konstruktor mit einem Parameter verfügt, der zu dem des übergebenen Objekts kompatibel ist, wird dieser Konstruktor verwendet. Kompatibel bedeutet, dass sich das übergebene Objekt in maximal einem Schritt in ein Objekt mit dem Typ des Parameters umwandeln lässt. Bei str3 wird ein Objekt vom Typ char const übergeben. Der Compiler findet in der Klasse einen Konstruktor, der mit einem int aufgerufen werden kann. Ein char lässt sich direkt in einen int umwandeln. Daher wählt der Compiler diesen Konstruktor aus. Analog wird für str1 ein Objekt vom Typ char const[9] übergeben und dieses ist zu char const* kompatibel.

Es ist im Falle von str3 jedoch sehr wahrscheinlich, dass eigentlich ein String definiert werden sollte, der aus dem einzelnen Zeichen 'A' besteht und der Programmierer nur übersehen hat, dass die Klasse gar nicht über einen entsprechenden Konstruktor verfügt. Das Problem wäre gelöst, wenn die implizite Konvertierung nicht stattfinden würde und genau das ist bei expliziten Konstruktoren der Fall. Das Schlüsselwort für die Deklaration lautet explicit.

#include <iostream>

class String{
public:
    String(char const* zeichenkette);
    explicit String(int anzahl, char zeichen = '*');

    char feld[80];
};

int main(){
    String str1 = "Ein Text"; // implizite Konvertierung
    String str3 = 'A';        // Fehler, keine implizite Konvertierung möglich
}

In vielen Fällen ist, wie bei str1, eine implizite Konvertierung gewünscht, da sie zur besseren Lesbarkeit des Quellcodes beitragen kann. Beachten Sie auch, dass die folgenden beiden Zeilen explizite Konstruktoraufrufe sind und entsprechend durchgeführt werden:

String str4('A');          // ist explizit
String str5 = String('A'); // ist explizit

Die Möglichkeit, einen Konstruktor mittels impliziter Konvertierung aufzurufen, sollte immer dann genutzt werden, wenn das Objekt, das Sie definieren wollen, den übergebenen Wert repräsentiert. In unserem String-Beispiel ist dies bei str1 der Fall. Das erzeugte Objekt repräsentiert den übergebenen String. Im Falle von str2 wird hingegen ein Objekt nach den Vorgaben der übergebenen Parameter erstellt. Der Unterschied ist rein sprachlicher Natur, im ersten Fall wird eben bereits ein String (in Form eines C-Strings) übergeben. Technisch gesehen wird natürlich in beiden Fällen ein Objekt anhand der Parameter konstruiert.

Wenn Sie nun beabsichtigen, einen String zu erstellen, der aus dem Zeichen 'A' besteht, würde das erstellte Objekt das Zeichen 'A' repräsentieren. Wenn Sie sich infolgedessen für die Anwendung einer impliziten Konvertierung wie bei str3 entscheiden, wird der Compiler dies dank explizitem Konstruktor mit einer Fehlermeldung quittieren. Wenn Sie beabsichtigen einen String mit 'A' vielen Zeichen zu konstruieren, dann wählen Sie die explizite Syntax wie bei str4 oder str5. Dies wird Ihr Compiler anstandslos übersetzen.