Kapitel 7. Entwurf von Klassen

In diesem Kapitel:

Mit der erweiterten Funktionalität zur Initialisierung von Objekten und den expliziten Klassendefinitionen durch neue Schlüsselwörter kann C++11 in zwei Aspekten deutlich punkten: Zum einen lässt sich Lebenszeit eines Objekts einfacher und expliziter steuern, zum anderen drücken Klassendefinitionen rein deklarativ ihre Intention aus und sind damit viel leichter wartbar.

Initialisierung von Objekten

Mit Initialisiererlisten für Konstruktoren, der Delegation und Vererbung von Konstruktoren und auch dem direkten Initialisieren von Klassenelementen wird die Initialisierung von Objekten in C++11 deutlich erweitert.

Initialisiererlisten für Konstruktoren

Sequenzkonstruktor

Konstruktoren, die ein Template vom Typ std::initializer_list annehmen, werden Konstruktoren mit Initialisiererliste genannt. Sie werden auch als Sequenzkonstruktor bezeichnet. Der Sequenzkonstruktor ist die Grundlage dafür, dass sich die STL-Container-Strings direkt über Initialisiererlisten wie C-Aggregate initialisieren lassen. Nicht nur die neuen Konstruktoren, auch Zuweisungsoperatoren unterstützen die neue Syntax. Dabei besitzt das äußerst praktische Template std::initializer_list, das unter der Decke ein Array ist, nur drei Methoden (Tabelle 7.1):

Aus diesem einfachen Interface ist unmittelbar ersichtlich: Initialisiererlisten können nicht modifiziert werden. Die drei Methoden sind ausreichend, um einen std::vector über eine Initialisiererliste zu initialisieren:

In Listing 7.1 wird der std::vector über die Initialisiererliste inList (Zeile 4) initialisiert. Die Methode reserve (Zeile 5) stellt ausreichend Speicher zur Verfügung, um mit der Methode unitialized_copy (Zeile 6) elem mit den Elementen von inList zu initialisieren. sz merkt sich abschließend die Anzahl der Elemente von inList (Zeile 7).

Überladen von Konstruktoren

Konstruktoren werden in der Regel überladen. Hierzu gilt es, ein paar Regeln im Gedächtnis zu behalten, wenn ein Datentyp sowohl einen Sequenzkonstruktor als auch klassische Konstruktoren besitzt.

  1. Im Konstruktoraufruf wird eine Initialisiererliste verwendet.

    • Falls sowohl der Sequenzkonstruktor als auch der klassische Konstruktor angewandt werden kann, wird der Sequenzkonstruktor vorgezogen.

    • Falls der Sequenzkonstruktor nicht angewandt werden kann (z. B. Typinkompatibilität), werden die klassischen Konstruktoren angewendet.

  2. Im Konstruktoraufruf wird keine Initialisiererliste genutzt.

    • Der Sequenzkonstruktor wird nicht verwendet.

Listing 7.2 soll die Theorie mit der Praxis verknüpfen.

initializerListOverload.cpp

Listing 7.2 ist aufs Wesentliche reduziert. So besitzen die Konstruktorargumente in MyData keinen Namen (Zeilen 7, 11 und 15), und die Objekte aus den Konstruktoraufrufen in Zeile 25, 28 und 31 werden an keine Variable gebunden. Die Ausgabe bestätigt die erweiterten Regeln zum Überladen von Konstruktoren:

MyData{1,2} in Zeile 25 wird auf den Sequenzkonstruktor in Zeile 15 abgebildet, obwohl der klassische Konstruktor in Zeile 11 anwendbar ist. Um diesen Konstruktor aufzurufen, muss MyData(1,2) in Zeile 31 mit runden Klammern verwendet werden. Anders verhält es sich mit dem dritten Konstruktoraufruf MyData{"dummy",2} in Zeile 37. Da kein Sequenzkonstruktor die richtige Signatur besitzt, wird trotz Initialisiererliste der klassische Konstruktor in Zeile 7 verwendet. Sowohl der Built-in-Typ int als auch das Aggregat intArray kann weiterhin über Initialisiererlisten initialisiert werden.

initListConstructor.cpp

Aufgabe 7-1

Schreiben Sie einen Datentyp, der eine Initialisiererliste von Paaren (int,std::string) annimmt.

Iterieren Sie im Initialisiererlisten-Konstruktor Ihres Datentyps über die Initialisiererliste und geben Sie alle Paare aus.

initializerListDirect.cpp

Aufgabe 7-2

Definieren Sie eine Initialisiererliste.

Viel hat eine std::initializer_list<std::string> nicht zu bieten (Tabelle 7.1). Definieren Sie eine Initialisiererliste und geben Sie deren Elemente aus.

myStrangeType.cpp

Aufgabe 7-3

Erweitern Sie den Datentyp MyStrangeType um einen klassischen Konstruktor und einen Sequenzkonstruktor.

template<typename T>
class MyStrangeType{};

Dabei soll der Sequenzkonstruktor der Standardkonstruktor von MyStrangeType sein. Entwerfen Sie den Datentyp und wenden Sie beide Konstruktoren an.

Da die Delegation von Konstruktoren recht intuitiv ist, gibt es zur Abhandlung der Delegation von Konstruktoren in Teil I, nur noch ein paar Anmerkungen.

Fertig konstruiertes Objekt

In klassischem C++ ist ein Objekt fertig konstruiert, wenn sein Konstruktor ausgeführt wurde. Dies ändert sich mit C++11. Hier gilt: Sobald der erste Konstruktor fertig ausgeführt wurde, ist das Objekt fertig konstruiert. Das bedeutet natürlich, dass jeder weitere Konstruktor auf einem fertig konstruierten Objekt agiert.

Auszeichner von Konstruktoren

Zugriffsbeschränkungen auf Konstruktoren wie public, protected oder private oder auch Auszeichner von Konstruktoren wie inline oder explicit haben keinen Einfluss auf die Delegation von Konstruktoren.

ill-formed

Eine Gefahr muss aber im Fokus bleiben. Konstruktoren, die direkt oder indirekt rekursiv aufgerufen werden, führen zu einem ill-formed-Programm. Ein ill-formed-Programm ist ein Programm, das nicht der Syntax genügt. Dabei muss der Compiler nicht mal eine Warnung ausgeben. Ein Beispiel aus dem aktuellen C++11 Draft (Komitee, 2008) zur Delegation von Konstruktoren folgt:

delegateConstructor.cpp

Aufgabe 7-4

Schreiben Sie ein Programm, das die rekursive Delegation der Konstruktoren von Listing 2.3 anwendet.

Was passiert, wenn Sie das Programm übersetzen bzw. ausführen? Diese spannende Frage lässt sich mit dem aktuellen GCC- und Clang-Compiler beantworten.

Ein einfaches using Base::Base in Listing 7.4 genügt, und alle Konstruktoren der Klasse Base stehen in der Klasse Derived zur Verfügung. In Teil I, ist dieses neue Feature in Aktion zu sehen.

Dabei werden die Konstruktoren in der Klasse Derived nur impliziert definiert, wenn sie auch benutzt werden.

Kopier-, Move- und Standardkonstruktor

Zwar werden alle Konstruktoren der Basisklasse in die abgeleitete Klasse vererbt, es gibt aber eine Ausnahme zu dieser Regel. Der Kopier-, der Move- und der Standardkonstruktor werden nicht geerbt. Da die drei geerbten Konstruktoren nicht als benutzerdefinierte Konstruktoren behandelt werden, erzeugt der Compiler bei Bedarf die drei Konstruktoren nach den bekannten Regeln.

Vorrang von benutzerdefinierten Konstruktoren

Besitzt ein benutzerdefinierter Konstruktor die gleiche Signatur wie ein geerbter Konstruktor, versteckt der benutzerdefinierte Konstruktor den geerbten. In Listing 7.5 wird daher der Konstruktor von Derived(int i){} verwendet.

Charakteristiken der geerbten Konstruktoren

Die abgeleitete Klasse erbt nicht nur die Konstruktoren, sondern auch deren Charakteristiken. Dies betrifft die Zugriffsbeschränkungen public, protected und privat sowie deren Deklaration als explicit- bzw. constexpr-Konstruktor. Somit quittiert der Compiler den Aufruf Derived d(5); in Listing 7.6 mit einer Fehlermeldung, da der Konstruktor der Basisklasse privat ist.

Nicht initialisierte Variablen

Besitzt eine abgeleitete Klasse eigene Variablen, ist die Gefahr recht groß, dass diese durch die neue Syntax nicht initialisiert werden. In Listing 7.7 wird die Variable j des Objekts d durch den Aufruf Derived d(5) nicht initialisiert.

Mehrfachvererbung

Eine typische Fehlerquelle bei der Mehrfachvererbung fehlt noch. Erbt eine Klasse die Konstruktoren von mehreren Klassen, sodass die erbende Klasse zwei Konstruktoren mit der gleichen Signatur einführt, führt dies zu einem Compiler-Fehler. Diese Zweideutigkeit lässt sich aber einfach auflösen, indem in der erbenden Klasse ein Konstruktor mit der gleichen Signatur definiert wird, der die geerbten versteckt. Durch den Konstruktor Derived(int i){} wird der Aufruf Derived d(5) gültig.

inheritConstructor.cpp

Aufgabe 7-5

Leiten Sie public, protected und private von einer Basisklasse Base ab und verwenden Sie das Vererben von Konstruktoren.

Die geerbten Konstruktoren der Basisklasse behalten ihre Sichtbarkeit der Basisklasse. Die abgeleiteten Klassen schränken ihre Sichtbarkeit durch protected und private ein. Was bedeutet das für die Sichtbarkeit der geerbten Konstruktoren?

Bevor Sie das kleine Testprogramm schreiben und ausführen, überlegen Sie sich zuerst den entscheidenden Punkt: Welches Verhalten erwarten Sie?

C++11 erlaubt das direkte Initialisieren von Klassenelementen. Damit hebt C++11 die Einschränkung des klassischen C++ auf, das diese Features nur für statische, konstante Elemente integralen Typs zulässt. In Teil I, wurde diese neue Funktionalität bereits eingeführt. Was noch fehlt, ist das direkte Initialisieren von Klassenelementen für eine Klasse. Als Beispiel soll die Klasse Widget in Listing 7.9 dienen, die sukzessive refaktoriert werden soll.

Zuerst die Ausgangsimplementierung, die ohne C++11-Feature auskommt:

Hier bietet es sich an, die Variablen frame und visible direkt zu initialisieren. Damit wird die Klasse Widget deutlich übersichtlicher (Listing 7.10).

Wird eine direkt initialisierte Variable auch in einem Konstruktoraufruf gesetzt, hat dieser Vorrang. Daher können die Variablen height und width zusätzlich direkt initialisiert werden. Damit erhalten wir die endgültige Version der Klasse Widget in Listing 7.11.

Soll die Klasse erweitert oder ein gemeinsamer Konstruktor identifiziert werden, an den alle anderen Konstruktoren ihre Arbeit delegieren, ist dies mit der endgültigen Fassung der Klasse Widget in Listing 7.11 deutlich einfacher und damit weniger fehleranfällig.

directInitializationOfClassElements.cpp

Aufgabe 7-6

Wenden Sie Listing 7.11 in einem kleinen Programm an.

Schreiben Sie ein kleines Programm rund um Listing 7.11 und seien Sie gespannt darauf, ob Ihr C++-Compiler das direkte Initialisieren von Klassenelementen in der erweiterten Form von C++11 unterstützt

Explizite Klassendefinitionen

Ermöglichen die neuen Schlüsselwörter default und delete, vom Compiler erzeugte Methoden anzufordern oder zu unterdrücken, so ermöglichen die neuen Schlüsselwörter override und final mehr Kontrolle über Ableitungshierarchien. Entsprechend dem expliziten Konvertierungskonstruktor erlaubt der explizite Konvertierungsoperator es nun in C++11, implizite Konvertierungen zu unterbinden.

default und delete

An die Syntax zur Definition von rein virtuellen Funktionen angelehnt (virtual pureVirtual()=0;), führt C++11 die zwei neuen Schlüsselwörter default und delete ein. Dabei erlaubt default, die vom Compiler erzeugte Implementierung spezieller Methoden oder Operatoren zu nutzen. Hingegen ermöglicht delete, die Definition automatisch sichtbarer Funktionen zu unterbinden.

In Teil I, wurden einige typische Anwendungsfälle, wie default und delete, gezeigt, die bewirken, dass Objekte nicht kopierbar sind, den von Compiler erzeugten Standardkonstruktor besitzen und auf dem Heap angelegt werden. Was nun folgt, sind die Details.

Es verwundert immer wieder, wenn man sich vergegenwärtigt, welche Methoden und Operatoren der Compiler bei Bedarf erzeugt.

Methoden:

Operatoren:

Operatoren (neu mit C++11):

Die Details lassen sich in Anhang D, nachlesen.

Mächtigkeit von default

Diese mächtige Compiler-Funktionalität lässt sich nur explizit mit default nutzen. So kann:

Nicht trivial

Die vom Compiler implizit erzeugte Methode oder der Operator ist trivial. Nicht trivial ist sie dann, wenn der Anwender die Charakteristik dieser speziellen Funktion verändert. Dies umfasst insbesondere die Punkte:

In diesem Fall muss die Methode außerhalb des Klassenkörpers definiert werden.

Listing 7.12 soll den verwirrenden Sachverhalt auflösen.

Listing 7.12 stellt ein paar nicht triviale spezielle Methoden und Operatoren vor. So ist der Default-Konstruktor (1) privat, der Destruktor ist virtuell (2), und er besitzt eine Ausnahmespezifikation (4), der Copy-Konstruktor ist explizit (3), und zuletzt nimmt der Zuweisungsoperator (5) sein Argument nicht const an.

Trennung von Interface und Implementierung

Die Mächtigkeit von default lässt sich in einem plakativen Satz zusammenfassen.

Listing 7.13 zeigt dieses einfache Prinzip explizit auf. In der Klasse MoveOnly gibt der Entwickler die Deklaration des Default-Konstruktors in Zeile 6 vor, während der Compiler mit =default für die Implementierung sorgt.

default.cpp

MoveOnly unterstützt nur die Move-Semantik, denn sowohl der Kopierkonstruktor (Zeile 8) als auch der Zuweisungsoperator (Zeile 12) ist auf delete gesetzt. Interessant an der Implementierung des Move-Konstruktors (Zeile 10) und des Move-Zuweisungsoperators (Zeile 14) ist, dass sie auf die Methode std::move zurückgreifen, um die Ressource explizit zu transferieren. Daher ist auch der Aufruf MoveOnly m2(std::move(m1)) in Zeile 24 gültig. Die Ausführung des Programms führt zur erwarteten Fehlermeldung, da der Kopierkonstruktor (Zeile 28) nicht unterstützt wird.

Eindeutiger kann eine Fehlermeldung nicht sein.

Mächtigkeit von delete

Ist es das Ziel, die vom Compiler erzeugte Funktion zu löschen, ist delete das Mittel der Wahl in C++11. Die Funktionalität von delete ist nicht auf die gleichen Methoden oder auch Operatoren wie default beschränkt. Zwei typische Anwendungsfälle bieten sich für delete an:

Listing 7.14 soll diese beiden Anwendungsfälle verdeutlichen. Die Klasse TypeOnHeap (Zeile 1) löscht den implizit erzeugten Destruktor. Damit können automatische Variablen nicht mehr angelegt werden, da die C++-Laufzeit den Destruktor beim automatischen Löschen des Objekts benötigt. Ähnlich interessant ist die Struktur OnlyInt, die nur mit int-Argumenten instanziiert werden kann. Erreicht wird dies durch die zwei Konstruktoren (Zeilen 7 und 10). Der erste wird ausschließlich für int-Argumente, der zweite wird bei jedem anderen beliebigen Argument aufgerufen. Damit wird jede Konvertierung nach int unterbunden, die ohne das Funktions-Template (Zeile 9) stattfinden würde.

delete.cpp

Dieses beeindruckende Ergebnis zeigt der Versuch, den Sourcecode zu übersetzen.

virtualDestruktor.cpp

Aufgabe 7-7

Verifizieren Sie, dass der vom Compiler erzeugte triviale virtuelle Destruktor performanter als der vom Anwender implementierte ist.

Polymorphe Basisklassen benötigen einen virtuellen Destruktor, denn das Löschen einer abgeleiteten Klasse durch einen Zeiger auf die polymorphe Basisklasse ist undefiniert, wenn dessen Destruktor nicht virtuell ist.

Das Unheil lässt sich schnell skizzieren:

C++11 bietet zwei Möglichkeiten an, den trivialen, virtuellen Destruktor zu definieren:

Die interessante Eigenschaft kommt zum Schluss. Der vom Compiler erzeugte virtuelle Destruktor soll performanter sein. Die Frage ist nun, wie kann das verifiziert werden?

singletonDefaultDelete.cpp

Aufgabe 7-8

Implementieren Sie das Singleton Pattern mit den neuen Schlüsselwörtern default und delete.

Eines der bekanntesten Design Patterns ist das Singleton Pattern (Singleton, 2011). Dieses Muster sichert im klassischen Fall zu, dass es nur ein Objekt einer Klasse gibt. Erreicht wird dieses Verhalten der Singleton-Klasse dadurch, dass der Standardkonstruktor privat ist und sowohl der Copy-Konstruktor als auch der Zuweisungsoperator privat deklariert sind. Um die einzige Instanz der Singleton-Klasse zu erhalten, bietet diese die statische Methode getInstance an. Beim ersten Aufruf der statischen Methode getInstance wird die statische Instanz erzeugt. Diese Prosa lässt sich auch in C++-Code formulieren (Listing 7.16).

Schreiben Sie MySingleton mit den neuen Sprachmitteln default und delete in C++11. Beachten Sie, welche der Methoden trivial bzw. nicht trivial sind. Verwenden Sie die Singleton-Klasse in einem minimalen Programm.

deleteVirtual.cpp

Aufgabe 7-9

Wenden Sie die virtuelle und die nicht virtuelle Ableitung in einer Klassenhierarchie an.

In Abbildung 7.4 ist die – zugegeben konstruierte – Ableitungshierarchie vorgegeben.

Implementieren Sie für jede Klasse eine Methode showMe, die den Namen der Klasse ausgibt. Variieren Sie nun, indem Sie Methoden der Hierarchie als delete deklarieren oder auch als virtuell erklären. Entspricht die Ausgabe des Programms Ihren Erwartungen?

Eine Klassenhierarchie, nicht nur von grafischen Frameworks, kann leicht unübersichtlich werden. Diese Problematik wird noch dadurch verstärkt, dass diese gern im Fluss sind. Nicht nur die Applikation, die das Framework nutzt, sondern das Framework selbst befindet sich häufig in der Überarbeitung. Um sicherzustellen, dass virtuelle Funktionen tatsächlich eine Methode der Basisklasse überschreiben und dass eine virtuelle Methode nicht irrtümlich überschrieben wird, führt C++11 zwei neue Bezeichner ein, override und final. Damit stellt der Compiler sicher, dass der Vertrag eingehalten wird. Tabelle 7.2 fasst die Eigenschaften der zwei Attribute zusammen.

Beide Schlüsselwörter werden erst durch den aktuellen GCC 4.7 (GCC 4.7, 2011) unterstützt.

virtualFunctionsOverride.cpp

Ein genaueres Studium von Listing 7.17 zeigt, dass das beabsichtigte, aber falsche Erklären neuer virtueller Methoden oder auch das unbeabsichtigte Überschreiben von Methoden der Klassenhierarchie, die als final deklariert wurden, deutlich schwieriger ist. Denn der Compiler prüft nicht nur den Namen, sondern auch die Parameter (Zeile 18), den Rückgabetyp (Zeile 24) und die constZusicherung der Methode (Zeile 21). Genau dies moniert der GCC 4.7.

final class

Die Geschichte mit final ist noch nicht zu Ende. Durch den Identifier final kann eine Klasse als final ausgezeichnet werden. Dadurch ist es nicht mehr möglich, von ihr abzuleiten.

Das Übersetzen des kleinen Codeschnipsels in Listing 7.18 quittiert der Compiler mit einer eindeutigen Fehlermeldung, zu sehen in Abbildung 7.6.

sort.cpp

Aufgabe 7-10

Verwenden Sie für die Implementierung der Template-Methode die Identifier final und override.

Die Schablonenmethode (template method) gibt eine Ablaufstruktur für eine Familie von Algorithmen vor, die aus mehreren Einzelschritten besteht. Während die Reihenfolge der Einzelschritte feststeht, hängt die Logik der Einzelschritte von dem konkreten Algorithmus ab. Die Details zur Schablonenmethode lassen sich bei Wikipedia (Schablonenmethode, 2011) nachlesen.

Entwerfen Sie eine abstrakte Basisklasse Sort, die eine Methode processData besitzt. Es soll nicht möglich sein, die Methode zu überschreiben. Diese Methode soll die virtuellen Methoden readData, sortData und writeData in dieser Reihenfolge aufrufen. Während die Basisklasse Sort einfache Implementierungen für readData und writeData vorhält, soll die Methode sortData rein virtuell sein. Implementieren und instanziieren Sie eine Dummyklasse QuickSort, um Ihren Entwurf zu testen. Implementieren Sie dazu auch die zwei Methoden readData und writeData. Stellen Sie sicher, dass die Methoden die Methoden der Basisklasse Sort überschreiben.

Konvertierungskonstruktor und Konvertierungsoperator

Beim Entwurf der Klasse MyClass stehen dem C++-Entwickler zwei Wege offen, die Konvertierung seines Datentyps MyClass in einen fremden Datentyp anzubieten. Über einen Konvertierungskonstruktor wird der fremde Datentyp zu MyClass konvertiert. Über einen Konvertierungsoperator wird MyClass in einen fremden Datentyp konvertiert. Abbildung 7.7 stellt diese beiden Richtungen exemplarisch dar.

Listing 7.19 zeigt die zwei Richtungen in Anwendung.

convertImplicit.cpp

Da die heimliche Konvertierung nicht immer erwünscht ist, lässt sich durch die Angabe des Schlüsselworts explicit die implizite Konvertierung nach MyClass in den Zeilen 23 und 24 unterbinden. Leider ist die Angabe des Schlüsselworts explicit im C++98-Standard nur für den Konvertierungskonstruktor möglich, aber nicht für den Konvertierungsoperator. Damit lassen sich die Aufrufe in den Zeilen 32 und 33 nicht verhindern.

Mit C++11 wurde diese Asymmetrie beseitigt. Sowohl der Konvertierungskonstruktor als auch der Konvertierungsoperator von MyClass ist explicit deklariert (Listing 7.20).

convertExplicit.cpp

Die entscheidenden Zeilen in Listing 7.20 sind die Schlüsselwörter explicit in den Zeilen 8 und 9. Sie führen dazu, dass das Programm nicht mehr kompiliert.

bool

Die Konvertierung nach bool mit dem expliziten Konvertierungsoperator verhält sich speziell. So ignoriert der Compiler das Schlüsselwort explicit, damit der Datentyp in Ausdrücken verwendet werden kann, die zu den Wahrheitswerten true oder false evaluiert werden. Eine implizite Konvertierung nach int führt der C++-Compiler hingegen nicht durch (Listing 7.21).

explicit operator bool() in Zeile 2 bewirkt, dass der arithmetische Ausdruck in Zeile 11 nicht gültig ist. Der Bezeichner explicit besitzt keinen Einfluss auf die implizite Konvertierung in den Zeilen 9 und 10 nach bool.

explicitConvertOperator.cpp

Aufgabe 7-11

Überprüfen Sie Ihren Sourcecode auf implizite Konvertierungen.

Wie erreichen Sie, dass der Code in Listing 2.22 gültig ist, obwohl Sie den Konvertierungsoperator nach bool als explicit erklärt haben.