Resource icon

[C] Krieg der Sternchen - Die Rache der Pointer.

Programmiersprache(n)
C, C++
<(°_°)> Nicht verstehen Pointers führt zu Wut, Wut führt zu Hass. Hass führt zu unsäglichem Leid.“ (frei nach Yoda)

Nein, nein. Das wird hier keine Fortsetzung von Star Wars :D Aber das eine oder andere Sternchen wird in den Beispielen auftauchen. Und manch einer steht auf Kriegsfuß mit ihnen, was der Grund für diesen Beitrag ist. Bringen wir also etwas Licht auf die dunkle Seite der Macht :)


Der Name Pointer (zu Deutsch: Zeiger) sagt schon einiges. Ein Pointer zeigt auf ein Objekt, er verweist auf ein Objekt, er kennt den Aufenthaltsort eines Objekts. Aber er ist nicht das Objekt selbst. Wie können wir uns das vorstellen? Nehmen wir an Jonas ist das "Objekt" von dem hier die Rede ist. Lena soll Jonas ein Päckchen senden. Jonas hatte Lena einen Zettel mit seiner Adresse gegeben. So weiß Lena wohin sie das Päckchen senden muss, damit es bei Jonas ankommt. Der Zettel mit der Adresse ist das, was wir im übertragenen Sinne einen Pointer nennen können.

Und tatsächlich funktioniert ein Pointer genau so. Die Pointervariable ist der Zettel und deren Wert ist die Adresse. Eine Speicheradresse in diesem Fall. Speicheradressen sind Abfolgen von Bits, die durch einen ganzzahlige Werte repräsentiert werden können, damit sie für uns Menschen etwas leichter zu begreifen sind. Aber das ist vermutlich trotzdem noch zu abstrakt. Also versuchen wir das ganze mal etwas bildlich darzustellen.
int num = 8;
num ist eine Variable vom Typ int, die den Wert 8 hat. Wie sieht das im Speicher aus?
1571955959357.png

Die grünen Rechtecke sind die Bytes, die die Zahl 8 repräsentieren. (Davor und dahinter existiert natürlich auch Speicher mit irgendeinem Inhalt, aber der geht uns nichts an.) Wieso habe ich 4 Bytes grün eingefärbt? Wir haben Variable num als int deklariert. Jeder Typ hat eine bestimmte Typbreite, die der Compiler kennt. Ich bin hier von 4 Bytes ausgegangen, da das die Breite ist, die ein int in den meisten modernen C und C++ Implementierungen hat. Der sizeof Operator würde Aufschluss darüber bieten. Er gibt die Breite in Bytes wieder.
Jedes dieser Bytes hat eine Adresse im Speicher.
1571956161052.png

Die blaue Adresse 0020A50521331000 markiert den Anfang des ersten Bytes von num. Die graue Adresse markiert bereits Speicher, der nicht mehr zu num gehört.

Deklarieren wir nun eine Pointervariable.
int *ptr = &num;
Bei der Deklaration wird durch das Sternchen (je nach Kontext Value-At-, Indirection- oder Dereference-Operator genannt) * markiert, dass es sich um einen Pointer handelt. Der Address-Of-Operator & gibt die Adresse von num wieder. Sie wird der Variablen ptr zugewiesen.

Im Speicher sieht das nun so aus:
1571956294369.png

Wie zu sehen ist, enthält der Speicherbereich von ptr (blau markierte Bytes) nun die Speicheradresse 0020A50521331000 der Variablen num (in der Regel in umgekehrter Bytereihenfolge, aber das ist ein anderes Thema).
Auch Variable ptr hat wieder eine Speicheradresse (orange), die mittels &ptr einer Variablen vom Typ int** zugewiesen werden könnte. Das aber nur nebenher. Wir bleiben hier bei der einfachen Referenzierung.

Fassen wir vorläufig zusammen: Der Wert einer Pointervariablen ist die Adresse eines Speicherbereichs. Nicht mehr und nicht weniger.

Wenn das so ist, warum reicht es dann nicht aus ptr wie folgt zu deklarieren?
*ptr = &num;
Was sagt uns (oder besser dem Compiler) die Deklaration mit dem vorangestellten Typ
int *ptr
Ich empfehle, Deklarationen von rechts nach links zu lesen. In unserem Fall wäre das "ptr ist ein Pointer auf ein int". Wie zu Anfang schon einmal erwähnt ist int das von der Programmiersprache vorgegebene Schlüsselwort, dem Compiler zu sagen, dass es sich um einen Speicherbereich einer bestimmten Breite handelt (4 Bytes im Beispiel).
Somit gibt uns ein Pointer also zwei Informationen:
1. Die Adresse eines Speicherbereichs als Wert der Pointervariablen.
2. Die Breite des Speicherbereichs, auf den bei der Dereferenzierung des Pointers zugegriffen werden soll, als Typinformation an den Compiler.


Apropos Dereferenzierung. Der Zettel mit der Adresse von Jonas ist, für sich allein genommen, noch nicht besonders wertvoll. Er wird erst dann interessant, wenn die Adresse wirklich benötigt wird, nämlich um ein Päckchen an die Adresse zu versenden. So verhält es sich auch mit den Pointervariablen. Wir benötigen einen Pointer um Werte aus dem Speicherbereich zu lesen, oder um Werte in den Speicherbereich zu schreiben, auf den er zeigt. Bleiben wir bei unserem Beispiel.
C:
int num = 8;
int *ptr = &num;
printf("%d\n", *ptr);
*ptr = 10;
printf("%d\n", *ptr);
printf("%d\n", num);
Die Ausgabe dieses Codeschnipsels ist

Wir erinnern uns, ptr hält die Adresse von num. Im Speicherbereich von num steht der Wert 8. Wenn wir nun die Variable ptr mit dem vorangestellten * dereferenzieren, greifen wir auf den Speicherbereich zu, auf den der Pointer zeigt. Darum bekommen wir 8 als Ausgabe des ersten printf Aufrufs.
Nun greifen wir erneut auf den Speicherbereich zu. Diesmal ändern wir den Wert auf 10. Der zweite printf Aufruf gibt auch wie erwartet 10 für den dereferenzierten Pointer aus. Der dritte printf Aufruf verdeutlicht noch einmal, dass wir tatsächlich in den Speicherbereich von num geschrieben haben.
Achtung Stolpergefahr: Bei der Deklaration einer Pointervariablen zeigt das vorangestellte Sternchen an, dass die Variable ein Pointer ist. Wenn die Variable deklariert ist und eine Speicheradresse als Wert zugewiesen bekommen hat, wird das Sternchen dazu genutzt um durch Dereferenzierung auf den Speicherbereich zuzugreifen, auf den der Pointer zeigt. Es hat also unterschiedliche Bedeutungen, je nach Kontext. Der Name der Variablen ist natürlich auch immer noch ptr und nicht etwa *ptr.


Arrays und Pointer verbindet eine verwandtschaftliche Beziehung. Sie sind aber nicht dasselbe. Ein Array ist immer auch ein Pointer, aber ein Pointer ist noch längst kein Array.
Sehen wir uns das noch einmal an einem Beispiel an.
int arr[2] = {8, 4};
Wie kann man sich die Visualisierung des Speichers dafür vorstellen?
1572095431181.png

Die beiden Werte 8 und 4 stehen in einem zusammenhängenden Speicherbereich. Jede dieser Werte/Arrayelemente nimmt die Breite eines int ein (je 4 Bytes im Beispiel).
Es ist kein Zufall, dass ich den Variablenname arr in die Zeile mit dem Pointerwert geschrieben und die [2] zu den Werten gezogen habe, wie hier zu sehen sein wird:
C:
int arr[2] = {8, 4};
printf("Wert von arr[0]: %d\n", arr[0]);
printf("Wert von arr[1]: %d\n", arr[1]);
printf("Wert von arr:       %p\n", arr);
printf("Adresse von arr[0]: %p\n", &arr[0]);
printf("Adresse von arr[1]: %p\n", &arr[1]);
Die Ausgabe könnte so aussehen:
Wert von arr[0]: 8
Wert von arr[1]: 4
Wert von arr: 0020A50521331000
Adresse von arr[0]: 0020A50521331000
Adresse von arr[1]: 0020A50521331004

Die Variable arr hält also die Adresse des Beginns des Speicherbereichs des Arrays. Nicht mehr. Das int ist das Schlüsselwort für den Typ und gibt dem Compiler Information über die Größe eines Elements. Die [2] sagt dem Compiler für wie viele Elemente Speicher benötigt wird. Aber nur wo das Array sichtbar ist, also wo es deklariert wurde. Nur dort ist bekannt, wie groß das Array ist. Den Typ des Arrays könnte man auch als int[2] verstehen (bei einigen Sprachen, wie C# oder Java, wäre das die Schreibweise, und somit syntaktisch etwas logischer als in C oder C++). Ein sizeof ergäbe somit die Elementgröße, multipliziert mit der Anzahl der Elemente.
Ein Array kann einer Pointervariablen zugewiesen werden oder an einen Pointerparameter übergeben werden. In diesem Fall geht die Längeninformation verloren, da ein Pointer diese Information nicht halten kann. Man spricht von Array-To-Pointer Decay (Zeigerverfall).

Wenn ein Array ein Pointer ist, warum ist ein Pointer dann kein Array?
int *ptr = (int*)4711;
Wir haben einen Pointer deklariert und wir haben ihm die Speicheradresse 4711 zugewiesen. Aber liegt diese Speicheradresse im Adressbereich, den unser Prozess zugewiesen bekommen hat? Nein. Wir dürfen nicht auf irgendeinen Speicherbereich zugreifen, von dem das Betriebssystem nicht weiß dass wir ihn verwenden. Ein ...
*ptr = 8;
... würde also eine Speicherverletzung verursachen und sehr wahrscheinlich zum Absturz des Programms führen. Ein Pointer ist nur dann valide, wenn er auf einen gültigen Speicherbereich zeigt. Aber auch der muss noch kein Array sein, wie ganz zu Anfang gezeigt.
Eine weitere Möglichkeiten Speicher anzufordern und einen validen Pointerwert zu bekommen, ist die Allokation mittels den Funktionen malloc, calloc, realloc in C, oder den Operatoren new und new[] in C++. Darauf gehe ich hier aber nicht weiter ein. Entsprechende Referenzen finden sich im Internet.


Wie im Beispiel gezeigt, lässt sich auf Elemente eines zusammenhängenden Speicherbereichs mit dem []-Operator zugreifen. Wir wissen aber auch dass ein Pointer mittels * dereferenziert werden kann um auf den Speicherbereich auf den ein Pointer zeigt zuzugreifen. Und wir wissen dass ein Array auch ein Pointer ist. Wie geht das alles zusammen?
Nun, dahinter steckt Pointerarithmetik. Darunter versteht man die Addition/Subtraktion eines ganzzahligen Wertes zu/von einem Pointerwert.
Mit arr[0] greifen wir auf das erste Element zu. arr ist ein Pointer auf das erste Element, wie die Ausgabe der Adressen gezeigt hat. Was würde passieren wenn wir arr dereferenzieren?
printf("Wert in *arr: %d\n", *arr);
Ausgabe:

Wie könnte man den Index 0 in arr[0] in oben gezeigte Dereferenzierung einbeziehen, um immer noch auf den Wert 8 zuzugreifen? Nun, wenn man 0 zu einer Speicheradresse addiert, haben wir immer noch dieselbe Speicheradresse. Test:
printf("Wert in *(arr + 0): %d\n", *(arr + 0));
Ausgabe:
Wert in *(arr + 0): 8

Wenn das also funktioniert, ließe sich auf die 4 zugreifen, wenn wir 1 zu arr addieren und dereferenzieren?
printf("Wert in *(arr + 1): %d\n", *(arr + 1));
Ausgabe:
Wert in *(arr + 1): 4

Mit arr + n verschieben wir den Pointer also um die n-fache Breite des Elementtyps. Der []-Operator erfüllt somit 2 Aufgaben. Er inkrementiert den Pointer um den angegebenen Index und dereferenziert den resultierenden Pointer. Es gilt
arr[n]
entspricht
*(arr + n)
n könnte dabei durchaus auch negativ sein, solange der resultierende Pointer auf validen Speicher zeigt.
Beispiel:
C:
int arr[2] = {8, 4};
int *ptr = arr + 1;
printf("Wert von ptr[-1]: %d\n", ptr[-1]);
Ausgabe:
Wert von ptr[-1]: 8


Wir haben bereits mit ein paar Operatoren gearbeitet, die spezielle Bedeutungen für Pointer haben. Ist aber noch nicht vollständig erklärt.
  • Mit dem Indirection-Operator * deklarieren wir eine Pointervariable oder greifen auf den Wert zu auf die sie zeigt.
  • Mit dem Address-Of-Operator & bekommen wir die Adresse von einem Wert, die wir einem Pointer zuweisen können.
  • Mit +, -, ++, --, += oder -= können Pointerwerte verändert werden (Pointerarithmetik). Die Schrittweite ist dabei automatisch die Breite des Typs.
  • Mit dem [] Operator wird der Pointerwert verändert und gleichzeitig auf den Wert zugegriffen auf den der Pointer zeigt. ptr[n] ist die Kurzform für *(ptr + n)
  • Der -> Operator ist eine Spezialform des Member-Access-Operators für Pointervariablen, die auf Strukturen zeigen. Auch er dereferenziert den Pointer und kürzt die Schreibweise (*ptr).member durch ptr->member ab.
  • Vergleichsoperatoren können mit Pointerwerten verwendet werden, sofern die Operanden Pointerwerte desselben Objekts sind (bspw. Pointer auf Elemente eines Arrays). Abweichend von dieser Regel, kann auf Gleichheit oder Ungleichheit mit NULL geprüft werden. Bei diesen Vergleichen ist zu bedenken, dass lediglich Speicheradressen verglichen werden und nicht die Werte auf die diese Pointer zeigen.


Jetzt gibt es noch zwei Kleinigkeiten zu erklären, nämlich einen speziellen Pointerwert und einen speziellen Pointertyp:

Starten wir mit dem Wert NULL. Das ist relativ einfach, denn NULL ist eine Speicheradresse mit einem festgelegten Wert (nicht zwingend 0, aber in den meisten Implementierungen schon). Wie jede andere Adresse die nicht vom Betriebssystem für unseren Prozess reserviert wurde, zeigt auch NULL auf einen ungültigen Speicherbereich. Wozu ist das dann gut? NULL indiziert einen definierten Fehlerzustand. Auch wenn es so eigentlich nicht richtig ist, kann man diesen Zustand recht gut mit "NULL zeigt auf nichts" erklären, da er in diesem Sinne verwendet wird. Viele Standardfunktionen nutzen diesen Wert. Beispielsweise geben Funktionen, die einen Pointer zurückgeben, oft dann NULL zurück, wenn die Funktion fehlgeschlagen ist. Sollte ein Pointer in der Parameterliste stehen, so erwarten manche Funktionen NULL als Argument, um anzuzeigen, dass dieser Parameter nicht verwendet werden soll. Man sollte aber nicht blindlings davon ausgehen, dass NULL korrekt behandelt wird, sondern die Funktionsreferenzen sorgsam lesen, um zu wissen was in diesem Fall passiert.

Dann gibt es noch den speziellen void* Pointertyp. Wie bei jedem anderen Pointer, ist auch hier der Wert eine Speicheradresse. Allerdings ist void ein unvollständiger Typ. Das heißt, seine Breite ist nicht bekannt. Beispiel:
void *ptr = (void*)&num;
1572096147817.png

Hier ist unbekannt, wie groß der Speicherbereich ist, auf den ptr zeigt. Somit darf ein Pointer auf void weder dereferenziert werden, noch darf Pointerarithmetik angewendet werden.
Welchen Sinn hat er dann aber? Ein void* ist dazu da Speicheradressen auf Speicherbereiche unterschiedlichster Typen zu transportieren. (Er erfüllt den Zweck eines sogenannten untypisierten Pointers.) Dein eigener Programmcode, oder Implementierungen in Libraries, sind dafür verantwortlich den void* in den Pointertyp zu casten, der letztlich benötigt wird. Dass man dabei Fehler machen kann und dass das einige Gefahren in sich birgt, sollte selbsterklärend sein.


Mit meiner Beschreibung bin ich am Ende, aber bezüglich C++ noch ein kurzes Addendum:
Ich habe oben als Programmiersprachen C und C++ angegeben, vor die Überschrift aber nur C als Tag gesetzt. Grundsätzlich ist alles was ich hier geschrieben habe ebenso gut auf C++ übertragbar. Allerdings sollte jeder der in C++ Pointer im Code hat, dies ganz pragmatisch als suspekt annehmen und ernsthaft darüber nachdenken, ob er dort nicht bereits einen Designfehler eingebaut hat. Wenn eine API oder Library Pointer verlangt, ist man relativ machtlos. Aber auch nicht ganz, denn manche Containerklassen haben Methoden, die einen Pointer auf die Rohdaten zurückgeben, der dann weitergegeben werden kann. std::string oder std::vector wären hier beispielhaft zu nennen. Es ist kaum nötig, selber mit new zu hantieren. Wenn man wirklich nicht um handgerollte Allokationen im Code herum kommt, dann aber wenigstens Smart Pointer verwenden (wie bspw. die std::unique_ptr Klasse), bei denen sichergestellt ist, dass reservierter Speicher über den Destruktor in jedem Fall auch wieder freigegeben wird.
In Parameterlisten eigener Funktionen braucht man für C++ in der Regel nie Pointer. Stattdessen sollte man mit Referenzen arbeiten. So erspart man sich mindestens die Prüfung ob ein NULL Pointer bzw. nullptr übergeben wurde.


Ich hoffe, das <(-_-)> Ins Exil gehen ich werde müssen. Versagt ich habe.” kannst du von nun an Yoda überlassen. Diese kurze Einführung sollte genügen, um im Umgang mit Pointern nicht mehr völlig zu versagen ;) Auch wenn sie längst keinen Anspruch auf Vollständigkeit erhebt.

Anregungen zu Ergänzungen oder Korrekturen nehme ich natürlich gern an.
Gefällt mir: gargyle
Autor
German
First release
Last update
Bewertung
0,00 Stern(e) 0 Bewertungen
Oben