C++ Core Guidelines: Regeln für Destruktoren

Modernes C++  –  0 Kommentare

Benötigt meine Klasse einen Destruktor? Das ist eine Frage,  die häufig vernommen wird. Meistens ist die Antwort nein, und dann wendet man die Nullerregel an. Manchmal ist die Antwort ja, und damit ist man bei der Fünferregel. Um genauer zu sein: Die Guidelines bieten acht Regeln für Destruktoren an.

Hier sind die acht Regeln:

Jede der Regel verdient einen genaueren Blick.

Regeln für Destruktoren

C.30: Define a destructor if a class needs an explicit action at object destruction

Es ist charakteristisch für C++, dass der Destruktor eines Objekts automatisch am Ende seiner Lebenszeit aufgerufen wird. Um noch genauer zu sein: Der Destruktor eines Objekts wird genau dann aufgerufen, wenn dieses seinen Gültigkeitsbereich verlässt. Dank dieses vollständig deterministischen Verhaltens kann man kritische Ressourcen im Destruktor freigeben.

Locks oder Smart Pointer nützen diese Charakteristik aus. Beide geben automatische ihre zugrunde liegende Ressource frei, wenn sie ihren Gültigkeitsbereich verlassen.

void func(){
  std::unique_ptr<int> uniqPtr = std::make_unique<int>(2011);
  std::lock_guard<std::mutex> lock(mutex);
  . . .
} // automatically released

uniqPtr gibt sein int frei und lock seinen Mutex. Beide setzen das RAII-Idiom (Resource Acquisition Is Initialization) um. Wer sich für die Details zu RAII interessiert, hier ist mein Artikel "Garbage Collection – No Thanks", der eine Anmerkung von Bjarne Stroustrup zu RAII enthält.

Man kann die Regel auch anders herum interpretieren. Falls alle Mitglieder einer Klasse einen Default-Destruktor besitzen, sollte man keinen definieren.

class Foo {   // bad; use the default destructor
public:
    // ...
    ~Foo() { s = ""; i = 0; vi.clear(); }  // clean up
private:
    string s;
    int i;
    vector<int> vi;
};

C.31: All resources acquired by a class must be released by the class’s destructor

Die Regel hört sich ziemlich einleuchtend an und hilft, Ressourcenlecks zu vermeiden. Aber man muss im Kopf behalten, welche deiner Klassenmitglieder einen vollen Satz an Default-Operationen besitzt. Damit sind wir wieder bei der Nuller- oder Fünferregel.

Eventuell besitzt die Klasse File im Gegensatz zur Klasse std::ifsteam keinen Destruktor. Somit bekommen wir unter Umständen ein Speicherleck, falls eine Instanz von MyClass ihren Gültigkeitsbereich verlässt.

class MyClass{
    std::ifstream fstream;   // may own a file
    File* file_;             // may own a file
    ...
};

C.32: If a class has a raw pointer (T*) or reference (T&), consider whether it might be owning

Es gibt eine Frage, die man beantworten muss, wenn eine Klasse einen Zeiger oder eine Referenz besitzt: Wer ist der Besitzer der Ressource? Falls es deine Klasse ist, musst du die Ressource natürlich löschen.

C.33: If a class has an owning pointer member, define a destructor

C.34: If a class has an owning reference member, define a destructor

Die Regeln C.33 und C.34 sind ziemlich einfach zu beschreiben. Falls man einen Zeiger oder eine Ressource besitzt, verwendet man einen Smart Pointer wie std::unique_ptr. Er ist per Design so effizient wie ein nackter Zeiger. Daher besitzt er keinen Overhead in Zeit oder Speicher, bietet aber einen deutlichen Mehrwert an: Er löscht automatisch die zugrunde liegende Ressource. Hier sind meine Artikel zu Smart Pointern in C++.

C.35: A base class destructor should be either public and virtual, or protected and nonvirtual

Die Regel hört sich sehr interessant für Klassen an, die virtuelle Methoden besitzen. Lasst mich die Regeln in ihre zwei Komponenten trennen.

Public und virtueller Destruktor

Falls eine Klasse ein public und virtuellen Destruktor besitzt, kann man Instanzen einer abgeleiteten Klasse durch einen Zeiger auf die Basiskasse löschen. Dasselbe gilt für Referenzen.

struct Base {  // no virtual destructor
    virtual void f(){};
};

struct Derived : Base {
    string s {"a resource needing cleanup"};
    ~D() { /* ... do some cleanup ... */ }
};

...

Base* b = new Derived();
delete b;

Der Compiler erzeugt für Base einen nichtvirtuellen Destruktor. Das Löschen eines Objects vom Typ Derived mittel eines Zeiger auf die Basisklasse Base stellt undefiniertes Verhalten dar, falls der Destruktor der Basisklasse nichtvirtuell ist.

Protected und nichtvirtueller Destruktor

Das ist einfach nachzuvollziehen. Falls der Destruktor der Basisklasse protected ist, kann man Instanzen von abgeleiteten Klassen nicht mittel eines Zeigers auf die Basisklasse löschen. Daher muss er nicht virtuell sein.

Diesen Punkt zu Datentypen (keine Zeiger und Referenzen) will ich noch genauer herausarbeiten:

  • Falls der Destruktor einer Klasse Base private ist, kann man den Datentyp nicht verwenden.
  • Falls der Destruktor einer Klasse Base protected ist, kann man nur Derived von Base ableiten und Instanzen vom Typ Derived verwenden.
struct Base{
    protected:
    ~Base() = default;
};

struct Derived: Base{};

int main(){
    Base b;   // Error: Base::~Base is protected within this context
    Derived d;
}

Der Aufruf Base b verursacht einen Fehler.

C.36: A destructor may not fail

C.37: Make destructors noexcept

Die Meta-Regel für die Regeln C.36 und C.36 besitzt einen breiten Fokus. Ein Destruktor soll nicht fehlschlagen, und man sollte ihn daher als noexcept deklarieren. Ich denke, ich sollte auf noexcept ein wenig eingehen.

  • noexcept: Falls man eine Funktion wie einen Destruktor als noexcept deklarierst, bewirkt eine Ausnahme einen std::terminate-Aufruf. std::terminate ruft den std::terminate_handler auf. Dieser ruft wiederum std:.abort auf, und dein Programm beendet sich unmittelbar. Indem man eine Funktion als void func() noexcept; deklariert, legst man das folgende Verhalten beim Auftreten einer Ausnahme fest:
    • Meine Funktion wirft keine Ausnahme.
    • Wenn meine Funktion eine Ausnahme wirft, kümmere ich mich nicht darum und beende sofort das Programm.

Der Grund, dass man deinen Destruktor explizit als noexcept deklarieren sollte, ist ziemlich offensichtlich. Es gibt keinen Königsweg, fehlerfreien Code zu schreiben, falls der Destruktor eine Ausnahme werfen darf. Falls alle Mitglieder einer Klasse einen noexcept-Destruktor besitzen, ist der benutzerdefinierte und der vom Compiler automatisch erzeugte Destruktor implizit noexcept.

Weiterführende Informationen:

Wie geht's weiter?

Vielleicht hört es sich ein wenig seltsam an, aber nach den Regeln für Destruktoren kommen in den Guidelines die für Konstruktoren. Genau um diese 10 Regeln für Konstruktoren geht es im nächsten Artikel.

Frisch veröffentlicht: Mein neues Buch "Concurrency with Modern C++"

"Concurrency with Modern C++" stellt auf gut 300 Seiten und mehr als hundert Beispiel Gleichzeitigkeit mit modernem C++ vor. Um den Jahreswechsel wird es wohl auch in Deutsch und Koreanisch erhältlich sein. Hier sind ein paar Wort zum Inhalt.

"Concurrency with Modern C++ is a journey through current and upcoming concurrency in C++".

  • C++11 and C++14 have the basic building blocks for creating concurrent or parallel programs.
  • With C++17 we got the parallel algorithms of the Standard Template Library (STL). That means, most of the algorithms of the STL can be executed sequential, parallel, or vectorized.
  • The concurrency story in C++ goes on. With C++20 we can hope for extended futures, coroutines, transactions, and more.

Genauere Details liefert der Link: https://leanpub.com/concurrencywithmodernc