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
|
|
Array new-delete
|
|
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.