C++ Core Guidelines: Zugriff auf Objekte in Klassenhierarchien

Modernes C++  –  0 Kommentare

Es gibt neun Regeln in den C++ Core Guidelines, um auf Objekte in Klassenhierarchien zuzugreifen. Diese sind einen genaueren Blick wert.

Hier sind die neun Regeln.

Slicing ist immer noch ein Problem in vielen Code-Basen.

C.145: Access polymorphic objects through pointers and references

Falls du eine virtuelle Funktion verwendest, weißt du nicht, welche Klasse die Funktionalität zur Verfügung stellt. Aus diesem Grund sollten Zeiger oder Referenzen verwendet werden. Das bedeutet in dem konkreten Fall, dass beide d "gesliced" werden.

struct B{ 
int a;
virtual int f();
};

struct D : B{
int b;
int f() override;
};

void use(B b)
{
D d;
B b2 = d; // slice
B b3 = b;
}

void use2()
{
D d;
use(d); // slice
}

Der erste und der zweite "slice" bewirkt, dass nur der B-Anteil von D kopiert wird.

Willst du mehr über "Slicing" wissen? Die Regel C.67: A base class should suppress copying, and provide a virtual clone instead if “copying” is desired beschäftigt sich damit.

Die nächsten drei Regeln gehen tiefer auf den dynamic_cast ein. Bevor ich loslege und über dynamic_cast schreibe, möchte ich einen Punkt betonen: Konvertierungen wie ein dynamic_cast werden viel zu häufig verwendet. Die Aufgabe des dynamic_cast ist es, typsicher zwischen Zeiger und Referenzen auf Klassen hoch, runter und seitwärts in der Klassenhierarchie zu konvertieren. (cppreference.con über dynamic_cast).

C.146: Use dynamic_cast where class hierarchy navigation is unavoidable

Los geht es mit einem Anwendungsfall der C++ Core Guidelines. Er besteht darin, durch eine Klassenhierarchie zu navigieren.

struct B {   // an interface
virtual void f();
virtual void g();
};

struct D : B { // a wider interface
void f() override;
virtual void h();
};

void user(B* pb)
{
if (D* pd = dynamic_cast<D*>(pb)) { // (1)
// ... use D's interface ...
}
else {
// ... make do with B's interface ...
}
}

Um den richtigen Datentyp für pb (1) herauszufinden, ist ein dynamic_cast zur Laufzeit notwendig. Falls die Konvertierung fehlschlägt, gibt sie einen Nullzeiger zurück.

void user2(B* pb)   // bad
{
D* pd = static_cast<D*>(pb); // I know that pb really points to a D; trust me
// ... use D's interface ...
}

void user3(B* pb) // unsafe
{
if (some_condition) {
D* pd = static_cast<D*>(pb); // I know that pb really points to a D; trust me
// ... use D's interface ...
}
else {
// ... make do with B's interface ...
}
}

void f()
{
B b;
user(&b); // OK
user2(&b); // bad error (1)
user3(&b); // OK *if* the programmer got the some_condition check right // (2)
}

Ein Zeiger auf B zu einem Zeiger auf D (1) zu konvertieren, ist ein Fehler. Das kann gegebenenfalls auch für die Zeile (2) gelten.

C.147: Use dynamic_cast to a reference type when failure to find the required class is considered an error

Falls du einen dynamic_cast auf einen Zeiger anwendest, bekommst du im Fehlerfall einen Nullzeiger zurück. Falls hingegen einen dynamic_cast auf eine Referenz zum Einsatz kommt, wird im Fehlerfall eine Ausnahme von Typ std::bad_cast geworfen.

// badCast.cpp

struct A{
virtual void f() {}
};
struct B : A {};

int main(){

A a;
B b;

B* b1 = dynamic_cast<B*>(&a); // nullptr, because 'a' is not a 'B'
B& b2 = dynamic_cast<B&>(a); // std::bad_cast, because 'a' is not a 'B'

}

Der g++-6 Compiler beschwert sich in beiden Fällen über den falsch angewandten dynamic_cast. Nur im Falle der Referenz führt dies dazu, dass die Programmausführung mit der Ausnahme std::bad_cast abgebrochen wird.

C.148: Use dynamic_cast to a pointer type when failure to find the required class is considered a valid alternative

Manchmal ist es notwendig, einen alternativen Code auszuführen, wenn die Konvertierung eines Zeigers mit einem dynamic_cast fehlschlägt und damit einen Nullzeiger zurückgibt.

C.149: Use unique_ptr or shared_ptr to avoid forgetting to delete objects created using new

std::unique_ptr oder std::shared_ptr zu verwenden, gehört zu den wichtigstes Regel der C++ Core Guidelines. Dank ihr gehören Speicherlecks der C++-Vergangenheit an. Falls du eine Anwendung programmierst und keine Infrastruktur wie ein Bibliothek zur Verfügung stellst, möchte ich die Regel gerne umformulieren: Verwende niemals new (und delete).

Das Anwenden der Regel bedeutet, dass du std::make_unique und std::make_shared verwenden sollst, um Zeiger zu erzeugen.

C.150: Use make_unique() to construct objects owned by unique_ptrs, C.151: Use make_shared() to construct objects owned by shared_ptrs

Da beide Regeln ziemlich ähnlich sind, werde ich sie zusammen behandeln. std::make_unique und std::make_shared geben die Garantie, dass ihre Operationen nicht unterbrochen werden. Das bedeutet für das folgende Beispiel, dass kein Speicher verloren geht.

f(std::make_unique<Foo>(), bar());

Diese Garantie, dass kein Speicherleck entstehen kann, gibt der folgende Ausdruck aber nicht.

f(std::unique_ptr<Foo>(new Foo()), bar());

Es ist möglich, dass zuerst Foo auf dem Heap angelegt und dann bar aufgerufen wird. Falls nun bar eine Ausnahme wirft, wird Foo nicht aufgeräumt und wir erhalten ein Speicherleck.

Die gleiche Beobachtung gilt auch für std::make_shared, um einen std::shared_ptr zu erzeugen. std::make_shared besitzt einen zusätzlichen Performanzvorteil gegenüber einem direkt std::shared_ptr-Aufruf. Um einen std::shared_ptr zu erzeugen, sind zwei Speicherallokationen notwendig: eine Allokation für die zugrunde liegende Ressource und eine Allokation für den Zähler. Dank std::make_shared lassen sich die beiden teuren Allokationen in einem Schritt durchführen. Der Performanzunterschied ist beeindruckend. Genauere Zahlen gibt es auf meinem Artikel: Speicher- und Performanz-Overhead von Smart-Pointern.

C.152: Never assign a pointer to an array of derived class objects to a pointer to its base

Es passiert nicht so häufig, aber wenn es passiert, sind die Konsequenzen sehr dramatisch. Der Ergebnis kann ein ungültiger Zugriff auf ein Objekt sein oder Speicherkorruption. Genau diesen ungültigen Zugriff auf ein Objekt stellt das folgende Beispiel vor.

struct B { int x; };
struct D : B { int y; };

D a[] = {{1, 2}, {3, 4}, {5, 6}};
B* p = a; // bad: a decays to &a[0] which is converted to a B*
p[1].x = 7; // overwrite D[0].y

Die letzte Zuweisung des Codesbeispiels sollte das x-Attribut der Instanz von B aktualisieren. Tatsächlich überschreibt die Zuweisung das y-Attribut eines D-Objekts. Der Grund ist, dass der Zeiger B* einem Zeiger von abgeleiteten Objekten D zugewiesen wird.

"Decay" ist der Name einer impliziten Konvertierung, die bei lvalues zu rvules, Arrays zu Zeigern und Funktionen zu Funktionszeigern angewandt wird und die const und volatile Qualifizierer entfernt. Das bedeutet in dem folgenden Beispiel, dass eine Funktion, die einen Zeiger auf D* erwartet, mit einem Array von D's aufgerufen werden kann. Das Argument d wird dadurch zu einem Zeiger auf das erste Element des Arrays. Wichtige Information wie die Länge des Arrays geht von D geht damit verloren.

void use(D* d);
D d[] = {{1, 2}, {3, 4}, {5, 6}};

use(d);

C.153: Prefer virtual function to casting

Dank dynamic_cast kann Virtualität, gerne auch späte Bindung genannt, simuliert werden. Dieser Missbrauch des dynamic_cast ist aber hässlich und fehleranfällig. Du erhälst unter Umständen einen Nullzeiger oder eine std::bad_cast Ausnahme (siehe C.147). Die Regel C.67: A base class should suppress copying, and provide a virtual clone instead if “copying” is desired geht tiefer auf virtuelle Funktionen ein.

C++ erlaubt das Überladen von Funktion, Funktions-Templates und sogar Operatoren. Insbesondere das Überladen von Operatoren wird sehr häufig kontrovers diskutiert. Zum Beispiel verbietet MISRA C++, eine Richtinie für eine sichere Teilmenge von C++, das Überladen von Operatoren. Um ehrlich zu sein, ich weiß nicht warum. Die C++ Core Guidelines bieten zehn Regeln zum Überladen an. Diese werde ich mir im nächsten Artikel genauer anschauen.

  • 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.