C-Programmierung: Zeiger

Eine Variable wurde bisher immer direkt über ihren Namen angesprochen. Um zwei Zahlen zu addieren, wurde beispielsweise der Wert einem Variablennamen zugewiesen:

 summe = 5 + 7;

Eine Variable wird intern im Rechner allerdings immer über eine Adresse angesprochen (außer die Variable befindet sich bereits in einem Prozessorregister). Alle Speicherzellen innerhalb des Arbeitsspeichers erhalten eine eindeutige Adresse. Immer wenn der Prozessor einen Wert aus dem RAM liest oder schreibt, schickt er diese über den Systembus an den Arbeitsspeicher.

Eine Variable kann in C auch indirekt über die Adresse angesprochen werden. Die (Beginn)-Adresse einer Variablen oder allgemeiner - eines Speicherbereiches - liefert der &-Operator (auch als Adressoperator bezeichnet). Diesen Adressoperator kennen Sie bereits von der scanf-Anweisung:

 scanf("%i", &a);

Wo diese Variable abgelegt wurde, lässt sich mit einer printf Anweisung herausfinden:

 printf("%p\n", (void*)&a);

Der Wert kann sich je nach Betriebssystem, Plattform und sogar von Aufruf zu Aufruf unterscheiden. Der Platzhalter %p steht für das Wort Zeiger (engl.: pointer).

Eine Zeigervariable dient dazu, ein Objekt (z.B. eine Variable) über seine Adresse anzusprechen. Eine Zeigervariable verhält sich genau wie eine "normale" Variable, deren Wert wird jedoch als Adresse interpretiert. Demzufolge besitzt auch jede Zeigervariable wiederum eine Adresse.

Beispiel

Bearbeiten

Im folgenden Programm wird die Zeigervariable a definiert:

#include <stdio.h>

int main()
{
  int *a, b;

  b = 17;
  a = &b;
  printf("Inhalt der Variablen b:    %i\n", b);
  printf("Inhalt des Speichers der Adresse auf die a zeigt:    %i\n", *a);
  printf("Adresse der Variablen b:   %p\n", (void*)&b);
  printf("Adresse auf die die Zeigervariable a verweist:   %p\n", (void*)a);
  /* Aber */
  printf("Adresse der Zeigervariable a: %p\n", &a);
  return 0;
}
 
Abb. 1 - Das (vereinfachte) Schema zeigt wie das Beispielprogramm arbeitet. Der Zeiger a zeigt auf die Variable b. Die Speicherstelle des Zeigers a besitzt lediglich die Adresse von b (im Beispiel 1462). Hinweis: Die Adressen für die Speicherzellen sind erfunden und dienen lediglich der besseren Illustration.

In Zeile 5 wird die Zeigervariable a definiert und eine Variable b vom Typ int.

Nach der Definition hat die Zeigervariable a einen nicht definierten Inhalt. Die Anweisung a=&b in Zeile 8 weist a deshalb einen neuen Wert zu,nämlich die Adresse von b. Damit zeigt die Variable a nun auf die Variable b. Die printf-Anweisung gibt den Wert der Variable aus, auf die der Zeiger verweist. Da ihr die Adresse von b zugewiesen wurde, wird die Zahl 17 ausgegeben.

Ob Sie auf den Inhalt der Adresse, auf den die Zeigervariable verweist, oder auf die Adresse, auf den die Zeigervariable verweist, zugreifen, hängt vom *-Operator (dem Inhalts- oder Dereferenzierungs-Operator) ab: *a greift auf den Inhalt der Zeigervariable zu. Will man aber die Adresse der Zeigervariable selbst haben, so muss man den &-Operator wählen, also &a.

Ein Zeiger darf nur auf eine Variable verweisen, die denselben oder einen kompatiblen Datentyp hat. Ein Zeiger vom Typ int kann also nicht auf eine Variable mit dem Typ float verweisen. Den Grund hierfür werden Sie im nächsten Kapitel kennen lernen. Nur so viel vorab: Der Variablentyp hat nichts mit der Breite der Adresse zu tun. Diese ist systemabhängig immer gleich. Bei einer 16 Bit CPU ist die Adresse 2 Byte, bei einer 32 Bit CPU 4 Byte und bei einer 64 Bit CPU 8 Byte breit - unabhängig davon, ob die Zeigervariable als char, int, float oder double deklariert wurde.

Zeigerarithmetik

Bearbeiten

Es ist möglich, Zeiger zu erhöhen und damit einen anderen Speicherbereich anzusprechen, z. B.:

#include <stdio.h>

int main()
{
  int x = 5;
  int *i = &x;
  printf("Speicheradresse %p enthält %i\n", (void *)i, *i);
  i++; // nächste Adresse lesen, äquivalent zu: i = (void *)i + sizeof(*i);
  printf("Speicheradresse %p enthält %i\n", (void *)i, *i);  
  return 0;
}

i++ erhöht hier nicht den Inhalt (*i), sondern die Adresse des Zeigers (i). Man sieht aufgrund der Ausgabe auch leicht, wie groß ein int auf dem System ist, auf dem das Programm kompiliert wurde. Im folgenden handelt es sich um ein 32-bit-System (Differenz der beiden Speicheradressen 4 Byte = 32 Bit):

Speicheradresse 134524936 enthält 5
Speicheradresse 134524940 enthält 0

Um nun den Wert im Speicher, nicht den Zeiger, zu erhöhen, wird *i++ nichts nützen. Das ist so, weil der Dereferenzierungsoperator * die niedrigere Priorität hat als das Postinkrement (i++). Um den beabsichtigten Effekt zu erzielen, schreibt man (*i)++, oder auch ++*i. Im Zweifelsfall und auch um die Les- und Wartbarkeit zu erhöhen sind Klammern eine gute Wahl.

Zeiger auf Funktionen

Bearbeiten

Zeiger können nicht nur auf Variablen, sondern auch auf Funktionen verweisen, da Funktionen nichts anderes als Code im Speicher sind. Ein Zeiger auf eine Funktion erhält also die Adresse des Codes.

Mit dem folgenden Ausdruck wird ein Zeiger auf eine Funktion definiert:

 int (*f)(float);

Diese Schreibweise erscheint zunächst etwas ungewöhnlich. Bei genauem Hinsehen gibt es aber nur einen Unterschied zwischen einer normalen Funktionsdefinition und der Zeigerschreibweise: Anstelle des Namens der Funktion tritt der Zeiger. Der Variablentyp int ist der Rückgabetyp und float der an die Funktion übergebene Parameter. Die Klammer um den Zeiger darf nicht entfernt werden, da der Klammeroperator () eine höhere Priorität als der Dereferenzierungsoperator * hat.

Wie bei einer Zeigervariable kann ein Zeiger auf eine Funktion nur eine Adresse aufnehmen. Wir müssen dem Zeiger also noch eine Adresse zuweisen:

 int (*f)(float);
 int func(float);
 f = func;

Diese Schreibweise f = func ist gleich mit f = &func, da die Adresse der Funktion im Funktionsnamen steht. Der Lesbarkeit halber sollte man im Allgemeinen nicht auf den Adressoperator & verzichten.

Die Funktion können wir über den Zeiger nun wie gewohnt aufrufen:

 (*f)(35.925);

oder

 f(35.925);

Hier ein vollständiges Beispielprogramm:

#include <stdio.h>

int zfunc()
{
    printf("zfunc ausgeführt!\n");
    return 0;
}

int main()
{
    int (*f)();

    f = &zfunc;

    printf("Rufe f, den pointer auf zfunc, auf:\n");
    f();

    return 0;
}

void-Zeiger

Bearbeiten

Der void *-Zeiger ist zu jedem anderen Daten-Zeiger kompatibel (Achtung, anders als in C++), d.h. in C Programmen wird kein Cast in den Ziel-Zeigertyp benötigt, in C++ ist er zwingend. Man spricht hierbei auch von einem untypisierten oder generischen Zeiger. Das geht so weit, dass man einen void Zeiger in jeden anderen Zeiger umwandeln kann, und zurück, ohne dass die Repräsentation des Zeigers Eigenschaften verliert. Ein solcher Zeiger wird beispielsweise bei der Bibliotheksfunktion malloc benutzt. Diese Funktion wird verwendet um eine bestimmte Menge an Speicher bereitzustellen, zurückgegeben wird die Anfangsadresse des allozierten Bereichs. Danach kann der Programmierer Daten beliebigen Typs dorthin schreiben und lesen. Daher ist Pointer-Typisierung irrelevant. Der Prototyp von malloc ist also folgender:

 void *malloc(size_t size);

 int *ptrint = malloc(100); /* der Cast in  " ptrint = (int *) malloc(100); "  ist NICHT notwendig */

Der Rückgabetyp void * ist hier notwendig, da ja nicht bekannt ist, welcher Zeigertyp (char*, int* usw.) zurückgegeben werden soll.

Der einzige Unterschied zu einem typisierten ("normalen") Zeiger ist, dass die Zeigerarithmetik schwer zu bewältigen ist, da dem Compiler der Speicherplatzverbrauch pro Variable nicht bekannt ist (wir werden darauf im nächsten Kapitel noch zu sprechen kommen) und man sich in diesem Fall selber darum kümmern muss, dass der void *-Pointer auf der richtigen Adresse zum Liegen kommt. Zum Beispiel mit Hilfe des sizeof-Operators.

 int *intP;
 void *voidP;
 voidP = intP;         /* beide zeigen jetzt auf das gleiche Element */
 intP++;               /* zeigt nun auf das nächste Element */
 voidP += sizeof(int); /* Fehler! nicht standardkonform, void* Zeiger ermöglichen keine Arithmetik */

Call by Value

Bearbeiten

Eine Funktion dient dazu, eine bestimmte Aufgabe zu erfüllen. Dazu können ihr Variablen übergeben werden oder sie kann einen Wert zurückgeben. Der Compiler übergibt diese Variable aber nicht direkt der Funktion, sondern fertigt eine Kopie davon an. Diese Art der Übergabe von Variablen wird als Call by Value bezeichnet.

Da nur eine Kopie angefertigt wird, gelten die übergebenen Werte nur innerhalb der Funktion selbst. Sobald die Funktion wieder verlassen wird, gehen alle diese Werte verloren. Das folgende Beispiel verdeutlicht dies:

#include <stdio.h>

void func(int wert)
{
  wert += 5;
  printf("%i\n", wert);
}

int main()
{
  int wert = 10;
  printf("%i\n", wert);
  func(wert);
  printf("%i\n", wert);
  return 0;
}

Das Programm erzeugt nach der Kompilierung die folgende Ausgabe auf dem Bildschirm:

10
15
10

Dies kommt dadurch zustande, dass die Funktion func nur eine Kopie der Variable wert erhält. Zu dieser Kopie addiert dann die Funktion func die Zahl 5. Nach dem Verlassen der Funktion geht der Inhalt der Variable wert verloren. Die letzte printf Anweisung in main gibt deshalb wieder die Zahl 10 aus.

Eine Lösung wurde bereits im Kapitel Funktionen angesprochen: Die Rückgabe über die Anweisung return . Diese hat allerdings den Nachteil, dass jeweils nur ein Wert zurückgegeben werden kann.

Ein gutes Beispiel dafür ist die swap() Funktion. Sie soll dazu dienen, zwei Variablen zu vertauschen. Die Funktion müsste in etwa folgendermaßen aussehen:

 void swap(int x, int y)
 {
   int tmp;
   tmp = x;
   x = y;
   y = tmp;
 }

Die Funktion ist zwar prinzipiell richtig, kann aber das Ergebnis nicht an die Hauptfunktion zurückgeben, da swap nur mit Kopien der Variablen x und y arbeitet.

Das Problem lässt sich lösen, indem nicht die Variable direkt, sondern - Sie ahnen es sicher schon - ein Zeiger auf die Variable der Funktion übergeben wird. Das richtige Programm sieht dann folgendermaßen aus:

#include <stdio.h>

void swap(int *x, int *y)
{
  int tmp;
  tmp = *x;
  *x = *y;
  *y = tmp;
}

int main()
{
  int x = 2, y = 5;
  printf("Variable x: %i, Variable y: %i\n", x, y);
  swap(&x, &y);
  printf("Variable x: %i, Variable y: %i\n", x, y);
  return 0;
}

In diesem Fall ist das Ergebnis richtig:

Variable x: 2, Variable y: 5 
Variable x: 5, Variable y: 2 

Das Programm ist nun richtig, da die Funktion swap nun nicht mit den Kopien der Variable x und y arbeitet, sondern mit den Originalen. In vielen Büchern wird ein solcher Aufruf auch als Call By Reference bezeichnet. Diese Bezeichnung ist aber nicht unproblematisch. Tatsächlich liegt auch hier ein Call By Value vor, allerdings wird nicht der Wert der Variablen sondern deren Adresse übergeben. C++ und auch einige andere Sprachen unterstützen ein echtes Call By Reference, C hingegen nicht.

Verwendung

Bearbeiten

Sie stellen sich nun möglicherweise die Frage, welchen Nutzen man aus Zeigern zieht. Es macht den Anschein, dass wir, abgesehen vom Aufruf einer Funktion mit Call by Reference, bisher ganz gut ohne Zeiger auskamen. Andere Programmiersprachen scheinen sogar ganz auf Zeiger verzichten zu können. Dies ist aber ein Trugschluss: Häufig sind Zeiger nur gut versteckt, so dass nicht auf den ersten Blick erkennbar ist, dass sie verwendet werden. Beispielsweise arbeitet der Rechner bei Zeichenketten intern mit Zeigern, wie wir noch sehen werden. Auch das Kopieren, Durchsuchen oder Verändern von Datenfeldern ist ohne Zeiger nicht möglich. Bei typsicheren Programmiersprachen gibt es i.d.R. keine Zeiger, die nach belieben benutzt werden können.

Es gibt Anwendungsgebiete, die ohne Zeiger überhaupt nicht auskommen: Ein Beispiel hierfür sind Datenstrukturen wie beispielsweise verkettete Listen, die wir später noch kurz kennen lernen. Bei verketteten Listen werden die Daten in einem sogenannten Knoten gespeichert. Diese Knoten sind untereinander jeweils mit Zeigern verbunden. Dies hat den Vorteil, dass die Anzahl der Knoten und damit die Anzahl der zu speichernden Elemente dynamisch wachsen kann. Soll ein neues Element in die Liste eingefügt werden, so wird einfach ein neuer Knoten erzeugt und durch einen Zeiger mit der restlichen verketteten Liste verbunden. Es wäre zwar möglich, auch für verkettete Listen eine zeigerlose Variante zu implementieren, dadurch würde aber viel an Flexibilität verloren gehen. Auch bei vielen anderen Datenstrukturen und Algorithmen kommt man ohne Zeiger nicht aus. Einige Algorithmen lassen sich darüber hinaus mithilfe von Zeigern auch effizienter implementieren, so dass deren Ausführungszeit schneller als die Implementierung des selben Algorithmus ohne Zeiger ist.

Bei Zeigern können Fehler passieren, z.B.:

#include <stdio.h>

int main()
{
	int start_value = 0;
	int *pointa = &start_value;
	for (int i = 0; i < 5; ++i) {
		printf("pointa points to %p with the value %d\n", pointa, *pointa);
		int new_value = 1;
		new_value += *pointa;
		pointa = &new_value;
	}
	return 0;
}

Bei diesem Programm zeigt ein Pointer auf eine Variable, die aus dem Gültigkeitsbereich fällt. Der Wert, auf den der Pointer zeigt, ist dann nicht definiert. Wenn das Programm mit gcc ohne Optimierung übersetzt wurde, sieht die Ausgabe beispielsweise so aus:

pointa points to 0x7fffdfe01d44 with the value 0
pointa points to 0x7fffdfe01d48 with the value 1
pointa points to 0x7fffdfe01d48 with the value 2
pointa points to 0x7fffdfe01d48 with the value 2
pointa points to 0x7fffdfe01d48 with the value 2

Die Adresse ist unterschiedlich bei erneuter Programmausführung.
Wenn das Programm für Debugging-Optimierung übersetzt wurde (-Og Parameter), steht beim Wert statt 2 eine 1.
Wenn das Programm mit einer anderen Optimierung übersetzt wurde, ist auch der Wert (abgesehen vom Startwert) bei erneuter Programmausführung unterschiedlich, Bsp:

pointa points to 0x7ffd880e3c60 with the value 0
pointa points to 0x7ffd880e3c64 with the value 21848
pointa points to 0x7ffd880e3c64 with the value 21848
pointa points to 0x7ffd880e3c64 with the value 21848
pointa points to 0x7ffd880e3c64 with the value 21848