Felder (arrays) in C

In einem Feld werden mehrer Komponenten von gleichem Datentyp zu einem Objekt zusammengefasst. Die Anzahl der Komponenten muss bei der Deklaration angegeben werden und darf sich während der Laufzeit des Programmes nicht ändern. Der häufigste Fehler beim Umgang mit Feldern besteht im Zugriff auf Komponenten jenseits des deklarierten Bereichs, was zu unbestimmtem Verhalten des Programmes führen kann. Die unterschiedlichen Möglichkeiten zur Initialisierung eines Feldes werden vorgestellt. Felder können in beliebig vielen Dimensionen angelegt werden; besprochen werden hier nur eindimensionale Felder (Vektoren) und zweidimensionale Felder (Matrizen).

Einordnung des Artikels

Einfache Aufgaben zu den hier behandelten Konzepten finden sich in Programmier-Aufgaben in C: erste Schritte mit Feldern und Zeigern.

Einführung

Werden mehrere Objekte von identischem Datentyp (wie etwa int oder double) zu einem Objekt zusammengefasst, spricht man von einem Feld (array). Die Elemente des Feldes werden meist wie bei einem Vektor als Komponenten bezeichnet.

Einfacher Umgang mit Feldern, also die Deklaration, Initialisierung und der Zugriff auf die Komponenten, können auch ohne die Kenntnis von Zeigern realisiert werden. Sobald anspruchsvollere Aufgaben mit Feldern erledigt werden, wie etwa die Übergabe eines Feldes an eine Funktion, oder das Erzeugen eines Feldes durch eine Funktion, kommt man ohne die Kenntnis des Zusammenhangs zwischen Feldern und Zeigern nicht mehr aus.

Deklaration und Initialisierung von Feldern (arrays)

Deklaration von Feldern

Ein Feld fasst mehrere Größen von identischem Datentyp zu einem Objekt zusammen. Die Komponenten sind dann numeriert und man kann leicht über den Index auf sie zugreifen. Wird ein Feld deklariert, muss bereits bekannt sein, wie viele Komponenten das Feld besitzt.

Ein Feld, das dynamisch seine Größe ändert, ist nur mit einigen Verrenkungen zu realisieren. So ist etwa die Aufgabe, mit Hilfe eines Zufallsgenerators das Zufallsexperiment "so lange Würfeln bis die erste 6 erscheint" zu simulieren, vorerst nicht lösbar, da man vor dem Würfeln nicht weiß, welche Größe das Feld besitzen wird. In solchen Fällen behilft man sich damit, das Feld "groß genug" zu wählen oder man arbeitet sich in die sogenannte Speicher-Allokation ein.

Die Deklaration eines Feldes sieht typischerweise wie folgt aus:

int arr [5];

Hier wird ein Feld namens arr der Länge 5 deklariert, aber noch nicht initialisiert, das heißt die Komponenten sind noch nicht mit Werten belegt. Genauer gesagt wird hier der Speicherplatz für die 5 Komponenten reserviert und die Komponenten sind mit denjenigen Werten belegt, die sich zum Zeitpunkt der Deklaration gerade in den Speicherzellen befinden.

Es ist daher ratsam, ein Feld sofort nach (oder zusammen mit) der Deklaration zu initialisieren, also die Komponenten mit definierten Werten zu belegen. Wird ein Feld nicht sofort initialisiert, sollte man besser im Kommentar den Grund angeben, warum es erst später initialisiert wird. Mehr zur Initialisierung folgt im nächsten Unterabschnitt.

Eine Deklaration besteht aus drei Elementen:

  1. Der Datentyp der Komponenten des Feldes wird vereinbart (hier int ).
  2. Der Name des Feldes wird festgelegt (hier arr ).
  3. Die Anzahl der Komponenten wird festgelegt (hier 5 in den eckigen Klammern).

Abbildung 1: Versuch einer graphischen Darstellung der Deklaration eines Feldes. Abhängig von der Länge und des Datentypes des Feldes wird eine Anzahl von Speicherzellen geeigneter Größe reserviert. Angesprochen werden die Speicherzellen über den Namen des Feldes (mehr dazu bei Initialisierung von Feldern).Abbildung 1: Versuch einer graphischen Darstellung der Deklaration eines Feldes. Abhängig von der Länge und des Datentypes des Feldes wird eine Anzahl von Speicherzellen geeigneter Größe reserviert. Angesprochen werden die Speicherzellen über den Namen des Feldes (mehr dazu bei Initialisierung von Feldern).

Initialisierung von Feldern

Die Initialisierung eines Feldes kann auf verschiedene Weisen erfolgen, sie werden im Folgenden gezeigt. Es gibt auch einige Varianten, die zu Compiler-Fehlern führen, obwohl man vielleicht erwartet, dass sie möglich sind; sie werden zwar gezeigt, sind aber auskommentiert. Es wird empfohlen, sie zu testen und auf die Fehlermeldung des Compilers zu achten.

Sowohl bei der Initialisierung eines Feldes als auch beim Zugriff auf die Komponenten ist darauf zu achten, dass die Indizierung mit 0 beginnt; ein Feld mit 5 Komponenten besitzt also die Indizes 0, 1, 2, 3, 4. Wenn später der Zusammenhang zwischen Feldern und Zeigern hergestellt wird, lässt sich diese Vereinbarung leichter nachvollziehen.

# define LENGTH_ARRAY 5

int arr1 [5] = {2, 4, 6, 8, 10};
// 2  4  6  8  10 

int arr2 [] = {2, 4, 6, 8, 10};
// 2  4  6  8  10

int arr3 [LENGTH_ARRAY];
for (int i = 0; i < LENGTH_ARRAY; ++i) {
    arr3[i] = 2 * i;
}
// 0  2  4  6  8  

// int arr4 [5] = arr3;     // Compiler-Fehler: rechts darf kein Feld stehen

int arr5 [5];
//arr5 = {2, 4, 6, 8, 10};      // Compiler-Fehler: Zuweisung erfolgt zu spät

Zur Erklärung:

Zeile 1: Es wird die symbolische Konstante LENGTH_ARRAY definiert. Der Präprozessor sorgt dafür, dass jede Verwendung von LENGTH_ARRAY durch die Zahl 5 ersetzt wird; dies geschieht bereits, bevor das Programm kompiliert wird. Warum man dies macht, wird erklärt, wenn die Konstante LENGTH_ARRAY verwendet wird (siehe Zeile 9 und 10).

Zeile 3: Das Feld arr1 wird deklariert und sofort initialisiert. Die Initialisierung erfolgt in der sogenannten Initialisierungsliste, das heißt die Werte der Komponenten werden in geschweiften Klammern durch Kommas getrennt angegeben.

Zeile 6: Beim Einsatz der Initialisierungsliste ist es nicht zwingend erforderlich, die Anzahl der Komponenten in den eckigen Klammern anzugeben. Der Compiler erkennt sie an der Liste auf der rechten Seite der Zuweisung. Wurde in Zeile 3 die Länge des Feldes explizit angegeben, wird in Zeile 6 die Länge des Feldes implizit bestimmt.

Zeile 9: Deklaration des Feldes arr3 mit Hilfe der symbolischen Konstante LENGTH_ARRAY . Da in Zeile 1 die Konstante gleich 5 gesetzt wurde, ist für den Compiler diese Zeile nicht von int arr3 [5]; zu unterscheiden. Den Vorteil der Verwendung der Konstante erkennt man daran, wie hier das Feld initialisiert wird.

Zeile 10 bis 12: Initialisierung des Feldes arr3 in einer for-Schleife. Als Variable für die Indizes wird i vereinbart, sie läuft von 0 bis 4. In jedem Schleifendurchlauf wird eine Komponente des Feldes gesetzt.

An der Initialisierung von arr3 erkennt man, warum die Verwendung der symbolischen Konstante vorteilhaft sein kann: Möchte man das Programm später mit einer anderen Länge des Feldes verwenden, muss man nur an einer Stelle in den Quelltext eingreifen, nämlich in Zeile 1, und LENGTH_ARRAY abändern. Wie oben erklärt, eine dynamische Änderung der Länge des Feldes ist deutlich komplizierter.

Zeile 15: Bei fundamentalen Datentypen ist es nicht ungewöhnlich, Zuweisungen der Art x = y vorzunehemen (wenn x bereits deklariert wurde und y sogar initialisiert). Bei Feldern ist dies nicht möglich. Der Grund ist leicht einzusehen, wenn der Zusammenhang zwischen Feldern und Zeigern hergestellt wird.

Zeile 17 und 18: Damit sollte auch klar sein, dass die Initialisierung mit einer Initialisierungsliste fehlerhaft ist, wenn sie von der Deklaration abgetrennt wird. Es spielt keine Rolle, ob in der Zuweisung rechts in Zeile 16 eine Variable oder eine Initialisierungsliste steht.

In Abbildung 2 wird versucht, die Initialisierung eines Feldes graphisch darzustellen: Das Feld a ist vom Datentyp int und besitzt 5 Komponenten. Bei der Deklaration werden 5 geeignete Speicherzellen reserviert, die dann mit den Werten des Feldes gefüllt werden. Der Zugriff auf die Komponenten erfolgt mit Hilfe des Operators [ ] ; zu beachten ist dabei, dass die Zählung der Komponenten immer bei 0 beginnt.

Abbildung 2: Graphischen Darstellung der Initialisierung eines Feldes.Abbildung 2: Graphischen Darstellung der Initialisierung eines Feldes.

Aufgabe:

Testen Sie das Verhalten, wenn eine Initialisierung wie in Zeile 3 vorgenommen wird, aber die Länge des Feldes (in eckigen Klammern) und die Länge der Initialisierungsliste nicht übereinstimmen. (Es gibt zwei Fälle, je nachdem welche Länge größer ist.)

Initialisierung von Feldern durch Konsolen-Eingaben

Eine spezielle Initialisierung eines Feldes soll separat besprochen werden: Die Eingabe der Werte über die Konsole. Dies kann leicht zu einem Konflikt führen, nämlich dann wenn der Nutzer des Programmes die zuvor vereinbarte Länge des Feldes nicht kennt und er versucht, weitere Werte einzugeben.

Im folgenden Programm wird dieser Konflikt vermieden, indem die Eingabe innerhalb einer Schleife erfolgt, die mit der gewünschten Feldlänge konfiguriert wird:

# define LENGTH_ARRAY 5

int a[LENGTH_ARRAY];

for (int i = 0; i < LENGTH_ARRAY; ++i){
    puts("Geben Sie eine Komponente ein: ");
    scanf ("%d", & a[i]);
}

Zeile 1: Es wird wieder die Feldlänge als Konstante LENGTH_ARRAY definiert.

Zeile 3: Mit Hilfe der Konstante wird das Feld a deklariert, aber noch nicht initialisiert.

Zeile 5 bis 8: Die for-Schleife ist so konfiguriert, dass sie die Indizes von a durchläuft. Mit dieser Konstruktion ist man somit auf der sicheren Seite, um alle Indizes abzuarbeiten und keine unerlaubten Indizes aufzurufen. In Zeile 6 wird der Nutzer aufgefordert, die nächste Komponente des Feldes einzugeben. Die Funktion scanf() besitzt 2 Argumente:

Oben wurde bereits gesagt, wie man sich behelfen kann, wenn eigentlich ein Feld mit dynamisch veränderlicher Größe benötigt wird: Man setzt die Länge groß genug an; die restlichen Komponenten werden dann auf einen default-Wert gesetzt.

Das folgende Listing zeigt, wie man dies realisieren kann, wenn der Nutzer die Werte der Komponenten eingeben kann, aber im Voraus nicht bekannt ist, wie viele er eingeben wird:

  1. Das Feld wird wie üblich deklariert (Zeile 1 und 3).
  2. Wie in der Schleife aus dem letzten Listing kann der Nutzer die Werte eingeben.
  3. Der Unterschied besteht jetzt darin, dass eine Zahl vereinbart wird, die den Abbruch der Eingabe signalisiert (hier -1). Diese Zahl muss natürlich so gewählt werden, dass die in dem Feld nicht vorkommen kann (wie etwa die -1 beim Würfeln).
  4. Die Komponenten, die vom Nutzer nicht gesetzt werden, werden in einer anschließenden Schleife auf einen geeigneten default-Wert gesetzt (hier 0).
# define L 5

int a[L];

int i;
for (i = 0; i < L; ++i){
    puts("Geben Sie eine Komponente ein: ");
    puts("(Abbruch durch Eingabe von -1):\n");
    scanf ("%d", & a[i]);
    if (a[i] < 0 ) break;
}

for (int j = i; j < L; ++j){
    a[j] = 0;
}

Man beachte, wie wichtig es ist, die Zählvariable i der ersten Schleife bereits vor der Schleife zu deklarieren (Zeile 5); in den früheren Beispielen wurde die Zählvariable erst innerhalb der for-Anweisung deklariert. Denn man benötigt nach der i-Schleife den aktuellen Wert von i, bei dem die break-Anweisung aufgerufen wurde, um die die j-Schleife mit dem richtigen i-Wert fortzusetzen (Zeile 13).

Zugriff auf die Komponenten eines Feldes

Der einfachste Zugriff auf die Komponenten eines Feldes erfolgt über den Index, der in eckigen Klammern angegeben wird. Zu beachten ist dabei immer, dass bei einem Feld mit n Komponenten der Index die Werte 0, 1, ..., n - 1 annimmt.

Sollen sämtliche Werte eines Feldes ausgegeben oder weiterverarbeitet werden, iteriert man über den Index in einer Schleife wie sie oben zur Initialisierung verwendet wurde:

int a[] = {2, 4, 6, 8, 10};

for (int i = 0; i < L; ++i) {
    printf("%d ", a[i]);
}
// 2 4 6 8 10

Der häufigste Fehler: Zugriff auf nicht zulässige Komponenten

Der häufigste Fehler, der im Umgang mit Feldern gemacht wird, ist der Zugriff auf Komponenten außerhalb des deklarierten Bereichs. Oft ist die Ursache, dass man man annimmt, ein Feld mit 5 Komponenten besitzt eine Komponente mit Index 5.

Es ist wichtig zu wissen, welche Konsequenzen es hat, wenn ein derartiger unerlaubter Zugriff stattfindet. Dazu wird im folgenden Listing versucht, in einem Feld mit 5 Komponenten eine 6. Komponente zu setzen und diese abzufragen.

# define L 5

int a [L];
for (int i = 0; i == L; ++i) {
    a[i] = 2 * i;
}

for (int i = 0; i == L; ++i) {
    printf("Komponente %d : %d \n", i, a[i]);
}

Man beachte, dass jetzt in Zeile 4 und 8 jeweils i == L; steht, richtig wäre i < L . Dadurch wird in beiden Schleifen auf die Komponente a[5] zugegriffen, die es laut Deklaration von a nicht gibt. Der Compiler zeigt weder eine Warnung noch einen Fehler. Das Verhalten des Programmes ist dann unbestimmt: Wurden die entsprechenden Speicherzellen vorher durch andere Variable belegt, wird darauf zugegriffen. Falls nicht, wird das Programm nicht ausgeführt.

Es liegt daher stets in der Verantwortung des Programmierers, unerlaubte Zugriffe auszuschließen.

Zweidimensionale Felder

Überblick

Bisher wurden Felder wie Vektoren betrachtet: Sie besitzen einen Index und werden durch eine Liste initialisiert. In der Mathematik wird aber auch mit Matrizen, also zweidimensionalen Feldern, gearbeitet, wobei die Komponenten durch zwei Indizes charakterisiert werden. Und diese können zu n-dimensionalen Feldern verallgemeinert werden, für die n Indizes benötigt werden.

In C lassen sich Felder beliebiger Dimension realisieren; hier werden aber nur zweidimensionale Felder beschrieben. Wenn man allerdings die Deklaration und Initialisierung eines zweidimensionalen Feldes genauer betrachtet, erkennt man, inwiefern sie rekursiv aufgebaut ist, und für n-dimensionale Felder verallgemeinert werden kann.

Es wird hier auch nicht auf alle Spitzfindigkeiten eingegangen, die im Zusammenhang mit zweidimensionalen Felder zu beachten sind – die meisten wurden für eindimensionale Felder bereits erklärt.

Ebenso werden hier keine komplizierten Matrix-Operationen behandelt, wie Matrizen-Multiplikation, Gauß-Algorithmus zum Lösen von Gleichungssystemen oder Invertierung von Matrizen. Wer auf diesem Abstraktionsgrad arbeiten möchte, sollte mit C++ arbeiten und entsprechende Bibliotheken nachladen. Derartige Operationen selber zu programmieren ist viel zu aufwendig und fehleranfällig.

Stattdessen wird hier gezeigt:

Deklaration eines zweidimensionalen Feldes

Da jetzt zwei Indizes den Zugriff auf die Komponenten regeln, ist es nicht schwer zu erraten, wie ein zweidimensionales Feld deklariert wird:

int m [2] [3];

Der erste Index gibt die Zeile der Matrix an, der zweite Index die Spalte. Dies entspricht genau unserer Leserichtung: Man liest zuerst von links nach rechts und dann von oben nach unten.

Abbildung 3: Oben: Deklaration einer Matrix mit 2 Zeilen und 3 Spalten. Intern wird die Matrix wie ein eindimensionales Feld abgespeichert; über die deklarierten Zeilen- und Spalten-Anzahlen kann nach der Initialisierung der Zugriff auf die Feld-Komponenten über die Matrizen-Indizes realisiert werden. Unten: Initialisierung der Matrix mit einer verschachtelten Initialisierungsliste.Abbildung 3: Oben: Deklaration einer Matrix mit 2 Zeilen und 3 Spalten. Intern wird die Matrix wie ein eindimensionales Feld abgespeichert; über die deklarierten Zeilen- und Spalten-Anzahlen kann nach der Initialisierung der Zugriff auf die Feld-Komponenten über die Matrizen-Indizes realisiert werden. Unten: Initialisierung der Matrix mit einer verschachtelten Initialisierungsliste.

Initialisierung eines zweidimensionalen Feldes und Zugriff auf die Komponenten

Für die Initialisierung gibt es zwei Möglichkeiten:

Die beiden Möglichkeiten werden im Folgenden gezeigt. Es wird zweimal die Matrix m erzeugt, die auf der Hauptdiagonale 1 stehen hat, ansonsten 0 (siehe auch Abbildung 3):

// 1 0 0
// 0 1 0

1. Initialisierungsliste:

int m[2][3] = {{1, 0, 0}, {0, 1, 0}};

Intern wird die Matrix wie ein eindimensionales Feld behandelt, siehe Abbildung 3 unten.

Man erkennt an der Initialisierung des zweidimensionalen Feldes die rekursive Struktur der Initialisierungsliste: Die Matrix m besitzt 2 Zeilen und 3 Spalten.

Man kann sich daher leicht vorstellen, wie ein mehr-dimensionales Feld initialisiert wird.

Da die Matrix aus sehr vielen Nullen besteht, was in Anwendungen oft vorkommt, gibt es auch eine vereinfachte Version der Initialisierungsliste: Man schreibt die Zeilen nur bis zur letzten Komponente, die ungleich 0 ist. Die nachfolgenden Komponenten werden automatisch auf 0 gesetzt. (Man sieht hier wieder, wie wichtig die Deklaration ist: ohne die Kenntnis der Zeilen- und Spalten-Anzahlen könnte der Compiler die Zeilen nicht geeignet auffüllen.)

Identisch zur Initialisierung oben ist daher:

int m[2][3] = {{1}, {0, 1}};

2. Initialisierung in einer Doppel-Schleife:

int m[2][3] = {{1, 0, 0}, {0, 1, 0}};

for (int i = 0; i < 2; ++i) {
    for (int j = 0; j < 3; ++j) {
        if (i == j) {           // Hauptdiagonale
            m[i][j] = 1;
        } else {                // Elemente außerhalb der Hauptdiagonale
            m[i][j] = 0;
        }
    }
}

Man beachte die Iterationen:

Der Programmieraufwand ist hier deutlich höher als bei der Initialisierungsliste; bei Matrizen, deren Komponenten aber erst während der Laufzeit des Programmes berechnet werden, wird man aber an dieser Art der Initialisierung nicht vorbeikommen.

Der Zugriff auf die Komponenten einer Matrix erfolgt dann über die Angabe beider Indizes (Zeile 5):

int m[2][3] = {{1, 0, 0}, {0, 1, 0}};

for (int i = 0; i < 2; ++i) {
    for (int j = 0; j < 3; ++j) {
            printf("%d ", m[i][j]);
    }
}
// 1 0 0 0 1 0

Da sich die printf()-Anweisung im Inneren der beiden Schleifen befindet, werden alle Komponenten der Matrix in einer Zeile ausgegeben. Für eine zeilenweise Ausgabe müsste man in der äußeren Schleife den Zeilenumbruch erzwingen.