Dynamisches Speichermanagement

Eine der zentralen Fragen bei der Programmierung: wo bekomme ich Speicher her, dessen genaue Menge und Beschaffenheit ich zur Entwicklungszeit noch nicht kenne (bspw. weil das von Nutzereingaben abhängt)? Und viel interessanter (vor allem bei lang laufenden Programmen): wie werde ich den auch wieder los? Dynamisches Speichermanagement wird in der einen oder anderen Form von eigentlich allen ernstzunehmenden Programmiersprachen angeboten. In C++ ist das wie üblich etwas flexibler, dafür aber manchmal auch etwas komplizierter.

Video

 

Quellcode

Einfaches new-delete von einzelnen Objekten 

#include <iostream>

class Greeter
{
public:
  Greeter()
  {
    std::cout << "Greeter initialisiert\n";
  }

  Greeter(int x)
  {
    std::cout << "Greeter mit " << x << " initialisiert\n";
  }
  
  ~Greeter()
  {
    std::cout << "Greeter zerstört\n";
  }
};

int main()
{
  Greeter *g = 0;
  bool create;
  std::cin >> create;
  if (create) {
    g = new Greeter;
  }
  std::cout << "Hier bin ich\n";
  if (g != 0) {
    delete g;
  }
}

Array new-delete 

#include <iostream>

class Greeter
{
public:
  Greeter()
  {
    std::cout << "Greeter initialisiert\n";
  }

  ~Greeter()
  {
    std::cout << "Greeter zerstört\n";
  }
};

int main()
{
  unsigned int count;
  std::cin >> count;
  Greeter *gs = new Greeter[count];
  std::cout << "Greeter sind vorhanden\n";
  delete[] gs;
  std::cout << "Programmende\n";
}

Erklärung

Die interessanten Teile sind jeweils die Zeilen mit new und delete (Zeilen 28 und 32 im ersten Listing, sowie 21 und 23 im zweiten). Diese Operatoren sind die Herzstücken der dynamischen Speicherverwaltung in C++.

new und delete

Das einfache new und delete dient der Verwaltung von Speicher für einzelne Objekte. new nimmt einen Typ entgegen und reserviert Speicher, der hinreichend groß und passend an Speichergrenzen ausgerichtet ist, um genau ein Objekt des betreffenden Typs aufzunehmen. In diesem Speicher wird das betreffende Objekt initialisiert, indem der Standardkonstruktor aufgerufen wird (es können auch andere Konstruktoren aufgerufen werden, aber dazu mehr in einem folgenden Video). Ergebnis des new-Ausdrucks (der Operator zählt nicht als Anweisung, sondern ist ein Ausdruck mit einem Ergebnis) ist ein Pointer vom Typ Ausgangstyp* (wobei Ausgangstyp der Typ ist, der hinter dem new angegeben wurde). Der Pointer zeigt auf das neu initialisierte Objekt und kann für den Zugriff verwendet werden. Wird der Speicher nicht mehr gebraucht, so kann er mittels delete gegenüber der Laufzeitumgebung freigegeben werden. delete nimmt einen Pointer auf einen zuvor mit new reservierten Speicherbereich, ruft für das dort liegende Objekt den Destruktor auf und gibt dann den Speicherbereich frei. Der Pointer an sich bleibt dadurch unverände  (wird also nicht auf 0 gesetzt oder ähnliches). Man sollte allerdings tunlichst vermeiden, diesen Pointer danach nochmal zu dereferenzieren. Das wäre undefiniertes Verhalten und führt im günstigsten Falle zum Absturz des betreffenden Programms. In C++ gibt es standardmäßig keine automatische Speicherverwaltung (auch wenn es Implementierungen gibt, die sowas hinzufügen). Man muss sich also selbst kümmern. Grundregel: ein new == ein delete. Man muss jeden mit new reservierten Bereich mit delete wieder freigeben, darf aber auch jeden reservierten Bereich nur einmal freigeben.

Array new und delete

Für die Reservierung von Arrays bietet C++ eine eigene Variante von new und delete. Im Falle des new-Ausdrucks (Zeile 21 im zweiten Listing) ist der Unterschied nicht so offensichtlich, denn die Array-Kennzeichnung (die eckigen Klammern) stehen hinter dem angegebenen Typ. Das deckt sich mit der Schreibweise zu Deklaration von Arrays fester Größe, weswegen die Syntax so gewählt wurde. Im Unterschied zum normalen new nimmt new[] einen Typ und (in den eckigen Klammern) eine Anzahl und reserviert einen Speicherbereich, der mindestens die angegebene Anzahl an Objekten des angegebenen Typs direkt hintereinander enthalten kann. Der reservierte Speicher kann (bspw. für Verwaltungsdaten) größer sein, als die Summe der reservierten Elemente (der C++-Standard macht hier explizit einen Unterschied zwischen normalem new und new[]). Der new[]-Ausdruck liefert einen Pointer auf das erste Element des Arrays zurück (und hat den gleichen Typ, wie ein normaler new-Ausdruck für denselben Objekttyp hätte). Die zurückgelieferten Pointer unterscheiden sich also nicht zwischen new und new[]. Was sich unterscheidet ist die Initialisierung: new[] initialisiert im reservierten Speicherbereich die angegebene Anzahl an Objekten des angegebenen Typs (ruft also entsprechend oft den Standardkonstruktor auf). Das Gegenstück delete[] (diesmal auch tatsächlich so geschrieben, siehe Zeile 23 im zweiten Listing) ruft für die Objekte im Array die Destruktoren auf (die Anzahl wird implizit mitgeführt und ist für den Programmierer unsichtbar) und gibt dann den gesamten Speicherbereich frei. Auch hier bleibt der übergebene Pointer wieder unverändert, darf aber danach nicht mehr derefenziert werden.

Dieser Doppelpack aus normelem und Array-new und -delete bildet die Grundlage für eine der hässlicheren Stolperfallen im dynamischen Speichermanagement: die Mischung der beiden Operatortypen. Typischerweise passiert das, wenn man ein Array allokiert, aber dann den normalen delete-Operator zur Freigabe verwendet. Was genau passieren wird, darüber trifft der Standard keine Aussage. Im Video wird der Destruktor für das erste im Array liegende Element aufgerufen und dann offenbar eine gewisse Menge an Speicher freigegeben, was zu einer Beschwerde der Laufzeitbibliothek über eine Memory Corruption führt (so ziemlich die letzte Meldung, die man von seinem Programm sehen will). Das ist keineswegs garantiert: das kann genauso gut funktionieren (auf manchen Plattformen sind die beiden Varianten gleich implementiert, was den Code zufällig korrekt laufen lässt), es kann aber auch still und leise Speicher verlieren oder zerwürfeln. Hier darf man also auf keinen Fall mischen und muss sich immer im Klaren darüber sein, was genau sich hinter einem Pointer verbirgt.

Diese Fehleranfälligkeit (und außerdem das Problem von Speicherlecks) lässt sich mit verschiedenen Mitteln in C++ lösen. Ein Beispiel wäre der Einsatz eines Garbage Collectors, wie Boehm GC. Damit bekommt man die Vorteile und Nachteile der Garbage Collection und kann so quasi wie in Java oder C# arbeiten. Eine andere, mit C++11 auch standardkonforme Variante ist der Einsatz von Smart Pointern. Das sind Objekte, die sich wie Pointer verhalten, aber den kleinen, aber netten Service bieten, dass sie, sobald der letzte Nutzer eines Pointers diesen aufgibt, den Speicher aufräumen (also delete aufrufen). Darüber werde ich noch ein Video machen. Für alle, die dazu zu ungeduldig sind bietet der entsprechende Wikipedia-Artikel Stoff zum Weiterlesen.

Tags: