C++: Die Basics - eps1.16_Pointer
Hallo und herzlich willkommen. Als Kind habe ich oft den Spruch gehört:
„Mit nacktem Finger zeigt man nicht auf angezogene Leute!“
Doch beim Programmieren ist das Zeigen auf andere vollkommen gestattet. Vor allem in hardwarenahen Programmiersprachen. Spachen wie C++ bieten sogar extra einen Datentypen dafür: den Pointer oder auch Zeiger auf Deutsch.
Damit wirst du in die Lage versetzt High-Level-Anwendungen, also Anwendungen mit hoher Abstraktion, zu schreiben und dennoch dicht an der Maschine zu bleiben. Hast du Pointer und auch Referenzen einmal verstanden, dann kannst du Programme schreiben, welche die Ressourcen deines Systems effektiv nutzen. Und genau das gehört zu den Herausforderungen bei der Entwicklung von Embedded Systems.
Was ist ein Zeiger (Pointer)
In eps1.3 haben wir Variablen als Platzhalter für unterschiedliche Werte im Speicher kennengelernt. Sie reservieren Platz im Speicher für einen Wert mit bestimmten Datentypen, der dann über den Variablennamen abgerufen werden kann.
Zeiger/Pointer sind auch Variablen. Doch anders als herkömmliche Variablen halten sie keinen Wert vor, sondern Adressen im Speicher. Wie alle Variablen, belegen natürlich auch Zeiger Speicherplatz. Das Besondere an ihnen ist, dass der enthaltene Wert als Speicheradresse interpretiert wird. Ein Zeiger ist also eine spezielle Variable, die auf eine Stelle im Speicher verweist.
Uff. Klingt im ersten Moment ziemlich verwirrend.
Wie sieht denn der Speicher überhaupt aus?
Stell dir den Speicher als ein Array mit byte-großen Elementen vor. Das Feld hat (2^n) - 1
Elemente, wobei n
die Anzahl von Bits auf dem Address Bus des verwendeten Prozessors ist. Kann also in Abhängigkeit der verwendeten CPU variieren. Die Adresse des ersten Bytes ist 0
und die des letzten Bytes ist (2^n) - 1
.
Typischerweise werden Speicheradressen als hexadezimale Zahl dargestellt. Erkennen kannst du diese Zahlen an dem Prefix 0x
. Das hexadezimale Zahlensystem ist zur Basis 16
.
Und was bedeutet das?
Egal, ob im Supermarkt oder auf Amazon, überall wird für die Darstellung von Preisen, Gewicht, Abmaßen oder Seriennummern das dezimale Zahlensystem verwendet. Es ist uns aus unserer Schulzeit sehr vertraut. Dezimal bedeutet zur Basis 10
. Wir können also die 10 Werte 0-9
in einer Ziffer darstellen.
Folglich können hex-Zahlen 16 Werte in einer Ziffer darstellen. 0-9
sind gleich dem dezimalen Werten, doch danach folgen noch die Zahlen A-F
. A
ist die hexadezimale Darstellung für 10
und F
steht für den Wert 15
.
Hast du nun die Adresse 0xC
, dann ist damit das 13. Element des Speichers (dezimal 12) gemeint. Hoffentlich hast du nicht vergessen, dass in Computersystemen ab 0
gezählt wird. Ich weiß, das macht es verwirrend. Aber wir müssen uns daran gewöhnen.
Das soll an dieser Stelle genug zum Speicher sein. Wir werden uns in einem eigenen Beitrag damit näher beschäftigen. Aber jetzt wissen wir vorerst genug, um Pointer zu verstehen.
Einen Pointer deklarieren und eine Adresse ermitteln
Schauen wir uns nun an, wie wir Zeiger verwenden können. Da diese ebenfalls Variablen sind, musst du sie natürlich erst deklarieren.
Pointer zeigen auf eine Adresse im Speicher. An dieser Adresse erwartet er einen Wert eines bestimmten Datentyps. Das ist der PointedType, der vor dem PointerVariableName steht. Damit der Pointer eindeutig von einer herkömmlichen Variablen zu unterscheiden ist, steht zwischen Typ und Name ein Dereferenz-Operator *
. Abgeschlossen wird die Deklaration bekanntermaßen mit einem Semikolon ;
// listing1: pointer declarartion
// PointedType * PointerVariableName = reserved memory address to point to nowhere;
int * pointToInt = nullptr;
Genau wie alle Variablen, bekommt auch der Pointer einen zufälligen Wert zugewiesen, wenn wir ihn nicht initialisieren. Wir wollen aber nicht, dass er auf irgendeinen Ort im Speicher zeigt. Oder noch schlimmer: er zeigt auf einen Adressbereich, der nicht existiert. Damit stürzt deine Anwendung ab.
Deshalb initialisieren wir ihn mit einem Null Pointer nullptr
. Dieser sorgt dafür, dass der zugewiesene Pointer auf keine gültige, aber auch auf keine ungültige Adresse zeigt. In C++ ist extra ein fester Wert für ihn reserviert und dieser lässt Zeigervariablen sicher initialisieren.
Und welche Adresse hat nun meine Variable?
Das erfährst du mit dem Referenz-Operator &
. Rufst du eine Variable ganz normal auf, wird dir ihr Wert zurückgeliefert. Setzt du dem Variablennamen allerdings &
voran, so bekommst du die Adresse im Speicher, an der die Variable liegt.
In lisitng2 kannst du den Aufruf einer Variablen und ihrer Adresse sehen.
// listing2: determining the memory address
#include <iostream>
int main()
{
int intVar = 12;
const double doubleConst = 7.56;
// get the value
std::cout << "Value of integer variable is: " << intVar << std::endl;
std::cout << "Size of integer variable is: " << sizeof(intVar) << " Byte" << std::endl;
std::cout << "Value of double constant is: " << doubleConst << std::endl;
std::cout << "Size of double constant is: " << sizeof(doubleConst) << " Byte" << std::endl;
// Get the address in memory
std::cout << "Integer variable is located at: " << &intVar << std::endl;
std::cout << "Double constant is located at: " << &doubleConst << std::endl;
return 0;
}
# Output
Value of integer variable is: 12
Size of integer variable is: 4 Byte
Value of double constant is: 7.56
Size of double constant is: 8 Byte
Integer variable is located at: 0x7fff160b788c
Double constant is located at: 0x7fff160b7890
Vorhin haben wir gesagt, dass eine Adresse ein Byte an Daten enthält. Das kannst du sehr schön in listing2 sehen. Die Integer-Variable inVar
ist 4 Bytes groß und 4 Adressblöcke, danach befindet sich die nächste Variable doubleConst
.
Mit Pointer arbeiten und auf Daten zugreifen
Zwischenstand: Wir können nun Pointer deklarieren und die Adresse von Variablen bestimmen. Zudem wissen wir, dass Pointer Variablen sind, in denen Adressen gespeichert werden.
Lass uns diese Dinge miteinander kombinieren und in einem Pointer eine Adresse, die wir mit Hilfe des Referenz Operators erhalten, speichern.
// listing3: using a pointer to store an address
#include <iostream>
int main()
{
// decalring a variable
int intVar = 12;
// declaring a pointer to type int and initialize to the address
int* pointToInt = &intVar;
std::cout << "Value of integer variable is: " << intVar << std::endl;
std::cout << "Value of integer pointer is: " << pointToInt << std::endl;
return 0;
}
# Output
Value of integer variable is: 12
Value of integer pointer is: 0x7ffeda83004c
Gar nicht so spannend. Die Ausgabe von listing3 ähnelt der von listing2 ziemlich. Nur mit dem Unterschied, dass die Adresse der Integer Variablen in einer Pointer Variablen gespeichert ist und dort jederzeit aufgerufen werden kann.
Ist dir aufgefallen, dass sich die Adressen bei jedem Durchlauf des Programms ändern? Das kommt daher, dass dein Computer bei jeder Ausführung die Daten neu im Speicher verteilt. Manchmal an derselben Stelle; doch häufig woanders.
„Super, jetzt kenne ich die Adresse einer Variablen,… aber eigentlich interessiert mich nur ihr Wert…“
Dafür gibt es den Dereferenz-Operator *
. Mit diesem kannst du auf den Wert an der Adresse, auf die der Pointer zeigt, zugreifen.
// listing4: access to the value
#include <iostream>
int main()
{
// decalring a variable
int intVar = 12;
// declaring a pointer to type int and initialize to the address
int* pointToInt = &intVar;
std::cout << "Value of intVar = " << intVar << std::endl;
std::cout << "Address of intVar = " << &intVar << std::endl;
std::cout << "Value of pointToInt: " << pointToInt << std::endl;
std::cout << "Stored value at the address pointToInt points to: " << *pointToInt << std::endl;
return 0;
}
# Output
Value of intVar = 12
Address of intVar = 0x7fff604b42cc
Value of pointToInt: 0x7fff604b42cc
Stored value at the address pointToInt points to: 12
Benutzt du den Dereferenz-Operator *
, dann springt die Anwendung an diese Adresse im Speicher und da es sich um einen Integer Pointer handelt, entnimmt er dort beginnend 4 Bytes (die Größe eines Integers). Du merkst schon, wie wichtig die Datentypen hierbei sind.
Und keinesfalls darfst du vergessen, einen Zeiger zu initialisieren. Sonst beinhaltet er eine zufällige Adresse, auf die deine Anwendung vielleicht keine Zugriffsberichtigung hat. Schon stürzt dein Programm ab und du bekommst eine Access Violation
.
Daten manipulieren und die Größe eines Zeigers
Bisher haben wir Pointer nur dazu verwendet, Daten und Informationen abzufragen. Doch wir können ihnen auch Werte zuweisen.
// listing5: assigning a value to a pointer
#include <iostream>
int main()
{
int yourAge;
double yourHeight;
int* pointsToAge = &yourAge;
double* pointsToHeight = &yourHeight;
// store input at the memory pointed to
std::cout << "How old are you?" << std::endl;
std::cin >> *pointsToAge;
std::cout << "How tall are you (in m)?" << std::endl;
std::cin >> *pointsToHeight;
// displaying the values of the variables
std::cout << "You are "<< yourAge << " years old and "<< yourHeight << " meter tall." << std::endl;
return 0;
}
In listing5 werden die Eingaben des Nutzers nicht einer normalen Variablen übergeben, sondern einem Pointer. Diese zeigt wiederum auf die Adresse der Variablen. Damit die Zuweisung des Wertes gelingt, musst du die Adresse mit dem Operator *
dereferenzieren.
Die Ausgabe der Variablen yourAge
und yourHeight
zeigt, dass die Eingabe über den dereferenzierten Pointer den Wert im Speicherbereich der beiden Variablen ablegt. Du kannst also mit Hilfe von Zeigern Werte im Speicher auslesen und manipulieren.
Aus deiner Sicht mag die Verwendung von Pointern oder Variablen jetzt keinen allzu großen Unterschied machen. Für das System allerdings schon. Deutlich wird das, wenn du dir den belegten Speicher anschaust.
// listing6: size of a pointer
#include <iostream>
int main()
{
std::cout << "Size of char: " << sizeof(char) << std::endl;
std::cout << "Size of pointer to char: " << sizeof(char*) << std::endl << std::endl;
std::cout << "Size of int: " << sizeof(int) << std::endl;
std::cout << "Size of pointer to int: " << sizeof(int*) << std::endl << std::endl;
std::cout << "Size of double: " << sizeof(double) << std::endl;
std::cout << "Size of pointer to double: " << sizeof(double*) << std::endl;
return 0;
}
# Output
Size of char: 1
Size of pointer to char: 8
Size of int: 4
Size of pointer to int: 8
Size of double: 8
Size of pointer to double: 8
Wir wissen, dass jeder Datentyp eine bestimmte Größe hat. Der Datentyp char
belegt einen Byte im Speicher, ein Integer int
belegt 4 Bytes und so weiter. Auch die Pointer Variable besitzt eine feste Größe im Speicher. Und diese muss jede Speicheradresse erfassen können. Bei 64 bit Prozessoren werden 8 Byte dazu benötigt. So viel Speicher belegt ein Pointer.
Es ist egal, auf welche Variable er zeigt und welchen Datentyp diese besitzt. Der Speicherplatzbedarf des Pointers ändert sich nicht. Das siehst du wunderbar in listing6.
Auswirkung von Inkrement- und Dekrement-Operatoren
Zum Schluss noch eine Frage, die du dir zwar nicht gestellt hast. Doch tu mir den Gefallen und setze dich einen winzigen Moment mit ihr auseinander, bevor du die Antwort liest.
Was passiert, wenn du einen Pointer inkrementierst (++) oder dekrementierst (–)?
Wenn du keine Vermutung hast, dann probiere es einfach mal aus!
// listing7: incrementing / decrementing a pointer
#include <iostream>
int main()
{
int anInt = 32;
int* pointsToInt = &anInt;
std::cout << "Address of anInt " << pointsToInt << std::endl;
pointsToInt++; // incrementing a pointer
std::cout << "Increased pointer " << pointsToInt << std::endl;
pointsToInt--; // decrementing a pointer
std::cout << "Decreased pointer " << pointsToInt << std::endl;
return 0;
}
# Output
Address of anInt 0x7fffffffd5fc
Increased pointer 0x7fffffffd600
Decreased pointer 0x7fffffffd5fc
Ein Pointer enthält eine Adresse. In listing7 ist es die Addresse der integer Variablen anInt
. Nimm beispielsweise an, dass die Speicheradresse der Variablen, auf die der Pointer pointsToInt
zeigt, ist 0x3c
.
Der Integer selbst ist 4 Byte groß. Damit belegt die Variable einen Speicherblock mit den vier Adressen 0x3c
, 0x3d
, 0x3e
,0x3f
.
Was geschieht nun, wenn du den Pointer inkrementierst?
Dieser wird nicht um eine Stelle von 0x3c
auf 0x3d
erhöht. Dann würde er ja mitten in die Datenmenge der Variablen zeigen und damit nutzlose Informationen geben. Aber das hast du sicherlich schon selbst gewusst.
Nein, mit dem Inkrement Operator ++
erhöht sich die Addresse des Pointers um die Größe des Datentyps, auf den er zeigt. In unserem Falle sind das die 4 Byte von Integern.
Der Compiler interpretiert die Inkrementanweisung auf einen Pointer als Aufforderung, auf den folgenden Wert nach unserer Integer-Variablen zu zeigen. Dabei nimmt er an, dass es sich dabei wieder um denselben Datentypen handelt. Somit sorgt der Compiler dafür, dass der Pointer immer auf den Anfang einer Datenmenge und niemals in die Mitte oder das Ende zeigt.
Wie du in listing7 sehen kannst, wird der Pointer pointsToInt
inkrementiert und zeigt anschließend auf eine vier Byte entfernte Adresse. Für unser Beispiel bedeutet das, dass er von 0x3c
auf 0x40
erhöht wird.
Der Dekrement-Operator --
hat natürlich den gleichen Effekt auf einen Pointer, nur in die entgegengesetzte Richtung. Hier wird die Adresse um die Größe des Datentyps reduziert.
Dynamische Speicherzuweisung
Lass uns ein Array deklarieren. Dazu frischen wir kurz unser Wissen auf. Ein Array umrahmt mehrere Variablen des selben Datentyps in aufeinanderfolgenden Speicherblöcken. Für die Deklaration benötigen wir also den Datentyp, den Namen des Arrays und die Anzahl seiner Elemente.
// listing8: array declaration
int anArray[25];
Nichts Neues für dich. Doch daraus ergeben sich zwei Probleme:
- Du bist auf eine feste Anzahl an Elementen begrenzt
- Du belegst immer den Speicher für alle Elemente, auch wenn du weniger nutzt
Ein Array ist statisch. Das meint, es wird beim Kompilieren festgelegt und bleibt während der Programmlaufzeit unveränderlich. Damit nutzt du die Ressourcen des Systems leider nicht optimal aus.
Um den Speicherbedarf auf Basis der Anforderungen des Anwenders zu optimieren, brauchst du eine dynamische Zuweisung von Speicherkapzitäten - Dynamic Memory Allocation. Das bedeutet soviel wie, mehr Speicher zuweisen, wenn er benötigt wird, und bei weniger Bedarf wieder freigeben.
Im Fall von Arrays bietet dir C++ die Lösung im Form von Vektoren an. Doch wir können eine dynamische Speicherzuweisung auch mit der Hilfe von Pointern erreichen.
Dabei helfen uns die beiden Operatoren new
und delete
. Der Operator new
lässt dich neue Speicherblöcke allokieren. Meist wird er so benutzt, dass er entweder bei erfolgreicher Speicheranforderung die Adresse in Form eines Pointers zurückgibt oder andernfalls eine Exception wirft.
Um new
zu nutzen, musst du zunächst den Datentypen der Variablen spezifizieren, für die Speicherplatz freigegeben werden soll. Die Adresse wird anschließend in einem Pointer abgelegt. listing9 zeigt dir, wie das Ganze für eine und für mehrere Integer Werte aufeinmal aussieht.
// listing9: requesting memory with "new" operator
// request memory for one element
int* pointToNewInt = new int;
// request memory for a block of five integers
int* pointToFiveInt = new int[5];
Du kannst also ganz einfach auch einen Block für mehrere Variablen des selben Datentyps freigeben. Bedenke aber, es gibt keine Garantie dafür, dass deine Anfrage auf neuen Speicherplatz erfolgreich ist und kann sogar zum Absturz deines Programms führen. Das hängt vom aktuellen Status des Systems und der Verfügbarkeit von Speicher ab.
// listing10: memory allocation fail
int main()
{
// request a large number of memory space
int* pointsToLargeArray = new int [0x1fffffffff];
// use the allocated memory
delete[] pointsToLargeArray;
return 0;
}
#Output
terminate called after throwing an instance of 'std::bad_alloc'
what(): std::bad_alloc
Aborted (core dumped)
Solche Fälle kannst du seit C++11 ganz leicht mit std::nothrow abfangen. std::nothrow
ist eine Konstante, die verwendet wird, um die Überladungen von werfenden und nicht werfenden Zuweisungsfunktionen aufzulösen.
Das bedeutet, es wird keine Exception bei fehlschlagender Speicherzuteilung geworfen. Dem Pointer wird stattdessen ein nullptr
zugewiesen.
// listing11: std::nothrow
#include <iostream>
int main()
{
// request a large number of memory space
int* pointsToLargeArray = new(std::nothrow) int [0x1fffffffff];
if (pointsToLargeArray == nullptr)
{
std::cout << "Allocation returned nullptr\n";
}
else
{
// use the allocated memory
delete[] pointsToLargeArray;
}
return 0;
}
#Output
Allocation returned nullptr
Speicher wieder freigeben
Brauchst du den Speicherplatz nicht mehr, dann solltest du ihn freigeben. Sonst bleibt er für deine Anwendung reserviert und kann für nichts anderes verwendet werden. Natürlich kannst du dich jetzt egoistisch oder rücksichtslos verhalten und sagen:
„Hauptsache, meine Anwendung hat Speicherplatz. I don’t give a…“
Doch dadurch wird der verfügbare Speicher für das ganze System reduziert und kann im schlimmsten Fall sogar deine Anwendung in der Ausführung verlangsamen. Ungenutzter, aber reservierter Speicher wird auch Memory leak genannt und sollte immer vermieden werden.
Deshalb geben wir den Speicher mit dem delete
Operator wieder frei. Jede Speicherzuweisung mit new
sollte von delete
deallokiert werden. Dazu wendest du den Operator einfach auf den Pointer des freizugebenden Speichers an.
// listing12: releasing memory with "delete" operator
// releasing memory of one element
delete pointToNewInt;
// releasing memory of a block of several elements
delete[] pointToFiveInt;
Dabei ist delete
der Datentyp vollkommen egal. Ihn interessiert auch nicht die Anzahl an Elementen im Speicherblock. Du musst ihm nur mit Anhängen von eckigen Klammern []
sagen, dass er den gesamten Block freigeben soll.
Es gibt eine Einschränkung zu beachten. Mit delete
können nur Adressen in einem Pointer freigeben werden, die zuvor mit new
allokiert worden sind. Auf andere Pointer kann der Operator nicht angewendet werden.
Das bleibt hängen
Das war unser erster Kontakt mit mit Pointern / Zeigern. Gar nicht so leicht auf Anhieb zu verstehen, aber extrem wichtig! Irgendwie ist er eine Variable, doch eigentlich zeigt er auf eine andere im Speicher. Dabei hat er aber selbst einen Speicherplatz.
Wir können jetzt Pointer deklarieren und ihm Speicheradressen zuweisen. Dazu haben wir Dereferenz Operator *
, Referenz Operator &
kennengelernt. Wir wissen nun auch, wie wir ihn richtig einsetzen und Daten mit ihm manipulieren können.
Zudem haben wir uns seine Größe im Speicher angeschaut und was passiert, wenn wir ihn inkrementieren oder dekrementieren.
Jetzt haben wir uns erstmal eine Pause verdient, bevor wir Referenzen und Pointer voneinander abgrenzen wollen. Die beiden sind nämlich leicht zu verwechseln.
Ich wünsche dir maximalen Erfolg!
Quellen
- [1] B. Stroustrup, A Tour of C++. Pearson Education, 2. Auflage, 29. Juni 2018.
- [2] B. Stroustrup, Programming: Principles and Practice Using C++. Addison Wesley, 2. Auflage, 15. Mai 2014.
- [3] U. Breymann, Der C++ Programmierer. C++ lernen – professionell anwenden – Lösungen nutzen. Aktuell zu C++17. München: Carl Hanser Verlag GmbH & Co. KG; 5. Auflage, 6. November 2017