C++11 und delegierende Konstruktoren
C++11 enthält einige Neuerungen, die oberflächlich betrachtet trivial erscheinen, hinter denen sich aber ein paar interessante Eigenschaften und Möglichkeiten verbergen. In diese Kategorie gehört für mich auch die Erweiterung, mit der Konstruktoren einen Teil ihrer Arbeit an einen anderen Konstruktor der gleichen Klasse delegieren können. Dieses Konstrukt ist aus anderen Programmiersprachen wie beispielsweise Java lange bekannt, und sein offensichtlicher Nutzen liegt in der Vermeidung von redundantem und fehleranfälligem Code.
Die Syntax orientiert sich an der bestehenden Syntax für die Initialisierung der Member-Variablen sowie an dem Aufruf der Basisklassen-Konstruktoren. Die bisherigen, nicht-delegierende Konstruktoren werden Principal Constructor
genannt. Da diese sowohl die Initialisierung aller Basisklassen als auch aller Member-Variablen übernehmen, ist das in einem delegierenden Konstruktor weder möglich noch erlaubt. Denn schließlich kann jedes Teilobjekt nur einmal initialisiert werden.
struct point
{
int _x;
int _y;
// non-delegating (principal) constructor
point(int x, int y)
: _x{x}, _y{y}
{ }
// delegating contructor
point()
: point{0, 0} // delegation
{ }
};
Dieses sehr einfach gehaltene Beispiel veranschaulicht diesen Mechanismus. Der Default-Konstruktor delegiert die Initialisierung an einen zweiten Konstruktor, der selbst alle Member-Variablen initialisiert. Erst nach der vollständigen Abarbeitung des Hauptkonstruktors wird der Rumpf des delegierenden Konstruktors ausgeführt – der in diesem Fall leer ist. Das Beispiel ist leider nicht geeignet, um die Vorteile der Delegation zu motivieren. Es sollte allerdings deutlich machen, wie sich die Initialisierung komplexer Klassen vereinheitlichen lässt.
Interessanter wird die Delegation, wenn man etwas tiefer einsteigt. Im folgenden Code ist ein Ausschnitt einer Klasse zu sehen. Wichtig ist eigentlich nur der leere Rumpf des Default-Konstruktors, und dass an den beiden Stellen mit ...
weitere Anweisungen beziehungsweise Deklarationen und Definitionen stehen können. Die interessante Frage dazu ist: Kann sich das Verhalten des Codes ändern, wenn der Konstruktor mit den beiden int
-Parametern zusätzlich an den leeren Default-Konstruktor delegiert?
struct foobar
{
foobar() { }
foobar(int foo, int bar)
{
// ...
}
// ...
};
Die Frage nimmt die Antwort schon vorweg: Ja, das Verhalten kann sich ändern. Und warum das so ist, lässt sich an einem konkreten Beispiel einfacher erklären.
Die folgende Klasse speichert den Vor- und Nachnamen einer Person. Ich zeige nur den relevanten Teil der Klasse, und ich nehme an, dass insbesondere die Copy- und Move-Operationen darüber hinaus sinnvoll deklariert und definiert sind.
class person
{
private: // --- fields ---
char* _first_name{nullptr};
char* _last_name{nullptr};
public: // --- con-/destruction ---
person() noexcept = default;
person(const char* first_name, const char* last_name)
{
_first_name = new_cstr(first_name);
_last_name = new_cstr(last_name);
}
~person() noexcept
{
delete [] _first_name;
delete [] _last_name;
}
private: // --- methods ---
auto new_cstr(const char* value) -> char*
{
return std::strcpy(new char[std::strlen(value) + 1], value);
}
};
Dieser Code ist aus mehreren Gründen schlecht, und ich verwende ihn nur zu Demonstrationszwecken. Mir geht es vor allem um den zweiten Konstruktor, der ein potentielles Speicherleck enthält. Denn wenn die Speicherallokation für _first_name
erfolgreich ist, für _last_name
jedoch scheitert, dann wird der zuerst allokierte Speicher nicht mehr freigegeben. Der Destruktor einer Klasse wird nämlich nur für vollständig konstruierte (Teil-)Objekte aufgerufen. Im Fall der fehlgeschlagenen Speicherallokation ist das aber nicht der Fall, da die Ausführung des Konstruktors vorzeitig abgebrochen wird.
Interessant ist nun, dass dieses potentielle Speicherleck durch die zusätzliche Delegation an den leeren Default-Konstruktor verschwindet.
person(const char* first_name, const char* last_name)
: person{}
{
_first_name = new_cstr(first_name);
_last_name = new_cstr(last_name);
}
Dieses Verhalten hat mit der Definition vollständig konstruierter Objekte zu tun – einer unabdingbaren Eigenschaft von C++. Durch die mit C++11 eingeführte Delegation können mehrere Konstruktoren durchlaufen werden, und daher musste im Rahmen dieser Erweiterung auch die Definition für vollständig konstruierte Objekte angepasst werden. Ein Objekt gilt nun als vollständig konstruiert, sobald ein Principal Constructor
erfolgreich durchgelaufen ist. Oder anders ausgedrückt: Wenn der Rumpf eines delegierenden Konstruktors durch eine Ausnahme abgebrochen wird, dann wird der Destruktor aufgerufen. Bricht hingegen der Principal Constructor
mit einer Ausnahme ab, so wird der Destruktor nicht aufgerufen.
Die neue Definition ist durchaus sinnvoll. Denn einerseits sollte jeder Konstruktor die Klasseninvariante herstellen, so dass der Destruktor in diesem Zustand sicher ausgeführt werden kann. Andererseits ermöglicht diese Aufteilung, dass man auch im Rumpf eines Konstruktors einfach Ressourcen allokieren kann, die auf jeden Fall durch den Destruktor wieder freigegeben werden.
Im letzten Beispiel hätte man die Exception-Sicherheit in der Praxis normalerweise anders gewährleistet: Sowohl die Verwendung von std::string
als auch von std::unique_ptr<char[]>
wäre an dieser Stelle einfacher gewesen. Es gibt aber durchaus Fälle, in denen das anders ist.
Die Situation existiert sogar so häufig, dass es ein verbreitetes Muster gibt, mit dem der gleiche Effekt in C++03 erreicht wird. Das gilt sowohl für den Aufruf des Destruktors, wenn der Konstruktor fehlschlägt, als auch für die Delegation von Funktionalität an andere Konstruktoren. Das obige Beispiel könnte man nämlich auch so schreiben:
class person_base
{
protected: // --- fields ---
char* _first_name;
char* _last_name;
public: // --- con-/destruction ---
person_base()
: _first_name(0), _last_name(0)
{ }
~person_base()
{
delete [] _first_name;
delete [] _last_name;
}
};
class person : private person_base
{
public: // --- con-/destruction ---
person() { }
person(const char* first_name, const char* last_name)
{
_first_name = new_cstr(first_name);
_last_name = new_cstr(last_name);
}
};
Der Trick besteht darin, die Klasse in eine Basisklasse für die Resourcenverwaltung und eine abgeleitete Klasse mit der eigentlichen Funktionalität aufzuteilen. Die Konstruktoren der abgeleiteten Klasse können damit einen Teil ihrer Arbeit an die Konstruktoren der Basisklasse delegieren. Dadurch werden die Aufräumarbeiten im Destruktor der Basisklasse auch ausgeführt, wenn im Konstruktorrumpf der abgeleiteten Klasse eine Ausnahme geworfen wird.
Dieses Muster ist nicht wirklich schlecht. Die Ableitung ist aufgrund der privaten Vererbung von außen nicht sichtbar. Und es liegt auch in der Natur von C++, dass eine solche Vererbung praktisch keinen Laufzeit- und Speicher-Overhead verursacht. Trotzdem: Mit den delegierenden Konstruktoren sieht der Code einfach besser aus.