C++ Core Guidelines: Regeln für die Ressourcenverwaltung

Modernes C++  –  13 Kommentare

Dieser und die nächsten Artikel beschäftigen sich mit dem wohl wichtigsten Aspekt im Programmieren: Ressourcenverwaltung. Die C++ Core Guidelines bieten Regeln für Ressourcenverwaltung im Allgemeinen, aber auch Regeln für das Anfordern und Freigeben von Speicher und Smart Pointern im Besonderen an. Los geht es in diesem Artikel mit den allgemeinen Regeln für die Ressourcenverwaltung.

Am Anfangs steht die Frage. Was ist eine Ressource? Eine Ressource ist etwas, das es zu verwalten gilt. Das bedeutet, dass du eine Ressource anfordern und auch wieder freigeben musst, den Ressourcen sind knappe Güter oder müssen geschützt werden. Es lässt sich nur eine begrenzte Menge an Speicher, Sockets, Prozessen oder auch Threads anfordern; nur ein Prozess kann seine Ressourcedatei oder ein Thread kann seine geteilte Ressource zu einem Zeitpunkt verändern. Beim Nichtbefolgen dieses Protokolls sind viele Probleme möglich.

  • Dem Programm kann der Speicher ausgehen, da du den Speicher nicht freigegeben hast.
  • Das Programm kann ein Data Race besitzen, da du vergessen hast, eine geteilte Variable vor deren Nutzung zu schützen.
  • Das Programm kann ein Deadlock aufweisen, da du deine geteilten Variablen in verschiedener Ordnung angefordert und freigegeben hast.

Data Races und Deadlock sind aber keine Domäne geteilter Variablen. Zum Beispiel lassen sie sich auch mit Dateien erzeugen.

Beim genaueren Nachdenken über Ressourcenverwaltung reduziert sich das auf einen Punkt: Wer ist der Besitzer? Daher will ich gerne erst das große Bild bieten, bevor ich tiefer in die Regeln eintauche.

Was ich besonders an modernem C++ schätze, ist, dass sich die verschiedenen Besitzverhältnisse direkt im Sourcecode ausdrücken lassen.

  • Lokale Objekte: Die C++-Laufzeit als Besitzer verwaltet automatisch den Lebenszyklus seiner Ressourcen. Dasselbe gilt für globale Objekte oder Mitglieder einer Klasse. Die Guidelines nennen diese lokalen Objekte scoped objects.
  • Referenzen: Ich bin nicht der Besitzer. Ich habe mir die Ressource, die nicht null sein kann, nur ausgeliehen.
  • Nackte Zeiger: Ich bin nicht der Besitzer. Ich habe mir die Ressource nur ausgeliehen. Ich darf die Ressource nicht freigeben.
  • std::unique_ptr: Ich bin der exclusive Besitzer der Ressource. Ich darf die Ressource freigeben.
  • std::shared_ptr: Ich teile mir die Ressoure mit anderen Besitzern. Ich darf meine Besitzverhältnisse explizt freigeben.
  • std::weak_ptr: Ich bin nicht der Besitzer der Ressource, aber ich kann zeitweise zum geteilten Besitzer werden, indem ich die Methode std::weak_ptr::lock verwende.

Vergleich doch diese fein-justierbare Besitzverhältnisse mit einem nackten Zeiger. Genau das ist der Punkt, den ich an modernem C++ sehr schätze.

Hier sind die sechs Regeln zu Ressourcenverwaltung im Überblick.

Jetzt geht es in die Tiefe.

Die Idee ist verblüffend einfach. Du erzeugt eine Art Stellvertreterobjekt für deine Ressource. Der Konstruktor deines Stellvertreters fordert die Ressource an und der Destruktor gibt sie wieder frei. Die zentrale Idee des RAII-Idiom ist es, dass die C++-Laufzeit der Besitzer der lokalen Objekte und damit der Ressource ist.

Zwei typische Beispiel des RAII-Idiom in modernem C++ sind Smart Pointer und Locks. Smart Pointer verwalten ihren Speicher und Locks ihre Mutexe.

Die folgende Klasse ResourceGuard setzt das RAII-Idiom um.

// raii.cpp

#include <iostream>
#include <new>
#include <string>

class ResourceGuard{
private:
const std::string resource;
public:
ResourceGuard(const std::string& res):resource(res){
std::cout << "Acquire the " << resource << "." << std::endl;
}
~ResourceGuard(){
std::cout << "Release the "<< resource << "." << std::endl;
}
};

int main(){

std::cout << std::endl;

ResourceGuard resGuard1{"memoryBlock1"}; // (1)

std::cout << "\nBefore local scope" << std::endl;
{
ResourceGuard resGuard2{"memoryBlock2"}; // (2)
}
std::cout << "After local scope" << std::endl;

std::cout << std::endl;


std::cout << "\nBefore try-catch block" << std::endl;
try{
ResourceGuard resGuard3{"memoryBlock3"}; // (3)
throw std::bad_alloc();
}
catch (std::bad_alloc& e){
std::cout << e.what();
}
std::cout << "\nAfter try-catch block" << std::endl;

std::cout << std::endl;

}

Unabhängig davon, ob der Lebenszyklus der Instanzen von ResoureGuard reguläre (1) und (2) oder irregulär (3) endet, wird der Destruktor der Klasse immer ausgerufen. Das bedeutet natürlich, dass die Ressource freigegeben wird.

Weitere Details zu dem Beispiel und RAII stehen in meinem Artikel Garbage Collection - No Thanks,den sogar Bjarne Stroustrup kommentiert hat.

Nackte Zeiger sollten nicht für Arrays verwendet werden, da das sehr fehleranfällig ist. Das gilt insbesondere, falls die Funktion einen Zeiger als Argument annimmt.

void f(int* p, int n)   // n is the number of elements in p[]
{
// ...
p[2] = 7; // bad: subscript raw pointer
// ...
}

Es ist viel zu einfach, die falsche Länge des Arrays als Funktionsparameter zu verwenden.

Für Arrays besitzen wir in C++ std::vector. Ein Container der Standard Template Library ist ein exklusiver Besitzer. Es fordert automatisch seinen Speicher an und gibt diesen wieder frei.

Die Frage der Besitzverhältnisse ist sehr interessant, falls du eine Fabrikfunktion verwendest. Eine Fabrikfunktion ist eine spezielle Funktion, die ein neues Objekt zurückgibt. Die Frage ist nun. Solltest du einen nackten Zeiger, ein Objekt, ein std::unique_ptr oder ein std::shared_ptr verwenden?

Hier sind die vier Variationen.

Widget* makeWidget(int n){                    // (1)
auto p = new Widget{n};
// ...
return p;
}

Widget makeWidget(int n){ // (2)
Widget g{n};
// ...
return g;
}

std::unique_ptr<Widget> makeWidget(int n){ // (3)
auto u = std::make_unique<Widget>(n);
// ...
return u;
}

std::shared_ptr<Widget> makeWidget(int n){ // (4)
auto s = std::make_shared<Widget>(n);
// ...
return s;
}

...

auto widget = makeWidget(10);

Wer soll der Besitzer der Widgets sein? Der Aufrufer oder der Aufgerufene? Ich nehme an, du kannst die Frage für den nackten Zeiger nicht beantworten. Mir geht es auch so. Das bedeutet, wir wissen nicht, wer das Widget letztlich löschen soll. Im Gegensatz sind die Fälle (2) bis (4) ziemlich offensichtlich. Im Falle des Objekts oder des std::unique_ptr ist der Aufrufer der Besitzer. Im Falle des std::shared_ptr teilen sich der Aufrufer und der Aufgerufene die Besitzverhältnisse.

Eine Frage bleibt aber bestehen. Sollten wir ein Objekt oder Smart Pointer einsetzen. Hier sind meine Gedanken.

  • Falls die Fabrikfunktion einen virtuellen Konstruktor abbilden soll, musst du Smart Pointer einsetzen. Ich habe bereits über diesen speziellen Anwendungsfall von Fabrikfunktionen geschrieben. Die Details sind in dem Artikel: C++ Core Guidelines: Konstruktoren (C.50).
  • Falls die Objekte billiger zu kopieren sind und der Besitzer der Widgets der Aufrufer sein soll, verwende ein Objekt. Falls sie nicht billig zu kopieren sind, setze einen std::unique_ptr ein.
  • Falls der Aufgerufene (die Fabrikfunktion) den Lebenszyklus seiner Widgets verwalten will, verwende einen std::shared_ptr.

Zu dieser Regel habe ich nichts hinzuzufügen. Eine nackte Referenz ist kein Besitzer und kann nicht leer sein.

Ein scoped object ist ein Objekt mit einem eigenen Bereich. Das kann ein lokaler oder globaler Bereich sein oder auch der Bereich einer Klasse. Entscheidend ist, dass die C++-Laufzeit diese Objekte automatisch verwaltet. Daher ist weder eine Speicheranforderung oder -freigabe notwendig noch ist eine std::bad_alloc Ausnahme möglich. Um es einfach zu machen. Falls möglich, verwende Objekte mit eigenem Bereich.

Ich höre immer und immer wieder: Globale Objekte sind bösartig. Das stimmt nicht ganz. Nicht-konstante (veränderliche) globale Objekte sind bösartig. Es gibt so viele Gründe, keine veränderlichen globalen Objekte einzusetzen. Hier sind einige die Gründe. Der Einfachheit nehme ich in den folgenden Punkten an, dass die Funktionen oder Objekte veränderliche Daten besitzen.

  • Kapselung: Funktion oder Objekte lassen sich außerhalb ihres Bereiches verändern. Damit ist es fast unmöglich, Funktionen und Objekte zu analysieren, die veränderliche globale Variablen verwenden.
  • Testbarkeit: Du kannst deine Funktion nicht mehr in Isolation testen. Die Auswirkung eines Funktionsaufrufes hängt nur vom Zustand des Programms ab.
  • Refaktorierung: Es ist sehr schwierig deine Funktionen zu refaktorieren, falls du dir deine Funktion nicht in Isolation analysieren kannst.
  • Optimierung: Der Aufruf der Funktion lässt sich nicht einfach umstellen oder auf anderen Thread ausführen, da die Funktion heimlich Abhängigkeiten besitzt.
  • Gleichzeitigkeit: Die notwendige Bedingung für ein Data Race ist nicht-konstanter geteilter Zustand. Genau dies sind veränderlich, geteilte Variablen.

In meinem nächsten Artikel werde ich über eine sehr wichtige Ressource schreiben: Speicher.