C++ Core Guidelines: Klassenhierarchien

Modernes C++  –  6 Kommentare

Hier geht es um Klassenhierarchien im Allgemeinen und im Speziellen. Die C++ Core Guidelines bieten gut 30 Regeln dazu an. Es gibt also viel zu erzählen.

Aber zuerst einmal was ist eine Klassenhierarchie? Die Guidelines geben eine eindeutige Antwort: Sie repräsentiert eine Menge hierarchisch organisierter Konzepte. Die Basisklassen stellen typischerweise das Interface dar. Es gibt zwei Typen von Interfaces. Die erste Form wird gerne als Schnittstellenvererbung (interface inheritance), die zweite als Implementierungsvererbung (implementation interface) bezeichnet.

Die ersten drei Regeln besitzen einen sehr allgemeinen Fokus. Sie sind quasi die Zusammenfassung für die deutlich konkreteren Regeln, die im Anschluss folgen.

C.120: Use class hierarchies to represent concepts with inherent hierarchical structure (only)

Diese Regel ist recht einleuchtend. Falls du etwas in Code modellierst, das eine inhärent hierarchische Struktur besitzt, solltest du es als Hierarchie modellieren. Für mich ist die einfachste Art, mir zu meinem Code Gedanken zu machen, die, wenn es gelingt, eine Analogie zwischen der Welt und meinem Code herzustellen.

Zum Beispiel kann die Aufgabe eines Softwarearchitekts darin bestehen, ein komplexes System zu modellieren, das aus einer Menge von Subsystemen besteht. Dieses komplexe System war in meinem konkreten Fall eine Familie von Defibrillatoren dar. Ein exemplarisches Subsystem stellt die Schnittstelle zum Benutzer dar. Damit gab es die Anforderung, verschiedene Benutzerschnittstellen wie Tastatur, Touchscreen oder einfach auch nur Buttons zu unterstützen. Solch ein System von Subsystemen besitzt eine inhärent hierarchische Struktur. Meine Modellierung bildete daher die physikalische Struktur ab und war damit relativ leicht im Top-down-Ansatz zu erfassen.

Selbstverständlich ist das exemplarische Beispiel für die Anwendung eine Hierarchie der Entwurf einer grafischen Beschnutzerschnittstelle (GUI). Genau dies Beispiel verwendet die C++ Core Guidelines:

class DrawableUIElement {
public:
virtual void render() const = 0;
// ...
};
class AbstractButton : public DrawableUIElement {
public:
virtual void onClick() = 0;
// ...
};
class PushButton : public AbstractButton {
virtual void render() const override;
virtual void onClick() override;
// ...
};
class Checkbox : public AbstractButton {
// ...
};

Wenn hingegen das zu modellierende System nicht inhärent hierarchisch ist, solltest du es auch nicht hierarchisch modellieren. Genau das bringt das Codefragment auf den Punkt.

template<typename T>
class Container {
public:
// list operations:
virtual T& get() = 0;
virtual void put(T&) = 0;
virtual void insert(Position) = 0;
// ...
// vector operations:
virtual T& operator[](int) = 0;
virtual void sort() = 0;
// ...
// tree operations:
virtual void balance() = 0;
// ...
};

Warum ist das Beispiel schlecht? Die Antwort steht direkt im Sourcecode. Das Klassen-Template Container besteht nur aus rein virtuellen Funktionen, um eine Liste, einen Vektor und einen Baum zu modellieren. Das bedeutet, falls du Container als Interface verwendest, musst du drei vollkommen verschiedene Konzepte implementieren.

C.121: If a base class is used as an interface, make it a pure abstract class

Eine abstrakte Klasse ist eine Klasse, die zumindestens eine rein virtuelle Funktion besitzt. Ein rein virtuelle Funktion (virtual void function() = 0) ist eine Funktion, die durch eine abgeleitete Klasse implementiert werden muss, falls die Klasse selbst nicht abstrakt sein soll.

Nur der Vollständigkeit halber. Eine abstrakte Klasse kann bereits Implementierungen für die rein virtuellen Funktionen anbieten. Diese Implementierungen lassen sich von abgeleiteten Klassen verwenden.

Interfaces sollten aus öffentlichen rein virtuellen Funktionen bestehen und einen default/leeren virtuellen Destruktor (virtual ~My_interface() = default) besitzen. Falls du dieser Regel nicht folgst, kann es zu bösen Überraschungen kommen.

class Goof {
public:
// ...only pure virtual functions here ...
// no virtual destructor
};
class Derived : public Goof {
string s;
// ...
};
void use()
{
unique_ptr<Goof> p {new Derived{"here we go"}};
f(p.get()); // use Derived through the Goof interface
} // leak

Falls p seinen Gültigkeitsbereich verlässt, wird es automatisch destruiert. Goof besitzt keinen virtuellen Destruktor. Daher wird der Destruktor von Goof und nicht von Derived aufgerufen. Die böse Überraschung ist, dass der Destruktor des Strings s nicht automatisch aufgerufen wird. Damit gibt es ein Speicherleck.

C.122: Use abstract classes as interfaces when complete separation of interface and implementation is needed

Bei abstrakten Klassen geht es um die Trennung von Interface und Implementierung. Wenn dein Client nur vom Interface abhängst, ist das Ergebnis, dass du verschiedene Implementierung des Devices im folgenden Beispiel zur Laufzeit verwenden kannst.

struct Device {
virtual void write(span<const char> outbuf) = 0;
virtual void read(span<char> inbuf) = 0;
};
class D1 : public Device {
// ... data ...
void write(span<const char> outbuf) override;
void read(span<char> inbuf) override;
};
class D2 : public Device {
// ... different data ...
void write(span<const char> outbuf) override;
void read(span<char> inbuf) override;
};

Ich nenne diese Regel gerne das Meta Design Pattern. Das Konzept, Interface und Implementeriung zu trennen und gegen das Interface zu programmieren, ist die Grundlage für viele der Design Pattern des wohl einflussreichsten Buch der Softwareentwicklung: Design Patterns: Elements of Reusable Object-Oriented Software.

Jetzt kommen die detallierten Regeln zur Klassenhierarchien. Die Guidelines besitzen fünfzehn davon.

In diesem Artikel geht es um die ersten drei Regeln.

C.126: An abstract class typically doesn’t need a constructor

Eine abstrakte Klasse besitzt typischerweise keine Daten. Damit benötigt sie natürlich auch keinen Konstruktor um diese zu initialisieren.

C.127: A class with a virtual function should have a virtual or protected destructor

Eine Klasse mit virtuellen Funktionen wird meist mittels eines Zeigers oder einer Referenz auf ihre Basisklasse verwendet. Falls du die abgeleitete Klasse explizit mit einem Zeiger oder einer Referenz auf die Basisklasse oder implizit mit einem Smart Pointer löschst, willst du sicher gehen, dass auch der Destruktor der abgeleiteten Klasse aufgerufen wird. Diese Regel ist sehr ähnlich zur Regel C.121, die sich mit rein virtuellen Funktionen beschäftigt.

Du kannst die Destruktionsherausforderung aber auch dadurch lösen, dass die Basisklasse einen protected und nichtvirtuellen Destruktor besitzt. Dieser Destruktor stellt sicher, dass sich ein abgeleitetes Objekt nicht mittels eines Zeigers oder einer Referenz auf die Basisklasse löschen lässt.

C.128: Virtual functions should specify exactly one of virtual, override, or final

In C++11 gibt es drei Schlüsselworte, die sich mit dem Überschreiben von Funktionen beschäftigen.

  • virtual: erklärt eine Funktion, die in einer abgeleiteten Klasse überschrieben werden kann.
  • override: stellt sicher, dass die Funktion virtuell ist und ein virtuelle Funktion einer Basisklasse überschreibt.
  • final: stellt sicher, dass die Funktion virtuell ist und sie nicht durch eine abgeleitete Klasse überschrieben werden kann.

Die Guidelines sind sehr eindeutig, wenn es um die richtige Verwendung der drei Schlüsselwörter geht.

  • Verwende virtual nur, wenn du eine neue, virtuelle Funktion definierst.
  • Verwende override nur, wenn du eine überschreibende Funktion erklärst.
  • Verwende final nur, wenn du eine überschreibende Funktion erklärst, die selbst nicht mehr überschrieben werden soll.
struct Base{
virtual void testGood(){}
virtual void testBad(){}
};

struct Derived: Base{
void testGood() final {}
virtual void testBad() final override {}
};

int main(){
Derived d;
}

Die Methode testBad() in der Klasse Derived besitzt sehr viel überflüssige Informationen.

  • Du sollst nur final oder override verwenden, falls die Funktion virtuell ist. Entferne virtual: void testBad() final override{}
  • Das Schlüsselwort final ohne das Schlüsselwort virtual zu verwenden, ist nur gültig, falls die Funktion bereits virtuell ist. Daher muss die Funktion eine Funktion einer Basisklasse überschreiben. Entferne override: void testBad() final {}

Die verbleibenden zwölf Regeln zu Klassenhierarchien habe ich noch nicht vorgestellt. Mein nächster Artikel wird dieses Loch schließen.

  • Bei der Umfrage, welches PDF-Päckchen ich als Nächstes zusammenstellen soll, hat C++17 deutlich die Nase vorn.
  • default, delete, override und final: Automatik mit Methode (freier Artikel für das Linux-Magazin)