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
Code
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ändert (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 normalem 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.