Pointer und Zeiger in C

Pointer und Arrays sind in der Programmiersprache C sehr eng verwandte Konzepte. Diese Vorlesungseinheit gibt Ihnen einen Überblick zu diesen Konzepten und vermittelt den Zusammenhang zwischen beiden.

Hinweise:

  • Die Vorlesungsaufzeichnung findet sich auf YouTube.
  • Die Vorlesungsfolien finden Sie hier.
  • Sie können mittels Mausklick durch die Folien navigieren. Mit Shift+Click können Sie in den Folien zurück navigieren.

1 Pointer

Wenn wir ganz dolle beide Augen zudrücken, können wir uns den RAM unserer Computer wie eine sehr lange Kette von Variablen der Größe 1 Byte vorstellen. In jeder dieser Speicherzellen können wir einen 8-Bit Wert speichern und wieder auslesen. Wenn wir uns jetzt aber vor Augen halten, dass wir inzwischen sehr sehr viel Speicher haben, wäre es sehr herausfordernd jeder dieser Speicherzellen einen sinnvollen symbolischen Namen zu gebenSchnuffi, fluffy, baerchen, mausi, hasi, ….. Außerdem würden uns diese vielen Namen auch gar nichts helfen, denn der real-existierende RAM kann keine symbolischen Namen verarbeiten. Der Prozessor ruft dem Speichercontroller also nicht zu Ey! Gibt mal den Inhalt der Speicherzelle 'schnuffi' her, sondern es sind ganze Zahlen, die sich Prozessor und Speicher hin und her werfen.

Jede Speicherzelle hat eine eindeutige, fixe Nummer, die wir auch Adresse nennen. Unter dieser Adresse ist die Speicherzelle bekannt, und der Prozessor kann nach ihrem Wert fragen und ihr einen neuen Wert zuweisen. Die CPU fragt gib mir den Inhalt von 101 und der Speicher antwortet mit "17".

Da wir in C auch Variablen haben, die größere Werte speichern (z. B. uint64_t), kann eine Variable in C auch mehrere Speicherzellen (und damit auch Adressen überdecken). So hat die Variable e auf den Folien 8 Byte (=64 Bit) und "lebt" in den Speicherzellen 108 bis 115. Damit wir auch dieser Variable eine Adresse zuordnen zu können, hat man sich darauf geeinigt die niedrigste Adresse zu verwenden. Im Fall von e ist dies die 108. Zusammen mit dem Wissen, dass e ein uin64_t ist und damit 8 Speicherzellen benötigt, wissen wir nun wieder das der Wert dieser Variable in den Zellen 108 bis 115 gespeichert ist.

Aber das hilft uns alles noch nichts mit unserem Namensproblem. Wir können uns auf Ebene des C Programms nicht so viele Namen ausdenken, wie wir Speicherzellen haben, selbst wenn wir sehr große Ganzzahltypen verwenden würden. Wir müssen es also irgendwie schaffen, dass wir Programme schreiben können, die unter einem Namen verschiedene Speicherzellen ansprechen können. Die, zum Beispiel zu Beginn des Programms, den Inhalt der Speicherzellen 100-107 meinen und am Ende vielleicht 108-115.

Und diese Problemstellung bringt und direkt zu Pointern. Pointer bilden nämlich genau diese Indirektion, wo wir aus dem Programmverlauf heraus Umdeuten können welche Speicherzelle gemeint angesprochen werden soll. Der zentrale Trick bei der Sache ist, dass C es dem Programmierer erlaubt Adressen als Werte zu verwenden. …. OK, sind sie noch bei mir? Wenn Sie jetzt nicht verwundert oder schockiert sind, gehen Sie noch einmal zurück und lesen Sie den Satz nochmal. Adressen, die bisher nur für die Kommunikation zwischen Prozessor und Speicher verwendet werden, dieses technische Implementierungsdetail, sollen jetzt plötzlich Teil unserer wunderschönen Typ- und Wertewelt werden? Das was ich, quasi nur als Hilfsmittel, unter die Variablen gezeichnet habe, soll nun plötzlich selbst in Variablen gespeichert werden? Das ja allerhand!

Also nochmal in prägnanter Kürze: Ein Pointer*wert* ist die Adresse einer Speicherzelle, den wir in einer Pointer*variable* speichern können. Im Beispiel haben wir die Variable int b, die an der Adresse 104 gespeichert ist, und wir speichern diese Adresse als Pointerwert in der Pointervariablen c, die selbst wiederum an der Adresse 108 liegt. Abstrakt, und daher heißen die Dinger auch Pointer bzw. Zeiger, zeigt nun die Variable c auf die Variable b (Siehe die Pfeile auf den Folien)

Damit uns Pointer wirklich bei unserem Problem mit den vielen Namen helfen brauchen wir allerdings noch mehrere Dinge: Zum einen müssen wir die Syntax lernen, die C für Pointer verwendet, und wir müssen die Operationen kennenlernen, die uns auf Pointern erlaubt sind, damit wir mit diesen auch etwas machen können. Grundlegend, wollen wir Pointervariablen anlegen, Pointerwerte berechnen, und dem Pointer zu seinem Ziel "nachlaufen". Später werden wir dann auch noch lernen, dass man mit Pointern auch rechnen kann und das dies uns sehr nah Arrays bringt. Aber eines nach dem anderen.

Deklaration/Definition von Pointervariablen: Da wir in C immer einen Typen brauchen um eine Variable anzulegen, brauchen wir auch für Pointer eine Notation. Allerdings ist es in C nicht so, dass es nur einen Typ pointer_t gibt, der die Wertemenge aller Pointer aufspannt, sondern C hat eine feinere Ausdifferenzierung hierfür. In C weiß (fast) jeder Pointertyp immer auf was er zeigt. Es gibt den Typen int *, der Pointer auf int umfasst, und long long *, mit dem wir Pointer auf long longs meinen. Bei Typen führt das Anhängen eines * dazu, dass wir meinen "Pointer auf".

Da die tatsächliche Syntax manchmal noch ein klein bisschen komplizierter ist als das, sei Ihnen noch das Programm cdecl (auch cdecl.org) ans Herz gelegt, mit dem Sie C Deklarationen "dekodieren" können:

$ cdecl
Type `help' or `?' for help
cdecl> explain int myvar
declare myvar as int
cdecl> explain int * myvar
declare myvar as pointer to int
cdecl> explain int *foo[23]
declare foo as array 23 of pointer to int

Für den C Übersetzer ist es wichtig zu wissen, worauf ein Pointer zeigt, damit er weiß wie viele Speicherzellen nach dieser Adresse noch kommen und "dazu gehören". Damit man sich dennoch manchmal mit rohen Adressen arbeiten kann, gibt es noch den besonderen Datentyp void *, mit dem wir sagen, dass wir gerne einen Zeiger hätten, aber nicht genau wissen auf was wir da zeigenvoid* meint also nicht "Zeiger auf nichts", sondern eher, Zeiger auf etwas von dem wir nicht (statisch) wissen was es ist.

Berechnung von Pointerwerten mit dem address-of Operator: Damit wir auch irgendwie an die Adresse einer Variable kommen, braucht es den address-of Operator, der in C mit dem Kaufmannsund in unärer Präfixschreibweise notiert wirdBitte beachten Sie, dass dies ein anderer Operator ist als das Bit-weise UND, welche zwar auch mit Kaufmannsund notiert wird, aber in Infix-Schreibweise daherkommt. Mit diesem Berechnen wir eine Adresse von etwas und bekommen Sie als Pointerwert vom passenden Typen geliefert, welchen wir dann rumreichen können (z.B in einer Pointervariable speichern). Wenn wir also die Adresse einer int-Variable mit &a berechnen, dann bekommen wir einen Wert vom Typen int *. Ist ja genau das, was wir haben wollten.

Wichtig ist allerdings, dass man Adressen nur von etwas berechnen kann, was auch einen Speicherplatz hat. Das prominente und einfache Gegenbeispiel ist &3. Das Literal 3 ist nirgends gespeichert, wenn wir es hinschreiben erzeugt der Übersetzer on-the-fly einen Wert 3, aber es gibt keinen dazugehörigen Speicherplatz. Daher liefert &3 eine Fehlermeldung beim Kompilieren:

$ cat /tmp/test.c
int main() {
    printf("%p\n", &3);
}

$ gcc /tmp/test.c
/tmp/test.c: In function ‘main’:
/tmp/test.c:3:20: error: lvalue required as unary ‘&’ operand
    3 |     printf("%p\n", &3);
      |        

Wir wollen an dieser Stelle nicht zu tief in die Begriffe des Übersetzerbaus eingehen, aber der address-of Operator funktioniert nur auf sog "lvalues" (left-values), also all dem was auf der linken Seite einer Zuweisung stehen kann. Macht ja auch irgendwie Sinn: Die Zuweisung mit = schreibt etwas in den Speicher, womit auch dort die Notwendigkeit besteht, dass auf der linken Seite etwas steht das eine Adresse hat. Da Variablen im Speicher leben, können sie auf der linken Seite stehen a = ... und wir können ihre Adresse berechnen. Da 3 keine Adresse hat, kann es auch bei der Zuweisung nicht links stehen 3 = stehen und wir können keine Adresse berechnen.

Dereferenz-Operator: Mit dem Dereferenz-Operator, welcher mit ein AsteriskFun-Fact Asterix und Obelix tragen diese Namen, weil ein Asterisk (*) klein ist und ein Obeliscus (†) groß ist. in unärer Präfixschriebweise notiert wird, können wir auf das Zugreifen, auf das der Zeiger zeigt. Wir laufen dem Zeiger beim dereferenzieren eine Ebene nach und lesen oder schreiben was dort, wobei der Zeiger selbst unverändert bleibt.

Im Beispiel greifen wir mit *ptr auf die Variable a zu, lesen ihren Wert zweimal und schreiben das Ergebnis zurück. Wir hätten also genauso gut a = a + a schreiben können, aber dann hätten wir nicht gelernt wie man dereferenziert. Außerdem zeigt uns das, dass wir *(IRGENDWAS) verwenden können wie einen Variablennamen, es kann sowohl auf der linken Seite einer Zuweisung stehen (lvalue), wie auch überall sonstrvalue. Und das doch cool! Wir haben auf a zugegriffen, ohne a zu sagen! Damit müssen wir jetzt was machen! Wie wäre es, wenn wir jemand anderem mit einem Pointer das Recht übertragen würden unsere Variable zu manipulieren? Denn das können wir jetzt schon tun!

#include <stdio.h>

void manipulate(int *ptr) {
    *ptr = (*ptr) + 20;
}

int main() {
    int variable = 3;
    manipulate(&variable);
    printf("%d\n", variable); // Ausgabe: 23
}

In diesem Beispiel, gibt die Funktion main() einen Pointer auf die Variable variable an die Funktion manipulate() weiter. manipulate() weiß jetzt aber gar nichts vom Namen a und kann dennoch den Inhalt von unserem a manipulieren. Noch besser, die Funktion manipulate() kann das jetzt mit allen int-Variablen machen, wenn wir ihr einen Pointer darauf geben.

Da Pointer ein wirklich schwieriges Thema sind, hier einige Fragen, die Sie für sich beantworten können. Alle antworten darauf ergeben sich recht direkt aus allem, was ich oben genannt habe:

  1. Gegeben einen int *ptr, was liefert uns der Ausdruck &(*(ptr))?
  2. Von welchem Typ sind die Objekte auf die ein int *** zeigt?
  3. Gegeben, dass Dereferenzierung stärker bindet als Multiplikation, was welchen Wert würde manipulate() in a hinterlassen, wenn es *ptr**ptr machen würde?
  4. Kann ein int * auf sich selbst zeigen? Falls ja, wie? Falls nein, warum nicht?
  5. Was verbraucht mehr Speicherplatz eine char-Variable oder eine char*-Variable?
  6. Warum ist es in Ordnung, dass alle Pointervariablen gleich viel Speicher einnehmen?

2 Pointer Arithmetik

Würden wir Pointer nur erzeugen und wieder dereferenzieren können, wären Pointer zwar eine nette Alternative zum direkten Hinschreiben von Variablennamen, aber sie hätten lange nicht die Relevanz die Sie haben. Wir können mit Pointern nämlich noch eine Sache machen, die ihre Relevanz erst richtig begründen: Wir können mit Ihnen rechnen.

Dazu rufen wir uns am besten noch einmal in Erinnerung wie Pointer implementiert sind. Pointer sind ganze Zahlen, die die Adresse einer Speicherzelle darstellen, und wir können Pointer in Pointervariablen speichern. Und da Pointer ganze Zahlen sind, liegt der Gedanke nahe, dass man ja auch mit diesen Zahlen rechnen könnte, um den Zeiger woanders hinzeigen zu lassen. Das nennt man dann Pointer Arithmetik und es gelten dabei noch einige Regeln, die erst dazu führen, dass das richtig sinnvoll funktioniert.

Im vorherigen Kapitel haben wir ja schon festgestellt, dass es Variablen gibt, die größer sind als ein einzelnes Byte und daher mehr als eine 1-Byte-Speicherzelle einnehmen und so auch mehr als eine Speicheradresse abdecken, wobei wir die niedrigste Adresse als den Pointer auf die Variable hernehmen. Wenn wir dies jetzt zusammenbringen mit Pointer Arithmetik, kommt man sehr schnell dazu, dass es keinen Sinn ergibt eine "+1" Operation direkt auf den Adresswert zu machen. Auf den Folien sehen wir ein Beispiel, bei dem zwei Pointer auf die Adresse 100 Zeigen und wir sehen, das diese, abhängig von ihrem Datentyp, die nächsten 1-3 Speicherzellen mit meinen. Würden wir bei einem ptr+1 einfach zum Adresswert 101 springen, würde der neue Pointer in die Mitte der alten Variablen zeigen. Das wäre massiver Quatsch!

Daher gibt es für die Addition/Subtraktion von Pointer und Ganzzahl eine angepasste Form der Addition, die den Adresswert um die Größe des Pointee-Typs verschiebt. Im Beispiel ist es dann so, dass der uint32_t * (Pointee-Typ: uint32_t) auf 4-Byte große Objekte zeigt und wir daher direkt bei einer +1 zum Adresswert 104 springen. Für den uint16_t *, der ja auf 2-Byte Objekte zeigt, gehen wir in 2er Schritten durch den Adressraum. Zusammen mit dem sizeof()-Operator, der uns die statische Größe eines Objekts oder Datentyps gibt und ein paar casts, könnten wir Pointer Addition/Subtraktion so abbilden:

void *pointer_add(void* ptr, unsigned int pointee_size, int value) {
    ptr = ptr + (value * pointee_size);
    return ptr;
}

int main() {
    uint32_t *ptr = ....;

    // Equivalent zu 'ptr += 3;'
    ptr = pointer_add((void*)ptr, sizeof(*ptr), 3);
    // HINT: sizeof(*ptr) == sizeof(uint32_t)
}

Dabei wollen wir erst einmal außen vorlassen, worauf unsere veränderten Pointer zeigen und konzentrieren uns ganz auf die Ebene der Pointerwerte. Nehmen Sie, für den Moment, einfach an, die Pointer würden irgendwo hinzeigen, wo etwas Sinnvolles vom entsprechenden Typen gespeichert ist.

Diese Art der Pointer-Addition bezieht sich auch wirklich nur auf Pointer + Ganzzahl (bzw. Ganzzahl + Pointer). Der Übersetzer wählt hier also anhand des statischen Typs eine andere Form der Addition aus, sobald eine Seite vom Typ T *. Es ist allerdings zu beachten, dass es zu einem Übersetzungsfehler kommt, wenn man zwei Zeiger addiert. Hieraus ergibt sich aus, wieso das folgende Stück Code nicht das was man als Java-Programmierer erwarten würde:

#include <stdio.h>

int main() {
    char *foo = "abc";
    char *bar = "xyz";

    printf("%s\n", foo + bar);
}

Anstatt beide Strings hintereinander zu hängen und abcxyz auszugeben, bekommen wir beim Übersetzen bereits den Fehler, dass diese Art der Addition nicht zulässig ist:

test.c: In function ‘main’:
test.c:7:24: error: invalid operands to binary + (have ‘char *’ and ‘char *’)
    7 |     printf("%s\n", foo + bar);
      |    

Um Ihr wissen zu testen, versuchen Sie die folgende Tabelle zu vervollständigen, sofern möglich, wobei Sie annehmen, dass ein Zeiger 32 Bit breit ist. Falls Sie keine Lösung finden, bzw. Ihre Lösung validieren möchten, schreiben Sie sich ein minimales Beispielprogramm.

Typ Wert (vorher) Operation Wert (nachher)
uint32_t x 100 x += 1  
uint32_t* x 98 x -= 1  
char* x 100   103
char** x   x += 2 58
uint32_t* x 200   197
uint64_t** x 150 x += 1  

Neben dieser Operation, gibt es noch die Subtraktion von Pointer und Pointer, welche definiert ist, wenn beide Pointer auf den gleichen Typen zeigen. Man kann also nicht beliebige Pointer voneinander abziehen, sondern braucht zwei Pointer vom gleichen Typ. Wenn dieser Fall auftritt ist das Ergebnis der Subtraktion nicht einfach die Differenz der Adresswerte, sondern diese wird noch durch die Größe des Pointee-Typs geteilt. Man bekommt also quasi eine Antwort auf die Frage: Wie viele Elemente des Pointee-Typs würden zwischen beiden Pointern liegen?

3 Arrays und Referenzsemantik

Wie beim vorherigen Thema Pointer Arithmetik angedeutet sind Pointer und Arrays in C Themen die sehr nahe zusammen liegen. Wir wollen uns daher in diesem Kapitel noch kurz anschauen, wie die beiden Themen zusammenhängen, nachdem wir geklärt haben, wie Arrays in C funktionieren.

Wie bei den Pointern sollten sich in Ihrem Kopf zum Thema Arrays bereits drei Fragen gestellt haben: (1) Was ist ein Array überhaupt? (2) Wie mache ich mir ein Array? (3) Wenn ich ein Array habe, was kann ich damit anstellen? Anhand dieser Fragen wollen wir uns nun an das Thema heranwagen.

Zunächst klären wir, da Sie das ja eigentlich auch aus Programmieren 1 bereits kennen, die Frage 1 kurz und prägnant: Ein Array ist ein Verbund aus mehreren Datenelementen gleichen Typs. Plain and Easy! Also nochmal langsamer: Beginnen wir bei den Werten eines Arrays. Wo ein Wert eines skalaren Typs (z.B. uint8_t) einen eindimensionalen Wertebereich hat (0 … 255), hat ein uint8_t-Array-Datentyp einen mehrdimensionalen Wertebereich, da er ein Verbund mehrerer uint8_t ist (Wir verwenden mal {}, um Verbünde anzuzeigen):

uint8_t x[3] -> {0..255, 0..255, 0..255}

Es sind also sowohl {0,0,0}, {3, 128, 255}, wie auch ~{255, 255, 255} ein Wert der Wertemenge "Drei-elementiges Array von uint8_t" (uint8_t[3]). Bemerkenswert sind hier mehrere Dinge: (1) Der Name der definierten Variable wird ganz komisch zwischen Element-Type (uint8_t) und Größe ([3]) eingefügtIm Gegensatz zu Java, wo der Typ zusammen bleibt: String[]. (2) Die Größe eines Arrays ist eine statische Eigenschaft, weil sie Teil des statischen Typs ist. Man kann C-Arrays zur Laufzeit also nicht größer oder kleiner machen.

Aus der zweiten Beobachtung ergibt sich aber auch, dass es gar keine Not gibt sich abzuspeichern, wie viele Elemente ein Array überhaupt hat. Der Übersetzer weiß ja im Prinzip, wie viele Elemente ein Array hatIm Prinzip. Wirklich nur im Prinzip. Das die C-Entwickler diese Optimierung getroffen haben, hat sehr viele Sicherheitsforscher in Lohn und Brot gebracht, da es eine der häufigsten Quellen für Sicherheitslücken ist. und kann es sich daher sparen dafür Speicherplatz herzugeben. Das Array aus unseren 10 uint32_t ist demnach, also genau 40 Bytes lang. Kein Fluff außen rum, nur die blanken Array-Elemente hintereinander im Speicher.

Dies gilt auch für mehrdimensionale Arrays. Auch dort werden die einzelnen Dimensionen einfach hintereinander in den Speicher geklopft und ein uint8_t m[3][3] ist genau 9 Byte groß und ein uint16_t X[4][10][27] belegt 2160 Bytes.

Unsere zweite Frage war, wie ich mir ein Array anlegen kann. Das ist relativ straight forward: Wir deklarieren uns (entweder global oder lokal) eine Variable vom entsprechenden Typ. Dabei wird jedoch, wie gesagt der Variablenname ganz komisch zwischen Element-Typ und Array-Specifier eingefügt. Das Ganze wird noch viel Schlimmer, wenn man bei der Definition sowas wie "Array von Pointern" oder "Pointer auf Array" haben möchte. Wieder kann uns das cdecl Programm eine Hilfestellung geben:

explain cdecl output
int var[3] declare var as array 3 of int
int* var[3] declare var as array 3 of pointer toint
int (*var)[3] declare var as pointer to array 3 of int

Für die genauen Feinheiten der Syntax verweise ich Sie hier an ein Buch über die Programmiersprache C. Ich möchte Sie nur darauf hinweisen, das es diese Feinheiten gibt!

Auf die dritte Frage, was man mit Arrays machen kann, gibt es eine einfache und eine komplizierte Antwort: (1) Mittels des []-Operators kann man auf die Elemente des Arrays sowohl lesend, als auch schreibend, zugreifen, wenn man eine Ganzzahl als Index angibt. Bemerkenswert dabei ist allerdings, dass der C-Übersetzer uns überhaupt nicht davon abhält dumme Dinge zu tun und ein Element außerhalb des Arrays zu verlangen. Das folgende Programm kompiliert, selbst mit gcc -Wall -Wextra -O1, ohne Fehler (oder gar Warning):

#include <stdio.h>

int main() {
    int X[3] = {1,2,3};    // Direkte Initialisierung
    printf("%d\n", X[10]); // Kein Fehler oder Warning
}

Warum das so ist, ist dann auch gleich die zweite, komplizierte Antwort auf die dritte Frage: Arrays haben keine Wertesemantik. Was bedeutet das? So wie wir C kennen, wo ja normalerweise die Wertesemantik herrscht, würden wir ja erwarten, dass wir den Werteverbund bekommen, wenn wir einen Array-Variablen-Namen hinschreiben. Das ist aber nicht so! Sondern C folgt hierUnd noch an einer anderen Stelle, wenn es um Funktionszeiger geht der Referenzsemantik für Variablen. Anstatt den Werteverbund zu bekommen, bekomme ich den Pointer auf das 0-te ElementWie in Java sind auch in C Arrays 0-indiziert.

void main() {
    int X[4] = {4, 8, 1, 0};

    int *ptr1 = X;
    int *ptr2 = &(X[0]);
}

Im Beispiel haben ptr1 und ptr2 am Ende den exakt gleichen Wert. Der Name X ist also ein Synonym für &(X[0]). Das ja wild! Um das ganze noch deutlicher zu verstehen, möchte ich Ihnen einen Teil des abstrakten Syntaxbaum für das obigen Programms zeigen, wie ihn das Programm clang-check -ast-dump ausgibt. Sie müssen hier nur wissen, dass der abstrakte Syntaxbaum die erste Zwischenstufe bei der Übersetzung vom Quellcode zur Binärdatei ist.

int *ptr1 =
      `-ImplicitCastExpr  'int *' <ArrayToPointerDecay>
         `-DeclRefExpr 'int[4]' lvalue Var 'X' 'int[4]'

int *ptr2 =
      `-UnaryOperator 'int *' prefix '&'
          `-ArraySubscriptExpr 'int' lvalue
            |-ImplicitCastExpr  'int *' <ArrayToPointerDecay>
            | `-DeclRefExpr  'int[4]' 'X' 
            `-IntegerLiteral 'int' 0

In beiden Teilbäumen (für ptr1 und ptr2) sehen wir, das der Variablenname mit einer DeclRefExpr referenziert wird. Dort verwendet der Übersetzer wirklich noch den Typ des Arrays (int[4]), wie wir ihn definiert haben. Danach, eine Ebene weiter oben sehen wir allerdings, das das Array, sofort mittels eines ArrayToPointerDecay zu einem int* gemacht wird. Der Übersetzer wirft also alles Wissen über das Array weg und X wird sofort zu einem int*. Für ptr2 wird dann nochmal der []-Operator (ArraySubscriptExpr) und der address-of-Operator (UnaryOperator prefix '&') angewendet, was aber ein NOP ist.

Aus dieser Betrachtung ergibt sich auch, das diese ganze Geschichte mit []-Operator nur syntaktischer Zucker ist. Das Ganze läßt sich, bis auf die Definition von Arrays, allesDas ist eine kleine Lüge! Es gibt eine Möglichkeit in einem begrenzten Umfang, Wertesemantik für Arrays zu bekommen auf diese Referenzsemantik, Pointer-Arithmetik, und den Dereferenz-Operator zurückführen.

Um hier Ihr wissen zu testen, versuchen Sie das Beispiel auf der letzten Folie komplett zu verstehen: Welche Pointer kommen hier vor? Welche Operationen werden hier durchgeführt. Als Übung sollten Sie die Funktion int* split(int* S, int* E) implementieren, die die Elemente zwischen S und E dahingehend sortiert, das vorne nur positive und hinten nur negative Zahlen stehen. Dabei dürfen die Elemente umsortiert werden. Am Ende soll die Funktion dann einen Zeiger auf das erste negative Element zurückgeben. Ein Gerüst für diese Übung sieht dann so aus:

#include <stdio.h>

void dump(int* S, int* E) {
    while(S != E) {
        printf(" %d", *S);
        S = S + 1;
    }
    printf("\n");
}

int * split(int* S, int* E) {
    // TODO
}

void main() {
  int a[8]={1,-2,3,4,-5,6,-7,0};
  int *b = split(a, a + 8);
  dump(a, a + 8);   // 1 0 3 4 6 -5 -7 -2
  dump(a, b);       // 1 0 3 4 6
  dump(b, a + 8);   // -5 -7 -2
}

Fußnoten:

1

Schnuffi, fluffy, baerchen, mausi, hasi, ….

2

void* meint also nicht "Zeiger auf nichts", sondern eher, Zeiger auf etwas von dem wir nicht (statisch) wissen was es ist

3

Bitte beachten Sie, dass dies ein anderer Operator ist als das Bit-weise UND, welche zwar auch mit Kaufmannsund notiert wird, aber in Infix-Schreibweise daherkommt

4

Fun-Fact Asterix und Obelix tragen diese Namen, weil ein Asterisk (*) klein ist und ein Obeliscus (†) groß ist.

5

rvalue

6

Im Gegensatz zu Java, wo der Typ zusammen bleibt: String[]

7

Im Prinzip. Wirklich nur im Prinzip. Das die C-Entwickler diese Optimierung getroffen haben, hat sehr viele Sicherheitsforscher in Lohn und Brot gebracht, da es eine der häufigsten Quellen für Sicherheitslücken ist.

8

Und noch an einer anderen Stelle, wenn es um Funktionszeiger geht

9

Wie in Java sind auch in C Arrays 0-indiziert

10

Das ist eine kleine Lüge! Es gibt eine Möglichkeit in einem begrenzten Umfang, Wertesemantik für Arrays zu bekommen

Creative Commond BY Logo
Kurzvorträge zu verschiedenen Themen aus dem Bereich Betriebssysteme, Christian Dietrich, 2020-2022.
Die Materialien zu dieser Kurzvorträge sind, soweit nicht anders deklariert, unter der Creative Commons Attribution International License (CC-BY 4.0) veröffentlicht.