C++ Core Guidelines: Die verbleibenden Regeln für Klassenhierchien

Modernes C++  –  19 Kommentare

Drei Artikel waren notwendig, um die 20 Regeln für Klassenhierarchien in den C++ Core Guidelines vorzustellen. Dieser Artikel beschließt die Miniserie mit den verbleibenden sieben Regeln ab.

In bekannter Manier gibt es erst mal das große Bild. Hier sind die speziellen Regeln für Klassenhierarchien.

Los geht's mit der Regel C.134:

C.134: Ensure all non-const data members have the same access level

Die vorherige Regel C.133 lautete, dass du keine "protected"-Daten verwenden sollst. Sie formuliert, dass deine nichtkonstanten Daten entweder alle public oder private sein sollen. Ein Objekt kann Attribute besitzen, die die Invarianz des Objekts festlegen oder auch nicht festlegen. Nichtkonstante Daten, die nicht die Invarianz von Attributen festlegen, sollten public sein. Im Gegensatz dazu gilt: Nichtkonstante und private Attribute definieren die Invarianz des Objekts. Nur zur Erinnerung: Ein Datenattribut, das eine Invarianz besitzt, besitzt einen eingeschränkten Gültigkeitsbereich.

Wenn wir das Klassendesign allgemeiner betrachten, lassen sich zwei Typen von Klassen identifizieren.

  • Alles public: Klassen, die nur public-Attribute besitzen, da für die Attribute keine Einschränkungen existieren. Hier sollte eine struct zum Einsatz kommen.
  • Alles private: Klassen, die nur private und konstante Attribute besitzen, die die Einschränkungen für die konkreten Objekte definieren.

Basierend auf dieser Beobachtung sollten alle nichtkonstanten Attribute der Klasse entweder public oder private sein.

Stelle dir vor, du hast eine Klasse mit nichtkonstanten und public-Attributen. Das bedeutet, dass die Einschränkungen für diese Attribute in der ganzen Klassenhierarchie gepflegt werden müssen. Das ist natürlich sehr fehleranfällig, denn die Einschränkungen lassen sich nicht einfach kontrollieren. Oder anders ausgedrückt: Das Klassendesign bricht eine der grundlegenden Regeln des objektorientierten Entwurfs: Kapselung.

C.135: Use multiple inheritance to represent multiple distinct interfaces

Es ist eine sehr gute Idee, wenn ein Interface nur einen Aspekt des Klassendesigns unterstützt. Was genau bedeutet das? Falls du ein reines Interface, das nur aus rein virtuellen Funktionen besteht, entwirfst, müssen konkreten Klassen alle Funktionen implementieren. Das bedeutet insbesondere in dem Fall, wenn das Interface zu mächtig angelegt wurde, dass die konkrete Klasse Funktionen implementieren muss, die sie weder benötigt noch einen Sinn für sie ergeben.

Ein Beispiel für zwei getrennte Interfaces sind die istream- und ostream-Interfaces der Ein- und Ausgabe-Streams.

class iostream : public istream, public ostream {   // very simplified
// ...
};

Durch die Kombination der Interfaces istream für die Eingabe- und ostream für die Ausgabeoperationen lässt sich einfach ein neues Interface entwerfen.

C.136: Use multiple inheritance to represent the union of implementation attributes, C.137: Use virtual bases to avoid overly general base classes

Beide Regeln sind sehr speziell. Daher werde ich auf sie nicht eingehen. Die Guidelines sagen, dass die Regeln C.137 verhältnismäßig selten zum Einsatz kommt und die Regel C.138 der Regel C.129 sehr ähnlich ist: "When designing a class hierarchy, distinguish between implementation inheritance and interface inheritance."

C.138: Create an overload set for a derived class and its bases with using

Diese Regel ist ziemlich offensichtlich und gilt für virtuelle und nicht virtuelle Funktionen. Falls du nicht die using-Deklaration verwendest, dann versteckt die abgeleitete Klasse die gleichnamigen Funktionen der Basisklassen. In der englischsprachigen Literatur werden die gleichnamigen Funktionen der Basisklasse als overload set bezeichnet. Für diesen Prozess die gleichnamigen Funktionen der Basisklasse zu verstecken, wird auch gerne der Begriff shadowing verwendet. Das Überraschungspotenzial ist sehr groß, wenn du diese Regel nicht beachtest.

Genau das zeigt ein Beispiel aus den Guidelines.

class B {
public:
virtual int f(int i) { std::cout << "f(int): "; return i; }
virtual double f(double d) { std::cout << "f(double): "; return d; }
};
class D: public B {
public:
int f(int i) override { std::cout << "f(int): "; return i + 1; }
};
int main()
{
D d;
std::cout << d.f(2) << '\n'; // prints "f(int): 3"
std::cout << d.f(2.3) << '\n'; // prints "f(int): 3"
}

Betrachte die letzte Zeile. d.f(2.3) wird mit einem double-Argument aufgerufen. Trotzdem kommt die für int überladene Funktion der Klasse D zum Einsatz. Das führt auch noch dazu, dass eine verengende Konvertierung von double auf int stattfindet. Das entspricht mit großer Wahrscheinlichkeit nicht der Intention des Autors. Um die double-Überladung der Klasse B zu verwenden, muss diese in den Scope for D eingeführt werden.

class D: public B {
public:
int f(int i) override { std::cout << "f(int): "; return i + 1; }
using B::f; // exposes f(double)
};

C.139: Use final sparingly

final ist ein neues Feature mit C++11. Du kannst es für Klassen oder virtuelle Funktionen anwenden.

  • Falls eine Klasse My_widget final von der Klasse Widget abgeleitet wird, kann von der Klasse My_widget nicht weiter abgeleitet werden.
class Widget { /* ... */ };

// nobody will ever want to improve My_widget (or so you thought)
class My_widget final : public Widget { /* ... */ };

class My_improved_widget : public My_widget { /* ... */ }; // error: can't do that
  • Eine virtuelle Funktion kann als final deklariert werden. Das bedeutet, dass die Funktion nicht mehr überschrieben werden kann.
struct Base
{
virtual void foo();
};

struct A : Base
{
void foo() final; // A::foo is overridden and it is the final override
};

struct B final : A // struct B is final
{
void foo() override; // Error: foo cannot be overridden as it's final in A
};

Wenn du final einsetzt, unterbindest du das Erweitern der Klasse oder seiner virtuellen Funktionen. Das hat oft Konsequenzen, die erst deutlich später offensichtlich werden. Einem potenziellen, kleinen Performanzvorteil durch die Verwendung von final sollte nicht die Erweiterbarkeit der Klassenhierarchie geopfert werden.

C.140: Do not provide different default arguments for a virtual function and an overrider

Falls du diese Regel nicht beachtest, können böse Überraschungen auftreten.

// overrider.cpp

#include <iostream>

class Base {
public:
virtual int multiply(int value, int factor = 2) = 0;
};

class Derived : public Base {
public:
int multiply(int value, int factor = 10) override {
return factor * value;
}
};

int main(){

std::cout << std::endl;

Derived d;
Base& b = d;

std::cout << "b.multiply(10): " << b.multiply(10) << std::endl;
std::cout << "d.multiply(10): " << d.multiply(10) << std::endl;

std::cout << std::endl;

}

Hier ist die böse Überraschung. Das Programm besitzt nicht das erwartete Ergebnis.

Was passiert hier? Beide Objekte b und d rufen dieselbe Funktion auf, da diese virtuell ist. Damit kommt späte Bindung zum Einsatz. Das gilt aber nicht für die Daten wie die Defaultargumente. Sie werden statisch gebunden und somit kommt frühe Bindung zum Einsatz.

Nun ist es vollbracht. In den zwei vorherigen und diesem Artikel habe ich alle 20 Regeln zu Klassenhierarchien vorgestellt. Eine Frage bleibt aber offen: Wie lassen sich die Objekte der Klassenhierarchien ansprechen. Genau mit der Beantwortung dieser Frage wird sich der nächste Artikel beschäftigen.

  • Das PDF-Päckchen mit Artikel zu C++17 steht auf www.grimm-jaud.de bereit. Es enthält neben dem gut 30-seitigen PDF alle Codebeispiele und eine einfache cmake-Datei.