C programmieren: Arrays, Pointer, Records und Typdefinitionen

Autor / Redakteur: Prof. Dr. Christian Siemers * / Sebastian Gerstl

Ohne Datentypen funktioniert in C nichts. Elemente wie Arrays und Zeiger bzw Pointer oder Strukturen zählen zu den wichtigsten Bausteinen der Programmiersprache. Zum Abschluss der syntaktischen Elemente von C wollen wir daher auf diese Dateitypen näher eingehen - und auch den Präprozessor zur Code-Vorverarbeitung kurz erklären.

Syntaktische Elemente wie Arrays (bzw Vektoren) oder Pointer (bzw. Zeiger) sind beim Programmieren mit C essentiell.
Syntaktische Elemente wie Arrays (bzw Vektoren) oder Pointer (bzw. Zeiger) sind beim Programmieren mit C essentiell.
(Bild: gemeinfrei / CC0 )

Vektoren bzw. Arrays in C

Vektoren (meist Arrays, deutsch zuweilen auch Felder genannt) sind als Aggregate komplexe Datentypen, die aus einer Anreihung gleicher Elemente bestehen. Diese Elemente werden aufeinander folgend in Richtung aufsteigender Adressen im Speicher abgelegt, sie können einfache oder selbst auch wieder komplexe Datentypen darstellen. Die Adresse des Arrays ist identisch mit der Adresse des Elements mit der Nummer 0, denn in C werden die Elemente beginnend mit 0 durchnummeriert.

Ein Array wird deklariert mit dem Operator [ ], in dem die Dimensionsangabe, d.h. die Anzahl der Elemente steht. Die angegebene Anzahl muss eine vorzeichenlose integrale Konstante sein, eine Anzahl 0 ist nicht erlaubt. Der Bezeichner für ein Array ist fest mit seinem Typ verbunden, stellt aber kein Objekt im Speicher dar.

In Zusammenhang mit dem sizeof-Operator wird die Größe des Arrays als Anzahl von Einheiten des Typs char geliefert. Der Compiler sorgt für eine korrekte Ausrichtung im Speicher. Ein Beispiel:

int iv[10]; /* Ein Array iv von zehn Elementen vom Typ int */

Mehr Dimensionen sind möglich. Im Gegensatz zu einigen anderen Programmiersprachen gibt es in C jedoch keine echten mehrdimensionalen Arrays. Essentiell gibt es hier nur Arrays von Arrays (mit beliebiger – vielleicht von der Implementation oder verfügbarem Speicherplatz begrenzter – Anzahl der Dimensionen). Ein Beispiel für die Deklaration eines zweidimensionalen Arrays:

double dvv[5][20]; /* Array von 5 Arrays von je 20 doubles */

Die Ablage im Speicher erfolgt hierbei zeilenweise (bei zwei Dimensionen), d.h. der der rechte Index (der Spaltenindex bei zwei Dimensionen) variiert am schnellsten, wenn die Elemente gemäß ihrer Reihenfolge im Speicher angesprochen werden. Auf die Elemente zugegriffen wird mit dem Operator [ ], in dem nun der Index steht, für ein Array mit n Elementen reicht der erlaubte Indexbereich von 0 bis n-1. Ein Beispiel:

abc = iv[3]; /* Zuweisung des 4. Elements von iv an abc */
xyz = dvv[i][j]; /* Zuweisung aus dvv, i und j sind Laufvariablen */

Die Schrittweite des Index ist so bemessen, dass immer das jeweils nächste – oder vorherige – Element erfasst wird. Es ist erlaubt, negative Indizes oder solche größer als n-1 zu verwenden. Was dann jedoch beim Zugriff außerhalb des erlaubten Bereichs des so indizierten Arrays passiert, ist implementationsabhängig (und generell nicht zu empfehlen!).

Zeiger bzw. Pointer

Zeiger, auch englisch Pointer genannt, sind komplexe Datentypen. Sie beinhalten sowohl die Adresse des Typs, auf den sie zeigen, als auch die Eigenschaften eben dieses Typs, insbesondere, wichtig für die mit ihnen verwendete Adressarithmetik, seine Speichergröße. Zeiger werden deklariert mittels des Operators *.

int *ip; /* Zeiger auf die Variable ip vom Typ int */

Pointer müssen initialisiert werden, bevor sie zum Zugriff auf die mit ihnen bezeigten Objekte benutzt werden können:

ip = &abc; /* ip wird auf die Adresse von abc gesetzt */

Zeiger können nur mit Zeigern gleichen Typs initialisiert werden, oder mit Zeigern auf void, (also auf nichts bestimmtes). Zum Initialisieren von Zeigern wird meist der Adressoperator & verwendet, der einen Zeiger auf seinen Operanden erzeugt. In einem Zuweisungszusammenhang gilt der Name eines Arrays als Zeiger auf das erste Element (das mit dem Index 0) des Arrays, d.h. wenn wie im Beispiel weiter oben iv ein Array vom Typ int ist:

ip = iv; /* gleichwertig mit ip = &iv[0] */

Der Zugriff auf das vom Zeiger referenzierte Objekt, (die sog. Dereferenzierung), geschieht mittels des Operators *:

if(*ip) /* Test des Inhalts der Variable, auf die ip zeigt */

Wenn ip auf iv zeigt, dann ist *ip identisch mit iv[0], man hätte auch schreiben können ip[0] oder *iv. Hier zeigt sich nun der grundlegende Zusammenhang zwischen Array- und Zeigernotation in der Sprache C. Es gilt: a[n] ist identisch mit *(a+n)

Zu beachten ist hierbei lediglich, dass Arraynamen in einem Zugriffskontext feste Zeiger sind (Adressen), sie stellen kein Objekt im Speicher dar und können somit auch nicht verändert werden, wohingegen Zeigervariablen Objekte sind. Ein Pointer kann inkrementiert und dekrementiert werden, d.h. integrale Größen können addiert oder subtrahiert werden. Der Zeiger zeigt dann auf ein dem Vielfachen seiner Schrittweite entsprechend entferntes Objekt.

Zeiger gleichen Typs dürfen miteinander verglichen oder voneinander subtrahiert werden. Wenn sie in den gleichen Bereich (z.B. ein entsprechend deklariertes Array) zeigen, ergibt sich eine integrale Größe, die den Indexabstand der so bezeigten Elemente bedeutet. Wenn das nicht der Fall ist, ist diese Operation nicht sinnvoll. Erlaubt (und häufig angewandt) ist auch das Testen des Wertes eines Zeigers.

Die Zuweisung integraler Werte an einen Zeiger hat die Bedeutung einer Adresse des vom Zeiger bezeigten Typs. Wenn die Bedingungen der Ausrichtung dieses Typs (z.B. ganzzahlige Vielfache einer bestimmten Größe) nicht erfüllt sind oder der Zugriff auf diese Adresse in der entsprechenden Typgröße nicht erlaubt sein sollte, kann dies zu Laufzeitfehlern führen. Der Wert 0 eines Zeigers hat die Bedeutung, dass dieser Zeiger ungültig ist, ein sogenannter Nullzeiger (null pointer). Der Zugriff auf die Adresse 0 ist in einem C-System, gleich ob lesend oder schreibend, allgemein nicht gestattet.

Zeiger auf den Typ void (also auf nichts bestimmtes) dienen als generische Pointer lediglich zur Zwischenspeicherung von Zeigern auf Objekte bestimmten Typs. Man kann sonst nichts sinnvolles mit ihnen anfangen, auch keine Adressberechnungen. Sie dürfen ohne weiteres allen Typen von Zeigern zugewiesen werden und umgekehrt.

Initialisierung von Arrays

Wenn erwünscht, können Arrays durch Angabe einer Initialisierungsliste mit konstanten Werten initialisiert werden, hierbei darf dann die Dimensionsangabe fehlen, man spricht dann von einem unvollständigen Arraytyp (incomplete array type), und der Compiler errechnet sie selbsttätig aus der Anzahl der angegebenen Elemente der Liste (und komplettiert damit den Typ!):

int magic[] = {4711, 815, 7, 42, 3}; /* magic hat 5 Elem. */

Ist die Dimension angegeben, werden die Elemente beginnend mit dem Index 0 mit den Werten aus der Liste initialisiert und der Rest, so vorhanden, wird auf 0 gesetzt:

long prim[100] = {2, 3, 5, 7, 11}; /* ab Index 5 alles 0 */

Die Dimensionsangabe darf nicht geringer als die Anzahl der Elemente der Initialisierungsliste sein:

float spec[2] = {1.414, 1.618, 2.718}; /* Fehler! */

Die Initialisierung geht auch bei mehr als einer Dimension, hier darf nur die höchste (linke) Dimension fehlen, der Compiler errechnet sie dann:

int num[][3] = {{1, 2, 3}, {4, 5, 6}, {7, 8, 9}}; /* 3 * 3 */

Sind alle Dimensionen angegeben und sind weniger Initialisierer da, werden die restlichen Elemente wie gehabt mit 0 initialisiert:

int num[3][3] = {{1, 2, 3}, {4, 5, 6}}; /* 3 * 3 */

Hier – oder im obigen Beispiel – hätte man die inneren geschweiften Klammern auch weglassen können, denn der Compiler füllt bei jeder Dimension beginnend mit dem Index 0 auf, wobei der rechte Index am schnellsten variiert. Oft besteht jedoch die Gefahr der Mehrdeutigkeit. Hilfreiche Compiler warnen hier!

Bei der Initialisierung von char-Arrays mit konstanten Zeichenketten darf man die geschweiften Klammern weglassen. Also:

char mword[] = „Abrakadabra“; /* mword hat 12 Elemente */

anstatt:

char mword[] = {‘A‘,‘b‘,‘r‘,‘a‘,‘k‘,‘a‘,‘d‘,‘a‘,‘b‘,‘r‘,‘a‘,‘\0‘};

oder:

char mword[] = {“Abrakadabra“};

Auch hier zählt der Compiler wieder die Anzahl der Elemente ab (inklusive der terminierenden Null) und dimensioniert das Array selbsttätig. Eine eventuell vorhandene Dimensionsangabe muss mindestens der erforderlichen Anzahl entsprechen. Überzählige Elemente werden auch hier mit 0 aufgefüllt:

char name[64] = „Heiner Mueller“; /* Ab Index 14 alles 0 */

Man beachte folgenden wichtigen Unterschied:

char xword[] = „Hokuspokus“; /* xword hat 11 Elemente */
char xptr = „Hokuspokus“; /* xptr zeigt auf Zeichenkettenkonstante */

Im ersten Fall handelt es sich um ein Array namens xword von 11 Elementen in Form eines C-Strings (mit terminierender Null), im zweiten Fall haben wir mit xptr einen Zeiger, der auf einen an anderer Stelle (möglicherweise im Nur-Lesebereich) gespeicherten C-String (jetzt als namenloses Array vom Typ char) zeigt.

Strukturen bzw. Records in C

Eine Struktur (in anderen Sprachen oft als record, Verbund, Datensatz bezeichnet) ist als Aggregat ein komplexer Datentyp, der aus einer Anreihung von einer oder mehreren Komponenten (members) oft auch verschiedenen Typs besteht, um diese so zusammengefassten Daten dann als Einheit behandeln zu können.

Eine Struktur wird definiert mit dem Schlüsselwort struct, gefolgt von einem Block mit den Deklarationen der Komponenten. Beispiel:

struct person {
    int num;
    char name[64];
    char email[64];
    char telefon[32];
    char level;
};

Hier werden mit dem Schlüsselwort struct und dem Bezeichner person, dem sog. Etikett (structure tag), zusammengehörige Daten in einer Struktur zusammen-gefasst: Es wird ein neuer, benutzerdefinierter Datentyp namens struct person geschaffen.

Die Namen der in dem Strukturblock deklarierten Komponenten befinden sich in einem eigenen Namensraum und können nicht mit anderen (äußeren) Namen oder Namen von Komponenten in anderen Strukturen kollidieren. Es wird hierbei auch noch kein Speicherplatz reserviert, sondern lediglich der Typ bekannt gemacht, seine Form beschrieben, also ein Bauplan zur Beschaffenheit dieses Typs und seiner Struktur vorgelegt.

Speicherplatz kann reserviert und somit Variablen dieses Typs erzeugt werden, indem man zwischen der beendenden geschweiften Klammer des Strukturblocks und dem abschließenden Semikolon eine Liste von Variablennamen einfügt. Übersichtlicher ist wohl aber meist, die Beschreibung der Form von der Speicherplatzreservierung zu trennen. Variablen dieses Typs werden dann z.B. so vereinbart:

struct person hugo, pp; /* 1 Variable und ein Zeiger */

Man kann natürlich auch gleich ganze Arrays von diesem neuen Typ erzeugen:

struct person ap[100]; /* Array von 100 struct person */

Der Compiler sorgt dafür, dass die Komponenten der Strukturen in der Reihenfolge ihrer Deklaration mit der korrekten Ausrichtung angelegt werden und dass die Gesamtheit der Struktur so gestaltet ist, dass sich mehrere davon als Elemente eines Arrays anreihen lassen.

Je nach Gestalt der Struktur, abhängig von Maschinenarchitektur und Compiler können dabei zwischen den Komponenten und am Ende der Struktur auch Lücken entstehen, so dass die Gesamtgröße einer Struktur (zu ermitteln mithilfe des sizeof-Operators) unter Umständen größer ist als die Summe der Größen ihrer Komponenten. Der Speicherinhalt der so entstandenen Lücken bleibt dabei undefiniert.

Auf die Komponenten zugegriffen wird direkt mit dem .-Operator:

hugo.num = 4711; /* Schreibzugriff auf Komp. num von hugo */

Der indirekte Zugriff (über Zeiger) geschieht mithilfe des ->-Operators:

pp = &hugo;
pp->level = 12; /* Zugriff auf Komponente level von hugo */

Oder entsprechend bei Zugriff auf ein Element eines Arrays:

ap[5].num = 4712; printf( „%d“, (ap+5)->num );

Strukturen können selbst auch wieder (andere) Strukturen als Komponenten enthalten. Erlaubt ist auch die Definition von Strukturen innerhalb des Strukturdefinitionsblocks – dieser Typ ist dann allerdings auch im Sichtbarkeitsbereich der ein-bettenden Struktur bekannt, daher sollte dies besser vermieden werden.

Wenn die Definition des Strukturblocks nicht erfolgt oder noch nicht abgeschlossen ist, spricht man von einem unvollständigen (incomplete) Datentyp. Davon lassen sich dann zwar keine Variablen erzeugen – Speicherplatzverbrauch und Gestalt sind ja noch unbekannt, es lassen sich aber schon Zeiger auf diesen Typ erstellen. Auf diese Weise können Strukturen Zeiger auf ihren eigenen Typ enthalten, eine Konstruktion, die oft zur Erzeugung von verketteten Listen verwandt wird. Beispiel:

struct mlist {
    struct mlist *prev;
    struct mlist *next;
    char descr[64];
};

Strukturen können (an Variablen gleichen Typs) zugewiesen werden, als Argumente an Funktionen übergeben und als Rückgabetyp von Funktionen deklariert werden. Die Zuweisung ist dabei als komponentenweise Kopie definiert. Bei größeren Strukturen empfiehlt sich bei den beiden letzteren Aktionen allerdings, lieber mit Zeigern zu arbeiten, da sonst intern immer über temporäre Kopien gearbeitet wird, was sowohl zeit- wie speicherplatzaufwendig wäre. Strukturvariablen lassen sich ähnlich wie Arrays mit Initialisierungslisten initialisieren.

Syntaktisch ähnlich einer Struktur ist die Variante oder Union (union), mit dem Unterschied, dass die verschiedenen Komponenten nicht nacheinander angeordnet sind, sondern alle an der gleichen Adresse liegend abgebildet werden. Vereinbart werden sie mit dem Schlüsselwort union, gefolgt von einem optionalen Etikett, gefolgt von einem Definitionsblock mit den Definitionen der Komponenten, gefolgt von einem Semikolon. Sie werden benutzt, um Daten unterschiedlichen Typs am gleichen Speicherplatz unterbringen zu können (natürlich immer nur einen Typ zur gleichen Zeit!), oder um den Speicherplatz anders zu interpretieren

Der Compiler sorgt dafür, dass die Größe der Union, ihre Ausrichtung inklusive etwaiger Auffüllung den Anforderungen der Maschine entsprechen, daher ist die Größe einer Unionsvariablen immer mindestens so groß wie die Größe ihrer größten Komponente.

Bitfelder

Als mögliche Komponenten von struct oder union können Bitfelder vereinbart werden. Ein Bitfeld dient zur Zusammenfassung von Information auf kleinstem Raum (nur erlaubt innerhalb struct oder union). Es gibt drei Formen von Bitfeldern:

  • normale Bitfelder (plain bitfields ) – deklariert als int
  • vorzeichenbehaftete (signed bitfields ) – deklariert als signed int
  • nicht vorzeichenbehaftete (unsigned bitfields ) – deklariert als unsigned int

Ein Bitfeld belegt eine gewisse, aufeinander folgende Anzahl von Bit in einem Integer. Es ist nicht möglich, eine größere Anzahl von Bit zu vereinbaren, als in der Speichergröße des Typs int Platz haben. Es darf auch unbenannte Bitfelder geben, auf die man dann natürlich nicht zugreifen kann, dies dient meist der Abbildung der Belegung bestimmter Register oder Ports. Hier die Syntax:

struct sreg {
    unsigned int
    cf:1, of:1, zf:1, nf:1, ef:1, :3, im:3, :2, sb:1, :1, tb:1;
};

Nach dem Doppelpunkt steht die Anzahl der Bit, die das Feld belegt. Wie der Compiler die Bitfelder anlegt, wie er sie ausrichtet und wie groß er die sie enthaltenden Integraltypen macht, ist völlig implementationsabhängig. Wenn man sie überhaupt je verwenden will, wird empfohlen, sie jedenfalls als unsigned int zu deklarieren.

Aufzählungstypen in C

Aufzählungstypen – Schlüsselwort enum – sind benannte Ganzzahlkonstanten (enumeration constants), deren Vereinbarungssyntax der von Strukturen ähnelt. Im Gegensatz zu mit #define vereinbarten Konstanten, die der C-Präprozessor verarbeitet, werden die enum-Konstanten vom C-Compiler selbst bearbeitet. Auf den C-Präprozessor werden wir im nächsten Artikel näher eingehen.

Sie sind kompatibel zum Typ, den der Compiler dafür wählt – einen Typ, aufwärts-kompatibel zum Typ int: Es könnte also auch char oder short sein, aber nicht long, das ist implementationsabhängig – und lassen sich ohne weiteres in diesen überführen und umgekehrt, ohne dass der Compiler prüft, ob der Wert auch im passenden Bereich liegt. Hier einige Beispiele zur Deklaration, bzw. Definition:

enum color {red, green, blue} mycolor, hercolor;
enum month {JAN=1, FEB, MAR, APR, MAY, JUN, JUL, AUG, SEP, OCT, NOV, DEC}; enum month mymonth;
enum range {VLO=-10, LLO=-5, LO=-2, ZERO=0, HI=2, LHI=5, VHI=10, OVL};
enum range myrange, hisrange;
enum level {AF=-3, BF, CF, DF, EF, FF, GF, HF} xx, yy, zz;

Bei aller semantischen Nähe zum Typ int sind enum-Konstanten oft der beste Weg, um mittels benannter Konstanten das Programm übersichtlicher zu machen und „magische“ Zahlen (magic numbers) zu vermeiden, besser oft als die übliche Methode der #define-Makros und daher für diesen Zweck sehr zu empfehlen. Diese Art der Verwendung funktioniert natürlich nur für Ganzzahlkonstanten, die den Wertebereich eines int nicht überschreiten.

Typdefinitionen

Das Schlüsselwort ist typedef. Der Name lässt es zwar vermuten, aber typedef dient nicht zur Definition neuer Datentypen, er erzeugt syntaktisch nur andere Namen (Synonyme, Aliasse) für schon bekannte Typen. Das kann, richtig angewandt, zur erhöhten Lesbarkeit des Quelltextes genutzt werden. Einerseits wird typedef dazu benutzt, komplizierte oder umständliche Deklarationen zu vereinfachen, andererseits kann durch geschickten Einsatz die Portabilität von Programmcode auf unterschiedliche Umgebungen erhöht werden. Der so erzeugte „neue“ Typ ist mit seinem Ursprungstyp voll kompatibel und syntaktisch quasi-identisch. Die Syntax ist:

typedef bekannter-Typ neuer-Typname ;

Ein Beispiel:

typedef int int32;
typedef short int16;
typedef signed char int8;

Bei einigen Elementen sind bereits die Begriffe „Präprozessor“ oder „Bibliothek“ erwähnt. Dabei handelt es sich nicht mehr länger um die „C-Grammatik“, vielmehr stellen sie eigene, essentielle Bestandteile der Programmiersprache dar. Im nächsten Kapitel werden wir daher näher darauf eingehen, was es mit der dem C-Präprozessor und der Standardbibliothek von C auf sich hat.

Hinweise: Dieser Beitrag, mit Ausnahme des letzten Absatzes, ist Copyright © Bernd Rosenlechner 2007-2010. Dieser Text kann frei kopiert und weitergegeben werden unter der Lizenz Creative Commons – Namensnennung – Weitergabe unter gleichen Bedingungen (CC – BY – SA) Deutschland 2.0.

Dieser Beitrag findet sich zudem im Handbuch „Embedded Systems Engineering“ im Kapitel „Einführung in die Sprache C“.

* Prof. Dr. Christian Siemers lehrt an der Technischen Universität Clausthal und arbeitet dort am Institut für Elektrische Informationstechnik (IEI).

Jetzt Newsletter abonnieren

Täglich die wichtigsten Infos zu Softwareentwicklung und DevOps

Mit Klick auf „Newsletter abonnieren“ erkläre ich mich mit der Verarbeitung und Nutzung meiner Daten gemäß Einwilligungserklärung (bitte aufklappen für Details) einverstanden und akzeptiere die Nutzungsbedingungen. Weitere Informationen finde ich in unserer Datenschutzerklärung.

Aufklappen für Details zu Ihrer Einwilligung

Artikelfiles und Artikellinks

(ID:45482273)