C++ Kurs

Überladen des Konstruktors

Die Themen:

Überladen des Konstruktors

Nachdem wir uns im vorherigen Kapitel das allgemeine Überladen von Funktionen bzw. Memberfunktionen angesehen haben, sehen wir uns jetzt das Überladen des Konstruktors an.

Hatten unsere Klassen bisher immer nur einen Konstruktor, so ermöglicht das Überladen des Konstruktors die Definition von Objekten mit verschiedenen Parametern. Und auch beim Überladen des Konstruktors gilt: die Konstruktore müssen sich in der Anzahl der Parameter und/oder Datentypen der Parameter unterscheiden. Welcher Konstruktor dann ausgeführt wird, hängt dann von den Argumenten bei der Definition des Objekts ab.

PgmHeaderclass Window
{
    ....
  public:
    Window();                   // Standard-ctor
    Window(char*, short);       // 2. ctor
    ....
};
int main()
{
    Window myWin;               // Aufruf Standard-ctor
    Window yourWin("Emil",10);  // Aufruf 2. ctor
    ....
}
AchtungIn diesem Zusammenhang ist zu beachten, dass der Standard-Konstruktor immer dann zusätzlich benötigt wird, wenn Objektfelder dynamisch angelegt werden sollen.

Ein anderes Einsatzgebiet des überladenen Konstruktors ist die Initialisierung einer Variante. Musste bisher der Datentyp des Initialwertes mit dem Datentyp des ersten Elements in der Variante übereinstimmen, so lässt sich bei der Definition eines Variantenobjekts durch bereitstellen eines entsprechenden Konstruktors nun jedes beliebige Variantenelement initialisieren.

PgmHeaderunion Month
{
    char *pText;     // Monat als Text
    char cNum;       // Monat als Wert
    // Monatstext initialisieren
    Month(char* text): pText(text)
    {}
    // Monatswert initialisieren
    Month(char num): cNum(num)
    {}
};
// main() Funktion
int main()
{
    // Monatswert ablegen
    Month actMonth{10};
    // Monatstext ablegen
    Month newMonth{"November"};
}

Und nun noch ein kleiner Hinweis:

HinweisJede Klasse kann aber nur einen Destruktor besitzen, da der Destruktor immer parameterlos ist!

Kopierkonstruktor

Eine besondere Form des Konstruktors stellt der Kopierkonstruktor (copy-ctor) dar. Er wird immer dann aufgerufen, wenn ein Objekt bei seiner Definition mit einem anderen Objekt der gleichen Klasse initialisiert wird, d.h. das neue Objekt ist eine Kopie eines bestehenden Objekts. Der Kopierkonstruktor hat folgende Syntax:

CAny::CAny(const CAny& source);

CAny ist eine beliebige Klasse und der Referenzparameter source eine Referenz auf das zu kopierende Objekt. Nachfolgend ein Beispiel für einen solchen Kopierkonstruktor.

PgmHeader// Klassendefinition
class Window
{
    char *pTitle;
    ....
  public:
    Window();                       // Standard ctor
    Window(const Window& source);   // copy-ctor
    ....
};
// Definition des Kopierkonstruktors
Window::Window(const Window& source)
{
    // Platz für Fenstertitel reservieren
    pTitle = new char[strlen(source.pTitle)+1];
    // Fenstertitel umkopieren
    strcpy(pTitel,source.pTitle);
    ....
}

int main()
{
    // Aufrufe des Standard-ctor
    Window firstWin;
    auto pAnyWin = new Window;
    // Beispiele für den Aufruf des copy-ctor
    Window myWin(firstWin);
    Window yourWin(*pAnyWin);
    auto pWin1 = new Window(firstWin);
    auto pWin2 = new Window(*pAnyWin);
    ...
    ...  // Hier dyn. angelegte Window-Objekte wieder löschen!
}

Bei Zeigerparametern ist zu beachten, dass der Zeiger auch dereferenziert wird!

HinweisSelbstverständlich sollten Sie in der Praxis für die Ablage des Fenstertitels ein Objekt von Typ string verwenden. Die Ablage des Fenstertitels in einem char-Feld dient hier nur zur Veranschaulichung, wie ein Kopierkonstruktor generell aufgebaut ist.

Da das Kopieren von Objekten oft vorkommt, definiert der Compiler den Kopierkonstruktor standardmäßig für jede Klasse. Und dieser Standard-Kopierkonstruktor kopiert einfach jede Eigenschaft des Quell-Objekts in das Ziel-Objekt. Enthält das Quell-Objekt jedoch dynamische Eigenschaften, so führt dies in der Regel zu einem ungewollten Verhalten, da nur die Inhalte der Zeiger kopiert werden und nicht die Daten selbst. In einem solchen Fall muss der Kopierkonstruktor explizit überladen werden. Dabei ist aber zu beachten, dass auch die anderen standardmäßig durch den Compiler erzeugten Memberfunktionen ebenfalls nicht mehr automatisch erzeugt werden. Wie der Compiler trotzdem dazu gebracht werden kann, diese Standard Memberfunktionen zu erzeugen, sehen wir uns gleich an.

Move-Konstruktor (Move-Semantik, Teil 1)

Hinweis Bevor wir uns den Move-Konstruktor ansehen, noch zwei Begriffsdefinitionen: lvalue bezeichnet einen Ausdruck der links vom Zuweisungsoperator steht und rvalue dementsprechend einen Ausdruck der rechts vom Zuweisungsoperator steht. So ist in der Anweisung
x = y + z;

x ein lvalue und y + z ein rvalue Ausdruck.

Der Move-Konstruktor ist ein Sonderfall des Kopierkonstruktors und kommt immer dann zum Einsatz, wenn es um die effektive Verwaltung von Ressourcen, wie z.B. Speicher, geht. Da sich der Einsatz des Move-Konstruktors am besten anhand eines Beispiels verdeutlichen lässt, sehen wir uns einmal das nachfolgende Standard-Beispiel für den Einsatz des Move-Konstruktors an. Eine Implementierung des Swap-Algorithmus zum Vertauschen von zwei Objekte für die Klasse Window dürfte in der Regel wie folgt aussehen:

PgmHeader// Klassendefinition
class Window
{
    char *pTitle;
    ....
  public:
    Swap(Window& obj);
    ....
};
// Swap Memberfunktion
void Window::Swap(Window& obj)
{
   Window tmp(*this);        // Sichere aktuelle Eigenschaften
   *this = obj;              // Kopiere obj-Eigenschaften ins aktuelle Objekt
   obj = tmp;                // Kopiere gesicherte Eigenschaften nach obj
};

Zunächst soll uns nur die erste Anweisung der Funktion Swap(..) interessieren. Hier wird durch den Aufruf des Kopierkonstruktors ein neues Window-Objekt tmp erstellt. Innerhalb des Kopierkonstruktors wird dann u.a. Speicher für die Ablage des Fenstertitels allokiert, d.h. es wird nun sowohl für den Fenstertitel des aktuellen Objekts als auch für den Fenstertitel des temporäre Objekts tmp Speicher belegt, der nach der Ausführung des Kopierkonstruktors den gleichen Inhalt hat. In der zweiten Anweisung der Swap-Memberfunktion werden jedoch die Eigenschaften des aktuellen Objekts mit denen des übergebenen Objekts überschrieben. D.h. nach der "Datensicherung" durch den Kopierkonstruktor in der ersten Anweisung werden die Eigenschaften des aktuellen Objekts eigentlich nicht weiter benötigt, da sie unmittelbar mit der nächsten Anweisung überschrieben werden. Und genau in solchen Fällen kommt der Move-Konstruktor zum Einsatz. Der Move-Konstruktor überträgt den Besitz einer Ressource, hier im Beispiel den für den Fenstertitel reservierten Speicher, von dem zu kopierenden Objekt an das neu erstellte Objekt, im Beispiel also vom aktuellen Objekt an das tmp Objekt.

Bauen wir den Move-Konstruktor nun Stück für Stück auf. Der Move-Konstruktor erhält, im Gegensatz zum Kopierkonstruktor, als Parameter eine sogenannte rvalue-Referenz übergeben, die dadurch gekennzeichnet wird, dass nach dem Datentyp des Parameters die Symbole && folgen.

PgmHeader// Klassendefinition
class Window
{
    char *pTitle;
    ....
  public:
    Window (const Window& obj);    // copy-ctor
    Window (Window&& obj);         // Deklaration Move-Konstruktor
    Swap(Window& obj);
    ....
};
...
// Definition Move-Konstruktor
Window::Window (Window&& obj)
{
   ...
}
// Swap Memberfunktion
void Window::Swap(Window& obj)
{
   ...
};

Es ist zu beachten, dass beim Move-Konstruktor keine const-Referenz übergeben wird, im Gegensatz zum Kopierkonstruktor, da das Quell-Objekt in der Regel verändert wird.

Innerhalb des Move-Konstruktors werden dann die Eigenschaften von Quell-Objekt an das aktuelle Objekt übertragen. Dabei gilt es allerdings zu beachten, dass sich das Quell-Objekt nach der Übertragung der Eigenschaften in einem gültigen Zustand befindet. D.h. ein Zugriff auf eine übertragene Ressource, z.B. Speicher, muss dann unbedingt verhindert werden.

PgmHeader// Klassendefinition
class Window
{
    char *pTitle;
    ....
  public:
    Window (Window&& obj);   // Deklaration Move-Konstruktor
    Swap(Window& obj);
    ....
};
// Definition Move-Konstruktor
Window::Window (Window&& obj)
{
   pTitle = obj.pTitle;     // Speicher-Ressource uebertragen
   ....                     // weitere Eigenschaften uebertragen
   obj.pTitle = nullptr;    // Speicher gehoert jetzt nicht mehr dem Quell-Objekt
}
// Swap Memberfunktion
void Window::Swap(Window& obj)
{
   ...
};

Wichtig ist die letzte Anweisung des Move-Konstruktors, denn diese versetzt das Quell-Objekt wieder in gültigen Zustand. Ohne diese Anweisung würden sowohl das Quell-Objekt als auch das neu erstellte Objekt auf den gleichen Speicherbereich verweisen, was wiederum bei der Freigabe des Speichers im Destruktor von Window zu einem Fehler führt. Dadurch, dass dem Zeiger auf den Speicher für den Fenstertitel ein nullptr zugewiesen wird, gibt das Quell-Objekt den Besitz des Speichers letztendlich frei, sodass dessen Destruktor den delete Operator mit diesem nullptr aufruft. Und ein Aufruf von delete mit einem nullptr führt einfach nichts aus.

So weit, so gut. Doch wenn wir jetzt die Swap(...) Memberfunktion ausführen würden, würden wir keinen Unterschied zur Ausführung ohne den explizit definierten Move-Konstruktor feststellen. Warum? Nun, woher soll der Compiler beim Übersetzen der ersten Zeile von Swap(...) wissen, dass die Ressourcen des Quell-Objekts nicht weiter benötigt werden? Die zweite Zeile, die die Übertragung der Ressource erst erlaubt, kennt er zu diesem Zeitpunkt noch nicht. Und hier müssen wir dem Compiler etwas unter der Arme greifen. Hierfür definiert die C++ Standard-Bibliothek die Funktion move(arg), die arg in eine rvalue-Referenz umwandelt, also in den Datentyp, den der Move-Konstruktor benötigt. Somit können wir die Swap(...) Memberfunktion jetzt wie folgt definieren:

PgmHeader// Klassendefinition
class Window
{
    char *pTitle;
    ....
  public:
    Window (Window&& obj);   // Deklaration Move-Konstruktor
    Swap(Window& obj);
    ....
};
// Definition Move-Konstruktor
Window::Window (Window&& obj)
{
   pTitle = obj.pTitle;     // Speicher-Ressource uebertragen
   ....                     // weitere Eigenschaften uebertragen
   obj.pTitle = nullptr;    // Speicher gehoert jetzt nicht mehr dem Quell-Objekt
}
// Swap Memberfunktion
void Window::Swap(Window& obj)
{
   Window tmp(std::move(*this));  // Aufruf des Move-Konstruktors
   *this = obj;
   obj = tmp;
};

Damit ist die Swap() Memberfunktion vorläufig definiert. Im nächsten Kapitel werden wir auch noch die beiden restlichen Anweisung (Zuweisungen) etwas optimieren.

Delegierender Konstruktor

Ein Konstruktor einer Klasse kann auch einen weiteren Konstruktor der gleichen Klasse aufrufen, d.h. er kann einen Teil seiner Aufgaben an einen anderen Konstruktor delegieren. Dies ist immer dann sehr nützlich, wenn zwei Konstruktore zumindest teilweise den gleichen Ablauf haben. Sehen wir uns dazu zunächst wieder ein Beispiel an:

PgmHeader// Klassendefinition
class Window
{
    std::string title;                 // Fenstertitel
    unsigned int xPos = 0, yPos = 0;   // Fensterposition
    unsigned int width, height;        // Fenstergroesse
    unsigned int area;                 // Fensterflaeche
 public:
    // 1. ctor
    Window():
        title("Default"), width(640), height(480)
    {
        area = width * height;
    };
    ...
    // 2. ctor
    Window(std::string t, unsigned int w, unsigned int h):
        title(t), width(w), height(h)
    {
        area = width * height;
    };
}

Die Klasse enthält einen Standard-Konstruktor sowie einen zweiten Konstruktor mit Parametern. Beide Konstruktore enthalten aber den gleichen Code, d.h. sie setzen die Fensterposition und berechnen anschließend die Fläche des Fensters. Und gleicher Code an mehreren Stellen ist bei Änderungen stets fehleranfällig. Eine Lösung wäre, eine gesonderte Memberfunktion Init() zu erstellen, die von beiden Konstruktoren aufgerufen wird und die entsprechenden Initialisierungen durchführt. Dies kann, wenn die Klasse auch noch Objekte enthält welche zu initialisieren sind, dazu führen, dass im Konstruktor diese Objekte zunächst defaultmäßig initialisiert werden und erst im Nachhinein in der Init() Funktion die Eigenschaften der Objekte entsprechend umgesetzt werden.

PgmHeader// Klassendefinition
class Window
{
    std::string title;                 // Fenstertitel
    unsigned int xPos = 0, yPos = 0;   // Fensterposition
    unsigned int width, height;        // Fenstergroesse
    unsigned int area;                 // Fensterflaeche
    // Init Funktion

    void Init()
    {
        area = width * height;         // Restl. Initialisierung
    };
 public:
    // 1. ctor
    Window():
        title("Default"), width(640), height(480)
    {
        Init();
    };
    // 2. ctor
    Window(std::string t, unsigned int w, unsigned int h):
        title(t), width(w), height(h)
    {
        Init();
    };
    ...
}

Doch es geht auch wesentlich eleganter und effektiver. Wir rufen von einem Konstruktor, dem delegating ctor, den anderen Konstruktor, den sogenannten target ctor, auf. Dieser "Aufruf" muss in der Initialisiererliste des delegierenden Konstruktors erfolgen. Und damit lässt sich das obige Beispiel wie folgt umschreiben:

PgmHeader// Klassendefinition
class Window
{
    std::string title;                 // Fenstertitel
    unsigned int xPos = 0, yPos = 0;   // Fensterposition
    unsigned int width, height;        // Fenstergroesse
    unsigned int area;                 // Fensterflaeche
 public:
    // target ctor
    Window(std::string t, unsigned int w, unsigned int h):
        title(t), width(w), height(h)
    {
        area = width * height;
    };
    // delegating ctor
    Window(): Window("Default", 640, 480)
    {
    };
    ...
}

Die Anzahl der Delegationen ist nicht begrenzt. So könnte der obige target ctor wiederum einen anderen Konstruktor aufrufen, der weitere Initialisierungen durchführt.

default und delete

Bei der Einführung von Klassen wurde bereits erwähnt, dass der Compiler bestimmte Memberfunktion automatisch generiert, so lange keine der dort beschriebenen Bedingung verletzt wird. So erzeugt das nachfolgende Beispiel beim Übersetzen einen Fehler, da durch die explizite Definition des Kopierkonstruktors der Standard-Konstruktor nicht mehr automatisch generiert wird.

PgmHeader// Klassendefinition
class Any
{
    ....
  public:
    Any(const Any& source);      // copy-ctor
    ....
};


int main()
{
    // Fehler, da Standard-ctor nicht definiert!
    Any firstObj;
    // Aufruf des copy-ctors
    Any myObj(firstObj);
}

Was aber tun, wenn zusätzlich noch den Standard-Konstruktor benötigt wird? Nun, die einfachste Art bis jetzt ist, einen entsprechenden Standard-Konstruktor zu definieren.

PgmHeader// Klassendefinition
class Any
{
    ....
  public:
    Any();                       // Standard-ctor
    Any(const Any& source);      // copy-ctor
    ....
};


int main()
{
    // Ok, da Standard-ctor definiert!
    Any firstObj;
    // Aufruf des copy-ctors
    Any myObj(firstObj);
}

Eleganter, und auch effektiver, wäre es aber, weiterhin den Standard-Konstruktor durch den Compiler generieren zu lassen. Und dies wird dadurch erreicht, indem bei der Deklaration des Standard-Konstruktors nach der leeren Parameterklammer = default angegeben wird.

PgmHeader// Klassendefinition
class Any
{
    ....
  public:
    Any() = default;             // Standard-ctor
    Any(const Any& source);      // copy-ctor
    ....
};


int main()
{
    // Ok, da Standard-ctor definiert!
    Any firstObj;
    // Aufruf des copy-ctors
    Any myObj(firstObj);
}

Durch die Angabe von = default wird der Compiler angewiesen, die angegebene Standard-Memberfunktion weiterhin zu erstellen. Beachten Sie bitte, dass hier Standard-Memberfunktion steht, d.h. = default darf nur nach einer der Standard-Memberfunktionen stehen!

Das Gegenstück zu = default ist = delete. Durch die Angabe von = delete wird die automatische Generierung der Standard-Memberfunktion unterdrückt. Dieses Verhalten kann zum Beispiel dann dazu verwendet werden, um zu verhindern, dass Objekte kopiert werden können.

PgmHeader// Klassendefinition
class Any
{
    ....
  public:
    Any() = default;                  // Standard-ctor
    Any(const Any& source) = delete;  // Standard copy-ctor unterdruecken
    ....
};


int main()
{
    // Ok, da Standard-ctor definiert
    Any firstObj;
    // Fehler, da copy-ctor nicht definiert
    Any myObj(firstObj);
}

D.h. durch die Angabe von = default und = delete wird die Schnittstelle einer Klasse definiert und dem Compiler die Implementierung der Funktion überlassen.

Beispiel und ÜbungUnd hier geht's zum Beispiel und zur Übung.