C++-Programmierung: Speicherverwaltung
Eine aktuelle Seite zum gleichen Thema ist unter C++-Programmierung/ Speicherverwaltung verfügbar.
Die Codebeispiele dieses Kapitels erfordern z.T. Grundkenntnisse über Klassen.
In C++-Programmen gibt es verschiedene Arten von Speicher zu verwalten:
- auf dem Stack
- auf dem Heap
- vom Compiler im Programm „verbaut“
Wenn hier und im Folgenden vom Compiler die Rede ist, soll alles vom Präprozessor bis zum Linker gemeint sein. Das ist zwar sachlich falsch, sei aber als Vereinfachung an dieser Stelle erlaubt.
Stack
BearbeitenDer einfachste und für den Programmierer „pflegeleichte“ Speicher ist der auf dem Stack (Stapel). Sie müssen ihn weder explizit anfordern noch freigeben, beides geschieht automatisch. (Übrigens gibt es das Schlüsselwort auto
, das genau diese Sorte von Variablen markiert. Weil das aber den „Normalfall“ darstellt, ist auto
redundant und wird faktisch nie verwendet.)
int f1(int wert, int *zeiger)
{
auto int add = 2; // "auto" ist unnötig
int lokal = wert + add;
*zeiger = lokal;
return 2*lokal;
}
Bei jedem Aufruf von f1
wird eine neue Schicht (frame) auf den Stack gelegt. Sie enthält die lokalen Variablen (add
, lokal
), die mit den Argumenten initialisierten Parameter (wert
, zeiger
) und eine Rücksprungadresse, damit das Programm „weiß“, von wo die Funktion aufgerufen wurde. Wird die Funktion mit return
verlassen, wird die Schicht vom Stack entfernt, die Variablen vernichtet und der Programmablauf bei der Rücksprungadresse fortgesetzt. Wohlgemerkt: Auch die Variablen wert
und zeiger
werden zerstört. Den Speicher, auf den zeiger
zeigt, beeinflusst das nicht. Rufen Sie die Funktion z.B. so auf:
int x = 1, y;
int z = f1(x, &y);
std::cout << "y = " << y << std::endl;
erhalten Sie die Ausgabe y = 3
.
Die oberste Schicht auf dem Stack entspricht immer dem aktuellen Block. Das kann ein Funktions- oder Schleifenrumpf sein, oder eine „einfach so“ in {}
eingeschlossene Passage:
int f2(int wert, int *zeiger)
{
auto int add = 2; // "auto" ist unnötig
int lokal = wert + add;
{
int innen = 7;
*zeiger = lokal;
} // Block zu Ende
std::cout << innen << std::endl; // Fehler: "innen" gibt's nicht mehr
return 2*lokal;
}
Das Beispiel lässt sich natürlich nicht übersetzen. Die Variable innen
existiert nur innerhalb des Blocks.
Das nächste Beispiel verwendet eine C++-Klasse klasseA
.
#include "klasseA.h"
void f3() {
klasseA a;
// ...
}
Wenn Sie mit Klassen vertraut sind, ahnen Sie es sicher: Die Instanz a
wird beim Eintritt in die Funktion (i.d.R. mit dem Default-Konstruktor) erzeugt und beim Verlassen mit Aufruf aller beteiligter Destruktoren wieder zerstört.
Globale Variablen
BearbeitenSie können sich die globalen Variablen in einer „untersten Schicht“ vorstellen, die beim Start des Programms angelegt und erst beim Programmende zerstört wird. Gemäß dem C++-Sprachstandard werden globale Variablen mit 0 initialisiert.
Speicherklassen: register und static
BearbeitenDas vor allem bei Schleifenzählern häufig verwendete Schlüsselwort register
weist den Compiler darauf hin, dass auf diese Variable oft zugegriffen werden muss. Diese Empfehlung, die Variable nicht auf dem Stack anzulegen, sondern in einem Register des Prozessors zu halten, kann vom Compiler ignoriert werden. Ansonsten verhält sich eine register
-Variable wie eine gewöhnliche lokale Variable.
Das Schlüsselwort static
bei einer Variablendeklaration innerhalb eines Blocks besagt, dass diese Variable nur einmal erzeugt und auf einer festen Adresse gespeichert wird, so dass ihr Wert beim Verlassen des Blocks nicht verloren geht. Sie verhält sich also hinsichtlich Lebensdauer wie eine globale Variable, ist aber nur innerhalb des Blocks sichtbar.
static
bei einer globalen Variablendeklaration besagt, dass die Variable intern gebunden wird, d.h. auf sie kann nur innerhalb dieser Quelltextdatei zugegriffen werden.
Heap
BearbeitenHäufig müssen Datenstrukturen erzeugt und bearbeitet werden, deren Größe von vornherein nicht bekannt ist bzw. sich zur Laufzeit ändern kann. Denken Sie an eine Liste, bei der - abhängig von den Eingaben des Benutzers - Elemente eingefügt oder entfernt werden sollen. Mit Stackvariablen lässt sich diese Aufgabe offensichtlich nicht lösen.
Zu diesem Zweck gibt es den Heap (dynamischen Speicher). Die Speicherverwaltung ist naturgemäß komplizierter und fehleranfälliger, denn vom Heap muss alles explizit angefordert und freigegeben werden. C++ stellt hierfür die Schlüsselwörter new
und delete
bereit (beide greifen intern auf die Routinen malloc/free der C-Standardbibliothek zu):
#include "klasseA.h"
void f4()
{
klasseA *azeiger = new klasseA; // anfordern
azeiger->machwas(); // verwenden
delete azeiger; // freigeben
int *x = new int[10]; // anfordern
for (int i = 0; i < 10; i++)
x[i] = 3*i; // verwenden
// ...
delete[] x; // freigeben
}
Wie im Beispiel zu sehen, können sowohl einzelne Instanzen als auch Arrays angefordert werden. Danach richtet sich die Syntax des delete
-Operators.
Mit new
angeforderter Heap-Speicher steht so lange zur Verfügung, bis er mit delete
explizit freigegeben wird, unabhängig von der Blockstruktur des Programms. Dies ermöglicht, mit rekursiv verketteten Daten, z.B. Listen oder Bäumen zu arbeiten. Beispiel mit einer simplen struct
:
#include <iostream>
using namespace std;
struct Element {
int zahl;
Element *next;
};
int main() {
int eingabe;
Element *ekopf = 0; // Start mit leerer Liste
do {
cin >> eingabe; // Benutzer gibt Zahl ein
Element *eneu = new Element; // anfordern
eneu->zahl = eingabe;
eneu->next = ekopf; // Liste verketten
ekopf = eneu; // neuer Listenkopf
} while (eingabe != 0);
while (ekopf) {
cout << ekopf->zahl << endl;
Element *enext = ekopf->next;
delete ekopf; // freigeben
ekopf = enext;
}
return 0;
}
Das Programm erfragt so lange eine Zahl, bis der Benutzer 0 eingibt. Die Eingaben werden in einer verketteten, mit dem Nullzeiger terminierten Liste gespeichert und im zweiten Teil des Programms in umgekehrter Reihenfolge wieder ausgegeben. Während der Ausgabe wird der Heap-Speicher quasi „nebenbei“ wieder freigegeben.
Bei komplexeren benutzerdefinierten Klassen erledigt der Operator new
zwei Schritte auf einmal:
- einen Heap-Speicherbereich angemessener Größe anfordern,
- einen Konstruktor aufrufen, um den Speicher „vernünftig“ zu initialisieren.
Entsprechend macht delete
das Umgekehrte:
- sämtliche nötigen Destruktoren aufrufen,
- Speicherbereich freigeben.
Es sei der Vollständigkeit halber erwähnt, dass es die Möglichkeit gibt, die beiden Schritte getrennt aufzurufen. Wenn der Speicherbereich bereits verfügbar ist, ruft inplace-new nur den Konstruktor auf:
#include <new> // für inplace-new nötig
#include "klasseA.h"
void f5(void* adresse)
{
klasseA *a = new(adresse) A; // Instanz an gegebener Adresse konstruieren
a->machwas(); // verwenden
a->~klasseA(); // Destruktor aufrufen
} // Programm-Ende, Speicher wird freigegeben
Hier verwendet new
den durch adresse
bezeichneten Speicherbereich, fordert also keinen neuen Heap-Speicher an. Deshalb kein delete
verwenden!
Inplace-new bietet sich an für eigene Speichermanager genauso wie für Neugierige, die experimentieren wollen.
Ansonsten ergeben sich mit new/delete noch viele weitere Probleme, insbesondere bezüglich Verteilungstechniken (COM/CORBA/DCOP/...) oder wenn Speicher die Compilergrenzen verläßt. ("Wer verwendet welche new/delete-Implementation aus welcher Library?")
Durch den Compiler „verbaut“
BearbeitenAuf diesen Speicher hat der Programmierer i.d.R. nur wenig Einfluß, deswegen wird hier nur kurz darauf eingegangen.
Prinzipiell kann der Compiler alles im Programm einflechten, was der Hersteller wünscht. Das umfaßt Vendor-Strings genauso wie Debug-Symbole, Lizenzinfos, Zusatzfunktionen oder Optimierungsverschnitt (Padding-Bytes). Manches lässt sich über Komandozeilenparameter beeinflussen, wie z.B. Debug-Symbole an-/ausschalten, Grad und/oder Schwerpunkt der Optimierung setzen, etc.
Außerdem generiert der Compiler für Klassen noch zusätzliche interne Strukturen (virtual function table, typeinfo), für die natürlich auch Speicher verbraucht wird, aber in denen nicht direkt die verarbeiteten Informationen enthalten sind. Diese Bereiche sind zwar nicht vom Programmierer abgeschottet, aber im Regelfall bei direktem Zugriff relativ nutzlos.
Seien Sie neugierig!
BearbeitenWenn Sie es sich erlauben können*, stöbern Sie auch mal auf sonst „unerwünschte“ Weise im Speicher herum! Heutige Betriebssysteme lassen sich von verbotenen Zugriffen normalerweise nicht beeindrucken - Ihr Programm „fliegt einfach raus“. Mehr als abstürzen kann es nicht.
(*) Gilt ausdrücklich nicht für hochkritische Rechner (z.B. Computer im AKW, Krankenhaus, etc). Auch mit den Computern des Arbeitgebers sollte man Experimente unterlassen.