Die objektorientierte Programmierung mit C++ erlaubt es, Konzepte elegant in Klassenhierarchien zu modellieren. Kein Wunder also, dass die C++ Core Guidelines dafür mehr als 30 Regeln parat halten.
Zunächst scheint eine Begriffsklärung angebracht: Eine Klassenhierarchie, die der Artikel bespricht, repräsentiert eine Menge hierarchisch organisierter Konzepte. Die Basisklassen stellen typischerweise das Interface dar. Davon gibt es zwei Typen. Den ersten Typ bezeichnen Entwickler gern als Schnittstellen-Vererbung (Interface Inheritance), den zweiten als Implementierungs-Vererbung (Implementation Inheritance).
Im ersten Fall findet die Ableitung öffentlich (»public«), im zweiten privat (»private«) statt. Ein Beispiel: Leitet ein Entwickler eine Klasse »Handball« öffentlich von einer anderen Klasse »Ball« ab, lässt sich die abgeleitete Klasse »Handball« anstelle der Basisklasse »Ball« verwenden. Dieses Prinzip ist auch als Liskovsches Substitutionsprinzip [1] bekannt. Formelhaft lautet es: »is a«. Ein Handball ist auch ein Ball.
Im Gegensatz dazu hilft private Vererbung typischerweise der abgeleiteten Klasse, die Funktionalität der Basisklasse in ihrer Implementierung zu verwenden. Ein schönes Beispiel dafür liefert das Entwurfsmuster Adapter [2], wenn der Entwickler es mit Mehrfachvererbung umsetzt. Die Idee des Musters Adapter besteht darin, ein bestehendes Interface an ein neues anzupassen. Der zugehörige Code vererbt die Implementierung privat und das neue Interface öffentlich. Letzteres bietet dann die privat geerbte Klassenfunktionalität an.
Blick in den Ratgeber
Die erste Regel (C.120) der C++ Core Guidelines [3] zum Thema lautet: “Benutze Klassenhierarchien nur, um Konzepte mit inhärenten hierarchischen Strukturen zu repräsentieren” (Abbildung 1). Bringen die in Code modellierten Datentypen also eine inhärent hierarchische Struktur mit, sollte die Klassenstruktur diese abbilden. Die Regel leuchtet ein, da Softwaresysteme meist intuitiv werden, wenn sie eine Analogie zur physischen Welt herstellen.
So kann die Aufgabe eines Software-Architekten darin bestehen, ein komplexes System zu modellieren, das aus einer Menge von Subsystemen besteht. Ein konkreter Fall wäre eine Familie von Defibrillatoren. Ein exemplarisches Subsystem stellt dann die Schnittstelle zum Benutzer dar. Die Anforderung lautet, Benutzerschnittstellen wie Tastatur, Touchscreen oder auch Buttons zu unterstützen. Solch ein System von Subsystemen besitzt eine inhärent hierarchische Struktur. Bildet die Modellierung die physische Struktur ab, ist sie im Top-down-Ansatz einfach zu verstehen.
Listing 1
Hierarchische Struktur der Benutzerschnittstelle
01 class DrawableUIElement {
02 public:
03 virtual void render() const = 0;
04 [...]
05 };
06 class AbstractButton : public DrawableUIElement {
07 public:
08 virtual void onClick() = 0;
09 [...]
10 };
11 class PushButton : public AbstractButton {
12 virtual void render() const override;
13 virtual void onClick() override;
14 [...]
15 };
16 class Checkbox : public AbstractButton {
17 [...]
18 };
Ein Positivbeispiel für eine hierarchische Struktur ist die Benutzerschnittstelle aus Listing 1. Es geht auch anders. Das Klassen-Template »Container« (Listing 2) gibt das Interface für eine Liste, einen Vektor und einen Baum vor. Das Listing modelliert, was nicht modellierbar ist.
Listing 2
Die falsche Abstraktion Container
01 template<typename T>
02 class Container {
03 public:
04 // list operations:
05 virtual T& get() = 0;
06 virtual void put(T&) = 0;
07 virtual void insert(Position) = 0;
08 [...]
09 // vector operations:
10 virtual T& operator[](int) = 0;
11 virtual void sort() = 0;
12 [...]
13 // tree operations:
14 virtual void balance() = 0;
15 [...]
16 };
Im nächsten Schritt geht es um Schnittstellen und die Faustregel C.121: “Willst du eine Basisklasse als Interface verwenden, mache sie zu einer rein abstrakten Klasse” (Abbildung 1). Eine abstrakte Klasse besitzt mindestens eine rein virtuelle Funktion, etwa »virtual void function() = 0«. Diese verhindert, dass C++ eine Instanz dieser Klasse erzeugt. Meist implementieren Entwickler die Methode in einer abgeleiteten Klasse.
Interfaces sollten aus öffentlichen, rein virtuellen Funktionen bestehen und einen leeren (»default«) virtuellen Destruktor besitzen: »virtual ~My_interface() = default«. Wer diese Regel vergisst, kann böse Überraschungen erleben (Listing 3). Zerstört der Destruktor der Basisklasse (»Goof«) eine abgeleitete Klasse (»Derived«), entsteht undefiniertes Verhalten, wenn der Destruktor der Basisklasse nicht virtuell ist. Undefiniertes Verhalten macht in der Regel den Destruktor der abgeleiteten Klasse und somit des Strings »s« wirkungslos. Es kann auch sein, dass das Programm sich per Segmentation-Fehler verabschiedet.
Listing 3
Ein Interface ohne virtuellen Destruktor
01 class Goof {
02 public:
03 [...] // hier nur rein virtuelle Funktionen
04 // Kein virtueller Destruktor
05 };
06 class Derived : public Goof {
07 string s;
08 [...]
09 };
10 void use()
11 {
12 unique_ptr<Goof> p {new Derived{"here we go"}};
13 f(p.get()); // benutze Derived über das Goof-Interface
14 } // Speicherleck
Den Vorteil abstrakter Klassen bringt die Regel C.122 auf den Punkt (Abbildung 1): “Benutze abstrakte Klassen als Schnittstellen, wenn du eine komplette Trennung von Interface und Implementierung benötigst.” Hängt der Client nur vom Interface ab, lassen sich verschiedene Implementierungen des Device zur Laufzeit verwenden (Listing 4).
Listing 4
Trennung von Interface und Implementierung
01 struct Device {
02 virtual void write(span<const char> outbuf) = 0;
03 virtual void read(span<char> inbuf) = 0;
04 };
05
06 class D1 : public Device {
07 [...] // Daten
08 void write(span<const char> outbuf) override;
09 void read(span<char> inbuf) override;
10 };
11
12 class D2 : public Device {
13 [...] // verschiedene Daten
14 void write(span<const char> outbuf) override;
15 void read(span<char> inbuf) override;
16 };
Schlüsselwörter
Modernes C++ bringt gleich drei Schlüsselwörter mit, die in Kombination allerdings zu häufig zum Einsatz kommen:
- »virtual« erklärt eine Funktion, die sich in einer abgeleiteten Klasse überschreiben lässt.
- »override« legt fest, dass die Funktion virtuell ist und eine virtuelle Funktion einer Basisklasse überschreibt.
- »final« stellt sicher, dass die Funktion virtuell ist und eine abgeleitete Klasse sie nicht überschreiben darf.
Vor allem der gleichzeitige Einsatz der drei Schlüsselwörter ist im besten Fall überflüssig und im schlechtesten verwirrend: “Virtuelle Funktionen sollten exklusiv entweder »virtual«, »override« oder »final« verwenden” (C.128).
Doch welche Anwendungsbereiche decken die drei Schlüsselwörter ab? Ganz einfach: »virtual« sollten Entwickler einsetzen, um eine neue, virtuelle Funktion zu definieren. Mit »override« erklären sie eine überschreibende Funktion, mit »final« eine überschreibende Funktion, die eine abgeleitete Klasse nicht mehr überschreiben darf.
Listing 5
Falsch eingesetzte Schlüsselwörter
01 struct Base{
02 virtual void testGood(){}
03 virtual void testBad(){}
04 };
05
06 struct Derived: Base{
07 void testGood() final {}
08 virtual void testBad() final override {}
09 };
10
11 int main(){
12 Derived d;
13 }
Die Funktion »testBad()« in Listing 5 trägt ihren Namen zu Recht. Als Methode der Klasse »Derived« besitzt sie viele überflüssige Informationen. So lassen sich »final« oder »override« nur einsetzen, wenn die Funktion virtuell ist. Entwickler sollten »virtual« entfernen und nur »void testBad() final override{}« schreiben. »final« ist nur zulässig, wenn die Funktion bereits virtuell ist. Dann entfernt der Entwickler »override« und schreibt »void testBad() final {}«. Das erhöht auch die Lesbarkeit des Codes.
Zu viel Virtualität
Die Regel C.132 lautet kurz und bündig: “Mache ohne Grund keine Funktion virtuell.” Getreu der C++-Metaregel, nicht für Dinge zu zahlen, die man nicht braucht, sollten Entwickler Funktionen nur als virtuell deklarieren, wenn es notwendig ist. Darin unterscheidet sich C++ deutlich von Python oder Java, da in diesen Programmiersprachen eine Funktion automatisch virtuell ist.
Das Problem ist, dass virtuelle Methoden Nebenkosten verursachen. Sie wirken sich auf die Performance des Programms und die Größe des Objekts aus. Führt ein Programm eine virtuelle Funktion aus, braucht es einerseits eine zusätzliche Zeiger-Indirektion. Andererseits muss es eine zusätzliche »Virtual Pointer Table« für jede Klasse erzeugen, die virtuelle Methoden besitzt. Zugleich ist eine virtuelle Funktion fehleranfällig. Steckt sie in einer abgeleiteten Klasse, lässt sie sich überschreiben.
Eine Frage, die in C++-Schulungen immer wieder auftaucht, beantwortet die Regel C.133: “Vermeide geschützte Daten.” Deklariert der Entwickler Daten als »protected«, lassen diese sich auch von abgeleiteten Klassen verwenden. Dies aber macht das Programm anspruchsvoller und fehlerträchtiger. Kommen in der Basisklasse als »protected« markierte Daten zum Einsatz, lassen sich die abgeleiteten Klassen nicht mehr in Isolation betrachten. Das bricht die Kapselung der Klasse auf, denn geschützte Daten verhalten sich innerhalb der Klassenhierarchie wie globale Variablen.
Nach einem Bruch der Kapselung sollte sich der Entwickler die folgenden Fragen stellen: Muss er einen Konstruktor implementieren, um die »protected«-Daten richtig zu initialisieren? Welchen Wert besitzt das »protected«-Datum, wenn es zum Einsatz kommt? Welche Funktionalität beeinflusst er noch, wenn er das geschützte Datum ändert?
Die Antworten darauf werden umso anspruchsvoller, je tiefer die Klassenhierarchie reicht. Es folgt auch eine konzeptionelle Frage: Was passiert, wenn er die Mitglieder einer Klasse als »public«, »protected« und »private« ausweist – unerheblich davon, ob es sich um Daten oder Funktionen handelt?
Für »public« und »private« lässt sich dies einfach beantworten. Die als »public« deklarierten Mitglieder stellen das Interface, die als »private« deklarierten die Implementierung der Klasse dar. Wie verhält es sich aber mit geschützten Mitgliedern? Sie sind Teil des Interface und agieren als Interface zur abgeleiteten Klassen. Jede Modifikation dieser geschützten Mitglieder beeinflusst konsequenterweise die abgeleiteten Klassen.
Wie geht’s weiter?
Natürlich waren dies noch nicht alle Regeln der C++ Core Guidelines zu Klassenhierarchien. Typische Fehler beim Einsatz der Polymorphie, die Herausforderungen beim Überladen von Funktionen und die Gefahren von Konvertierungs-Konstruktoren und -Operatoren sind allemal einen Artikel wert.
Infos
-
Liskovsches Substitutionsprinzip: https://de.wikipedia.org/wiki/Liskovsches_Substitutionsprinzip
-
Adapter-Pattern: https://de.wikipedia.org/wiki/Adapter_(Entwurfsmuster)
-
C++ Core Guidelines: http://isocpp.github.io/CppCoreGuidelines/CppCoreGuidelines







