Lektion 6 - Speicher 2 (Heap und sizeof Operator)

Betrachte dieses Code Beispiel aus der letzten Lektion:

int* create_array(int size) {
  int array[size];
  return array;
}

Anhand dieses Beispiels haben wir gesehen, dass es wenig Sinn macht, Speicherplatz für einen Puffer so zu reservieren. Der Speicherplatz, den er belegt, ist nur für die Dauer der Ausführung der Funktion gültig. Das bedeutet, dass wir bei der Allokation von Speicher auf dem Stack sehr vorsichtig sein müssen. Wir müssen dafür sorgen, dass der Scope so lange aktiv bleibt, wie wir ihn brauchen. In einem großen Programm würden wir sehr schnell den Überblick verlieren.

Betrachte außerdem den Kontakt struct, den wir schon ein paar Mal verwendet haben:

struct contact {
  char[100] first_name;
  char[100] last_name;
  int telephone_number;
};

Die Größen von first_name und last_name sind festgelegt. Für die meisten Namen würde viel Speicherplatz verschwendet werden, während die Erfassung des Namens dieses Herrn ein wenig problematisch sein könnte.

Nicht zuletzt laufen auf einem Computer normalerweise mehrere Programme gleichzeitig. Jedem Programm die gleiche Menge an Speicher zuzuweisen, mag zwar fair sein, aber realistisch betrachtet gibt es einige Programme, die sehr wenig Speicher benötigen, und andere, die mehr benötigen.

Diese Probleme werden durch den Heap gelöst, einen vom Stack getrennten Speicherbereich. Die Idee des Heaps ist, dass wir unseren Speicher nicht so strukturiert behandeln wie beim Stack (zur Erinnerung: neue Daten werden im Stack immer an die bereits verwendeten Daten angehängt). Stattdessen sendet das Programm eine Anfrage an das Betriebssystem wie: “Hey, ich hätte gerne diese Menge an Speicher”, und das Betriebssystem antwortet daraufhin: “Geht nicht” oder “Klar, der von dir angeforderte Speicher ist jetzt unter dieser Adresse zugänglich”. Dieser Speicher bleibt so lange gültig, bis wir ihn nicht mehr benötigen, völlig unabhängig vom Scope einer Funktion.

Glücklicherweise müssen wir diese Funktionalität nicht selbst implementieren. Durch Einbindung des stdlib.h Headers erhalten wir Zugriff auf die Funktionen malloc und free. Schauen wir uns ein kleines Beispiel an.

#include <stdlib.h>

int main() {
  int* p = malloc(100);
  if(p == NULL) {
    // memory allocation failed
  }
    // memory allocation successful, we can use the new memory location
    // ...
    //
    free(p);
  }
}

Wir rufen malloc auf, um 100 Bytes Speicher beim Betriebssystem anzufordern. Der Aufruf der Funktion malloc gibt eine Adresse zurück. Da wir diesen Speicherplatz verwenden wollen, speichern wir den Rückgabewert in einem Pointer. Wenn die Speicherzuweisung fehlschlägt, ist der Rückgabewert NULL. Andernfalls können wir auf p zugreifen, um Daten in den zugewiesenen Speicher zu schreiben. Wenn wir den Speicher nicht mehr benötigen, rufen wir die Funktion free auf, um dem Betriebssystem mitzuteilen, dass wir ihn nicht mehr benötigen. Der Speicherplatz wird daraufhin ungültig.

Wie kann ich entscheiden, wie viele Bytes ich genau brauche? Betrachten wir noch einmal das erste Beispiel:

int* create_array(int size) {
  return malloc(size * sizeof(int));
}

In diesem Beispiel wollen wir den Speicher für size viele Integer zuweisen. Um genau zu wissen, wie viel das in Bytes ist, können wir den sizeof Operator verwenden (Hinweis: es ist ein eingebauter Operator der Sprache, keine Funktion), der uns die Größe in Bytes für jeden Typ oder Variable liefert. Dieses Mal wird der Speicher für das Array auf dem Heap reserviert und die Adresse zurückgegeben. Abgesehen von der Kontrolle, ob der Rückgabewert NULL ist, kann der Aufrufer der Funktion davon ausgehen, dass der angegebene Speicherplatz gültig ist. Sobald das Array nicht mehr benötigt wird, kann die free Funktion aufgerufen werden.

Heap-Allokationen werden oft in Kombination mit Arrays verwendet, da wir die benötigte Größe oft erst zur Laufzeit erfahren. Schauen wir uns ein weiteres Beispiel an.

struct contact {
  char* first_name;
  char* last_name;
  int telephone_number;
};

Zunächst ändern wir die Definition von struct contact. Statt Puffer mit fester Größe enthält die Struktur nun zwei Pointer, mit deren Hilfe wir auf verschiedene Speicherorte verweisen können werden. Nehmen wir nun an, wir wollen einen neuen Kontakt erstellen und wissen, wie lang der Vor- und Nachname ist. Um das Beispiel nicht komplizierter zu machen als es ohnehin schon ist, werden wir die tatsächlichen Namen noch nicht zuweisen.

struct contact* create_empty_contact(int len_first_name, int len_last_name) {
  struct contact new_contact = malloc(sizeof(struct contact));

  if(new_contact == NULL) {
    // allocation failed
    return NULL;
  }

  new_contact->first_name = malloc(len_first_name * (sizeof(char) + 1));
  new_contact->last_name = malloc(len_last_name * (sizeof(char) + 1));

  // if allocation failed for any of the two strings, return NULL
  if (new_contact->first_name == NULL ||
      new_contact->last_name == NULL) {
    return NULL;
  }

  new_contact->first_name[len_first_name] = 0;
  new_contact->last_name[len_last_name] = 0;

  return new_contact;
}

In der ersten Zeile weisen wir unserem neuen Kontakt Speicherplatz auf dem Heap zu. Dies ist sinnvoll, da wir vor der Ausführung nicht unbedingt wissen, wie viele Kontakte wir insgesamt speichern müssen. Das Programm wird nur den Speicher anfordern, den es während der Ausführung benötigt. Bevor wir versuchen, auf den Speicherplatz zuzugreifen, müssen wir prüfen, ob die Zuweisung erfolgreich war. Der Versuch, auf new_contact->first_name zuzugreifen, wenn new_contact NULL enthält, wäre ein Segmentation Fault, da wir versuchen würden, Speicher von einer ungültigen Adresse zu lesen. Unter der Annahme, dass new_contact wirklich auf einen gültigen Speicherplatz zeigt, können wir nun die Pointer first_name und last_name setzen. Die angebliche Länge der Namen erhalten wir über Parameter, also verwenden wir sie für unsere malloc Aufrufe. Zusätzlich reservieren wir Platz für ein extra Byte: den NULL Terminator am Ende des Strings, den wir anschließend setzen. Der Pointer auf diesen neuen Kontakt wird zurückgegeben, und der Aufrufer kann den Speicher nach Belieben verwenden.

void delete_contact(struct contact* to_delete) {
  free(to_delete->first_name);
  free(to_delete->last_name);
  free(to_delete);
}

Um den zugewiesenen Speicher wieder freizugeben, benötigen wir einen free Aufruf für jedes malloc. Das Freigeben von to_delete vor dem Freigeben seiner Mitglieder wäre ein Fehler, da wir den Zugriff auf die Pointer, die auf den reservierten Speicher zeigen, verlieren würden.

Ein häufiger Fehler kann auftreten, wenn man versucht, solche Strings zu initialisieren. Lasst uns create_empty_contact zu einer Funktion umwandeln, die auch dem reservierten Speicher Werte zuweist:

// NOTE the string lenghths can be deduced from the strings themselves, by checking for the terminating 0.
// we pass them by parameter to keep the example shorter. Typically this is done with the strnlen() function provided by string.h
struct contact* create_empty_contact(int len_first_name, int len_last_name, char* first_name, char* last_name, int telephone_number) {
  struct contact new_contact = malloc(sizeof(struct contact));

  new_contact->telephone_number = telephone_number;

  new_contact->first_name = malloc(len_first_name * (sizeof(char) + 1));
  new_contact->last_name = malloc(len_last_name * (sizeof(char) + 1));

  new_contact->first_name = first_name;
  new_contact->last_name = last_name;

  return new_contact;
}

Der Kürze halber haben wir die NULL Checks weggelassen. Kannst du den Fehler erkennen? Die Parameter first_name und last_name sind Pointer auf einen String, nicht die Strings selbst. Durch das Ausführen von

new_contact->first_name = first_name;

schreiben wir nicht den Inhalt des Strings first_name in den Puffer, der für new_contact->first_name alloziert wurde, sondern wir überschreiben den Pointer auf den Speicher, den wir gerade reserviert haben! Angenommen, first_name und last_name befinden sich auf dem Stack, dann können sie irgendwann in Zukunft aus dem Scope fallen, und unser struct contact kann auf ungültigen Speicher statt auf den im Heap allozierten Speicher zeigen.

Dieses Konzept gilt nicht nur für Strings, sondern für jedes Array. Wenn wir ein Array in ein anderes kopieren wollen, müssen wir die einzelnen Elemente (d.h. den Inhalt) kopieren, nicht die Pointer. Während die Funktion memcpy (stdlib.h) auf jedes beliebige Paar von Puffern angewendet werden kann, ist die Funktion strncpy (string.h) speziell für Strings konzipiert. Der Funktionsaufruf würde wie folgt aussehen:

strncpy(new_contact->first_name, first_name, len_first_name+1);

Dabei ist new_contact->first_name die Zieladresse, first_name die Quelladresse und len_first_name die maximale Anzahl der zu kopierenden Zeichen (+1, um sicherzustellen, dass die terminierende Null enthalten ist).