Werbung

  1. Diese Seite verwendet Cookies. Wenn du dich weiterhin auf dieser Seite aufhältst, akzeptierst du unseren Einsatz von Cookies. Weitere Informationen

[C++] Das RAII-Prinzip

Do it RAIIght from the start.

  1. -AB-
    Programmiersprache(n):
    C++
    RAII - Resource Acquisition Is Initialization - ist eins der Konzepte, ohne deren Kenntnis man eigentlich keinen schönen und sicheren Code schreiben kann.

    Der Wikipedia-Artikel ist relativ kurz, wer mehr darüber lesen möchte, dem Empfehle ich Scott Meyers "Effective C++", dort geht ein ganzes Kapitel über Ressourcenverwaltung mittels RAII. Da Bjarne Stroustrup das RAII "erfunden" hat, findet man sicher auch in seinen Büchern einiges, die hab ich aber nie gelesen, kann sie also auch nicht empfehlen.

    Worum es geht:
    Es klingt furchtbar trocken: Das RAII besagt, dass dass Management einer Ressource (allokieren und freigeben von Speicher, öffnen/schließen eines Dateihandles, ...) an die Initialisierung eines Objektes gekoppelt sein sollte. Wer jetzt schon weiß, worum es geht, darf sich glücklich schätzen, für die normalen Menschen gibt es ein paar Beispiele:

    Beispiel 1:
    Ein Mutex wird gelockt, eine Operation wird ausgeführt, dann wird das Mutex wieder unlocked:
    Code (C++):
    Quelltext kopieren
    1. int countClients()
    2. {
    3.     MyMutex& myMutex = getMutex();
    4.     myMutex.lock();
    5.  
    6.     Clients* clients = getClients();
    7.     unsigned int count = 0;
    8.     while(clients = clients->getNextClient())
    9.         ++count;
    10.  
    11.     myMutex.unlock();
    12.     return count;
    13. }
    Sagen wir, das ist der Code. Abgesehen von dem unglaublich hässlichen Weg, wie die Clients gespeichert sind, ist das durchaus Code, den man irgendwo finden könnte, besonders, wenn man in einer Firma arbeitet, die nicht an die STL oder generell vordefinierte Container glaubt.

    Jetzt hat dieser Code natürlich einen üblen Bug, und mein Mitarbeiter sieht den natürlich und fixt ihn schnell:
    Code (C++):
    Quelltext kopieren
    1. int countClients()
    2. {
    3.     MyMutex& myMutex = getMutex();
    4.     myMutex.lock();
    5.  
    6.     Clients* clients = getClients();
    7.     if(!clients)
    8.         return 0;
    9.     unsigned int count = 0;
    10.     while(clients = clients->getNextClient())
    11.         ++count;
    12.  
    13.     myMutex.unlock();
    14.     return count;
    15. }
    Und schon haben wir einen Deadlock. Klar, der ist schnell gefunden, aber es gibt ja auch komplexeren Code als ein bewusst simples Beispiel.

    Beispiel 2:
    Eine Datei soll geöffnet werden, ein Bildobjekt daraus erzeugt werden und danach das Filehandle wieder geschlossen werden:
    Code (C++):
    Quelltext kopieren
    1. Image* newImageFromFile(char* filename)
    2. {
    3.     File* file = fopen(filename);
    4.  
    5.     Image* img = new Image(file);
    6.  
    7.     fclose(file);
    8.     return img;
    9. }
    Hier können mehrere Dinge schief laufen.
    Eher eine seltene Fehlerquelle, aber - ein new kann jederzeit fehlschlagen, und wirft eine bad_alloc Exception. (Es gibt auch eine Variante, die einen nullptr returned.)
    Außerdem kann der Konstruktor von Image eine Exception werfen - z.B., wenn die Datei leer oder anderweitig fehlerhaft ist. (In dem Fall wird durch das new übrigens kein Memory geleaked!)
    Trotzdem wird in diesem Fall das File-Handle nicht geschlossen. Eine weitere Möglichkeit ist, das flcose() einfach zu vergessen - war in Java ein häufiger Bug bei mir, nasty deshalb, weil der *nächste* Programmaufruf fehlschlug weil Windows noch ein offenes Dateihandle hatte.

    Wie hilft das RAII?
    Streng nach dem RAII würden wir eine Ressource, die der Verwaltung bedarf, in ein Objekt packen. Schauen wir mal, wohin das mit dem Mutex führt:
    Code (C++):
    Quelltext kopieren
    1. struct Lock
    2. {
    3.     Lock(MyMutex& m) : mutex(m)
    4.     {
    5.         mutex.lock();
    6.     }
    7.  
    8.     ~Lock()
    9.     {
    10.         mutex.unlock();
    11.     }
    12. private:
    13.     MyMutex& mutex;
    14. };
    15.  
    16. int countClients()
    17. {
    18.     Lock scopeLock(getMutex());
    19.  
    20.     Clients* clients = getClients();
    21.     if(!clients)
    22.         return 0;
    23.     unsigned int count = 0;
    24.     while(clients = clients->getNextClient())
    25.         ++count;
    26.  
    27.     return count;
    28. }
    Das Verwalten des Mutex läuft jetzt über die Initialisierung und der Zerstörung des Lock-Objekts.
    Und schon kann nichts mehr schief gehen! Auch wenn Kollege C sich morgen überlegt, dass getClients() doch eine Exception werfen sollte, wenn es keine Clients gibt, wird das Lock-Objekt trotzdem sauber aufgeräumt (und damit das Mutex freigegeben).

    Wenn man das Lock nicht wie hier für den gesamten Scope halten will, bietet sich ein einfaches weiteres Paar geschweifte Klammern an:
    Code (C++):
    Quelltext kopieren
    1. int doMoreStuff()
    2. {
    3.     //perform some calculations
    4.  
    5.     //estimate some things
    6.  
    7.     //prepare data structures
    8.     {
    9.         Lock scopeLock(getMutex());
    10.  
    11.         //swap prepared estimations with old data
    12.     }
    13.  
    14.     //find out how much old data and new estimations deviate
    15.  
    16.     //calculate a bias for next function call
    17.  
    18.     return theValue;
    19. }
    Am Ende des eingerückten Klammernblocks wird die lokale Variable scopeLock zerstört (weil sie, nunja, aus dem Scope läuft) und das Mutex direkt wieder freigegeben.

    Das Anlegen eines solchen Objektes sollte im Normalfall keinerlei Overhead bedeuten. Aber auch ein zusätzlicher Funktionsaufruf wäre durchaus zu verschmerzen - immerhin nimmt der Compiler dem Coder es ab, überall dorthin, wo die Funktion (oder der Scope) verlassen wird, das Mutex freizugeben. So ist es sogar einfacher zu optimieren - anstatt herauszufinden, dass z.B. in zwei (Fehler) Fällen jeweils nur das Mutex unlocked und 0 returned wird, muss der Compiler hier nur berücksichtigen, dass eventuell zwei Fehlerfälle (mit einem return 0) gleich behandelt werden können - denn lokale Objekte muss er sowieso in jedem Fall aufräumen.

    Zum zweiten Beispiel - STL-Nutzer können sich freuen, denn der std::fstream schließt die Datei bereits automatisch, wenn er zerstört wird. (Genau genommen ist es der Destruktor des std::filebuf.)


    Genau wie mit dem Mutex und der Datei kann man mit Speicher verfahren: Als eine Ressource, die verwaltet werde soll.

    Im simpelsten Fall werden einfach ein paar Bytes allokiert und später wieder freigegeben. Hier hilft eine Klasse, die das new/delete im Konstruktor und Destruktor abhandelt. Glücklicherweise gibt es die schon, in C++11 wäre das der st::unique_ptr. (Vorher gäbe es den std::auto_ptr mit gewöhnungsbedürftiger Kopiersemantik, oder aber den boost::unique_ptr)

    Beispiel:
    Code (C++):
    Quelltext kopieren
    1. void potentiallyDangerous()
    2. {
    3.     unsigned int bufferSize;
    4.     std::cin >> bufferSize;
    5.  
    6.     char* buffer = new char[bufferSize];
    7.  
    8.     for(unsigned int i = 0; i < bufferSize; ++i)
    9.         buffer[i] = std::cin.get();
    10.  
    11.     //do sth with buffer
    12.  
    13.  
    14.     delete [] buffer;
    15. }
    16.  
    17. void farLessDangerous()
    18. {
    19.     unsigned int bufferSize;
    20.     std::cin >> bufferSize;
    21.  
    22.     std::unique_ptr<char[]> buffer(new char[bufferSize]);
    23.  
    24.     for(unsigned int i = 0; i < bufferSize; ++i)
    25.         buffer[i] = std::cin.get();
    26.  
    27.     //do sth with buffer
    28. }
    29.  
    30.  
    31. void easiest()
    32. {
    33.     unsigned int bufferSize;
    34.     std::cin >> bufferSize;
    35.  
    36.     std::vector<char> buffer(bufferSize, 0);
    37.  
    38.     for(unsigned int i = 0; i < bufferSize; ++i)
    39.         buffer[i] = std::cin.get();
    40.  
    41.     //do sth with buffer
    42. }
    43.  
    44. void evenEasier()
    45. {
    46.     std::vector<char> buffer;
    47.     while(!std::cin.eof())
    48.         buffer.push_back(std::cin.get());
    49.     //do sth with buffer
    50. }
    51.  
    Im ersten Beispiel muss nur eine Exception fliegen oder jemand den Programmfluss verändern, und schon hat man ein Memory Leak. *Man selbst* kann den Code nach einem Monat noch einmal anfassen und ändern. Einfach hier und da ein bisschen Code in Funktionen auslagern, und plötzlich wird Speicher gar nicht mehr (oder doppelt!) freigegeben.

    Besonders in diesen simplen Fällen kann man es mit 4, 5 zusätzlichen Zeichen schnell in eine verwaltete Klasse wrappen. Und das manuelle delete fällt sogar weg!

    Die Array-Varianten gibt es glaube ich erst seit C++11 - allerdings kann man sich - wie man sieht - auch ohne weiteres mit einem std::vector behelfen, wenn man mehrere Objekte halten will.

    In komplizierten Fällen, etwa, wenn eine Ressource in eine Warteschlange eingefügt werden soll, aus einer Factory erzeugt wird und nach einer bestimmten Zeit gelöscht werden soll, und so weiter - in diesen Fällen hilft der unique_ptr nicht, denn dieser ist per Definition nicht kopierbar. Auch ein auto_ptr gehört in keinen Container.
    Auch lassen sich Dateiobjekte oder Mutexes aufgrund ihrer eigenen Unkopierbarkeit nicht direkt in Container einfügen. Eine Lösung hierzu sind shared_ptr, also ab C++11 der std::shared_ptr, vorher z.B. der boost::shared_ptr, die intern mitzählen, wie oft dieser Pointer noch referenziert wird. Fällt die Zahl auf Null, wird die Ressource freigegeben.

    So könnte eine Factory shared_ptr<ClientSession> erzeugen, die dann in eine Warteschleife eingefügt werden - mehrere Threads können die Sessions dann herausnehmen, (teilweise) bearbeiten, wieder einfügen und nach Prüfung aller Konditionen in die ClientDatabase einfügen - und nach Beendigung des Tasks einfach vergessen.

    Auch hier ist der Overhead, den das Reference Counting mit sich führt, lächerlich niedrig im Vergleich zu dem komplizierten Code, den man schreiben müsste, um gleiches Verhalten mit rohen Pointern zu erreichen.

    Nochmal: Der beste Code ist der, der am schwierigsten falsch benutzt werden kann. Auch wer alleine arbeitet, und seinen Code nur selbst verwendet: Früher oder später schreibt man mal mehr als ein einseitiges Skript, oder man kehrt nach einem Monat Pause zu seinem Code zurück, und hat vergessen, dass man *eigentlich* dies und das implizit angenommen hatte.
    Das RAII macht es schwer, Ressourcen falsch zu benutzen.

    Nebeneffekte:
    Das RAII hat ein paar kleine Nebeneffekte, die sich wieder auf den Programmierstil auswirken. So verhindert das RAII effektiv, dass man Variablen mit Ressourcen anlegt, die in einem undefinierten oder in einem Fehlerzustand sind:

    Ein File* ist erstmal undefiniert - oder ich weise ihn auf NULL zu, dann ist er in einem Fehlerzustand.
    Nachdem ich mit fclose() einen File* geschlossen habe, ist das Dateihandle *wieder* in einem Fehlerzustand - diesmal einem anderen. Ein C-Neuling könnte sogar auf die Idee kommen, den Pointer mittels free() zu löschen. Diese Variablen erschweren nicht nur die Übersicht beim Debuggen, sie verleiten den Coder auch, auf sie zuzugreifen - immerhin sind sie da.

    Wenn man dem RAII folgt, sind Ressourcen an die Lebenszeit eines Objektes gebunden - und damit das Objekt auch an die Ressource. Das Lock oben kann in keinem "falschen" Zustand sein - wohingegen man versucht sein könnte, auf das myMutex Objekt zuzugreifen. Deshalb werden in allen Beispielen auch die Ressourcen (myMutex, Speicher mit new allokiert) direkt an die verwaltende Klasse (Lock, unique_ptr) gegeben, ohne sie in eine Variable zwischenzuspeichern: Das könnte dazu verleiten, dazwischen darauf zuzugreifen, oder aber den Programmfluss zu verändern - in dem Fall wäre es nur RA Is Close To Initialization - das hilft aber nicht. Einen rohen Pointer zwischenzuspeichern und ihn dann an einen uniqe_ptr zu geben, erlaubt, den Pointer manuell zu löschen - mit desaströsem Ergebnis. RA Is Initialization - das verwaltende Objekt wird mit der Ressource initialisiert.

    Behält man diesen Stil bei, legt man auch nur dann Variablen an, wenn man ihnen einen sinnvollen Wert zuweisen kann. Also eine Zählvariable *in* der for-Schleife usw... Das deklarieren aller Variablen am Beginn des Scopes ist selbst in C seit bald 15 Jahren veraltet ;)

    Übrigens spart man sich durch das RAII auch die Notwendigkeit für ein finally-Statement.

    Nah mit dem RAII verwandt sind ist natürlich auch das Benutzen von Exceptions zur Fehlerbehandlung. Aber dazu ein anderes Mal...

    Wie immer, Fragen und Anmerkungen sind willkommen - einfach oben unter dem Titel auf "Diskussion" gehen und frei raus die Meinung sagen.