C++ Core Guidelines: Mehr Regeln zu Klassenhierarchien

Modernes C++  –  3 Kommentare
Anzeige

Im letzten Artikel ging unsere Reise mit den Regeln zu Klassenhierarchien los. Die ersten Regeln besaßen einen allgemeineren Fokus. Nun geht unsere Reise mit einem speziellerem Fokus weiter.

Hier sind die Regeln zu Klassenhierarchien im Überblick.

Weiter geht unsere Reise mit der vierten Regel.

C.129: When designing a class hierarchy, distinguish between implementation inheritance and interface inheritance

Am Anfang steht die Frage. Was ist der Unterschied zwischen der Implementierungs- und der Schnittstellenvererbung? Die Guidelines geben eine eindeutige Antwort.

  • Schnittstellenvererbung stellt die Verwendung der Vererbung dar, um den Anwender von der Implementierung zu trennen. Insbesondere erlaubt sie es, neue abgeleitete Klassen hinzuzufügen oder zu modifizieren, ohne dass der Anwender der Basisklasse davon betroffen ist.
  • Implementierungsvererbung stellt Verwendung der Vererbung dar, um das Implementieren von neuer Funktionalität zu vereinfachen. Dies geschieht, in dem die bestehende neue Funktionalität mithilfe der bestehenden Funktionalität implementiert wird. Dies wird auch gerne "programming by difference" genannt.

Rein Schnittstellenvererbung ist es, wenn die Schnittstelle (Interface) nur aus rein virtuellen Funktionen besteht. Im Gegensatz dazu gilt. Falls die Basisklasse Daten und Funktionen anbietet, ist dies Implementierungsvererbung.

Die Guidelines geben ein Beispiel für das Vermischen der Konzepte.

class Shape {   // BAD, mixed interface and implementation
public:
Shape();
Shape(Point ce = {0, 0}, Color co = none): cent{ce}, col {co} { /* ... */}

Point center() const { return cent; }
Color color() const { return col; }

virtual void rotate(int) = 0;
virtual void move(Point p) { cent = p; redraw(); }

virtual void redraw();

// ...
public:
Point cent;
Color col;
};

class Circle : public Shape {
public:
Circle(Point c, int r) :Shape{c}, rad{r} { /* ... */ }

// ...
private:
int rad;
};

class Triangle : public Shape {
public:
Triangle(Point p1, Point p2, Point p3); // calculate center
// ...
};

Warum ist dies ein schlechtes Klassendesign?

  • Je größer und tiefer die Klassenhierarchie wird, desto schwieriger und damit auch fehleranfälliger wird es, die verschiedenen Konstruktoren zu pflegen.
  • Die Funktionen der Klasse Shape werden unter Umständen nie verwendet.
  • Falls du Daten zu der Klasse Shape hinzufügst, wirst du mit hoher Wahrscheinlichkeit den Code neu übersetzen müssen.

Falls Shape ein reines Interface wäre, das nur aus rein virtuellen Funktionen bestünde, würde es keinen Konstruktor benötigen. Klar, du musst nun die ganze Funktionalität in den abgeleiteten Klassen implementieren.

Jetzt ist natürlich die Frage, wie lässt sich das Beste aus beiden Welten vereinen: ein stabiles Interface mit Schnittstellenvererbung und Code Wiederverwendung mit Implementierungsvererbung. Eine Antwort ist Mehrfachvererbung. Die Guidelines bieten ein ziemlich ausgefeiltes Rezept dafür an.

1. Definiere die Basisklasse Shape der Klassenhierarchie als reines Interface.

class Shape {   // pure interface
public:
virtual Point center() const = 0;
virtual Color color() const = 0;

virtual void rotate(int) = 0;
virtual void move(Point p) = 0;

virtual void redraw() = 0;

// ...
};

2. Leite ein reines Interface Circle von Shape ab.

class Circle : public Shape {   // pure interface
public:
virtual int radius() = 0;
// ...
};

3. Biete eine Implementierung Impl::Shape an.

class Impl::Shape : public Shape { // implementation
public:
// constructors, destructor
// ...
Point center() const override { /* ... */ }
Color color() const override { /* ... */ }

void rotate(int) override { /* ... */ }
void move(Point p) override { /* ... */ }

void redraw() override { /* ... */ }

// ...
};

4. Implementiere ein Klasse Impl::Circle, die von dem Interface und der Implementierung ableitest.

class Impl::Circle : public Circle, public Impl::Shape {   // implementation
public:
// constructors, destructor

int radius() override { /* ... */ }
// ...
};

5. Falls du die Klassenhierarchie erweitern willst, musst du von dem Interface und der Implementierung ableiten.

Die Klasse Smiley ist ein reines Interface, das von Circle abgeleitet ist. Die Klasse Impl::Smiley ist die neue Implementierung, die sowohl von Smiley als auch von Impl::Circle erbt.

class Smiley : public Circle { // pure interface
public:
// ...
};

class Impl::Smiley : public Smiley, public Impl::Circle { // implementation
public:
// constructors, destructor
// ...
}

Hier ist das große Bild zu der Klassenhierarchie nochmals.

  • Interface: Smiley -> Circle -> Shape
  • Implementierung: Impl::Smiley -> Impl::Circle -> Impl::Shape

Hattest du vielleicht ein déjà vu? Ich schon. Eine ähnlliche Technik kommt gerne für das Adapter Pattern zum Einsatz, wenn es mit Mehrfachvererbung implementiert wird. Das Adapter Pattern ist ein Pattern aus dem berühmten Design-Pattern-Buch.

Die Idee des Adapter Pattern ist es, ein Interface in ein anderes zu übersetzen. Du erreichst dies mit Mehrfachvererbung, indem du öffentlich von dem neuen und private von dem bestehenden Interface ableitest. Das bedeutet, dass das bestehende Interface als Implementierung zum Einsatz kommt.

C.130: Redefine or prohibit copying for a base class; prefer a virtual clone function instead

Die Erläuterung zu dieser Regel kann ich sehr kurz halten. Die Regel C.67 liefert die ganze Begründung.

C.131: Avoid trivial getters and setters

Falls ein einfacher getter oder setter keinen Mehrwert liefert, erkläre das Attribut als public. Hier sind zwei Beispiele für einfache getter und setter.

class Point {   // Bad: verbose
int x;
int y;
public:
Point(int xx, int yy) : x{xx}, y{yy} { }
int get_x() const { return x; }
void set_x(int xx) { x = xx; }
int get_y() const { return y; }
void set_y(int yy) { y = yy; }
// no behavioral member functions
};

x oder y können beliebige Werte annehmen. Das bedeutet formaler: Instanzen der Klasse Point sichern keine Invariante für x oder y zu. x oder y sind einfach nur Werte. In diesem Fall ist eine struct als eine Sammlung von Werten deutlich angebrachter.

struct Point {
int x {0};
int y {0};
};

C.132: Don’t make a function virtual without reason

Diese Regel ist einfach nachzuvollziehen. Eine virtuelle Funktion ist ein Feature in C++, dass du nicht umsonst bekommst.

Eine virtuelle Funktion

  • wirkt sich auf die Performanz des Programms und die Größe des Objekts aus.
  • ist empfänglich für Fehler, da sie in einer abgeleiteten Klasse überschrieben werden kann.

C.133: Avoid protected data

Daten, die protected deklariert sind, machen dein Programm anspruchsvoller und fehleranfälliger. Falls in der Basisklasse protected-Daten zum Einsatz kommen, lässt sich nicht mehr über abgeleiteten Klassen in Isolation nachdenken. Damit brichst du die Kapselung. Du musst dir immer Gedanken zu ganzen Klassenhierarchie machen.

Das bedeutet, diese drei Fragen müssen in der Regel beantwortet werden.

  1. Muss ich einen Konstruktor implementieren um die proteced-Daten richtig zu initialisieren?
  2. Welchen Wert besitzt das protected-Datum, wenn ich es verwende?
  3. Welche Funktionalität wird in Mitleidenschaft gezogen, wenn ich das proteced-Datum verändere?

Natürlich wird die Beantwortung dieser Fragen immer anspruchsvoller, wenn die Klassenhierarchie vor allem in die Tiefe wächst.

Genau genommen ist ein protected-Datum eine globale Variable in der Klassenhierarchie. Das erste Gebot der Softwareentwicklung lautet aber : Vermeide nicht konstante globale Daten.

Zum Abschluss stellt die Guidelines noch das Interface Shape vor, das um proteced-Daten erweitert ist.

class Shape {
public:
// ... interface functions ...
protected:
// data for use in derived classes:
Color fill_color;
Color edge_color;
Style st;
};

Wir sind noch nicht fertig mit unserer Reise durch die Regeln für Klassenhierarchien. Daher geht die Reise im nächsten Artikel weiter.

  • Das PDF-Päckchen zu C++17 bekam mit Abstand die meisten Stimmen. Daher werde ich dies spätestens am Donnerstag veröffentlichen. Hier ist das Ergebnis der Abstimmung: Welches Päckchen soll ich zusammenstellen? Mache dein Kreuz!
  • Ich möchte ehrlich sein. Ich habe sehr viel gelernt, indem ich über die C++ Core Guidelines schreibe und noch zusätzliche Hintergrundinformationen anbiete, falls ich dies für notwendige erachte. Daher möchte ich diese Worte nutzen, um Feedback einzufordern. Gegebenenfalls werde ich gerne den Fokus und den Anspruch meiner Artikel anpassen.
Anzeige