C++-Programmierung: Speicherverwaltung

Aus Wikibooks
Alte Seite
Diese Seite gehört zum alten Teil des Buches und wird nicht mehr gewartet. Die Inhalte sollen in das neue Buch einfließen: C++-Programmierung/ Inhaltsverzeichnis.
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[Bearbeiten]

Der 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[Bearbeiten]

Sie 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[Bearbeiten]

Das 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[Bearbeiten]

Hä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“[Bearbeiten]

Auf 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![Bearbeiten]

Wenn 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.