In der Welt der C++-Programmierung stößt man häufig auf Konzepte, die auf den ersten Blick intuitiv erscheinen, in der Praxis jedoch zu unerwartetem Verhalten führen können. Eines dieser Themen ist die Beurteilung, ob eine Klasse kopierbar ist oder nicht. Besonders irritierend ist es, wenn der Compiler Ihre Klasse fälschlicherweise als kopierbar einstuft, obwohl das Implementieren einer tatsächlichen Kopie unmöglich ist. Dieses Phänomen führt nicht selten zu Verwirrung und Frustration, insbesondere bei Entwicklern, die sich tief in die Feinheiten von C++ einarbeiten. In diesem ausführlichen Beitrag werden die Ursachen dieses Verhaltens beleuchtet, die Funktionsweise der C++-Kopierkonstruktoren erklärt und praktische Beispiele gezeigt, die das Thema verständlich machen.
Zudem erfahren Sie, wie Sie dieses Verhalten korrekt interpretieren und sinnvoll damit umgehen können. C++ definiert „Kopierkonstruktibilität“ im Kern als die Existenz eines nicht-gelöschten (non-deleted) Kopierkonstruktors. Das bedeutet, dass der Compiler bei der Analyse eines Typs lediglich prüft, ob eine Kopierkonstruktor-Deklaration vorhanden und nicht mit =delete markiert ist. Dabei wird nicht zwangsläufig überprüft, ob das Kopieren tatsächlich funktioniert oder zur Kompilierzeit erfolgreich ausgeführt werden kann. Dadurch entsteht eine Diskrepanz zwischen dem, was der Compiler als Schnittstelle akzeptiert, und dem tatsächlichen Verhalten bei der Instanziierung des Kopierkonstruktors.
Ein klassisches Beispiel ist die Vererbung von Basisklassen, die nicht kopierbar sind, in abgeleiteten Klassen, die den Kopierkonstruktor explizit definieren. Angenommen, eine Basisklasse verfügt über einen gelöschten Kopierkonstruktor (also „=delete“), womit sie nicht kopierbar ist. Wird nun in einer abgeleiteten Klasse ein eigener Kopierkonstruktor implementiert, etwa mit der Konstruktor-Initialisierer-Liste, die den Base-Konstruktor mit dem zu kopierenden Objekt aufruft, so existiert formal ein nicht-gelöschter Kopierkonstruktor in der abgeleiteten Klasse. Der Compiler sieht also die Deklaration und registriert diese Klasse als kopierbar, obwohl beim Versuch der tatsächlichen Kopie ein Fehler auftritt – nämlich weil die Basisklasse ihren Kopierkonstruktor gelöscht hat und daher nicht instanziiert werden kann. Ein wichtiger Grund für dieses Verhalten liegt im Design von C++.
Der Compiler analysiert die Kopierkonstruktibilität anhand der Deklarationen, nicht anhand der Implementierungen. Zudem müssen Kopierkonstruktoren nicht zwingend im Header vollständig definiert sein, was beispielsweise für die Trennung von Schnittstelle und Implementierung relevant ist. Würde der Compiler schon bei der Typüberprüfung erwarten, dass die Implementierung erfolgreich kompiliert, würde das Header-only-Design teilweise zerstört und zu erzwungenen Komplettdefinitionen führen, was weder praktikabel noch erwünscht ist. Es ist also eine Abwägung zwischen Benutzerfreundlichkeit und Funktionalität, bei der der Compiler lieber eine Kopierkonstruktor-Deklaration als Nachweis für Kopierkonstruktibilität akzeptiert, ohne die tatsächliche Ausführbarkeit zu garantieren. Der Unterschied zwischen einem explizit definierten Kopierkonstruktor und einem defaulted Kopierkonstruktor spielt hierbei eine wesentliche Rolle.
Wenn der Kopierkonstruktor einer Klasse standardmäßig vom Compiler generiert oder explizit mit =default versehen wird, dann prüft der Compiler genauer, ob die Kopie überhaupt möglich ist. Beispielsweise wird in diesem Fall, wenn eine Basisklasse nicht kopierbar ist, der Gesamtkonstruktor automatisch als gelöscht markiert. Das Resultat ist, dass traits wie std::is_copy_constructible korrekt melden, dass die Klasse nicht kopierbar ist. Anders verhält es sich jedoch, wenn Sie selbst einen Kopierkonstruktor implementieren und diesen nicht löschen. Der Compiler nimmt dann an, dass Sie genau wissen, was Sie tun, und bewertet die Klasse als kopierbar.
Doch beim Versuch, eine solche Klasse tatsächlich zu kopieren, kann ein Linker- oder Kompilierfehler auftreten, weil die Basisfunktion nicht aufgerufen werden darf oder fehlt. Es ist daher essenziell, bei der Definition eigener Kopierkonstruktoren in Klassen mit nicht-kopierbaren Basisklassen besonders vorsichtig zu sein. Es lohnt sich zu verstehen, dass der Begriff „nicht instanziierbar“ hier entscheidend ist: Die Existenz eines Kopierkonstruktors heißt nicht, dass er verwendet oder ausgeführt werden kann – nur, dass er formal existiert. Ein weiteres Beispiel zeigt, dass Sie mit einem eigenen Kopierkonstruktor theoretisch sogar die Basisklasse komplett ignorieren könnten, indem Sie etwa im Initialisierer den Standardkonstruktor aufrufen anstatt den Basis-Kopierkonstruktor. Das führt zwar zu keinem Kompilierfehler, verändert aber das semantische Verhalten drastisch, denn die Basisklasse wird dann bei einer Kopie nicht wirklich kopiert, sondern neu erstellt.
Möglicherweise ist das gewollt oder nicht, je nach Anwendung. Aus Sicht von C++ und dem Standard gibt es keine Möglichkeit, im Vorfeld sicher festzustellen, ob ein Kopierkonstruktor tatsächlich instanziierbar ist, wenn nur die Deklaration vorliegt. Die Komplexität von Templates, inkrementellen Build-Systemen und Header-Implementierungen macht diese Prüfung praktisch unmöglich. Deshalb setzt der Standard auf Minimalanforderungen und erlaubt es Entwicklern, ihre eigene Verantwortung zu übernehmen. Zusammenfassend lässt sich sagen, dass C++ eine Klasse als kopierbar einstuft, sobald sie eine sichtbare, nicht-delete-Deklaration eines Kopierkonstruktors besitzt.
Ob der Konstruktor tatsächlich funktioniert, wird nicht überprüft. Dadurch ist das Verhalten einer statischen Trait-Prüfung wie std::is_copy_constructible manchmal irreführend. Für Entwickler ist es wesentlich, insbesondere bei Vererbungshierarchien und expliziten Kopierkonstruktoren, zu verstehen, dass Deklarationen nicht immer mit tatsächlich möglichen Instanziierungen übereinstimmen. Für bestmögliche Aussagekraft sollte man die Standard-Default-Kopierkonstruktoren nutzen, wann immer möglich. Nur dann erkennt der Compiler zuverlässig die Kopierbarkeit anhand der Basisklassen und Mitglieder.
Falls man eigene Kopierkonstruktoren definieren muss, empfiehlt es sich, sehr genau die Basisklassen zu prüfen und klar zu dokumentieren, ob und wie eine Kopie wirklich möglich ist. Andernfalls entstehen Fehler bei der Nutzung und schwere Fehlerursachen, die im Codeanalysen und bei der Fehlersuche viel Aufwand verursachen können. Verstehen, wie der Compiler Kopierkonstruktibilität interpretiert, hilft dabei, diese Fallstricke zu umgehen und robusten, wartbaren C++-Code zu schreiben. Letztendlich ist das Wissen um diese Eigenheiten der Schlüssel, um bei der Entwicklung mit C++ die Kontrolle über die Objektsemantik zu behalten und Fehlinterpretationen der Sprache zu vermeiden.