C++-Programmierung/ Eigene Datentypen definieren/ Leere Klassen?

Aus Wikibooks


Natürlich soll es uns in diesem Kapitel nicht so sehr darum gehen, was in einer Klasse ohne Inhalt alles existiert, sondern vielmehr darum, welche Member der Compiler automatisch erzeugt. Eine Klasse, die Sie ohne einen einzigen Member angelegen, kann zwar unter bestimmten Umständen auch nützlich sein. Darauf werden wir aber erst bei der Behandlung fortgeschrittener Programmiertechniken zu sprechen kommen. Um es vorweg kurz zusammenzufassen, der Compiler erstellt unter bestimmten Umständen automatisch:

  • einen Standardkonstruktor
  • einen Kopierkonstruktor
  • einen Destruktor
  • eine Kopierzuweisung

Alle diese Methoden werden natürlich nur dann vom Compiler generiert, wenn sie im Laufe des Programms auch irgendwo aufgerufen werden. Welche Eigenschaften diese vier Methoden haben und unter welchen Umständen der Compiler sie nicht erstellen kann, werden Sie im Folgenden sehen.

Compilergenerierte Funktionen explizit verbieten[Bearbeiten]

Diese vier Methoden sind für die meisten Klassen gewünscht und würden auch meist in der vom Compiler erzeugten Weise implementiert werden. Somit erspart er Ihnen hier eine Menge Arbeit. Leider gibt es aber auch Klassen, für die beispielsweise überhaupt keine Kopierzuweisung gewünscht ist. In diesem Fall muss es eine Möglichkeit geben, die Erstellung zu verhindern. Die Lösung ist einfach die Deklaration der entsprechenden Methode im private-Bereich der Klasse vorzunehmen. Eine Definition ist nicht erforderlich, da sie ohnehin nie aufgerufen wird.

Standardkonstruktor[Bearbeiten]

Falls kein einziger Konstruktor in einer Klasse deklariert wurde, erstellt der Compiler einen Standardkonstruktor, der für alle Membervariablen der Klasse den Standardkonstruktor zur Initialisierung aufruft. Das implizite Erstellen eines solchen Konstruktors ist dem Compiler somit nur möglich, falls alle Membervariablen über einen Standardkonstruktor verfügen. Für die Basisdatentypen ist das Verhalten leider wieder komplizierten Regeln unterworfen und im Falle von Klassen wirkt sich dies ausgesprochen verworren aus. Abhängig davon, wo ein Objekt der Klasse deklariert wird, wird eine solche Variable mit 0 initialisiert oder eben nicht. Sehen Sie sich etwa folgendes Beispiel an.

#include <iostream>

std::size_t const count = 1000000;

class A{
public:
    int data[count];
};

A global; // data wird komplett mit 0 initialisiert

int main(){
    A lokal; // data wird nicht initialisiert

    for(std::size_t i = 0; i < count; ++i){
        if(global.data[i] != 0){
            std::cout << "global" << std::endl;
            break;
        }
    }
    for(std::size_t i = 0; i < count; ++i){
        if(lokal.data[i] != 0){
            std::cout << "lokal" << std::endl;
            break;
        }
    }
}

Dieses Programm wird höchstwahrscheinlich die Zeile „lokal“ ausgeben. Sollte dies bei Ihnen nicht der Fall sein, dann standen an der Stelle des Speichers, den lokal.data belegt, zufällig gerade überall Nullen. Damit solche Effekte nicht auftreten, sollten Klassen, die Basisdatentypen enthalten, immer einen selbstgeschriebenen Konstruktor besitzen, der die entsprechenden Variablen explizit mit 0 initialisiert. Für alle nicht-Basisdatentyp-Variablen, die nicht in der Initialisierungsliste auftauchen, wird implizit der Standardkonstruktor aufgerufen. Bei Klassen, die keine Basisdatentypen enthalten, können Sie getrost die compilergenerierte Version verwenden.

Kopierkonstruktor und Kopierzuweisungsoperator[Bearbeiten]

Es wird ein Kopierkonstruktor erstellt, der für alle Membervariablen den Kopierkonstruktor aufruft. Offensichtlich müssen also hier für die implizite Erstellung alle Membervariablen über einen Kopierkonstruktor verfügen. In äquivalenter Weise erstellt der Compiler einen Kopierzuweisungsoperator. Für die Basisdatentypen werden in beiden Fällen einfach die Bitfolgen kopiert. Das folgende Beispiel zeigt eine Klasse B, für die beide Methoden implizit erstellt werden, allerdings nur, weil beide in der main()-Funktion auch aufgerufen werden. Die Klasse A deklariert beide Methoden explizit. Dies wäre für dieses Beispiel nicht zwingend notwendig, zeigt jedoch, dass der Compiler tatsächlich auch explizit deklarierte Methoden aufrufen lassen kann.

#include <iostream>

class A{
public:
    A(int v):
        value(v)
        {}

    int value;

    // Expliziter Kopierkonstruktor (äquivalent zum compilergenerierten)
    A(A const& a):
        value(a.value)
        {}

    // Explizite Kopierzuweisung (äquivalent zur compilergenerierten)
    A& operator=(A const& a){value = a.value; return *this;}
};

class B{
public:
    B(int v):
        data(v)
        {}

    A data;
};

int main(){
    B b1(5);
    B b2(10);
    B b3(b1); // Kopierkonstruktor (compilergeneriert)
    std::cout << b3.data.value << std::endl;
    b3 = b2;  // Kopierzuweisung (compilergeneriert)
    std::cout << b3.data.value << std::endl;
}
Ausgabe:
5
10

Wenn Sie eine der beiden Methoden in A im private-Bereich deklarieren und trotzdem versuchen, die entsprechende compilergenerierte Methode in B aufzurufen, dann wird der Compiler dies mit einer Fehlermeldung quittieren.

Für diese beiden Funktionen werden Sie wahrscheinlich meist die compilergenerierte Version verwenden. Eine handgeschriebene Funktion ist immer dann zwingend erforderlich, wenn Member nicht einfach binär kopiert werden dürfen. Enthält Ihre Klasse beispielsweise einen Zeiger, der auf einen dynamisch angeforderten Speicherbereich verweist, welcher im Destruktor wieder freigegeben wird, so wäre es nicht ratsam, diesen Zeiger einfach zu kopieren. Das Resultat wäre, dass anschließend 2 Objekte auf den gleichen Speicherbereich verweisen, doch damit nicht genug. Die Zerstörung des ersten Objektes würde im Destruktor ordnungsgemäß den Speicherbereich wieder freigeben, sobald jedoch das zweite Objekt zerstört wird, würde dessen Destruktion erneut versuchen den Speicher freizugeben, was in den meisten Fällen zu einem Programmabsturz führt.

Es gibt natürlich auch noch eine reichliche Menge anderer Situationen, in denen es erforderlich ist, die beiden Funktionen manuell zu implementieren. Überlegen Sie immer genau, ob das Verhalten der compilergenerierten Standardversionen genau dem entspricht, was Sie auch händisch implementieren würden. Ist dies der Fall, sollten Sie auch die Standardversionen verwenden, denn Ihre Klasse wird dadurch übersichtlicher und leichter wartbar.

Destruktor[Bearbeiten]

Der Destruktor wird vom Compiler so generiert, dass er für alle Membervariablen, die über einen Destruktor verfügen, diesen aufruft. Beachten Sie, dass der Compiler diese Aufrufe auch automatisch tätigt, wenn Sie explizit einen Destruktor deklarieren. In diesem Fall wird zunächst der Funktionsrumpf Ihres expliziten Destruktors ausgeführt und anschließend werden die Destruktoren der Membervariablen, in umgekehrter Reihenfolge zu ihrer Deklaration, aufgerufen. Es ist also nicht sinnvoll in diesem Zusammenhang explizit Destruktoren aufzurufen. Je nach Funktion des aufgerufen Destruktors, kann ein doppelter Aufruf (einmal manuell, einmal vom Compiler) sogar zu Programmabstürzen führen. Das folgende Beispiel verdeutlicht was passiert.

#include <iostream>

struct A{ ~A(){std::cout << "~A();" << std::endl;} };
struct B{ ~B(){std::cout << "~B();" << std::endl;} };

struct C{ // Impliziter Destruktor, nur compilergenerierte Aufrufe
    A a; B b;
};

struct D{ // Expliziter Destruktor, gefolgt von impliziten Aufrufen des Compilers
    A a; B b;
    ~D(){a.~A();}
};

int main(){
    std::cout << "Destruktor von C:" << std::endl; {C c;}
    std::cout << "Destruktor von D:" << std::endl; {D d;}
}
Ausgabe:
Destruktor von C:
~B();
~A();
Destruktor von D:
~A();
~B();
~A();

Eine wichtige Eigenschaft des compilergenerierten Destruktors werden Sie in Zusammenhang mit Vererbung kennen lernen. Sie besteht darin, dass dieser Destruktor nicht virtuell ist, es sei denn, eine Basisklasse verfügt über einen virtuellen Destruktor. Da Vererbung jedoch noch nicht behandelt wurde, wird dieses Thema später besprochen.

Gemeinsame Hindernisse[Bearbeiten]

Konstante Membervariablen und auch Referenz-Membervariablen verhindern das implizite Erstellen eines Standardkonstruktors und einer Kopierzuweisung. Dass es keine allgemeingültig sinnvolle Version einer Kopierzuweisung gibt, leuchtet in beiden Fällen sofort ein. Wie sollte sich die Funktion denn verhalten? Ein konstantes Objekt darf ja nicht geändert werden und im Falle einer Referenz ist es in jedem Fall unmöglich, sie zu ändern. Dass der Standardkonstruktor nicht erstellt werden kann, wenn eine der Membervariablen eine Referenz ist, ist ebenfalls offensichtlich. Referenzen müssen immer initialisiert werden, aber woher sollte ein Konstruktor wissen womit. Für konstante Variablen könnte man argumentieren, dass der Standardkonstruktor des jeweiligen Datentyps aufgerufen werden könnte. Da jedoch für Basisdatentypen kein Konstruktor existiert wäre dieses Vorgehen nicht allgemeingültig anwendbar.

Für den Kopierkonstruktor gelten diese Einschränkungen nicht, denn in beiden Fällen ist es durchaus legitim und auch sinnvoll, die Variablen mit den Werten des zu kopierenden Objekts zu initialisieren.