Definition und einfache Anwendungen von C-Zeigern

Zeiger sind Variable, deren Wert eine Adresse ist. Man kann sie mit der Adresse einer anderen Variable initialisieren. Da unterschiedliche Datentypen unterschiedlich großen Speicherplatz belegen, muss bei der Deklaration eines Zeigers angegeben werden, welchen Datentyp die Variable besitzt, auf deren Speicherplatz er verweist. Diese Eigenschaften von Zeigern und mit welchen Operatoren (Adressoperator, Indirektionsoperator) dies realisiert wird, wird hier ausführlich diskutiert. Als Anwendung wird gezeigt, wie man mit Zeigern Funktionen realisieren kann, die mehrere Rückgabewerte besitzen.

Einordnung des Artikels

Einfache Aufgaben zu den hier behandelten Konzepten finden sich in Programmier-Aufgaben in C: erste Schritte mit Feldern und Zeigern.

Begriffe im Zusammenhang mit Zeigern

Die folgende Tabelle gibt einen kurzen Überblick über die Begriffe, die mit dem Zeiger-Konzept von C verbunden sind und die in den folgenden Abschnitten erklärt werden.

Variable Bei der Deklaration einer Variable wird gemäß ihres Datentyps ein geeigneter Speicherplatz reserviert. Über den Namen der Variable kann man den Speicherplatz jederzeit ansprechen.
Referenz auf eine Variable Referenzen gibt es erst in C++, aber noch nicht in C. Mit Referenzen kann man in C++ unter einem anderen Namen auf eine Variable zugreifen; in C muss dies mit Zeigern realisiert werden.
Zeiger (pointer) Ein Zeiger ist eine Variable, die als Wert die Speicher-Adresse einer anderen Variable besitzt; der Zeiger erlaubt somit den Zugriff auf diejenige Variable, "auf die er zeigt".
Adressoperator & Mit dem Adressoperator kann auf die Speicher-Adresse einer Variable zugegriffen werden.
Dereferenzierung eines Zeigers Zeigt px auf die Variable x , so kann mit * px auf den Wert von x zugegriffen werden.
Indirektionsoperator (oder Inhaltsoperator) Der Operator * zur Dereferenzierung eines Zeigers wird als Indirektionsoperator bezeichnet. Er ist wie der Adressoperator unär, was bedeutet, dass er auf ein Objekt angewendet wird. (Zum Vergleich: Die Addition + ist ein binärer Operator.)

Um mit der Syntax für Zeiger und verwandten Konzepten vertraut zu werden, soll gezeigt werden, wie sie im Quelltext eingesetzt werden:

// Deklaration einer Variable:
int x;

// Deklaration einer Referenz auf eine Variable (nicht in C):
int & y;

// Deklaration eines Zeigers:
int * ptr_x;

// Initialisierung eines Zeigers mit der Adresse einer Variable:
ptr_x = &x;

// Dereferenzierung eines Zeigers (Zugriff auf den Wert der Variable, auf die er zeigt):
* ptr_x;

Einfache Anwendungen eines Zeigers

Das folgende Listing zeigt die einfachen Anwendungen, die man jetzt mit Zeigern ausführen kann. Es soll ausdrücklich betont werden, dass Zeiger nicht für derartige Operationen eingeführt wurden – die relevanten Anwendungen von Zeigern können erst im Zusammenhang mit Feldern und Funktionen gezeigt werden.

int x = 5;
printf("x = %i\n", x);          // x = 5

int * p;        // Deklaration eines Zeigers
p = &x;         // Initialisierung des Zeigers p

// Dereferenzierung des Zeigers p:
int y = *p;
printf("y = %i\n", y);          // y = 5

*p = 17;
printf("x = %i\n", x);          // x = 17
printf("y = %i\n", y);          // y = 5

Zeile 1 und 2: Eine int-Variable wird mit 5 initialisiert und sofort ausgegeben.

Zeile 4 und 5: Ein Zeiger auf eine int-Variable wird zuerst deklariert und dann mit der Adresse von x initialisiert.

Zeile 8 und 9: Eine weitere int-Variable y wird mit *p initialisiert. So wie bisher Variablen initialisiert wurden, ist die Zuweisung in Zeile 8 nicht verständlich, da der Ausdruck auf der rechten Seite mit einem Zeiger arbeitet. Durch die Ausgabe in Zeile 9 erkennt man, dass y mit dem Wert von x initialisiert wurde, wozu der Zeiger p verwendet wurde.

Der unäre Operator * in Zeile 9 wird als Indirektionsoperator (oder Inhaltsoperator) bezeichnet. Wird der Operator * auf den Zeiger p angewendet, greift man auf den Wert der Variable x zu, auf die der Zeiger p zeigt (siehe Initialisierung von p in Zeile 5).

Zeile 11 bis 13: Der Zugriff auf einen Zeiger mit dem Indirektionsoperator * kann nicht nur auf der rechten Seite einer Zuweisung erfolgen: Wie in Zeile 11 kann man der Variable, die sich hinter p verbirgt, einen Wert zuweisen, indem man *p setzt. Dadurch wird x neu gesetzt (siehe Zeile 12). Die Variable y wird nicht verändert. Man beachte den Unterschied: y wurde initialisiert mit Hilfe des Zeigers p, aber p ist ein Zeiger auf die Variable x; Zeile 11 wirkt nur auf x, nicht auf y.

Kennt man den Indirektionsoperator * , kann man die Deklaration des Zeigers p, also int * p; auf zwei Arten lesen – die Klammern sollen die Lesarten verdeutlichen, sie sind in der Syntax von C nicht vorgesehen:

int * p;        // Deklaration eines Zeigers

// 1. Lesart:
(int *) p;       // "p ist vom Datentyp Zeiger auf eine int-Variable"

// 2. Lesart:
int (*p);       //"die Größe *p kann wie eine int-Variable eingesetzt werden"
  1. Lesart: p ist vom Datentyp "Zeiger auf eine int-Variable". Hier wird also * auf den Datentyp int bezogen.
  2. Lesart: Die Größe * p , also die Anwendung des Indirektionsoperators auf einen Zeiger, kann wie eine int-Variable eingesetzt werden. (Betrachtet man die Anweisungen aus dem Listing oben, in denen * p vorkommt, so sind alle syntaktisch korrekt, wenn man anstelle von * p eine int-Variable verwendet.)

Zeiger und Speicherzugriff

Wie man Variablen einsetzt, muss hier nicht mehr erklärt werden, es soll lediglich aus einer anderen Perspektive beleuchtet werden – und soll dann zu einem besseren Verständnis von Zeigern beitragen:

int x = 5;
int y = x + 17;

Das letzte Listing definiert zuerst eine int-Variable x, die mit dem Wert 5 belegt wird. Anschließend wird eine weitere int-Variable y deklariert, die mit Hilfe von x initialisiert wird; der Wert von y muss jetzt 22 betragen.

Die Verwendung von Variablen soll dem Programmierer die Arbeit mit veränderlichen Größen erleichtern – Wertzuweisungen und andere Operationen werden ganz ähnlich wie in der Mathematik geschrieben (immer unter em Vorbehalt, dass das Gleichheitszeichen eine Zuweisung vornimmt und vom Vergleichs-Operator == unterschieden werden muss).

Intern verbirgt sich hinter einer Variable die Adresse einer Speicherzelle; die Größe der Speicherzelle wird durch den Datentyp der Variable vorgegeben. Eine Zuweisung wie x = 5 greift also auf die Speicherzelle zu, deren Adresse an die Variable x gekoppelt ist. Entsprechend wird bei der Zuweisung y = x + 17 zuerst der Wert aus der "Speicherzelle x" geholt, die Zahl 17 addiert und anschließend das Ergebnis in der "Speicherzelle y" abgelegt.

Abbildung 1 soll dies veranschaulichen. Dazu wird der Speicher in zwei Teile zerlegt: jeweils links stehen die Adressen und rechts die Inhalte der Speicherzellen. Die Größen der Speicherzellen (also wie viele Bytes für eine Variable reserviert werden müssen) und die Adressen sind willkürlich gewählt.

Abbildung 1: Die Deklaration und Initialisierung von Variablen wird intern über Adressen von Speicherzellen organisiert. Bei der Deklaration einer Variable wird ein geeigneter Speicherplatz reserviert; bei der Initialisierung wird der Speicherplatz mit Inhalt gefüllt. Im Programm greift man auf den Speicherplatz über die Variablen und nicht über die Adressen zu.Abbildung 1: Die Deklaration und Initialisierung von Variablen wird intern über Adressen von Speicherzellen organisiert. Bei der Deklaration einer Variable wird ein geeigneter Speicherplatz reserviert; bei der Initialisierung wird der Speicherplatz mit Inhalt gefüllt. Im Programm greift man auf den Speicherplatz über die Variablen und nicht über die Adressen zu.

In den meisten Programmiersprachen kann man nicht – oder nur sehr schwer – nachvollziehen, welche Adressen die Variablen besitzen und man kann auf die Speicherzellen nur über die Variablen, aber nicht über die Adressen zugreifen.

Das Zeiger-Konzept von C erlaubt diesen direkten Zugriff auf den Speicher über die Adressen der Speicherzellen.

Abbildung 2 soll dies veranschaulichen: Zur Variable x aus Abbildung 1 wird jetzt ein Zeiger px definiert, der mit der Adresse & x der Variable x initialisiert wird. Der Zugriff auf den Inhalt von x kann jetzt

Der Zeiger kann verwendet werden:

Abbildung 2: Darstellung der Operationen mit einem Zeiger px auf die Variable x. Links: Deklaration des Zeigers und Initialisierung mit der Adresse von x. Mitte: Zugriff auf den Wert von x mit Hilfe des Zeigers px und des Indirektionsoperators; der so gewonnene Wert wird einer neuen Variable y zugewiesen. Rechts: Der Indirektionsoperator kann auch eingesetzt werden, um der Variable x einen neuen Wert zuzuweisen. Der Wert von y bleibt dadurch unverändert, da px ein Zeiger auf x ist (und nicht auf y).Abbildung 2: Darstellung der Operationen mit einem Zeiger px auf die Variable x. Links: Deklaration des Zeigers und Initialisierung mit der Adresse von x. Mitte: Zugriff auf den Wert von x mit Hilfe des Zeigers px und des Indirektionsoperators; der so gewonnene Wert wird einer neuen Variable y zugewiesen. Rechts: Der Indirektionsoperator kann auch eingesetzt werden, um der Variable x einen neuen Wert zuzuweisen. Der Wert von y bleibt dadurch unverändert, da px ein Zeiger auf x ist (und nicht auf y).

Weitere Eigenschaften von Zeigern

Ein Zeiger ist an einen Datentyp gebunden, nicht an eine Variable

So wie in obigen Beispielen Zeiger eingesetzt wurde, kann der Eindruck entstehen, dass ein Zeiger an eine Variable gebunden ist. Dies ist falsch. Die Deklaration eines Zeigers legt nur fest, von welchem Datentyp die Variable sein muss, auf die er zeigt. Man kann ihn jederzeit auf eine andere Variable zeigen lassen. Man sollte dies aber nur mit Bedacht einsetzen: Zeiger können Quelltexte schwerer verständlich machen, wechselnde Zeiger noch viel mehr.

Das folgende Listing zeigt die Bindung eines Zeigers an einen Datentyp:

int x = 5;
int * p = &x;

printf("x = %i\n", *p);         // x = 5

int y = 17;
p = &y;

printf("y = %i\n", *p);         // y = 17

double z = 1.5;
// p = &z;          // Syntax-Fehler: Deklaration von p beachten!

Der Zeiger p wird zuerst mit der Adresse von x initialisiert und eingesetzt, um auf den Wert von x zuzugreifen (Zeile 4).

Später wird p die Adresse von y zugewiesen; jetzt kann auf den Wert von y zugegriffen werden (Zeile 9).

Die Adresse der double-Variable z kann p nicht zugewiesen werden (Zeile 12).

Ein Zeiger kann wie eine Variable eingesetzt werden

Ein Zeiger kann als Spezialfall einer Variable aufgefasst werden; speziell in dem Sinn, dass sein Inhalt eine Adresse ist, über die man auf eine andere Variable zugreifen kann. Aber wenn ein Zeiger eine Variable ist, kann man Zuweisungen vornehmen und der Zeiger darf darin sowohl auf der rechten als auch auf der linken stehen. Das folgende Listing zeigt einige Beispiele:

int x = 5;
int y = 17;

int * px = & x;
int * py = & y;

printf("Dereferenzierung von px und py: %d || %d \n", * px, * py);
// Dereferenzierung von px und py: 5 || 17 

py = px;

printf("Dereferenzierung von px und py: %d || %d \n", * px, * py);
// Dereferenzierung von px und py: 5 || 5

* py = 42;

printf("x, y: %d %d \n", x, y);
// x, y: 42 || 17

Zeile 1 bis 8: Hier werden nur Variablen und Zeiger definiert und über die Zeiger wird auf die Werte der Variablen zugegriffen.

Zeile 10 weist dem Zeiger py den Zeiger px zu. An der folgenden Ausgabe (Zeile 12 und 13) sieht man, dass man jetzt mit py auf den Wert von x zugreift.

Zeile 15: Da py jetzt auf x verweist, wird in der Zuweisung der Wert von x verändert. Die folgende Ausgabe zeigt, dass y bei allen Operationen unverändert geblieben ist (Zeile 17 und 18).

Der NULL-Pointer

Für "herkömmliche" Variablen wurde schon mehrfach gesagt, dass man sie nach der Deklaration sofort initialisieren sollte. Nicht-initialisierte Variablen können unbestimmt sein. Dies gilt ebenso für Zeiger.

Wird ein Zeiger erst deklariert und später initialisiert, so sollte man ihn – zur Sicherheit – mit dem NULL-Pointer (oder Nullzeiger) initialisieren.

Das folgende Skript zeigt zuerst die Verwendung eines nicht-initialisierten Zeigers und dann die Initialisierung mit dem Nullzeiger:

int * p;

printf("Nicht initialisierter Zeiger: %p \n", p);           // Compiler-Warnung
// Nicht initialisierter Zeiger: 00401B8B 

printf("Worauf verweist der nicht initialisierte Zeiger: %d \n", * p);
// Worauf verweist der nicht initialisierte Zeiger: 1528349827 

int * q = NULL;
int x = 5;

if (q != NULL) {
    printf("if-Zweig: Worauf verweist q? %d \n", * q);
} else {
    q = & x;
    printf("else-Zweig: Worauf verweist q? %d \n", * q);
}
// else-Zweig: Worauf verweist q? 5

Zeile 3 Die Verwendung des nicht-initialisierten Zeigers erzeugt eine Compiler-Warnung.

Zeile 6: Der nicht-initialisierte Zeiger lässt sich auch dereferenzieren – das Verhalten ist aber unbestimmt.

Zeile 9: Um dieses Verhalten zu vermeiden, sollte man Zeiger, die nicht sofort initialisiert werden, vorübergehend mit NULL initialisieren. Dabei handelt es sich um einen vordefinierten Zeiger, der auf die Speicher-Adresse 0 zeigt – mit seiner Verwendung kann man keinen Schaden anrichten, da man auf diese Adresse niemals zugreifen darf.

Wie man am Verhalten des nicht-initialisierten Zeigers p sieht, lässt er sich nicht von einem initialisierten Zeiger unterscheiden (bis auf die Compiler-Warnung, die man leicht übersehen kann).

Zur Sicherheit kann man Zeiger vor ihrer Verwendung auf NULL prüfen, siehe Zeile 12.

Die Funktion swap(): Vertauschen der Werte zweier Variabler

Im Abschnitt Funktionsaufruf mit call by reference in Spezielle Konzepte der strukturierten Programmierung in C++: call by reference, Rekursion, Function Templates wurde diskutiert, wie die Funktion swap() zum Vertauschen der Werte zweier Variabler in C++ realisiert wird.

In C gibt es keine Referenzen, daher muss die Funktion swap() hier mit Zeigern implementiert werden. Das folgende Listing zeigt:

#include <stdio.h>
#include <stdlib.h>

void swap(int * pa, int * pb);

int main(void) {

    int x = 5;
    int y = 17;

    // Adressen von x und y:
    printf("& x: %p || & y: %p \n", & x, & y);
    // & x: 0061FF1C || & y: 0061FF18

    swap(& x, & y);

    printf("x = %d ; y = %d\n", x, y);
    // x = 17 ; y = 5

    // Adressen von x und y:
    printf("& x: %p || & y: %p \n", & x, & y);
    // & x: 0061FF1C || & y: 0061FF18

    return 0;
}

void swap(int * pa, int * pb){
    int tmp = * pa;
    * pa = * pb;
    * pb = tmp;
}

Zeile 4: Die Deklaration der Funktion swap() zum Vertauschen der Werte zweier ganzer Zahlen. Man beachte, dass die Eingabewerte jetzt Zeiger auf integer sind; in C++ stehen hier Referenzen.

Zeile 6 bis 25: In der main()-Funktion wird swap() getestet.

Zeile 8 und 9: Es werden zwei int-Variablen x und y deklariert und sofort initialisiert.

Zeile 12 und 13: Die Ausgabe zeigt die Adressen von x und y. Diese Ausgabe ist natürlich nicht reproduzierbar, da man auf die Vergabe der Adressen bei der üblichen Definition von Variablen keinen Einfluss nehmen kann. Mit Hilfe des Adressoperators wird auf die Adressen von x und y zugegriffen; dies sind die Anweisungen & x und & y innerhalb von printf().

Zeile 15: Beim Aufruf der Funktion swap() werden ebenfalls die Adressen von x und y übergeben.

Zeile 17 bis 22: Die erneute Ausgabe der Werte und Adressen zeigt, dass die Werte tatsächlich vertauscht wurden, den Variablen aber noch ihre ursprünglichen Adressen zugewiesen sind.

Zeile 27 bis 31: Die Implementierung von swap().

Zeile 27 wiederholt die Deklaration von swap().

Zeile 28 initialisiert eine int-Variable, in der der Wert der ersten Variable aus dem Funktionsaufruf von swap() abgespeichert wird. Man beachte den Unterschied zwischen der linken und der rechten Seite. Links wird wie üblich eine int-Variable deklariert (hier steht kein * ). Rechts wird mit Hilfe des Indirektionsoperators* auf den Wert der Variable zugegriffen, die sich hinter dem Zeiger pa verbirgt. Dies ist das erste Argument von swap() und im Beispiel aus der main()-Funktion erhält man hier den Wert von x, also 5.

Zeile 29: Man beachte wieder den Unterschied zwischen der linken und der rechten Seite. Links steht * pa , somit wird der Wert der Variable neu gesetzt, auf die der Zeiger pa verweist. Im Beispiel wird also der Wert von x neu gesetzt. Rechts findet dagegen der lesende Zugriff auf eine Variable statt; es wird der Wert derjenigen Variable gelesen, auf die der Zeiger pb verweist. Im Beispiel ist dies y. Somit wird in die Speicherzelle von x der Wert von y geschrieben.

Zeile 30: Und ganz ähnlich wird der in tmp zwischengespeicherte Wert der Variable zugewiesen, auf die pb verweist. Im Beispiel wird also der Wert von x (der in tmp gespeichert wurde) in die Speicherzelle von y eingetragen.

Man beachte weiter, dass die Funktion swap() keinen Rückgabewert besitzt; der Zugriff auf x und y erfolgt über die Zeiger und muss nicht in einem Rückgabewert an das Hauptprogramm übertragen werden.

Aufgaben:

1. Lesen Sie in der C++-Dokumentation nach, wie dort swap() deklariert ist und welche Eigenschaften sie hat; sie befindet sich in der Algorithm library.

Formulieren Sie die Unterschiede zwischen der C++ Version von swap() und der oben implementierten Funktion.

Hinweis: In C gibt es keine Function Templates.

2. Veranschaulichen Sie die den Aufruf der Funktion swap() ähnlich wie in Abbildung 2.

Funktionen mit mehreren Rückgabewerten: Realisierung durch Übergabe von Zeigern

Die Funktion swap() aus dem letzten Abschnitt mag auf den ersten Blick wie eine kleine Spielerei wirken – die Werte von zwei Variablen kann man auch vertauschen, indem man direkt mit der temporären Variable arbeitet. Bei näherer Betrachtung eröffnet eine Funktion eine Möglichkeit, "Funktionen mit mehreren Rückgabewerten" zu implementieren.

Warum hier Funktionen mit mehreren Rückgabewerten in Anführungstrichen geschrieben wird, muss näher erklärt werden. In C muss bei der Deklaration einer Funktion ein Datentyp für den Rückgabewert angegeben werden. Dieser kann entweder void sein (kein Rückgabewert) oder ein fundamentaler Datentyp (wie int, double und so weiter). Felder, also eine gewisse Anzahl von Elementen, die alle denselben fundamentalen Datentyp besitzen, sind dagegen in C nicht erlaubt. Erst mit dem Konzept der Struktur (struct) lassen sich mehrere Rückgabewerte zu einem Objekt zusammenfassen.

Möchte man auf Strukturen verzichten, kann man die Rückgabe mehrerer Werte durch Zeiger realisieren:

Das folgende Beispiel soll dies demonstrieren; zu einem gegebenen x-Wert werden die zweite, dritte und vierte Potenz von x in einer Funktion berechnet. Die Potenzen von x werden als Adressen übergeben.

Wer schon mit Feldern vertraut ist, wird für dieses Beispiel eine deutlich einfachere Version angeben können.

#include <stdio.h>
#include <stdlib.h>

void powers(double x, double * x2, double * x3, double * x4);

int main(void) {

    double x, x2, x3, x4;
    x = 2.5;
    powers(x, & x2, & x3, & x4);
    printf("Potenzen von %1.2f : %1.2f, %2.3f, %2.4f", x, x2, x3, x4);
    // Potenzen von 2.50 : 6.25, 15.625, 39.0625
    return EXIT_SUCCESS;
}

void powers(double x, double * x2, double * x3, double * x4){
    * x2 = x * x;
    * x3 = *x2 * x;
    * x4 = *x3 * x;
}

Zeile 8 In der main()-Funktion werden die Variablen x2, x3, x4 nur deklariert, aber nicht initialisiert.

Zeile 11: An der Ausgabe in printf() erkennt man, dass man jetzt dennoch auf die Werte x2, x3, x4 zugreifen kann. Initialisiert wurden sie durch die Berechnungen innerhalb der Funktion powers().

Zeile 17 bis 19: Man erkennt, dass man bei gleichzeitiger Verwendung der Multiplikation und des Indirektionsoperators sehr auf die Lesbarkeit des Quelltextes achten muss (Umdrehen der Multiplikationsreihenfolge würde nur zu Verwirrung führen).

Das Beispiel mag sehr gekünstelt wirken, es zeigt aber den Mechanismus, wie man aus einer Funktion heraus mehrere Variablen initialisieren kann, ohne diese ausdrücklich zurückzugeben.

Es sei aber auch davor gewarnt, dass man die Zeiger-Übergabe leicht übersehen kann. Denn bei einem flüchtigen Blick auf die main()-Funktion könnte man meinen, die Variablen x2, x3, x4 werden nur deklariert, aber nirgends initialisiert.