Lektion 2 - Speicher 1 (Stack, Pointer)

Der Speicheraspekt von C, einschließlich Pointer, ist ein Thema, mit dem viele Schwierigkeiten haben. Das liegt vor allem daran, dass man an andere Programmiersprachen gewöhnt ist, die eine höhere Abstraktionsebene bieten. Die Funktionsweise von C ist jedoch eng damit verbunden, wie Computer rechnen. Sobald man das verstanden hat, wird man feststellen, dass C eine unglaublich einfache Sprache ist (damit ist gemeint, dass C eine kleine Sprache ist, mit wenigen Regeln und Features, nicht unbedingt, dass das Schreiben von C Code einfach sein soll).

Programme und Speicher

Stell dir vor, du müsstest einem Grundschulkind erklären, wie es ein schwieriges Problem lösen soll. Einige sehr kleine Aufgaben kann das Kind durch Kopfrechnen alleine lösen, z.B. “5+2” berechnen. Man müsste einen Weg finden, das Problem in viele kleine Aufgaben aufzuteilen, die das Kind lösen kann, und dem Kind sehr genaue Schritte vorgeben, die es befolgen muss. Außerdem bräuchte das Kind etwas zum Schreiben, denn wir können ja nicht erwarten, dass es sich alles merkt, was es in einem früheren Schritt getan hat. Also besorgen wir dem Kind ein kariertes Schulheft. Das Kind kann dann seine kleinen Berechnungen machen und das Ergebnis in die Mitte eines Kästchens ins Heft schreiben, genau wie wir es damals in der Grundschule auch gemacht haben. Wir brauchen dem Kind also nur genau zu beschreiben, welche Kästchen es sich anschauen muss, welche Operation es durchführen muss und in welches Kästchen es diese Dinge schreiben muss, und zwar so oft, bis das schwierige Problem gelöst ist. Das Kind würde es sicherlich weiterhin verhauen.

Ein Computer ist ähnlich wie ein Kind: dumm. Aber mit zwei Unterschieden:

  1. Der Computer wird nie schlauer werden
  2. Der Computer wird bei der Lösung unseres Problems keinen Mist bauen

Ohne zu sehr ins Detail zu gehen, tun CPUs, die ein Programm ausführen, nicht mehr als das, was oben vom Kind verlangt wurde. Einige Probleme, wie das Addieren zweier Zahlen, können durch eine einfache Kopfberechnung (Nerds würden es “Instruktion” nennen) gelöst werden. Für komplexere Probleme muss der Computer gelegentlich einige Dinge in sein Heft, d.h. in den Speicher (RAM), schreiben.

Ein Programm ist im Wesentlichen eine Folge von Anweisungen, bei denen die CPU angewiesen wird, den Inhalt bestimmter “Kästchen” zu nehmen, eine Berechnung durchzuführen und das Ergebnis in ein anderes “Kästchen” zurückzuschreiben. Um zu wissen, auf welchen Teil des Speichers sich die Programme genau beziehen, werden die “Kästchen” durchnummeriert. Die Nummer eines bestimmten Speicherplatzes wird als “Adresse” bezeichnet.

Man könnte sagen, es ist die Adresse, an der die gespeicherten Zahlen wohnen :clown_face:.

Der Stack und wie C ihn für Variablen nutzt

Die meisten der von C-Programmen durchgeführten Speicheroperationen finden auf dem “Stack”, einem bestimmten Speichersegment, statt. Im Grunde werden neue Daten einfach an Daten angehängt, die zuvor geschrieben wurden. Betrachten wir die Ausführung eines C-Programms, das die folgende Zeile beinhaltet:

int x = 5;

Das tatsächlich ausgeführte Programm nimmt die nächste freie Adresse auf dem Stack und reserviert 4 Bytes (die übliche Größe eines Integers), um den Wert 5 zu speichern. Wenn das C-Programm später auf die Variable x verweisen würde, wüsste das Programm, wo es auf dem Stack nachsehen muss, um den Wert zu ermitteln. Was ist, wenn der Wert von x geändert wird, zum Beispiel durch diesen Befehl:

x = 4;

Derselbe Speicher, an der die 5 gespeichert wurde, wird hiernach zum Speichern der 4 verwendet. Wie man sieht, sind Variablen eine einfache und mächtige Abstraktion, um einfach auf den Inhalt einer bestimmten Adresse im Speicher zu verweisen. Als C-Programmierer ist es uns oft egal, an welcher Adresse die 5 und später die 4 tatsächlich gespeichert ist.

Pointer

Dies ist jedoch nicht immer der Fall. Manchmal wollen wir die Adresse wissen, an der ein bestimmter Wert gespeichert ist. Mit dem & Operator können wir auf die Adresse einer Variable zugreifen. Beispielsweise im Fall von x:

&x

Außerdem gibt uns C die Möglichkeit, Variablen zu haben, die lediglich dazu da sind, eine Adresse zu speichern: Pointer. Wir deklarieren nun den Pointer p.

int* p;

Das Programm würde nun die nächste freie Adresse auf dem Stack nehmen und genügend Platz zum Speichern einer Adresse (normalerweise 8 Byte) reservieren. Da dies eine Variable ist, können wir ihr auch einen Wert zuweisen. Wir sollten p nur Dinge zuweisen, von denen wir wissen, dass sie eine Adresse sind. Zum Beispiel dies:

p = &x;

Nun enthält die Adresse, an der p gespeichert ist, als Wert die Adresse, an der x gespeichert ist. Du fragst dich vielleicht, warum der Pointer ein Integer-Pointer sein muss? Schließlich ist eine Adresse eine Adresse, unabhängig davon, welche Art von Daten dort gespeichert werden. Betrachte diese Zeilen C-Code:

void* p1 = &x;
char* p2 = &x;
float* p3 = &x;

Sie tun eigentlich alle das Gleiche, sie schreiben die Adresse von x auf den Stack. Der Typ eines Pointers ist wichtig, wenn wir den Inhalt einer Adresse lesen wollen. Angenommen, wir wollen eine neue Integer-Variable y erstellen, in der wir den Wert von x speichern, ohne direkt auf x zuzugreifen. Wir können dies tun, indem wir p mit dem Operator * dereferenzieren.

int y = *p;

Hier ist die Tatsache, dass p vom Typ int* ist, endlich nützlich. Da p ein Integer-Pointer ist, wissen wir, dass wir die Größe eines Integers lesen müssen, um ihn zu dereferenzieren. Hätten wir einen Character-Pointer (char*) verwendet, hätten wir nur auf das erste Byte (die Größe eines Zeichens) zugegriffen.

Das Dereferenzieren eines Pointers kann gefährlich sein. Wenn der Pointer eine ungültige Adresse speichert, wird unser Programm abstürzen und die vielleicht bekannte Meldung “Segmentation fault” ausgeben. Um dieses Problem zu lösen, ist es in C üblich, Pointer mit dem Wert NULL zu initialisieren (im Grunde die Adresse 0). Auf diese Weise können wir die Dereferenzierung ungültiger Pointer vermeiden. Wir können einfach überprüfen, ob der Pointer vorher den Wert NULL enthält.

int y;
if(p != NULL) {
  y = *p;
} else {
  // handle the error
}

Und das ist auch schon die ganze Magie, die in Pointern steckt. Sie sind einfach nur eine Variable, die eine Adresse speichert. Übrigens gibt es dieses Konzept nicht nur in C. Alle Programmiersprachen verwenden Pointer auf die eine oder andere Weise. Jedes Objekt in Java ist insgeheim ein Pointer, die Sprache abstrahiert einfach davon weg.

Ok, aber warum brauchen wir überhaupt Pointer? Können wir nicht einfach alles in normalen Variablen speichern? Ich denke, für viele Dinge könnte man das tatsächlich tun. Aber das würde zu kompliziertem, hässlichem Code führen. In späteren Lektionen werden wir mehr Beispiele dafür sehen, wie Pointer nützlich sein können und uns helfen, eleganten (und effizienten) Code zu schreiben.

Die nächste Lektion konzentriert sich jedoch auf etwas, das wir ohne Pointer nicht implementieren könnten: Arrays und Strings.