C++ Teil 9 – Zeiger

Das Konzept der Zeiger bzw. Pointer(eng. für Zeiger) ist für Anfänger wahrscheinlich der unbeliebteste und für einen Profi der mächtigste Bestandteil von C++.
Viele Anfänger verstehen sie anfangs nicht und sogar Profis machen manchmal Fehler im Umgang mit ihnen. Nichtsdestotrotz sind Zeiger ein sehr wichtiges Thema, von dem sich kein C++ – Programmierer drücken kann.

In diesem Abschnitt werde ich eine Einführung in dieses komplexe Gebiet geben.

Arbeitsspeicher

RAM ( Random Access Memory) – Speicher mit wahlfreiem Zugriff, auf Deutsch auch Arbeitsspeicher genannt. Alle Variablen werden im RAM erzeugt. Spricht ein Programmierer vom Speicher, so meint er damit gewöhnlich den Arbeitsspeicher.

Bevor wir lernen was Zeiger sind, ist es wichtig zu wissen wie es im Arbeitsspeicher (RAM) aussieht, wenn wir eine Variable anlegen.
Man kann sich den Arbeitsspeicher als eine Art Tabelle bzw. ein Array vorstellen.

RAMRAM. Der Speicheradressenbereich ist hier ohne Bedeutung.
RAM. Der Speicheradressenbereich ist hier ohne Bedeutung.

Wir wollen sehen was im RAM passiert wenn wir eine long-Variable var deklarieren.
Zuerst schaut das Betriebssystem ob für die Variable genügend Speicher zur Verfügung steht, denn außer unserem Programm laufen noch dutzende andere Programme im Hintergrund, die auch Speicher brauchen. Wenn genug Speicher da ist, dann reserviert das Betriebssystem 4 Kästchen (4 Byte) für uns.

Variable im RAM. Der Speicheradressenbereich ist hier ohne Bedeutung.
Variable im RAM. Der Speicheradressenbereich ist hier ohne Bedeutung.

Das Betriebssystem weiß von den Variablennamen nichts, es arbeitet nur mit den Speicheradressen. Die Variablennamen sind nur für den Programmierer da, damit er beim Hantieren mit Speicheradressen nicht verrückt wird. Sie werden durch den Compiler intern in Speicheradressen umgewandelt.
In C++ kann ein Programmierer direkt auf diese Speicherbereich zugreifen und die Inhalte verändern.

Adressoperator

Um die Adresse einer Variablen zu bekommen steht ein Adressoperator & bereit, der direkt vor die Variable geschrieben wird, von der wir die Adresse haben wollen.

Hinweis: Man verwechsle den Adressoperator nicht mit dem UND-Operator. Die Bedeutung folgt immer eindeutig aus dem Zusammenhang.


long var1 = 0;
long var2 = 0;
long var3 = 0;

std::cout << "Adressen: " << &var1 << " " << &var2 << " " << &var3 << std::endl; 

Die Adressen der Variablen werden als hexadezimale Zahlen ausgegeben. Der Abstand zwischen den Adressen entspricht genau der Byte-Größe des verwendeten Variablentyps, weil die Variablen direkt nacheinander erzeugt wurden.

Zeiger deklarieren

Ein Zeiger ist eine spezielle Variable, die auf eine Speicheradresse zeigt.
Deklariert wird ein Zeiger ähnlich einer Variable, nur dass ein Stern * vor den Namen steht.

// Schematisch 
Datentyp *Zeigername = Adresse;

// Beispiel in C++
int *zeiger = 0;

Erstellen wir eine Variable und lassen einen Zeiger auf sie zeigen.

int var = 5;  // Variable var erstellt
int *zeiger = &var; // der Zeiger zeigt auf den Speicherblock der Variable var

Man beachte, dass der Datentyp des Zeigers identisch mit dem Datentyp der Variable, auf die gezeigt wird, sein muss.

Unser Zeiger beinhaltet also die Adresse der Variable var, die wir weiter verwenden können.

Lesen & Schreiben

Da die Adresse der Variable bekannt ist, können wir direkt auf den Speicherblock im RAM zugreifen, ohne den Variablennamen var zu benutzen.
Um auf den Inhalt des Speicherblocks zuzugreifen, verwendet man wieder das Sternchen * vor dem Variablennamen. Man bezeichnet das Sternchen in diesem Fall auch als Inhaltsoperator (Es hat hier also eine komplett andere Bedeutung, als oben dargestellt). Mit diesem Operator bekommt man den Inhalt der unter der Zeigeradresse liegt, also den Wert der Variable auf die der Zeiger zeigt.

Lesen:

Datentyp Variablenname = *Zeigername;

Als Beispiel weisen wir den Inhalt auf den der Zeiger zeigt (also die Variable var), einer neuen Variablen zu.

int inhalt_von_var = *zeiger;

Wie man sieht, haben wir den Wert der Variable var bekommen, ohne auf sie selbst zu zugreifen. Kennt man also die Adresse einer Variablen, so kann man ihren Inhalt auslesen. Zumindest dann, wenn die Variable in dem gleichen Programm angelegt wurde, ansonsten greift das Betriebssystem ein und verhindert den Zugriff. Man überzeuge sich selbst in dem man einem Zeiger eine beliebige Adresse erstellt und versucht den Wert an diese Adresse auszulesen.

int *zeiger = (int*)98234242;   // eine beligebige Adresse
int inhalt_von_var = *zeiger;  // Laufzeitfehler
std::cout << "inhalt_von_var: " << inhalt_von_var << std::endl;
Lesezugriff verweigert
Lesezugriff verweigert

Als Nächstes werden wir den Wert einer Variablen über einen Zeiger ändern.
Dies geschieht ähnlich wie beim Lesen mit dem Inhaltsoperator.

*Zeigername = Neuer Wert;

Als Beispiel wird der Wert der Variable auf 10 gesetzt.

int var = 5;
int *zeiger = &var;

std::cout << "var: " << var << std::endl;
std::cout << "zeiger: " << zeiger << std::endl;

// der Variablen var neuen Wert zuweisen
*zeiger = 10;

std::cout << "\nvar: " << var << std::endl;
std::cout << "zeiger: " << zeiger << std::endl;
Zuweisung über einen Zeiger
Zuweisung über einen Zeiger

Der Wert der Variable wurde verändert ohne auf die Variable selbst zu zugreifen. Man beachte, dass die Adresse sich nicht ändert. Man verändert lediglich den Inhalt des Speicherblocks, nicht die Position des Blocks selbst.

Dieser indirekte Zugriff auf Inhalte im Arbeitsspeicher ist ein sehr mächtiges Werkzeug, deren Bedeutung man an dieser Stelle vielleicht noch nicht erkennen kann. Spätestens wenn es um die dynamische Speicherverwaltung geht, kommt man an dem Zeiger-Konzept nicht vorbei.

Zeigeroperatoren
Operator Name Zweck Beispiel
* Zeigeroperator /
Zeiger-Deklarationsoperator
Deklaration long *z = 0;
& Adressoperator Zugriff auf eine Adresse long *z = &variable;
* Inhaltsoperator Schreib- und Lesezugriff auf den Inhalt // Schreiben
*z = 10;
// Lesen
int var2 = *z;

Zeiger, Arrays und Zeigerarithmetik

Zeiger können nicht nur auf einfachen Variablen zeigen, sondern auf alle Objekte in C++, ob es einfache Variablen, Arrays, Funktionen oder Klassen sind, die wir später kennen lernen werden.
Wir haben im letzten Teil dieser Artikelreihe bereits mit Arrays gearbeitet, deswegen werde ich an dieser Stelle das Zusammenspiel von Feldern und Zeigern beleuchten. Vor allem möchte ich hier auf die Zeigerarithmetik eingehen.
Aber zuerst schauen wir uns an, wie man einen Array-Zeiger erstellt.

// Einen Zeiger auf einen Array erzeugen
long arr[] =  {1,2,4,6,8,10,12,14};
long *ptr = arr;

Wer aufgepasst hat, der wird sich fragen warum denn in der letzten Codezeile kein Adressoperator & verwendet wurde. Dies ist in der Tat etwas verwirrend, denn es handelt sich um einen Sonderfall. Der Feldname in C++ ist als ein Zeiger auf das erste Feldelement definiert.
Man kann sich davon überzeugen, wenn man folgende Zeile ausführt.

std::cout << ptr  << std::endl;

Als Resultat bekommt man die Speicheradresse auf das erste Arrayelement geliefert.

Man könnte die Zeigerzuweisung auch mit einem Adressoperator schreiben.

long arr[] =  {1,2,5,7,8,11,12,14,15,17};
long *ptr = &arr[0];

Dies ist eine Eigenart von C/C++ und wir wollen uns hier damit nicht weiter aufhalten.
Ein Array, wie wir es erstellt haben, sieht im Arbeitsspeicher folgendermaßen aus.

Array in RAM
Array in RAM

Wir werden dieses Bild später noch mal verwenden um uns die Funktionsweise der Zeigeroperationen klar zu machen.

Zeigeroperationen

Wie der Name Zeigerarithmetik schon aussagt, kann man mit Zeigern rechnen.
Es sind nur folgende Operationen erlaubt:

Zeigeroperationen
Operation Ergebnis Bedeutung
Zeiger + Integerwert Zeiger Springe Anzahl (Integerwert) Speicherblöcke vorwärts
Zeiger – Integerwert Zeiger Springe Anzahl (Integerwert) Speicherblöcke zurück
Zeiger – Zeiger Integerwert Berechne die Anzahl der Speicherblöcke,
die zwischen den beiden Zeigeradressen liegen

Alle anderen Operationen wie Multiplikation, Division ect. sind nicht erlaubt. Für die ersten beiden Operationen gibt es die typische C++ Kurzschreibweise: Zeiger++ und Zeiger–, wenn der Integerwert eins beträgt.

Der Integerwert (das kann eine Zahl oder eine ganzzahlige Variable sein) bei den beiden ersten Operationen gibt die Anzahl der Schritte im Arbeitsspeicherbild an. Das heißt die Adresse des Zeigers wird je nach Datentyp um einen unterschiedlichen Wert erhöht, da die Adressbreite eines Elements von dem Datentyp des Array abhängt. Die nachfolgende Grafik sollte dies verdeutlichen.

Zeigeraddition
Zeigeraddition

Die letzte Operation berechnet die Anzahl der Speicherblöcke, die zwischen zwei Zeigern liegen.

Schauen wir uns ein Beispiel an, welches diese Operationen verwendet. Der Code soll die Länge einer Zeichenkette bestimmen.

// Eine Zeichenkette erstellen
char str[] = "Ich lerne C++";

// Einen Zeiger auf die Zeichenkette erstellen
char *ptr = str;

// Der Zeiger soll zum nächsten Speicherblock (Arrayelement springen,
// solange die "String-Ende"-Escape-Sequenz nicht erreicht wurde
while(*ptr != '\0')
{
	// Zum nächsten Speicherblock springen
	ptr++;
}

// Die Diffrenez zwischen dem Zeiger auf das letzte und das erste Element 
// liefert die Anzahl der Speicherblöcke dazwischen 
// und somit auch die Anzahl der Buchstaben
int laenge = ptr-str;

// Ergebnis anzeigen
std::cout << "Die Zeichenkette \"" << str << "\" ist " 
		<< laenge << " Zeichen lang." << std::endl;

Wie man sieht kann man im Zusammenhang mit Zeiger auch Vergleichsoperatoren, wie ==, !=, >, < , >= und <= verwenden. Dies waren die Grundlagen, die jeder C++ Programmierer sicher beherrschen soll. Selbstverständlich ist das nicht alles gewesen, was es zum Zeiger-Konzept zu wissen gibt. Es gibt noch weitere Punkte, wie beispielsweise Zeiger auf Zeiger oder Zeiger als Funktionsparameter. Auf einige dieser Punkte werden später eingehen, wenn sie benötigt werden, aber für den Anfang sollte das genug sein. Übungsaufgaben:

  1. Beschreiben Sie in wenigen eigenen Sätzen das Zeiger-Konzept.
  2. Erklären Sie in eigenen Worten, was der Unterschied zwischen dem Zeigeroperator (*) und dem Inhaltsoperator (*) ist. Woran erkennt man im Code, welcher Operator gerade verwendet wird.
  3. Deklarieren Sie einen Zeiger auf eine Variable und verändern Sie den Wert der Variable, ohne den Variablennamen zu verwenden.
  4. Erstellen Sie ein char-Array und lassen sie einen Zeiger ptr darauf zeigen. Erhöhen Sie den Wert des Zeigers um eins und geben Sie den Zeiger auf mit std::cout ausgeben. Was beobachten Sie? Wie erklären Sie es?
  5. Erweitern Sie den Code aus der letzten Aufgabe so, dass der Text wiederholt ausgeben wird, wobei bei jeder Ausgabe ein Buchstabe weniger angezeigt wird.

2 Gedanken zu „C++ Teil 9 – Zeiger“

  1. „long var1 = 0;
    long var2 = 0;
    long var3 = 0;

    std::cout << "Adressen: " << &var1 << " " << &var2 << " " << &var3 << std::endl;"

    Da muss ich protestieren, dass stimmt nämlich nicht immer. Ich weis nicht genau, wie der VC Compiler das macht, aber wenn man beim Intel Compiler nicht Optimierungsstufe 3 oder kleiner gewählt hat, wird immer die gleiche Addresse ausgegeben. Der Compiler lässt die Variable dann einfach auf den Speicherbereich von var1 zeigen. Da sollte man drauf achten, wenn man sich auf die Pointer verlassen will.

    gruß

  2. Eigentlich sollte es so etwas nicht geben. Da macht wohl der die Optimierung von Intel Compiler komische Sachen. Der Programmierer sollte sich nur an die Programmiersprachen-Spezifikation halten und darauf verlassen, dass der Compiler sie richtig umsetzt. Wenn der Compiler es nicht tut, dann sucht man sich einen neuen.

    Hast du einen Screenshot?
    Beim VC++ gibt es auch auf volle Optimierungsstufe keine Probleme.

Schreibe einen Kommentar