Wie Zeiger auf Strukturen in C funktionieren

12

Sie können in C auf fast alles zeigen. Mit der Sprache können Sie Zeiger für primitive Typen, Arrays, Funktionen und benutzerdefinierte Typen erstellen. Aber Hinweise auf Strukturen sind der Punkt, an dem es interessant wird. Sie sind äußerst häufig. Wenn Sie C-Code schreiben, werden Sie wahrscheinlich häufig auf sie stoßen.

Hier ist ein einfaches Beispiel. Es zeigt, wie Sie eine Struktur definieren und dann einen Zeiger darauf erstellen.

„c
Strukturpunkt {
int x;
int y;
};

struct Point p1 = {10, 20};
struct Point *ptr = &p1;

Dieses Snippet leistet mehr als nur die Zuweisung einer Speicheradresse. Es stellt eine Beziehung zwischen der Variablen „ptr“ und der Struktur „p1“ her. Der Zeiger enthält die Adresse der Struktur. Es ermöglicht Ihnen, indirekt auf die Mitglieder „x“ und „y“ zuzugreifen. Dies ist ein grundlegendes Muster. Sie sehen es in verknüpften Listen, dynamischer Speicherzuweisung und Funktionsargumenten.

Warum Zeiger auf Strukturen verwenden?

Das Kopieren ganzer Strukturen ist teuer. Wenn Ihre Struktur viele Daten enthält, führt die Übergabe als Wert an eine Funktion dazu, dass der Compiler eine vollständige Kopie erstellt. Das verschwendet Zeit und Speicher. Durch die Verwendung eines Zeigers wird das Kopieren vermieden. Stattdessen übergeben Sie die Adresse. Die Funktion kann die Originaldaten ohne Mehraufwand ändern.

Es stellt sich auch die Frage der dynamischen Größenbestimmung. Arrays haben zur Kompilierzeit feste Größen. Strukturen lösen das dynamische Problem nicht unbedingt allein. Wenn Sie jedoch eine Struktur mit „malloc“ kombinieren, erhalten Sie eine flexible Datenspeicherung. Sie können dem Heap eine Struktur zuweisen. Mit einem Zeiger auf diese Struktur können Sie deren Lebensdauer manuell verwalten.

Zugriff auf Mitglieder über Zeiger

Sobald Sie einen Zeiger haben, müssen Sie auf die darin enthaltenen Daten zugreifen. Sie können den Punktoperator nicht verwenden. Der Ausdruck „ptr.x“ kann nicht kompiliert werden. „ptr“ ist eine Adresse, nicht die Struktur selbst. Sie müssen es zuerst dereferenzieren.

Es gibt zwei Möglichkeiten, dies zu tun. Der erste ist ausführlich.

„c
(*ptr).x = 10;

Die Klammern sind hier zwingend erforderlich. Der Punktoperator hat eine höhere Priorität als der Dereferenzierungsoperator. Ohne sie versucht der Compiler, auf „ptr.x“ zuzugreifen, das nicht existiert.

Der zweite Weg ist sauberer. C stellt den Pfeiloperator „->“ bereit. Es kombiniert Dereferenzierung und Mitgliederzugriff in einem Schritt.

„c
ptr->x = 10;

Diese Syntax ist Standard. Es ist das, was Sie in echten Codebasen sehen werden. Es liest sich natürlich wie folgt: „Gehen Sie zu dem Objekt, auf das „ptr“ zeigt, und holen Sie sich dessen „x“-Mitglied.“ Es ist prägnant. Es reduziert visuelle Unordnung.

Häufige Fallstricke

Nullzeiger sind ein Risiko. Wenn Sie einen Zeiger deklarieren, ihn aber nicht initialisieren, zeigt er auf Müll. Der Zugriff auf „ptr->x“ mit einem Nullzeiger führt zu einem Segmentierungsfehler. Sie müssen vor der Verwendung des Zeigers nach Null suchen.

„c
if (ptr != NULL) {
ptr->x = 10;
}

Diese Prüfung ist in robustem Code nicht optional. Es verhindert Abstürze.

Ein weiteres Problem sind Speicherlecks. Wenn Sie mit „malloc“ Speicher zuweisen und den Zeiger verlieren, bleibt dieser Speicher reserviert, bis das Programm endet. Sie müssen jede Zuordnung im Auge behalten. „free()“ liegt in Ihrer Verantwortung. Vergiss es

Zeiger auf Strukturen in C bringen oft Entwickler zum Stolpern, die mit einfachen Zeigern vertraut sind, sich aber in der Syntax verheddern. Betrachten Sie eine einfache Datensatzdefinition für ein Benutzerprofil.

typedef struct {
char name[21];
Char-Stadt[21];
char state[3];
} Rec;

typedef Rec *RecPointer;

Hier ist „RecPointer“ nur ein Alias ​​für einen Zeiger auf eine „Rec“-Struktur. Wenn Sie eine Variable dieses Typs deklarieren, handelt es sich um eine Speicheradresse, nicht um die Daten selbst.

RecPointer r;

Die Variable „r“ belegt auf einem 32-Bit-System vier Bytes (bzw. acht auf einem 64-Bit-System). Es ist nur ein Hinweis. Es enthält weder den Namen noch die Stadt oder das Bundesland. Um tatsächliche Daten zu speichern, müssen Sie Speicher auf dem Heap zuweisen.

r = (RecPointer)malloc(sizeof(Rec));

Dieser „malloc“-Aufruf reserviert 45 Bytes. Das sind 21 Bytes für den Namen, 21 für die Stadt und 3 für das Bundesland, plus ein Byte zum Auffüllen oder Ausrichten, um Speichergrenzen einzuhalten. Jetzt zeigt „r“ auf einen gültigen Speicherblock, der sich genau wie eine „Rec“-Struktur verhält.

Zugriff auf Mitglieder über Dereferenzierung

Um mit den Daten zu interagieren, müssen Sie den Zeiger dereferenzieren. Hier schleichen sich Prioritätsfehler ein. Sie können auf Elemente mit der standardmäßigen Punktnotation zugreifen, müssen die Dereferenzierung jedoch in Klammern setzen.

strcpy((r).name, “Leigh”);
strcpy((
r).city, “Raleigh”);
strcpy((*r).state, “NC”);

Beachten Sie die Syntax. „(r).name“ ist korrekt. Wenn Sie „ r.name“ schreiben, schlägt die Kompilierung fehl. Warum? Weil der Punktoperator eine höhere Priorität hat als der Dereferenzierungsoperator. Der Compiler interpretiert „r.name“ als „ (r.name)“. Da „r“ ein Zeiger ist, ist „r.name“ eine ungültige Syntax und der Ausdruck bricht zusammen. Die Klammern erzwingen, dass zuerst die Dereferenzierung „*r“ erfolgt, wodurch die Struktur entsteht, und dann greift der Punktoperator auf das Feld „Name“ zu.

Das Tippen ist mühsam. Es sieht vollgestopft aus. Es lädt zu Fehlern ein.

Die Pfeilnotation

C bietet eine sauberere Möglichkeit, damit umzugehen. Der Pfeiloperator „->“ ist syntaktischer Zucker für „(*pointer).member“. Es handelt sich nicht um einen anderen Mechanismus. Es handelt sich nicht um einen neuen Operator, der die Art und Weise ändert, wie auf den Speicher zugegriffen wird. Es ist einfach eine kürzere Möglichkeit, die Dereferenzierung und den Mitgliedszugriff in einem Schritt zu schreiben.

strcpy(r->name, „Leigh“);

Dies ist identisch mit „strcpy((*r).name, „Leigh“)“. Es werden zwei Zeichen eingespart. Es macht verschachtelte Klammern überflüssig. Dies ist die Standardmethode, mit der die meisten C-Entwickler mit Strukturzeigern interagieren.

Auswirkungen auf die Speicherverwaltung

Der „free(r)“-Aufruf ist obligatorisch. Der Speicher wurde vom Heap zugewiesen. Wenn Sie es nicht loslassen, läuft es aus. Der Zeiger „r“ selbst, die Vier-Byte-Variable, die die Adresse enthält, ist lokal im Stapelrahmen oder im globalen Bereich, aber die Daten, auf die er zeigt, befinden sich an anderer Stelle.

Wenn Sie „r->name“ verwenden, ändern Sie die Daten an der in „r“ gespeicherten Adresse. Der Zeiger „r“ bleibt unverändert. Die Adresse tut es

Das spontane Zuweisen von Speicher für Arrays ist ein Grundbestandteil der C-Programmierung, erfordert jedoch ein Verständnis dafür, wie Zeiger mit Rohspeicherblöcken interagieren. Wenn Sie ein Array mit fester Größe benötigen, das zur Kompilierungszeit nicht bekannt ist, reicht die Standard-Stack-Zuweisung nicht aus. Man muss nach dem Haufen greifen.

Der folgende Codeausschnitt zeigt ein häufiges Muster:

„c
int p;
int i;
p = (int
)malloc(sizeof(int[10]));
für (i=0; i<10; i++) p[i] = 0; frei(p); „

Hier reserviert „malloc“ Platz für zehn ganze Zahlen. Die Umwandlung in „(int )“ stellt sicher, dass der Zeiger dem erwarteten Typ entspricht, auch wenn moderne C-Compiler häufig vor expliziten Umwandlungen für „void “ warnen. Anschließend initialisiert die Schleife jedes Element mithilfe der Indexnotation auf Null. Schließlich gibt „Free“ den Speicher wieder an das System frei.

Aber das ist nicht die einzige Möglichkeit, es zu schreiben.

Sie können genau das gleiche Ergebnis erzielen, indem Sie „p[i]“ durch Zeigerarithmetik ersetzen:

„c
int p;
int i;
p = (int
)malloc(sizeof(int[10]));
für (i=0; i<10; i++) *(p+i) = 0; frei(p); „

Warum ist das wichtig? Weil „p[i]“ nur syntaktischer Zucker für „*(p+i)“ ist. Der Compiler behandelt sie identisch. Wenn Sie mit eingebetteten Systemen arbeiten oder enge Schleifen schreiben, bei denen jeder Zyklus zählt, hilft Ihnen das Wissen, dass diese austauschbar sind, beim Lesen von Legacy-Code – und beim Schreiben Ihres eigenen ohne Verwirrung.

Dennoch gibt es eine subtile Falle.

Wenn Sie einen Zeiger auf einen Array-Typ direkt deklarieren – wie „int (p)[10]“ – haben Sie es mit einer ganz anderen Sache zu tun. Dieser Zeiger zeigt auf das gesamte Array, nicht nur auf das erste Element. Durch Inkrementieren wird der Zeiger um die Größe des gesamten Arrays verschoben, nicht um eine einzelne Ganzzahl. Die meisten Entwickler bleiben bei „int “, weil es einfacher und flexibler ist.

Wann welcher Ansatz zu verwenden ist

Die Wahl zwischen Indexnotation und expliziter Zeigerarithmetik hängt oft von der Lesbarkeit und der Absicht ab.

  • Verwenden Sie „p[i]“, wenn Sie den indexbasierten Zugriff hervorheben möchten. Für die meisten Leser ist es klarer.
  • Verwenden Sie „*(p+i)“, wenn Sie Speichermanipulationen auf niedriger Ebene durchführen oder Array-Zerfallsfehler in komplexen Ausdrücken vermeiden müssen.

Beide Ansätze erfordern eine sorgfältige Speicherverwaltung. Vergessen Sie „frei“, und Sie lecken. Rufen Sie es zu früh an, und Sie werden auf undefiniertes Verhalten stoßen. Und obwohl „malloc“ hier einfach ist, stellen Sie vor der Dereferenzierung immer sicher, dass der Rückgabewert nicht „NULL“ ist.

„Zeiger auf Arrays sind mächtig, aber sie erfordern Disziplin. Ein Fehltritt und Sie lesen Müll – oder schlimmer noch, Sie beschädigen den Speicher einer anderen Variablen.“

In der Praxis müssen die meisten Entwickler selten rohe Zeigerarithmetik für einfache Arrays schreiben. Bibliotheken wie „std::vector“ in C++ oder Abstraktionen höherer Ebenen in anderen Sprachen handhaben dies automatisch. Aber in C? Du bist auf dich allein gestellt.

Und deshalb ist es wichtig, die Mechanik zu verstehen. Nicht nur, um Interviews zu bestehen oder Lehrbücher zu schreiben, sondern auch, wenn der Code um 2 Uhr morgens kaputt geht und Sie genau wissen müssen, was im Gedächtnis passiert.

Wenn Sie einen Zeiger auf ein Integer-Array deklarieren, erstellen Sie nichts Exotisches. Es ist nur ein Standardzeiger auf ein „int“. Die Magie geschieht mit „malloc“. Sie weisen einen Speicherblock zu, der groß genug für die Anzahl der benötigten Ganzzahlen ist. Der Zeiger zielt dann auf das erste Element dieses Blocks.

C ist es egal, wie Sie darauf zugreifen. Sie können eckige Klammern wie „p[5]“ oder Zeigerarithmetik wie „*(p + 5)“ verwenden. Der Compiler behandelt sie als identisch. Diese Flexibilität ist der Grund, warum dynamische Arrays für Strings so nützlich sind. Sie können die Größe nicht erraten. Sie weisen genau genug Speicherplatz für die Zeichenfolgenlänge plus das Null-Terminator zu.

Arrays von Zeigern vs. Arrays von Strukturen

Warum ein Array von Zeigern verwenden, wenn Sie auch einfach ein Array von Strukturen verwenden könnten? Raum. Oder besser gesagt, das Fehlen davon.

Betrachten Sie eine Struktur „Rec“ mit drei Zeichenarrays von jeweils 81 Bytes. Das sind 243 Bytes pro Datensatz. Wenn Sie „Rec Records[10]“ deklarieren, reservieren Sie sofort 2.430 Byte im Speicher. Alles davon. Auch wenn Sie immer nur einen Datensatz verwenden.

Ein Array von Zeigern verändert die Mathematik.

„c
typedef struct {
char s1[81];
char s2[81];
char s3[81];
} Rec;

Rec *a[10]; // Array aus 10 Zeigern, nicht 10 Strukturen

Das Array „a“ ​​selbst enthält nur 10 Zeiger. Auf einem 64-Bit-System sind das 80 Bytes. Das ist ein Bruchteil des Speicherbedarfs für die vollständigen Strukturen. Der Speicher für die eigentlichen Datensätze bleibt ungenutzt, bis Sie ihn benötigen.

Sie können bei Bedarf einen einzelnen Datensatz zuweisen.

„c
a[0] = (Rec *)malloc(sizeof(Rec));
strcpy(a[0]->s1, “hello”);
frei(a[0]);

Dieses Muster löst speicherintensive Probleme durch Aufschieben der Zuweisung. Sie zahlen nur für das, was Sie nutzen. Wenn Sie mit der Aufnahme fertig sind, rufen Sie „Free“ auf. Der Zeiger wird zu einer baumelnden Referenz, der Speicher wird jedoch an das System zurückgegeben.

Strukturen, die Zeiger enthalten

Strukturen können Zeiger enthalten. Dadurch können Sie Daten fester Größe mit Daten variabler Größe im selben Objekt mischen.

Nehmen Sie einen Adressbucheintrag vor. Der Name, die Stadt und die Telefonnummer können angemessene Höchstlängen haben. Ein Kommentar kann jedoch alles sein, von einem einzelnen Wort bis hin zu einem Roman. Sie möchten nicht Platz verschwenden und für jeden Eintrag einen großen Puffer reservieren, nur für den Fall, dass eine Person einen langen Kommentar schreibt.

„c
typedef struct {
char name[21];
Char-Stadt[21];
char phone[21];
char *Kommentar;
} Addr;

Addr s;
char comm[100];

// Eingabeverarbeitung
gets(s.name, 20);
gets(s.city, 20);
gets(s.phone, 20);
gets(comm, 100);

// Dynamische Zuordnung für den Kommentar
s.comment = (char *)malloc(sizeof(char[strlen(comm)+1]));
strcpy(s.comment, comm);

Die Struktur „Addr“ selbst ist klein

Wie Kommentarfelder mit leeren und ausgefüllten Datensätzen umgehen

Nicht jeder Datensatz in der Datenbank trägt einen Kommentar. Wenn ein Feld leer bleibt, bleibt es nicht leer. Es enthält einen Zeiger. Konkret handelt es sich um einen 4-Byte-Zeiger, der auf nichts Wesentliches hinweist. Das System behandelt diese Abwesenheit als gültigen Zustand. Der Datensatz ist noch vollständig. Die Metadaten sind intakt.

Aber was passiert, wenn ein Benutzer tatsächlich etwas eingibt?

Die Zuordnung ändert sich. Für diese Kommentare reserviert die Datenbank keinen festen Puffer. Es wird nicht auf eine maximale Länge geschätzt und der Rest mit Nullbytes aufgefüllt. Das würde Platz verschwenden. Stattdessen berechnet das System die exakte Saitenlänge. Dann werden genau so viele Bytes zugewiesen.

Diese dynamische Zuordnung ist effizient. Es verhindert ein Aufblähen der Fragmentierung. Eine kurze Notiz benötigt einige Bytes. Ein langer Aufsatz braucht mehr. Der Zeiger im leeren Datensatz zeigt auf eine Nullreferenz, wodurch der Platzbedarf minimal bleibt. Die Datensätze mit Inhalt passen sich den Daten an. Nichts wird verschwendet. Nichts wird erzwungen.

Ist dies die einzige Möglichkeit, Text zu speichern? Nein. Aber es ist eine clevere Möglichkeit, Geschwindigkeit und Raum in Einklang zu bringen. Sie erhalten die Reaktionsfähigkeit von Headern fester Größe mit der Flexibilität von Daten variabler Länge. Es ist ein kleines Detail. Ein Mechaniker auf niedrigem Niveau. Aber es summiert sich, wenn Sie Millionen von Datensätzen verwalten. Die Datenbank atmet leichter. Die Festplattennutzung bleibt gering.

Und der Benutzer? Sie sehen den Zeiger nie. Sie sehen nur ihren Kommentar. Oder das Fehlen eines solchen. Die Komplexität bleibt verborgen. Die Lagerung ist optimiert. Das Ergebnis ist ein System, das sich leicht anfühlt, selbst wenn die Datenmenge wächst.