vorheriges KapitelInhaltsverzeichnisStichwortverzeichnisFeedbacknächstes Kapitel


Woche 2

Tag 12


Arrays und verkettete Listen

In den vorherigen Kapiteln haben Sie einzelne Elemente vom Typ int, char oder andere Einzelobjekte deklariert. Oftmals wünscht man sich aber eine Sammlung von Objekten, wie etwa 20 Ganzzahlen oder ein Körbchen voller Katzen (CAT-Objekte). Heute lernen Sie,

Was ist ein Array?

Ein Array ist eine Zusammenfassung von Speicherstellen für Daten desselben Datentyps. Die einzelnen Speicherstellen bezeichnet man als Elemente des Array.

Man deklariert ein Array, indem man den Typ, gefolgt vom Namen des Array und dem Index schreibt. Der Index bezeichnet bei der Deklaration die Anzahl der Elemente im Array und wird in eckige Klammern eingeschlossen.

Zum Beispiel deklariert

long LongArray[25];

ein Array namens LongArray mit 25 Zahlen des Typs long. Der Compiler reserviert bei dieser Deklaration einen Speicherbereich für die Aufnahme aller 25 Elemente. Da jede Zahl vom Typ long genau 4 Byte erfordert, reserviert diese Deklaration im Speicher 100 aufeinanderfolgende Bytes (Abbildung 12.1).

Abbildung 12.1:  Ein Array deklarieren

Array-Elemente

Auf die Elemente des Array greift man zu, indem man einen Index als Offset (Verschiebung) vom Array-Anfang angibt. Die Durchzählung der Array-Elemente beginnt dabei mit 0. Das erste Element des Array ist demzufolge arrayName[0]. Für das LongArray -Beispiel wäre also LongArray[0] das erste Array-Element, LongArray[1] das zweite usw.

Diese Zählweise ist genau zu beachten. Das Array EinArray[3] enthält drei Elemente: EinArray[0], EinArray[1] und EinArray[2]. Allgemein ausgedrückt, verfügt EinArray[n] über n Elemente, die von EinArray[0] bis EinArray[n-1] durchnumeriert sind.

Die Elemente in LongArray[25] sind demnach von LongArray[0] bis LongArray[24] numeriert. Listing 12.1 zeigt, wie man ein Array mit fünf Ganzzahlen deklariert und jedes Element mit einem Wert füllt.

Listing 12.1: Ein Array für Integer-Zahlen

1:     // Listing 12.1 - Arrays
2: #include <iostream.h>
3:
4: int main()
5: {
6: int myArray[5];
7: int i;
8: for (i=0; i<5; i++) // 0-4
9: {
10: cout << "Wert fuer myArray[" << i << "]: ";
11: cin >> myArray[i];
12: }
13: for (i = 0; i<5; i++)
14: cout << i << ": " << myArray[i] << "\n";
15: return 0;
16: }

Wert fuer myArray[0]:  3
Wert fuer myArray[1]: 6
Wert fuer myArray[2]: 9
Wert fuer myArray[3]: 12
Wert fuer myArray[4]: 15

0: 3
1: 6
2: 9
3: 12
4: 15

Zeile 6 deklariert das Array myArray, das fünf Integer-Variablen aufnimmt. Zeile 8 richtet eine Schleife ein, die von 0 bis 4 zählt und damit genau die Indizes zum Durchlaufen eines fünfelementigen Array erzeugt. Der Anwender gibt einen Wert ein, den das Programm am richtigen Offset im Array speichert.

Der erste Wert kommt nach myArray[0], der zweite nach myArray[1] und so weiter. Die zweite for-Schleife gibt alle Werte auf dem Bildschirm aus.

Die Indizierung von Arrays beginnt bei 0 und nicht bei 1. Hier liegt die Ursache für viele Fehler, die gerade Neueinsteigern in C++ unterlaufen. Man sollte immer daran denken, daß ein Array mit zehn Elementen von ArrayName[0] bis ArrayName[9] numeriert wird und es kein Element ArrayName[10] gibt.

Über das Ende eines Array schreiben

Wenn Sie einen Wert in einem Array speichern, errechnet der Compiler anhand der Größe der Elemente und dem angegebenen Index die Position im Array, an der der Wert zu speichern ist. Angenommen, es ist ein Wert in LongArray[5] - also im sechsten Element - einzutragen. Der Compiler multipliziert den Offset - 5 - mit der Größe der Einzelelemente - in diesem Beispiel 4. Es ergibt sich eine Verschiebung (Offset) von 20 Byte bezüglich des Array-Beginns. An diese Stelle schreibt das Programm den Wert.

Wenn Sie in das Element LongArray[50] schreiben, ignoriert der Compiler die Tatsache, daß es kein derartiges Element gibt, berechnet die Verschiebung zum ersten Element - 200 Byte - und schreibt den Wert an diese Speicherstelle - ohne Rücksicht darauf, was an dieser Speicherstelle bereits abgelegt ist. Dort können nahezu beliebige Daten stehen, so daß das Überschreiben mit den neuen Daten zu unvorhersehbaren Ergebnissen führen kann. Wenn man Glück hat, stürzt das Programm sofort ab. Andernfalls erhält man wesentlich später im Programm seltsame Ergebnisse und kann nur schwer herausfinden, was hier schiefgegangen ist.

Der Compiler verhält sich wie ein Blinder, der die Entfernung von seinem Haus abschreitet. Er beginnt beim ersten Haus, Hauptstrasse[0]. Wenn man ihn bittet, zum sechsten Haus in der Hauptstraße zu gehen, denkt er sich: »Ich muß noch weitere fünf Häuser gehen. Jedes Haus ist vier große Schritte lang. Ich gehe also noch weitere 20 Schritte.« Bittet man ihn, zur Hauptstrasse[100] zu gehen, und die Hauptstraße hat nur 25 Häuser, wird er 400 Schritte zurücklegen. Bevor er allerdings an seinem Ziel ankommt, läuft er vielleicht in einen fahrenden Bus. Passen Sie also auf, wo Sie den Mann hinschicken.

Listing 12.2 zeigt, was passiert, wenn Sie über das Ende eines Array hinausschreiben.

Führen Sie dieses Programm nicht aus. Es kann einen Systemabsturz auslösen!

Listing 12.2: Über das Ende eines Array schreiben

1:     //Listing 12.2
2: // Demonstriert, was passiert, wenn Sie über das Ende
3: // eines Array hinausschreiben
4:
5: #include <iostream.h>
6: int main()
7: {
8: // Waechter
9: long sentinelOne[3];
10: long TargetArray[25]; // zu fuellendes Array
11: long sentinelTwo[3];
12: int i;
13: for (i=0; i<3; i++)
14: sentinelOne[i] = sentinelTwo[i] = 0;
15:
16: for (i=0; i<25; i++)
17: TargetArray[i] = 0;
18:
19: cout << "Test 1: \n"; // aktuelle Werte testen (sollten 0 sein)
20: cout << "TargetArray[0]: " << TargetArray[0] << "\n";
21: cout << "TargetArray[24]: " << TargetArray[24] << "\n\n";
22:
23: for (i = 0; i<3; i++)
24: {
25: cout << "sentinelOne[" << i << "]: ";
26: cout << sentinelOne[i] << "\n";
27: cout << "sentinelTwo[" << i << "]: ";
28: cout << sentinelTwo[i]<< "\n";
29: }
30:
31: cout << "\nZuweisen...";
32: for (i = 0; i<=25; i++)
33: TargetArray[i] = 20;
34:
35: cout << "\nTest 2: \n";
36: cout << "TargetArray[0]: " << TargetArray[0] << "\n";
37: cout << "TargetArray[24]: " << TargetArray[24] << "\n";
38: cout << "TargetArray[25]: " << TargetArray[25] << "\n\n";
39: for (i = 0; i<3; i++)
40: {
41: cout << "sentinelOne[" << i << "]: ";
42: cout << sentinelOne[i]<< "\n";
43: cout << "sentinelTwo[" << i << "]: ";
44: cout << sentinelTwo[i]<< "\n";
45: }
46:
47: return 0;
48: }

Test 1:
TargetArray[0]: 0
TargetArray[24]: 0

SentinelOne[0]: 0
SentinelTwo[0]: 0
SentinelOne[1]: 0
SentinelTwo[1]: 0
SentinelOne[2]: 0
SentinelTwo[2]: 0

Zuweisen...
Test 2:
TargetArray[0]: 20
TargetArray[24]: 20
TargetArray[25]: 20

SentinelOne[0]: 20
SentinelTwo[0]: 0
SentinelOne[1]: 0
SentinelTwo[1]: 0
SentinelOne[2]: 0
SentinelTwo[2]: 0

Die Zeilen 9 und 11 deklarieren zwei Arrays mit jeweils drei Integer, die als Sentinel (Wächter) für TargetArray fungieren. Diese Sentinel-Arrays werden mit dem Wert 0 initialisiert. Wenn Speicherplatz über das Ende von TargetArray hinaus beschrieben wird, sollten sich die Werte in den Sentinels ändern. Einige Compiler zählen dabei den Speicher hoch, andere zählen runter. Aus diesem Grunde stehen die Sentinels zu beiden Seiten von TargetArray.

In Test 1 werden die Sentinel-Werte zur Kontrolle ausgegeben (Zeilen 19 bis 29). Zeile 33 initialisiert alle Elemente von TargetArray mit 20, aber der Zähler zählt für TargetArray bis zum Offset 25, der für TargetArray gar nicht existiert.

Die Zeilen 36 bis 38 geben als Teil von Test 2 einige Werte von TargetArray aus. Beachten Sie, daß es TargetArray keine Probleme bereitet, den Wert 20 auszugeben. Wenn jedoch SentinelOne und SentinelTwo ausgegeben werden, sieht man, daß sich der Wert von SentinelOne geändert hat. Das liegt daran, daß der Speicherplatz, der 25 Elemente hinter TargetArray[0] liegt, von SentinelOne[0] eingenommen wird. Mit dem Zugriff auf das nicht existierende Element TargetArray[25] wurde daher auf SentinelOne[0] zugegriffen.

Dieser unangenehme Fehler ist ziemlich schwer zu finden, da der Wert von SentinelOne[0] in einem Codeabschnitt geändert wurde, der überhaupt nicht in SentinelOne schreibt.

Dieser Code verwendet »magische Zahlen«, wie 3 für die Größe des Sentinel-Array und 25 für die Größe von TargetArray. Es ist sicherer, Konstanten zu verwenden, so daß alle diese Werte an einer Stelle geändert werden können.

Beachten Sie, daß jeder Compiler Speicherplatz unterschiedlich verwendet. Deshalb können Ihre Ergebnisse von den hier gezeigten Werten abweichen.

Fence Post Errors

Es kommt so häufig vor, daß man die Grenzen des Array um genau ein Element überschreitet, daß dieser Fehler sogar einen eigenen Namen bekommen hat: Fence Post Error, zu deutsch etwa »Zaunpfahlfehler«. Bei Denkspielen, in denen es um Schnelligkeit geht, begegnet man des öfteren der Frage, wie viele Zaunpfähle für einen Zaun von 10 Meter Länge erforderlich sind, wenn man einen Pfahl pro Meter benötigt. Die unüberlegte Antwort lautet oft 10, obwohl es natürlich 11 sind. Abbildung 12.2 verdeutlicht das.

Abbildung 12.2:  Fence Post Errors

Gleichermaßen liegt man bei der intuitiven Indizierung des letzten Array-Elements meist daneben, und unzählige Programmierer haben sich darüber schon die Haare gerauft. Mit der Zeit geht es aber in Fleisch und Blut über, daß ein 25elementiges Array nur bis zum Element 24 numeriert ist, und daß man beim Zählen bei 0 beginnen muß. (Programmierer wundern sich oft, warum Häuser keine 0. Etage haben. In der Tat sind manche so schlau, die Taste 4 zu drücken, wenn sie in den fünften Stock möchten.)

Einige Programmierer bezeichnen ArrayName[0] als das nullte Element. Diese Angewohnheit ist nicht zur Nachahmung zu empfehlen. Wenn ArrayName[0] das nullte Element ist, was ist dann ArrayName[1]? Das erste? Wenn ja, sind Sie dann noch imstande zu erkennen, daß sich hinter ArrayName[24] nicht das 24ste, sondern das 25ste Element verbirgt? Wesentlich besser ist es zu sagen, daß ArrayName[0] den Offset 0 hat und es sich um das erste Element handelt.

Arrays initialisieren

Einfache Arrays von vordefinierten Typen wie int-Zahlen oder Zeichen (char) kann man bei der Deklaration direkt initialisieren. Dazu tippt man hinter den Array-Namen ein Gleichheitszeichen und eine in geschweifte Klammern eingeschlossene Liste mit den durch Komma getrennten Werten ein. Beispielsweise deklariert

int IntegerArray[5] = { 10, 20, 30, 40, 50 };

das Array IntegerArray mit fünf Ganzzahlen. Das Element IntegerArray[0] erhält den Wert 10 zugewiesen, das Element IntegerArray[1] den Wert 20 und so weiter.

Wenn man die Größe des Array nicht angibt, wird ein Array erzeugt, das gerade groß genug ist, um die spezifizierten Initialisierungswerte aufzunehmen. Schreibt man beispielsweise

int IntegerArray[] = { 10, 20, 30, 40, 50 };

erzeugt man exakt das gleiche Array wie im vorherigen Beispiel.

Muß man die Größe des Array ermitteln, kann man den Compiler die Größe berechnen lassen. Zum Beispiel setzt

const USHORT IntegerArrayLength;
IntegerArrayLength = sizeof(IntegerArray)/sizeof(IntegerArray[0]);

die konstante USHORT-Variable IntegerArrayLength auf das Ergebnis, das man aus der Division der Gesamtgröße des Array durch die Größe eines einzelnen Elements des Array erhält. Der Quotient gibt die Anzahl der Elemente im Array an.

Die Initialisierungsliste darf nicht mehr Werte enthalten, als Elemente für das Array deklariert wurden. Die Anweisung

int IntegerArray[5] = { 10, 20, 30, 40, 50, 60};

führt zu einem Compiler-Fehler, da man ein fünfelementiges Array deklariert hat, aber sechs Werte initialisieren will. Dagegen ist die folgende Anweisung zulässig:

int IntegerArray[5] = { 10, 20};

Obwohl es keine Garantie dafür gibt, daß nichtinitialisierte Array-Elemente vom Compiler einen bestimmten Wert zugewiesen bekommen, werden Aggregate praktisch immer auf 0 gesetzt. Wenn man also ein Array-Element nicht initialisiert, erhält es automatisch den Wert 0.

Was Sie tun sollten

... und was nicht

Lassen Sie für initialisierte Arrays den Compiler die Größe festlegen.

Verwenden Sie genau wie bei Variablen aussagekräftige Namen für Ihre Arrays.

Denken Sie daran, daß das erste Element im Array den Offset 0 hat.

Schreiben Sie nicht über das Ende des Array hinaus.

Arrays deklarieren

Arrays können einen beliebigen Variablennamen haben, solange der Name noch nicht von einer anderen Variablen oder einem anderen Array innerhalb des gleichen Gültigkeitsbereichs besetzt ist. Deshalb ist es nicht erlaubt, gleichzeitig ein Array namens meineKatzen[5] und eine Variable namens meineKatzen zu deklarieren.

Sie können die Array-Größe mit Konstanten oder mit einer Aufzählungskonstanten festlegen. Wie das geht, sehen Sie in Listing 12.3.

Listing 12.3: Konstanten und Aufzählungskonstanten zur Array-Indizierung

1:     // Listing 12.3
2: // Arraygroessen mit Konstanten und Aufzählungskonstanten festlegen
3:
4: #include <iostream.h>
5: int main()
6: {
7: enum WeekDays { Sun, Mon, Tue,
8: Wed, Thu, Fri, Sat, DaysInWeek };
9: int ArrayWeek[DaysInWeek] = { 10, 20, 30, 40, 50, 60, 70 };
10:
11: cout << "Der Wert am Dienstag beträgt: " << ArrayWeek[Tue];
12: return 0;
13: }

Der Wert am Dienstag beträgt: 30

Zeile 7 definiert einen Aufzählungstyp namens WeekDays mit acht Elementen. Sunday (Sonntag) entspricht dem Wert 0 und DaysInWeek (TageDerWoche) dem Wert 7.

Zeile 11 verwendet die Aufzählungskonstante Tue als Offset im Array. Da Tue als 2 ausgewertet wird, wird in Zeile 11 das dritte Element im Array, ArrayWeek[2], zurückgeliefert und ausgegeben.

Arrays

Die Deklaration eines Array setzt sich folgendermaßen zusammen: zuerst der Typ des zu speichernden Objekts, gefolgt von dem Namen des Array und einem Index, der angibt, wie viele Objekte in dem Array abgelegt werden.

Beispiel 1:

     int MeinIntegerArray[90];

Beispiel 2:

     long * ArrayVonZeigernAufLongs[100];

Für den Zugriff auf die Elemente im Array wird der Index-Operator eingesetzt.

Beispiel 1:

     int NeunterInteger = MeinIntegerArray[8];

Beispiel 2:

     long * pLong = ArrayVonZeigernAufLongs[8];

Die Zählung in den Arrays beginnt bei Null. Ein Array mit n Elementen, würde von 0 bis n-1 durchnumeriert.

Arrays von Objekten

Jedes Objekt - gleichgültig ob vordefiniert oder benutzerdefiniert - läßt sich in einem Array speichern. Wenn man das Array deklariert, teilt man dem Compiler den zu speichernden Typ und die Anzahl der Objekte mit, für die Platz zu reservieren ist. Der Compiler weiß anhand der Klassendeklaration, wieviel Speicher jedes Objekt belegt. Die Klasse muß über einen Standardkonstruktor ohne Argumente verfügen, damit sich die Objekte bei der Definition des Array erzeugen lassen.

Der Zugriff auf Datenelemente von Objekten, die in einem Array abgelegt sind, erfolgt in zwei Schritten. Man identifiziert das Element des Array mit dem Index-Operator ([ ]) und fügt dann den Elementoperator (.) an, um auf die gewünschte Elementvariable zuzugreifen. Listing 12.4 demonstriert, wie man ein Array mit fünf CAT-Objekten erzeugt.

Listing 12.4: Ein Array von Objekten erzeugen

1:     // Listing 12.4 - Ein Array von Objekten
2:
3: #include <iostream.h>
4:
5: class CAT
6: {
7: public:
8: CAT() { itsAge = 1; itsWeight=5; } // Standardkonstruktor
9: ~CAT() {} // Destruktor
10: int GetAge() const { return itsAge; }
11: int GetWeight() const { return itsWeight; }
12: void SetAge(int age) { itsAge = age; }
13:
14: private:
15: int itsAge;
16: int itsWeight;
17: };
18:
19: int main()
20: {
21: CAT Litter[5];
22: int i;
23: for (i = 0; i < 5; i++)
24: Litter[i].SetAge(2*i +1);
25:
26: for (i = 0; i < 5; i++)
27: {
28: cout << "Katze #" << i+1 << ": ";
29: cout << Litter[i].GetAge() << endl;
30: }
31: return 0;
32: }

Katze #1: 1
Katze #2: 3
Katze #3: 5
Katze #4: 7
Katze #5: 9

Die Zeilen 5 bis 17 deklarieren die Klasse CAT. Diese Klasse muß über einen Standardkonstruktor verfügen, damit sich CAT-Objekte in einem Array erzeugen lassen. Denken Sie daran, daß der Compiler keinen Standardkonstruktor bereitstellt, wenn man irgendeinen anderen Konstruktor erzeugt - in diesem Fall müssen Sie einen eigenen Standardkonstruktor erstellen.

Die erste for-Schleife (in den Zeilen 23 und 24) setzt das Alter aller fünf CAT-Objekte im Array. Die zweite for-Schleife (in den Zeilen 26 bis 30) greift auf die einzelnen Elemente des Arrays zu und ruft GetAge() für die Objekte auf.

Der Aufruf der GetAge()-Methode für die einzelnen CAT-Objekte erfolgt über den Namen des Array-Elements Litter[i], gefolgt vom Punktoperator (.) und der Elementfunktion.

Mehrdimensionale Arrays

Arrays lassen sich in mehreren Dimensionen anlegen. Jede Dimension stellt man durch einen eigenen Index im Array dar. Ein zweidimensionales Array hat demzufolge zwei Indizes und ein dreidimensionales drei Indizes. Prinzipiell ist die Anzahl der Dimensionen nicht begrenzt, obwohl man in der Praxis kaum mit mehr als zwei Dimensionen arbeitet.

Ein Schachbrett liefert ein gutes Beispiel für ein zweidimensionales Array. Die acht Reihen sind der einen Dimension, die acht Spalten der anderen Dimension zugeordnet. Abbildung 12.3 verdeutlicht dies.

Abbildung 12.3:  Ein Schachbrett und ein zweidimensionales Array

Nehmen Sie an, Sie hätten eine Klasse SQUARE für die Felder des Schachbretts. Ein Array Brett zur Darstellung des Schachbretts deklariert man dann wie folgt:

SQUARE Brett[8][8];

Die gleichen Daten ließen sich auch in einem eindimensionalen Array für 64 Quadrate unterbringen:

SQUARE Brett[64];

Diese Darstellung lehnt sich allerdings nicht so eng an das reale Objekt an wie ein zweidimensionales Array. Bei Spielbeginn steht der König in der vierten Spalte der ersten Reihe. Da die Zählung bei 0 beginnt, entspricht diese Position

Brett[0][3];

- vorausgesetzt, daß der erste Index der Zeile und der zweite Index der Spalte zugeordnet ist. Die Anordnung aller Brettpositionen ist aus Abbildung 12.3 ersichtlich.

Mehrdimensionale Arrays initialisieren

Um mehrdimensionale Arrays zu initialisieren, weist man den Array-Elementen die Liste der Werte der Reihe nach zu, wobei sich zuerst der letzte Index ändert, während die vorhergehenden Indizes konstant bleiben. Im Array

int dasArray[5][3];

kommen zum Beispiel die ersten drei Elemente nach dasArray[0], die nächsten drei nach dasArray[1] usw.

Zur Initialisierung des Array schreibt man

int dasArray[5][3] = { 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15 };

Diese Anweisung läßt sich übersichtlicher darstellen, wenn man die einzelnen Initialisierungen mit geschweiften Klammern gruppiert:

int dasArray[5][3] = {  {1,2,3},
{4,5,6},
{7,8,9},
{10,11,12},
{13,14,15} };

Die inneren geschweiften Klammern ignoriert der Compiler - sie dienen lediglich dem Programmierer zur übersichtlichen Gruppierung der Zahlen.

Die Werte sind durch Kommata zu trennen, unabhängig von eventuell vorhandenen Klammern. Die gesamte Initialisierungsliste ist in geschweifte Klammern einzuschließen und muß mit einem Semikolon enden.

Listing 12.5 erzeugt ein zweidimensionales Array.

Listing 12.5: Ein mehrdimensionales Array erzeugen

1:     #include <iostream.h>
2: int main()
3: {
4: int SomeArray[5][2] = { {0,0}, {1,2}, {2,4}, {3,6}, {4,8}};
5: for (int i = 0; i<5; i++)
6: for (int j=0; j<2; j++)
7: {
8: cout << "SomeArray[" << i << "][" << j << "]: ";
9: cout << SomeArray[i][j]<< endl;
10: }
11:
12: return 0;
13: }

SomeArray[0][0]: 0
SomeArray[0][1]: 0
SomeArray[1][0]: 1
SomeArray[1][1]: 2
SomeArray[2][0]: 2
SomeArray[2][1]: 4
SomeArray[3][0]: 3
SomeArray[3][1]: 6
SomeArray[4][0]: 4
SomeArray[4][1]: 8

Zeile 4 deklariert SomeArray als zweidimensionales Array. Die erste Dimension besteht aus fünf, die zweite Dimension aus zwei Ganzzahlen. Damit entsteht eine 5-mal-2-Matrix, wie sie Abbildung 12.4 zeigt.

Abbildung 12.4:  Eine 5-mal-2-Matrix

Die Initialisierung des Array erfolgt mit Wertepaaren, obwohl man die Werte genausogut auch berechnen könnte. Die Zeilen 5 und 6 bilden eine verschachtelte for-Schleife. Die äußere for-Schleife durchläuft alle Element der ersten Dimension. Für jedes dieser Elemente durchläuft die innere for-Schleife alle Elemente der zweiten Dimension. Dieser Ablauf dokumentiert sich in der Ausgabe: auf SomeArray[0][0] folgt SomeArray[0][1] . Jeweils nach dem Inkrementieren der zweiten Dimension erhöht die äußere for-Schleife den Index für die erste Dimension um 1. Dann beginnt die zweite Dimension wieder von vorn (bei 0).

Ein Wort zum Speicher

Bei der Deklaration eines Array teilt man dem Compiler die genaue Anzahl der im Array zu speichernden Objekte mit. Der Compiler reserviert Speicher für alle Objekte, auch wenn man sie überhaupt nicht verwendet. Bei Arrays, deren Größe von vornherein in etwa bekannt ist, stellt das kein Problem dar. Beispielsweise hat ein Schachbrett genau 64 Felder, und Katzen (CAT-Objekte) bekommen zwischen 1 und 10 Kätzchen. Wenn man allerdings nur eine vage Vorstellung von der Anzahl der benötigten Objekte hat, muß man auf ausgefeiltere Datenstrukturen ausweichen.

Arrays von Zeigern

Die bisher behandelten Arrays speichern ihre Elemente auf dem Stack. Normalerweise ist der Stack-Speicher stark begrenzt, während im Heap wesentlich mehr Platz zur Verfügung steht. Man kann die Objekte aber auch im Heap anlegen und im Array nur Zeiger auf die Objekte speichern. Damit reduziert sich die benötigte Stack-Größe drastisch. Listing 12.6 ist eine Neuauflage von Listing 12.4, speichert aber nun alle Objekte im Heap. Als Hinweis auf den jetzt möglichen größeren Speicher habe ich das Array von 5 auf 500 Elemente erweitert und den Namen von Litter (Körbchen) in Family (Familie) geändert.

Listing 12.6: Ein Array im Heap unterbringen

1:     // Listing 12.6 - Ein Array von Zeigern auf Objekte
2:
3: #include <iostream.h>
4:
5: class CAT
6: {
7: public:
8: CAT() { itsAge = 1; itsWeight=5; } // Standardkonstruktor
9: ~CAT() {} // Destruktor
10: int GetAge() const { return itsAge; }
11: int GetWeight() const { return itsWeight; }
12: void SetAge(int age) { itsAge = age; }
13:
14: private:
15: int itsAge;
16: int itsWeight;
17: };
18:
19: int main()
20: {
21: CAT * Family[500];
22: int i;
23: CAT * pCat;
24: for (i = 0; i < 500; i++)
25: {
26: pCat = new CAT;
27: pCat->SetAge(2*i +1);
28: Family[i] = pCat;
29: }
30:
31: for (i = 0; i < 500; i++)
32: {
33: cout << "Katze #" << i+1 << ": ";
34: cout << Family[i]->GetAge() << endl;
35: }
36: return 0;
37: }

Katze #1: 1
Katze #2: 3
Katze #3: 5
...
Katze #499: 997
Katze #500: 999

Die in den Zeilen 5 bis 17 deklarierte CAT-Klasse ist mit der in Listing 12.4 deklarierten CAT-Klasse identisch. Das in Zeile 21 deklarierte Array heißt jetzt aber Family und ist für die Aufnahme von 500 Zeigern auf CAT-Objekte ausgelegt.

Die Initialisierungsschleife (in den Zeilen 24 bis 29) erzeugt 500 neue CAT-Objekte im Heap und setzt die Werte für das Alter auf den zweifachen Indexwert plus 1. Das Alter im ersten CAT-Objekt enthält demnach den Wert 1, das zweite den Wert 3, das dritte den Wert 5 und so weiter. Schließlich legt die Initialisierungsschleife die Zeiger im Array ab.

Da das Array für die Aufnahme von Zeigern deklariert ist, wird im Array der Zeiger - und nicht der dereferenzierte Wert des Zeigers - gespeichert.

Die zweite Schleife (Zeilen 31 bis 35) gibt alle Werte aus. Mit Hilfe des Index Family[i] greift das Programm auf die Zeiger zu. Über die zurückgelieferte Adresse wird die Methode GetAge() aufgerufen.

Dieses Beispiel speichert das Array Family und alle dazugehörenden Zeiger auf dem Stack, während die 500 erzeugten CAT-Objekte im Heap abgelegt werden.

Arrays auf dem Heap

Man kann auch das gesamte Array auf dem Heap unterbringen. Dazu ruft man new auf und verwendet den Index-Operator zur Angabe der Array-Größe. Das Ergebnis ist ein Zeiger auf einen Bereich im Heap, der das Array enthält. Beispielsweise deklariert

CAT *Family = new CAT[500];

Family als Zeiger auf das erste Objekt in einem Array von 500 CAT-Objekten. Mit anderen Worten zeigt Family auf das Element Family[0] bzw. enthält dessen Adresse.

Mittels Zeigerarithmetik kann man über Family auf jedes Element von Family zugreifen. Beispielsweise kann man schreiben:

CAT *Family = new CAT[500];
CAT *pCat = Family; // pCat zeigt auf Family[0]
pCat->SetAge(10); // setzt Family[0] auf 10
pCat++; // geht weiter zu Family[1]
pCat->SetAge(20); // setzt Family[1] auf 20

Dieses Codefragment deklariert ein neues Array von 500 CAT-Objekten und einen Zeiger, der auf den Beginn des Array verweist. Über diesen Zeiger ruft man die Funktion SetAge() des ersten CAT-Objekts auf und übergibt den Wert 12. Die vierte Anweisung inkrementiert den Zeiger, der danach auf das nächste CAT-Objekt verweist. Daran schließt sich der Aufruf der SetAge()-Methode für das zweite CAT-Objekt an.

Zeiger auf Arrays und Arrays von Zeigern

Sehen Sie sich die folgenden drei Deklarationen an:

1:  CAT   FamilyOne[500];
2: CAT * FamilyTwo[500];
3: CAT * FamilyThree = new CAT[500];

FamilyOne ist ein Array von 500 CAT-Objekten, FamilyTwo ein Array von 500 Zeigern auf CAT-Objekte und FamilyThree ein Zeiger auf ein Array mit 500 CAT-Objekten.

Die Unterschiede zwischen den drei Codezeilen haben entscheidenden Einfluß auf die Arbeitsweise der drei Arrays. Überraschend ist vielleicht, daß FamilyThree eine Variante von FamilyOne ist, sich aber grundsätzlich von FamilyTwo unterscheidet.

Damit stellt sich die heikle Frage, wie sich Zeiger zu Arrays verhalten. Im dritten Fall ist FamilyThree ein Zeiger auf ein Array. Das heißt, die Adresse in FamilyThree ist die Adresse des ersten Elements in diesem Array. Genau das trifft auch auf FamilyOne zu.

Zeiger und Array-Namen

In C++ ist ein Array-Name ein konstanter Zeiger auf das erste Element des Arrays. In der Deklaration

CAT Family[50];

ist Family demzufolge ein Zeiger auf das Element &Family[0] - also die Adresse des ersten Array-Elements von Family.

Es ist zulässig, Array-Namen als konstante Zeiger - und konstante Zeiger als Array- Namen - zu verwenden. Demzufolge kann man mit Family + 4 auf die Daten in Family[4] zugreifen.

Der Compiler führt die korrekten Berechnungen aus, wenn man Zeiger addiert, inkrementiert oder dekrementiert. Die Adresse für den Ausdruck Family + 4 liegt nämlich nicht einfach 4 Byte, sondern 4 Objekte hinter der Adresse von Family. Hat jedes Objekt eine Länge von 4 Byte, bedeutet Family + 4 eine Adreßverschiebung von 16 Byte. Handelt es sich zum Beispiel um CAT-Objekte mit vier Elementvariablen vom Typ long (jeweils 4 Byte) und zwei Elementvariablen vom Typ short (jeweils 2 Byte), dann beträgt die Länge eines CAT-Objekts 20 Byte und Family + 4 liegt 80 Byte hinter dem Array-Anfang.

Listing 12.7 zeigt die Deklaration und Verwendung eines Arrays auf dem Heap.

Listing 12.7: Ein Array mit new erzeugen

1:     // Listing 12.7 - Ein Array auf dem Heap
2:
3: #include <iostream.h>
4:
5: class CAT
6: {
7: public:
8: CAT() { itsAge = 1; itsWeight=5; }
9: ~CAT();
10: int GetAge() const { return itsAge; }
11: int GetWeight() const { return itsWeight; }
12: void SetAge(int age) { itsAge = age; }
13:
14: private:
15: int itsAge;
16: int itsWeight;
17: };
18:
19: CAT :: ~CAT()
20: {
21: // cout << "Destruktor aufgerufen!\n";
22: }
23:
24: int main()
25: {
26: CAT * Family = new CAT[500];
27: int i;
28:
29: for (i = 0; i < 500; i++)
30: {
31: Family[i].SetAge(2*1 +1);
32: }
33:
34: for (i = 0; i < 500; i++)
35: {
36: cout << "Katze #" << i+1 << ": ";
37: cout << Family[i].GetAge() << endl;
38: }
39:
40: delete [] Family;
41:
42: return 0;
43: }

Katze #1: 1
Katze #2: 3
Katze #3: 5
...
Katze #499: 997
Katze #500: 999

Zeile 26 deklariert das Array Family für 500 CAT-Objekte. Der Aufruf new CAT[500] erzeugt das gesamte Array auf dem Heap.

Arrays im Heap löschen

Was passiert mit dem Speicher, der den CAT-Objekten aus obigem Beispiel zugewiesen wurde, wenn das Array zerstört wird? Besteht die Gefahr eines Speicherlecks? Beim Löschen von Family wird automatisch der gesamte Speicherplatz, der für das Array reserviert wurde, zurückgegeben, wenn Sie den delete[]-Operator auf das Array anwenden und nicht die eckigen Klammern vergessen. Der Compiler ist intelligent genug, um alle Objekte im Array zu zerstören und dessen Speicher an den Heap zurückzugeben.

Um sich selbst davon zu überzeugen, ändern Sie die Größe des Arrays in den Zeilen 26, 29 und 34 von 500 auf 12. Entfernen Sie außerdem die Kommentarzeichen vor der cout-Anweisung in Zeile 21. Erreicht das Programm Zeile 43 und zerstört das Array, wird der Destruktor für jedes CAT-Objekt aufgerufen.

Wenn man mit new ein einzelnes Element auf dem Heap erzeugt, ruft man zum Löschen des Elements und zur Freigabe des zugehörigen Speichers immer den delete- Operator auf. Wenn man ein Array mit new <class>[size] erzeugt, schreibt man delete[] , um dieses Array zu löschen und dessen Speicher freizugeben. Die eckigen Klammern signalisieren dem Compiler, daß ein Array zu löschen ist.

Wenn man die Klammern wegläßt, wird nur das erste Objekt im Array gelöscht. Sie können das selbst nachprüfen, indem Sie die eckigen Klammern in Zeile 38 entfernen. Zeile 21 sollte auch hier keine Kommentarzeichen enthalten, damit sich der Destruktor meldet. Beim Programmlauf stellen Sie dann fest, daß nur ein einziges CAT- Objekt zerstört wird. Herzlichen Glückwunsch! Gerade haben Sie eine Speicherlücke erzeugt.

Was Sie tun sollten

... und was nicht

Denken Sie daran, daß ein Array mit n Elementen von 0 bis n-1 numeriert ist.

Verwenden Sie die Array-Indizierung für Zeiger, die auf Arrays verweisen.

Verwechseln Sie nicht ein Array von Zeigern mit einem Zeiger auf ein Array.

Schreiben oder lesen Sie nicht über das Ende eines Array hinaus.

char-Arrays

Ein String ist eine Folge einzelner Zeichen. Die bisher gezeigten Strings waren ausnahmslos unbenannte String-Konstanten in cout-Anweisungen wie zum Beispiel

cout << "hello world.\n";

Ein String in C++ ist ein Array mit Elementen vom Typ char und einem Null-Zeichen zum Abschluß. Man kann einen String genauso deklarieren und initialisieren wie jedes andere Array. Dazu folgendes Beispiel:

char Gruss[] = { 'H', 'e', 'l', 'l', 'o', ' ', 'W','o','r','l','d', '\0' };

Das letzte Zeichen, '\0', ist das Null-Zeichen, das viele Funktionen in C++ als Abschlußzeichen eines Strings erkennen. Obwohl diese zeichenweise Lösung funktioniert, ist sie umständlich einzugeben und bietet viel Spielraum für Fehler. C++ erlaubt daher die Verwendung einer Kurzform für die obige Codezeile:

char Gruss[] = "Hello World";

Bei dieser Syntax sind zwei Punkte zu beachten:

Der String Hello World hat eine Länge von 12 Byte: Hello mit 5 Byte, ein Leerzeichen mit 1 Byte, World mit 5 Byte und das abschließende Null-Zeichen mit 1 Byte.

Man kann auch nicht initialisierte Zeichen-Arrays erzeugen. Wie bei allen Arrays darf man nur so viele Zeichen in den Puffer stellen, wie Platz dafür reserviert ist.

Listing 12.8 zeigt die Verwendung eines nicht initialisierten Puffers.

Listing 12.8: Ein Array füllen

1:     // Listing 12.8 Puffer für Zeichen-Array
2:
3: #include <iostream.h>
4:
5: int main()
6: {
7: char buffer[80];
8: cout << "Geben Sie den String ein: ";
9: cin >> buffer;
10: cout << "Inhalt des Puffers: " << buffer << endl;
11: return 0;
12: }

Geben Sie den String ein: Hello World
Inhalt des Puffers: Hello

Zeile 7 deklariert einen Puffer für 80 Zeichen. Diese Größe genügt, um eine Zeichenfolge mit 79 Zeichen und dem abschließenden Null-Zeichen aufzunehmen.

Die Anweisung in Zeile 8 fordert den Anwender zur Eingabe einer Zeichenfolge auf, die das Programm in Zeile 9 in den Puffer übernimmt. cin schreibt nach der eingegebenen Zeichenfolge ein abschließendes Null-Zeichen in den Puffer.

Das Programm in Listing 12.8 weist zwei Probleme auf. Erstens: Wenn der Anwender mehr als 79 Zeichen eingibt, schreibt cin über das Ende des Puffers hinaus. Zweitens: Wenn der Anwender ein Leerzeichen eingibt, nimmt cin das Ende des Strings an und beendet die Eingabe in den Puffer.

Um diese Probleme zu lösen, muß man die cin-Methode get() aufrufen, die drei Parameter übernimmt:

Der Standardbegrenzer ist newline (Zeichen für neue Zeile). Listing 12.9 zeigt dazu ein Beispiel.

Listing 12.9: Ein Array füllen

1:     // Listing 12.9 Arbeiten mit cin.get()
2:
3: #include <iostream.h>
4:
5: int main()
6: {
7: char buffer[80];
8: cout << "Geben Sie den String ein: ";
9: cin.get(buffer, 79); // Maximal 79 Zeichen oder bis newline einlesen
10: cout << "Inhalt des Puffers: " << buffer << endl;
11: return 0;
12: }

Geben Sie den String ein: Hello World
Inhalt des Puffers: Hello World

Zeile 9 ruft die Methode get() von cin auf und übergibt den in Zeile 7 deklarierten Puffer als erstes Argument. Das zweite Argument gibt die maximal zu holenden Zeichen an. In diesem Fall muß dieser Wert gleich 79 sein, um Platz für das abschließende Null-Zeichen zu lassen. Auf die Angabe eines Zeichens für das Ende der Eingabe kann man verzichten, da der Standardwert newline unseren Ansprüchen genügt.

strcpy() und strncpy()

C++ erbt von C eine Funktionsbibliothek für die Behandlung von Strings. Unter den zahlreichen Funktionen finden sich zwei für das Kopieren eines Strings in einen anderen: strcpy() und strncpy(). Die Funktion strcpy() kopiert den gesamten Inhalt eines Strings in den angegebenen Puffer. Listing 12.10 zeigt hierfür ein Beispiel.

Listing 12.10: Die Funktion strcpy()

1:     #include <iostream.h>
2: #include <string.h>
3: int main()
4: {
5: char String1[] = "Keiner lebt fuer sich allein.";
6: char String2[80];
7:
8: strcpy(String2,String1);
9:
10: cout << "String1: " << String1 << endl;
11: cout << "String2: " << String2 << endl;
12: return 0;
13: }

String1: Keiner lebt fuer sich allein.
String2: Keiner lebt fuer sich allein.

Zeile 2 bindet die Header-Datei STRING.H ein. Diese Datei enthält den Prototyp der Funktion strcpy(). Als Parameter übernimmt die Funktion strcpy() zwei Zeichen-Arrays - ein Ziel und anschließend eine Quelle. Wenn die Quelle größer als das Ziel ist, schreibt strcpy() über das Ende des Puffers hinaus.

Mit der Funktion strncpy() aus der Standardbibliothek kann man sich dagegen schützen. Diese Version übernimmt die Maximalzahl der zu kopierenden Zeichen. Die Funktion strncpy() kopiert bis zum ersten Null-Zeichen oder bis zu der als Argument übergebenen Maximalzahl von Zeichen in den Zielpuffer.

Listing 12.11 zeigt die Verwendung von strncpy().

Listing 12.11: Die Funktion strncpy()

1:     #include <iostream.h>
2: #include <string.h>
3: int main()
4: {
5: const int MaxLength = 80;
6: char String1[] = "Keiner lebt fuer sich allein.";
7: char String2[MaxLength+1];
8:
9:
10: strncpy(String2,String1,MaxLength);
11:
12: cout << "String1: " << String1 << endl;
13: cout << "String2: " << String2 << endl;
14: return 0;
15: }

String1: Keiner lebt fuer sich allein.
String2: Keiner lebt fuer sich allein.

In Zeile 10 wurde der Aufruf von strcpy() in strncpy() geändert. Hinzugekommen ist im Aufruf ein dritter Parameter: die maximale Anzahl der zu kopierenden Zeichen. Der Puffer String2 ist für die Aufnahme von MaxLength+1 Zeichen deklariert. Der zusätzliche Platz ist für das Null-Zeichen reserviert, das sowohl strcpy() als auch strncpy() automatisch an das Ende des Strings anfügt.

String-Klassen

Zu den meisten C++-Compilern gehört eine umfangreiche Bibliothek mit Klassen für die Manipulation von Daten. Zu den Standardkomponenten dieser Bibliotheken gehört auch eine String-Klasse.

C++ hat zwar den Null-terminierten String und die Funktionsbibliothek mit der Funktion strcpy() von C geerbt, allerdings fügen sich diese Funktionen nicht in das objektorientierte Konzept ein. Eine String-Klasse stellt einen abgekapselten Satz von Daten und Funktionen für die Manipulation dieser Daten sowie Zugriffsfunktionen bereit, so daß die Daten selbst gegenüber den Klienten der String-Klasse verborgen sind.

Wenn zu Ihrem Compiler noch keine String-Klasse gehört (oder deren Implementierung unbefriedigend ist), sollten Sie sich eine eigene zusammenbauen. Im Anschluß an diesen Abschnitt werden der Entwurf und die teilweise Implementierung von String- Klassen diskutiert.

Das mindeste, was eine String-Klasse leisten sollte, ist, die grundlegenden Beschränkungen von Zeichen-Arrays aufzuheben. Wie alle Arrays sind auch Zeichen-Arrays statisch. Damit ist ihre Größe von vornherein festgelegt, und sie belegen immer den gleichen Platz im Speicher, auch wenn man ihn überhaupt nicht nutzt. Das Schreiben über das Ende des Array hinaus hat verheerende Folgen.

Eine gute String-Klasse reserviert nur soviel Speicher wie nötig. Kann die Klasse nicht genügend Speicher für einen aufzunehmenden String reservieren, sollte die Klasse darauf in angemessener Weise reagieren.

Listing 12.12 zeigt eine erste Annäherung an eine String-Klasse.

Listing 12.12: Verwendung einer String-Klasse

1:     //Listing 12.12
2:
3: #include <iostream.h>
4: #include <string.h>
5:
6: // Rudimentaere String-Klasse
7: class String
8: {
9: public:
10: // Konstruktoren
11: String();
12: String(const char *const);
13: String(const String &);
14: ~String();
15:
16: // Ueberladene Operatoren
17: char & operator[](unsigned short offset);
18: char operator[](unsigned short offset) const;
19: String operator+(const String&);
20: void operator+=(const String&);
21: String & operator= (const String &);
22:
23: // Allgemeine Zugriffsfunktionen
24: unsigned short GetLen()const { return itsLen; }
25: const char * GetString() const { return itsString; }
26:
27: private:
28: String (unsigned short); // privater Konstruktor
29: char * itsString;
30: unsigned short itsLen;
31: };
32:
33: // Standardkonstruktor erzeugt einen String von 0 Byte
34: String::String()
35: {
36: itsString = new char[1];
37: itsString[0] = '\0';
38: itsLen=0;
39: }
40:
41: // privater (Hilfs-)Konstruktor, wird nur von
42: // Klassenmethoden verwendet, um einen neuen Null-String
43: // von erforderlicher Groesse zu erzeugen.
44: String::String(unsigned short len)
45: {
46: itsString = new char[len+1];
47: for (unsigned short i = 0; i<=len; i++)
48: itsString[i] = '\0';
49: itsLen=len;
50: }
51:
52: // Konvertiert ein Zeichenarray in einen String
53: String::String(const char * const cString)
54: {
55: itsLen = strlen(cString);
56: itsString = new char[itsLen+1];
57: for (unsigned short i = 0; i<itsLen; i++)
58: itsString[i] = cString[i];
59: itsString[itsLen]='\0';
60: }
61:
62: // Kopierkonstruktor
63: String::String (const String & rhs)
64: {
65: itsLen=rhs.GetLen();
66: itsString = new char[itsLen+1];
67: for (unsigned short i = 0; i<itsLen;i++)
68: itsString[i] = rhs[i];
69: itsString[itsLen] = '\0';
70: }
71:
72: // Destruktor, gibt zugewiesenen Speicher frei
73: String::~String ()
74: {
75: delete [] itsString;
76: itsLen = 0;
77: }
78:
79: // Selbstzuweisung pruefen, Speicher freigeben,
80: // dann String und Groesse kopieren
81: String& String::operator=(const String & rhs)
82: {
83: if (this == &rhs)
84: return *this;
85: delete [] itsString;
86: itsLen=rhs.GetLen();
87: itsString = new char[itsLen+1];
88: for (unsigned short i = 0; i<itsLen;i++)
89: itsString[i] = rhs[i];
90: itsString[itsLen] = '\0';
91: return *this;
92: }
93:
94: // Nicht-konstanter Offset-Operator, gibt
95: // Referenz auf Zeichen zurueck, so dass es
96: // geaendert werden kann!
97: char & String::operator[](unsigned short offset)
98: {
99: if (offset > itsLen)
100: return itsString[itsLen-1];
101: else
102: return itsString[offset];
103: }
104:
105: // konstanter Offset-Operator für konstante
106: // Objekte (siehe Kopierkonstruktor!)
107: char String::operator[](unsigned short offset) const
108: {
109: if (offset > itsLen)
110: return itsString[itsLen-1];
111: else
112: return itsString[offset];
113: }
114:
115: // erzeugt einen neuen String, indem er rhs den
116: // aktuellen String hinzufügt
117: String String::operator+(const String& rhs)
118: {
119: unsigned short totalLen = itsLen + rhs.GetLen();
120: String temp(totalLen);
121: unsigned short i;
122: for ( i= 0; i<itsLen; i++)
123: temp[i] = itsString[i];
124: for (unsigned short j = 0; j<rhs.GetLen(); j++, i++)
125: temp[i] = rhs[j];
126: temp[totalLen]='\0';
127: return temp;
128: }
129:
130: // aendert aktuellen String, gibt nichts zurueck
131: void String::operator+=(const String& rhs)
132: {
133: unsigned short rhsLen = rhs.GetLen();
134: unsigned short totalLen = itsLen + rhsLen;
135: String temp(totalLen);
136: unsigned short i;
137: for (i = 0; i<itsLen; i++)
138: temp[i] = itsString[i];
139: for (unsigned short j = 0; j<rhs.GetLen(); j++, i++)
140: temp[i] = rhs[i-itsLen];
141: temp[totalLen]='\0';
142: *this = temp;
143: }
144:
145: int main()
146: {
147: String s1("Erster Test");
148: cout << "S1:\t" << s1.GetString() << endl;
149:
150: char * temp = "Hello World";
151: s1 = temp;
152: cout << "S1:\t" << s1.GetString() << endl;
153:
154: char tempTwo[20];
155: strcpy(tempTwo,"; schoen hier zu sein!");
156: s1 += tempTwo;
157: cout << "tempTwo:\t" << tempTwo << endl;
158: cout << "S1:\t" << s1.GetString() << endl;
159:
160: cout << "S1[4]:\t" << s1[4] << endl;
161: s1[4]='x';
162: cout << "S1:\t" << s1.GetString() << endl;
163:
164: cout << "S1[999]:\t" << s1[999] << endl;
165:
166: String s2(" Ein anderer String");
167: String s3;
168: s3 = s1+s2;
169: cout << "S3:\t" << s3.GetString() << endl;
170:
171: String s4;
172: s4 = "Warum funktioniert dies?";
173: cout << "S4:\t" << s4.GetString() << endl;
174: return 0;
175: }

S1:        initial test
S1: Hello world
tempTwo: ; schoen hier zu sein!
S1: Hello world; schoen hier zu sein!
S1[4]: o
S1: Hello World; schoen hier zu sein!
S1[999]: !
S3: Hello World; schoen hier zu sein! Ein anderer String
S4: Warum funktioniert dies?

Die Zeilen 7 bis 31 enthalten die Deklaration einer einfachen String-Klasse. Die Zeilen 11 bis 13 deklarieren drei Konstruktoren: den Standardkonstruktor, den Kopierkonstruktor und einen Konstruktor, der einen existierenden, Null-terminierten String (C-Stil) übernimmt.

Unsere String-Klasse überlädt den Offset-Operator ([]), den Plus-Operator (+) und den Plus-Gleich-Operator(+=). Der Offset-Operator wird zweimal überladen: einmal als konstante Funktion, die ein char zurückliefert, und einmal als nicht-konstante Funktion, die eine Referenz auf einen char zurückliefert.

Die nicht-konstante Version wird in Anweisungen wie

SomeString[4]='x';

in Zeile 161 verwendet. Damit wird der direkte Zugriff auf jedes der Zeichen im String möglich. Es wird eine Referenz auf das Zeichen zurückgeliefert, so daß die aufrufende Funktion dieses manipulieren kann.

Die konstante Funktion wird eingesetzt, wenn auf ein konstantes String-Objekt zugegriffen wird, wie zum Beispiel bei der Implementierung des Kopierkonstruktors (Zeile 63). Beachten Sie, daß der Zugriff auf rhs[i] erfolgt, aber rhs als const String & deklariert ist. Es ist nicht zulässig, auf dieses Objekt mit einer nicht-konstanten Elementfunktion zuzugreifen. Deshalb muß der Offset-Operator für den konstanten Zugiff überladen werden.

Ist das zurückgegebene Objekt groß, wollen Sie vielleicht den Rückgabewert als konstante Referenz deklarieren. Da char jedoch nur ein Byte groß ist, besteht dafür kein Anlaß.

Die Zeilen 33 bis 39 implementieren den Standardkonstruktor. Er erzeugt einen String der Länge 0. In unserer String-Klasse gilt die Regel, daß bei Angabe der Länge des Strings das abschließende Null-Zeichen nicht mitgezählt wird. Der Standardstring enthält lediglich das Null-Zeichen.

Die Zeilen 63 bis 70 implementieren den Kopierkonstruktor. Er setzt die Länge des neuen Strings auf die Länge des existierenden Strings plus 1 für das abschließende Null-Zeichen. Er kopiert jedes Zeichen des existierenden Strings in den neuen String, der dann mit dem Null-Zeichen abgeschlossen wird.

Die Zeilen 53 bis 60 implementieren den Konstruktor, der einen existierenden C-Stil- String übernimmt. Dieser Konstruktor ist dem Kopierkonstruktor ähnlich. Die Länge des existierenden Strings wird durch einen Aufruf der Standardfunktion strlen() aus der String-Bibliothek eingerichtet.

Zeile 28 deklariert einen weiteren Konstruktor, String(unsigned short), als private Elementfunktion. Die private-Deklaration soll sicherstellen, daß keine Client-Klasse je einen String von beliebiger Länge erzeugt. Dieser Konstruktor dient lediglich dazu, die interne Erzeugung von Strings zu unterstützen, wie sie zum Beispiel vom +=-Operator in Zeile 131 benötigt werden. Doch darauf werden wir später im Zusammenhang mit dem +=-Operator noch näher eingehen.

Der Konstruktor String(unsigned short) füllt jedes Element seines Array mit dem Null-Zeichen. Deshalb testet die for-Schleife auf i<=len und nicht auf i<len.

Der in den Zeilen 73 bis 77 implementierte Destruktor löscht den Zeichenstring, der von der Klasse verwaltet wird. Vergessen Sie nicht die eckigen Klammern mit in den Aufruf des delete-Operators aufzunehmen, so daß jedes Element des Array und nicht nur das erste Element gelöscht wird.

Der Zuweisungsoperator prüft zuerst, ob die rechte Seite der Zuweisung mit der linken identisch ist. Wenn nicht, wird der aktuelle String gelöscht, der neue String wird erzeugt und der übergebene String kopiert. Mit einer Referenz als Rückgabewert ermöglicht man Zuweisungen wie

String1 = String2 = String3;

Der Offset-Operator wird zweimal überladen. In beiden Versionen wird eine rudimentäre Begrenzungsprüfung ausgeführt. Wenn der Benutzer versucht, auf ein Zeichen an einer Position hinter dem Array-Ende zuzugreifen, wird das letzte Zeichen - das heißt len-1 - zurückgeliefert.

Die Zeilen 117 bis 128 implementieren den Plus-Operator (+) als Verkettungsoperator. Dies ermöglicht die bequeme Aneinanderreihung von Strings:

String3 = String1 + String2;

Um dies zu erreichen, berechnet die Operatorfunktion die Gesamtlänge der beiden String-Operanden und erzeugt den temporären String temp. Dazu wird der private Konstruktor aufgerufen, der einen Integer übernimmt und einen mit Null-Zeichen gefüllten String erzeugt. Die Null-Zeichen werden dann durch den Inhalt der beiden Strings ersetzt. Zuerst wird der linke String (*this) kopiert und anschließend der rechte String (rhs).

Die erste for-Schleife durchläuft den linken String und schreibt die einzelnen Zeichen in den neuen String. Die zweite for-Schleife durchläuft den rechten String. Beachten Sie, daß i auch in der for-Schleife für den rhs-String weiter inkrementiert wird, um die Einfügeposition in den neuen String vorzurücken.

Der Plus-Operator liefert den temp-String als Wert zurück, der dem String links des Zuweisungsoperators (string1) zugewiesen werden kann. Der +=-Operator operiert auf dem bestehenden String auf der linken Seite der Anweisung string1 += string2. Er funktioniert genauso wie der +-Operator, mit der Ausnahme, daß der temp-Wert dem aktuellen String (*this = temp) zugewiesen wird (Zeile 142).

Die main()-Funktion (Zeile 145 bis 175) fungiert als Testrahmen für die String-Klasse. Zeile 147 erzeugt ein String-Objekt unter Verwendung des Konstruktors, der einen C-Stil-String mit abschließendem Null-Zeichen übernimmt. Zeile 148 gibt dessen Inhalt mit Hilfe der Zugriffsmethode GetString() aus. Zeile 150 erzeugt einen weiteren C-Stil-String. Zeile 151 testet den Zuweisungsoperator und Zeile 152 gibt die Ergebnisse aus.

Zeile 154 erzeugt einen dritten C-Stil-String, tempTwo. Zeile 155 ruft strcpy() auf, um den Puffer mit den Zeichen ; schön hier zu sein! aufzufüllen. Zeile 156 ruft den +=- Operator auf und verknüpft tempTwo mit dem bestehenden String s1. Zeile 158 gibt das Ergebnis aus.

In Zeile 160 wird das fünfte Zeichen in s1 ausgegeben. In Zeile 161 wird dem Zeichen ein neuer Wert zugewiesen. Damit wird der nicht-konstante Offset-Operator ([]) aufgerufen. Zeile 162 zeigt das Ergebnis, aus dem ersichtlich wird, daß sich der Wert tatsächlich geändert hat.

Zeile 164 versucht, auf ein Zeichen hinter dem Ende des Array zuzugreifen. Es wird, wie es der Entwurf vorsieht, das letzte Zeichen des Array zurückgeliefert.

Die Zeilen 166 und 167 erzeugen zwei weitere String-Objekte, und Zeile 168 ruft den Additionsoperator auf. Das Ergebnis wird in Zeile 169 ausgegeben.

Zeile 171 erzeugt das neues String-Objekt s4. Zeile 172 ruft den Zuweisungsoperator auf. Zeile 173 gibt das Ergebnis aus. Vielleicht fragen Sie sich, warum es zulässig ist, daß der Zuweisungsoperator, dessen Definition in Zeile 21 die Übernahme einer konstanten String-Referenz vorsieht, hier vom Programm einen C-Stil-String übernimmt.

Die Antwort lautet, daß der Compiler ein String-Objekt erwartet, aber ein Zeichen- Array erhält. Deshalb prüft er, ob er aus dem, was ihm gegeben wurde, ein String- Objekt erzeugen kann. In Zeile 12 haben Sie einen Konstruktor deklariert, der String- Objekte aus Zeichen-Arrays erzeugten. Der Compiler erzeugt ein temporäres String- Objekt aus dem Zeichen-Array und übergibt es dem Zuweisungsoperator. Dies wird auch als implizite Typumwandlung oder Promotion bezeichnet. Hätten Sie keinen Konstruktor definiert, der ein Zeichen-Array übernimmt, hätte diese Zuweisung einen Kompilierfehler zur Folge gehabt.

Verkettete Listen und andere Strukturen

Arrays lassen sich in vielerlei Hinsicht mit Tupperware vergleichen. Es sind hervorragende Behälter, sie besitzen aber eine feste Größe. Wählt man einen zu großen Behälter, verschwendet man Platz im Kühlschrank. Nimmt man einen zu kleinen Behälter, quillt der Inhalt über.

Zur Lösung dieses Problems bietet sich eine verkettete Liste an. Dabei handelt es sich um eine Datenstruktur aus kleinen Behältern, die aneinander»gekoppelt« werden. Der Grundgedanke besteht darin, eine Klasse zu schreiben, die genau ein Objekt der Daten enthält - etwa ein CAT- oder ein Rectangle-Objekt - und auf den nächsten Behälter in der Liste zeigen kann. Dann erzeugt man einen Behälter für jedes zu speichernde Objekt und verkettet die Behälter.

Die Behälter heißen Knoten. Den ersten Knoten in der Liste bezeichnet man als Kopf.

Man unterscheidet bei Listen drei grundlegende Formen. Von der einfachsten zur komplexesten sind das

In einer einfach verketteten Liste zeigt jeder Knoten auf den nächsten, jedoch nicht auf den vorhergehenden. Um einen bestimmten Knoten zu finden, beginnt man am Anfang der Liste und tastet sich wie bei einer Schnitzeljagd (»der nächste Knoten liegt unter dem Sofa«) von Knoten zu Knoten. Eine doppelt verkettete Liste kann man sowohl in Vorwärts- als auch in Rückwärtsrichtung durchlaufen. Ein Baum ist eine komplexe Struktur, die sich aus Knoten aufbaut. Jeder Knoten kann in zwei oder drei Richtungen zeigen. Abbildung 12.5 zeigt diese drei fundamentalen Strukturen.

Fallstudie zu den verketteten Listen

In diesem Abschnitt untersuchen wir anhand einer Fallstudie zum Aufbau einer verketteten Liste, wie man komplexe Strukturen erzeugt und wie man große Projekte mit Vererbung, Polymorphie und Kapselung verwaltet.

Delegierung von Verantwortlichkeit

Eine grundlegende Prämisse der objektorientierten Programmierung besteht darin, daß jedes Objekt genau eine Aufgabe erledigt und alles, was nicht zu seinem »Kerngeschäft« gehört, an andere Objekte delegiert.

Ein Auto ist ein gutes Beispiel für dieses Konzept: Die Aufgabe des Motors besteht in der Leistungserzeugung. Die Verteilung dieser Leistung gehört nicht zu den Aufgaben des Motors, sondern kommt den Einrichtungen der Kraftübertragung zu. Weder die Rollbewegung noch die Kraftübertragung gehören zum Job des Motors, diese Aufgabe wird an die Räder weitergereicht.

Eine gut konzipierte Maschine besteht aus einer Menge kleiner Teile mit genau umrissenen Aufgaben, die in ihrer Gesamtheit die vorgesehene Funktionalität realisieren. Das gleiche gilt für ein gut konzipiertes Programm: jede Klasse arbeitet still vor sich hin, zusammengenommen bilden sie die eierlegende Wollmilchsau.

Die einzelnen Komponenten

Die verkettete Liste besteht aus Knoten. Die Knotenklasse selbst ist abstrakt, die eigentliche Arbeit wird in drei Untertypen geleistet. Es gibt einen Kopfknoten, der den Kopf der Liste verwaltet, einen Endknoten und Null oder mehrere interne Knoten. Die internen Knoten verwahren die eigentlich in die Liste aufzunehmenden Daten.

Beachten Sie, daß die Daten und die Liste zwei verschiedene Paar Schuhe sind. Man kann theoretisch jeden beliebigen Datentyp in einer Liste speichern. Nicht die Daten sind verkettet, sondern die Knoten, die die Daten aufnehmen.

Das Rahmenprogramm bekommt von den Knoten nichts mit. Es arbeitet mit der Liste. Die Liste führt allerdings kaum Aufgaben aus, sondern delegiert sie einfach an die Knoten.

Listing 12.13 zeigt den Code, den wir eingehend untersuchen werden.

Listing 12.13: Eine verkettete Liste

0:   // ***********************************************
1: // Datei: Listing 12.13
2: //
3: // Zweck: Demonstriert verkettete Liste
4: // Hinweise:
5: //
6: // COPYRIGHT: Copyright (C) 1997 Liberty Associates, Inc.
7: // All Rights Reserved
8: //
9: // Zeigt objektorientierte Loesung fuer verkettete Listen.
10: // Die Liste delegiert die eigentliche Arbeit an die
11: // Knoten. Die Knoten sind abstrakte Datentypen. Es gibt
12: // drei Knotentypen: Kopfknoten, Endknoten und interne
13: // Knoten. Nur die internen Knoten nehmen Daten auf.
14: //
15: // Die Klasse Data dient als Objekt, das in der
16: // verketteten Liste gespeichert wird.
17: //
18: // ***********************************************
19:
20:
21: #include <iostream.h>
22:
23: enum { kIsSmaller, kIsLarger, kIsSame};
24:
25: // Data-Klasse für die Speicherung in der Liste
26: // Jede Klasse in dieser Liste muss zwei Methoden unterstuetzen:
27: // Show (zeigt den Wert an) und Compare (gibt relative Position zurück)
28: class Data
29: {
30: public:
31: Data(int val):myValue(val){}
32: ~Data(){}
33: int Compare(const Data &);
34: void Show() { cout << myValue << endl; }
35: private:
36: int myValue;
37: };
38:
39: // Compare entscheidet, wohin ein bestimmtes Objekt
40: // in der Liste gehoert.
41: int Data::Compare(const Data & theOtherData)
42: {
43: if (myValue < theOtherData.myValue)
44: return kIsSmaller;
45: if (myValue > theOtherData.myValue)
46: return kIsLarger;
47: else
48: return kIsSame;
49: }
50:
51: // Vorwaertsdeklarationen
52: class Node;
53: class HeadNode;
54: class TailNode;
55: class InternalNode;
56:
57: // ADT, der das Knotenobjekt in der Liste darstellt
58: // Alle abgeleiteten Klassen müssen Insert und Show redefinieren
59: class Node
60: {
61: public:
62: Node(){}
63: virtual ~Node(){}
64: virtual Node * Insert(Data * theData)=0;
65: virtual void Show() = 0;
66: private:
67: };
68:
69: // Dieser Knoten nimmt das eigentliche Objekt auf.
70: // Hier hat das Objekt den Typ Data.
71: // Bei Besprechung der Templates wird eine
72: // moegliche Verallgemeinerung vorgestellt.
73: class InternalNode: public Node
74: {
75: public:
76: InternalNode(Data * theData, Node * next);
77: ~InternalNode(){ delete myNext; delete myData; }
78: virtual Node * Insert(Data * theData);
79: virtual void Show() {myData->Show(); myNext->Show();} //delegieren!
80:
81: private:
82: Data * myData; // Die Daten selbst
83: Node * myNext; // Zeigt auf naechsten Knoten in der Liste
84: };
85:
86: // Der Konstruktor fuehrt nur Initialisierungen aus.
87: InternalNode::InternalNode(Data * theData, Node * next):
88: myData(theData),myNext(next)
89: {
90: }
91:
92: // Der Kern der Listenkonstruktion. Fuegt man ein
93: // neues Objekt in die Liste ein, wird es an den Knoten
94: // weitergereicht. Dieser ermittelt, wohin das Objekt
95: // gehört, und fügt es in die Liste ein.
96: Node * InternalNode::Insert(Data * theData)
97: {
98:
99: // Ist das neue Objekt groesser oder kleiner als ich?
100: int result = myData->Compare(*theData);
101:
102:
103: switch(result)
104: {
105: // Ist das neue gleich gross, kommt es per Konvention vor das aktuelle
106: case kIsSame: // Gleich zum naechsten case-Zweig
107: case kIsLarger: // Neue Daten vor mir einordnen
108: {
109: InternalNode * dataNode = new InternalNode(theData, this);
110: return dataNode;
111: }
112:
113: // Groesser als ich, also an naechsten Knoten
114: // weiterreichen. ER soll sich drum kuemmern.
115: case kIsSmaller:
116: myNext = myNext->Insert(theData);
117: return this;
118: }
119: return this; // Tribut an MSC
120: }
121:
122:
123: // Endknoten ist einfach eine Markierung
124:
125: class TailNode : public Node
126: {
127: public:
128: TailNode(){}
129: ~TailNode(){}
130: virtual Node * Insert(Data * theData);
131: virtual void Show() { }
132:
133: private:
134:
135: };
136:
137: // Wenn Daten zu mir kommen, muessen sie vor mir eingefuegt werden,
138: // da ich der Endknoten bin und NICHTS nach mir kommt.
139: Node * TailNode::Insert(Data * theData)
140: {
141: InternalNode * dataNode = new InternalNode(theData, this);
142: return dataNode;
143: }
144:
145: // Kopfknoten enthaelt keine Daten, sondern zeigt einfach
146: // auf den Beginn der Liste.
147: class HeadNode : public Node
148: {
149: public:
150: HeadNode();
151: ~HeadNode() { delete myNext; }
152: virtual Node * Insert(Data * theData);
153: virtual void Show() { myNext->Show(); }
154: private:
155: Node * myNext;
156: };
157:
158: // Nach Erzeugen des Kopfknotens erzeugt dieser
159: // sofort den Endknoten.
160: HeadNode::HeadNode()
161: {
162: myNext = new TailNode;
163: }
164:
165: // Vor dem Kopf kommt nichts, also die Daten einfach
166: // an den naechsten Knoten weiterreichen
167: Node * HeadNode::Insert(Data * theData)
168: {
169: myNext = myNext->Insert(theData);
170: return this;
171: }
172:
173: // Ich stehe im Mittelpunkt, mache aber gar nichts.
174: class LinkedList
175: {
176: public:
177: LinkedList();
178: ~LinkedList() { delete myHead; }
179: void Insert(Data * theData);
180: void ShowAll() { myHead->Show(); }
181: private:
182: HeadNode * myHead;
183: };
184:
185: // Bei Geburt erzeuge ich den Kopfknoten.
186: // Er erzeugt den Endknoten.
187: // Eine leere Liste zeigt damit auf den Kopf, dieser
188: // zeigt auf das Ende, dazwischen ist nichts.
189: LinkedList::LinkedList()
190: {
191: myHead = new HeadNode;
192: }
193:
194: // Delegieren, delegieren, delegieren
195: void LinkedList::Insert(Data * pData)
196: {
197: myHead->Insert(pData);
198: }
199:
200: // Rahmenprogramm zum Testen
201: int main()
202: {
203: Data * pData;
204: int val;
205: LinkedList ll;
206:
207: // Anwender zum Erzeugen von Werten auffordern.
208: // Diese Werte in die Liste stellen.
209: for (;;)
210: {
211: cout << "Welcher Wert? (0 zum Beenden): ";
212: cin >> val;
213: if (!val)
214: break;
215: pData = new Data(val);
216: ll.Insert(pData);
217: }
218:
219: // Jetzt Liste durchlaufen und Daten zeigen.
220: ll.ShowAll();
221: return 0; // ll verliert Gueltigkeitsbereich und wird zerstoert!
222: }

Welcher Wert? (0 zum Beenden): 5
Welcher Wert? (0 zum Beenden): 8
Welcher Wert? (0 zum Beenden): 3
Welcher Wert? (0 zum Beenden): 9
Welcher Wert? (0 zum Beenden): 2
Welcher Wert? (0 zum Beenden): 10
Welcher Wert? (0 zum Beenden): 0
2
3
5
8
9
10

Als erstes fällt ein Aufzählungstyp auf, der drei konstante Werte bereitstellt: kIsSmaller , kIsLarger und kIsSame (kleiner, größer, gleich). Jedes Objekt, das sich in der verketteten Liste speichern läßt, muß eine Compare()-Methode (Vergleichen) unterstützen. Diese Konstanten repräsentieren die von der Methode Compare() zurückgegebenen Werte.

Die Zeilen 28 bis 37 erzeugen zu Demonstrationszwecken die Klasse Data. Die Implementierung der Methode Compare() steht in den Zeilen 39 bis 49. Ein Data-Objekt nimmt einen Wert auf und kann sich selbst mit anderen Data-Objekten vergleichen. Außerdem unterstützt es eine Methode Show(), um den Wert des Data-Objekts anzuzeigen.

Am besten läßt sich die Arbeitsweise der verketteten Liste anhand eines Beispiels verstehen. Zeile 201 deklariert ein Rahmenprogramm, Zeile 203 einen Zeiger auf ein Data-Objekt, und Zeile 205 definiert eine lokale verkettete Liste.

Beim Erzeugen der verketteten Liste wird der Konstruktor in Zeile 189 aufgerufen. Der Konstruktor hat einzig die Aufgabe, ein HeadNode-Objekt (Kopfknoten) zu reservieren und die Adresse dieses Objekts dem in der verketteten Liste gespeicherten Zeiger (siehe Zeile 182) zuzuweisen.

Für die Speicherallokation und Erzeugung des HeadNode-Objekts wird der in den Zeilen 160 bis 163 implementierte HeadNode-Konstruktor aufgerufen. Dieser wiederum erzeugt ein TailNode-Objekt (Endknoten) und weist dessen Adresse dem Zeiger myNext (Zeiger auf nächsten Knoten) des Kopfknotens zu. Die Erzeugung des TailNode-Objekts ruft den TailNode-Konstruktor auf (Zeile 128), der inline deklariert ist und keine Aktionen ausführt.

Durch einfaches Reservieren einer verketteten Liste auf dem Stack werden somit die Liste erzeugt, die Kopf- und Endknoten erstellt und verknüpft. Die sich daraus ergebende Struktur zeigt Abbildung 12.6.

Abbildung 12.6:  Die verkettete Liste nach der Erzeugung

Zeile 209 leitet eine Endlosschleife ein. Der Anwender kann jetzt Werte eingeben, die in die verkettete Liste aufgenommen werden. Er kann beliebig viele Werte eingeben, mit einer Null zeigt er das Ende der Eingabe an. Der Code in Zeile 213 wertet die eingegebenen Daten aus. Bei einer Null verläßt das Programm die Schleife.

Ist der Wert ungleich 0, erzeugt Zeile 215 ein neues Data-Objekt, das Zeile 216 in die Liste einfügt. Nehmen wir zu Demonstrationszwecken an, daß der Anwender den Wert 15 eingibt. Das bewirkt den Aufruf der Methode Insert() aus Zeile 195.

Die verkettete Liste delegiert sofort die Verantwortlichkeit für das Einfügen des Objekts an ihren Kopfknoten. Das führt zum Aufruf der Methode Insert() aus Zeile 167. Der Kopfknoten übergibt die Verantwortlichkeit sofort an den Knoten, auf den myNext momentan zeigt. In diesem (ersten) Fall zeigt er auf den Endknoten. (Erinnern Sie sich: Beim Erzeugen des Kopfknotens hat dieser einen Verweis auf einen Endknoten angelegt.) Das zieht den Aufruf der Methode Insert() aus Zeile 139 nach sich.

Die Methode TailNode::Insert() weiß, daß das übernommene Objekt unmittelbar vor sich selbst einzufügen ist - das heißt, das neue Objekt kommt direkt vor dem Endknoten in die Liste. Daher erzeugt die Methode in Zeile 141 ein neues InternalNode-Objekt und übergibt die Daten und einen Zeiger auf sich selbst. Dies wiederum führt zum Aufruf des Konstruktors für das InternalNode-Objekt aus Zeile 87.

Der InternalNode-Konstruktor initialisiert lediglich seinen Data-Zeiger mit der Adresse des übergebenen Data-Objekts und seinen myNext-Zeiger mit der Adresse des übergebenen Knotens. In diesem Fall zeigt der Knoten auf den Endknoten. (Der Endknoten hat seinen eigenen this-Zeiger übergeben.)

Nachdem nun der InternalNode-Knoten erstellt ist, wird die Adresse dieses internen Knotens in Zeile 141 an den Zeiger dataNode zugewiesen und dieser wird wiederum von der Methode TailNode::Insert() zurückgegeben. Das bringt uns zurück zu HeadNode::Insert() , wo die Adresse des InternalNode-Knotens dem Zeiger myNext von HeadNode (in Zeile 169) zugewiesen wird. Schließlich wird die Adresse von HeadNode an die verkettete Liste zurückgegeben und (in Zeile 197) verworfen. (Das Programm stellt nichts damit an, da die verkettete Liste bereits die Adresse des Kopfknotens kennt.)

Warum schlägt man sich mit der Rückgabe der Adresse herum, wenn man sie überhaupt nicht verwendet? Die Methode Insert() ist in der Basisklasse - Node - deklariert. Den Rückgabewert benötigen andere Implementierungen. Wenn man den Rückgabewert von HeadNode::Insert() ändert, erhält man einen Compiler-Fehler. Es ist einfacher, den HeadNode zurückzugeben und die Adresse von der verketteten Liste verwerfen zu lassen.

Was ist bisher geschehen? Die Daten wurden in die Liste eingefügt. Die Liste hat sie an den Kopf übergeben. Der Kopf hat diese Daten blindlings dorthin weitergereicht, wohin der Kopf gerade verweist. In diesem (ersten) Fall hat der Kopf auf das Ende gezeigt. Der Endknoten hat sofort einen neuen internen Knoten erzeugt und den neuen Knoten mit einem Verweis auf das Ende initialisiert. Dann hat der Endknoten die Adresse des neuen Knotens an den Kopf zurückgegeben, und der Kopf hat diesen Zeiger auf den neuen Knoten in seinen Zeiger myNext eingetragen. Die Daten in der Liste befinden sich nun am richtigen Platz, wie es Abbildung 12.7 verdeutlicht.

Abbildung 12.7:  Die verkettete Liste nach Einfügen des ersten Knotens

Nach dem Einfügen des ersten Knotens setzt das Programm mit Zeile 211 fort, nimmt einen weiteren Wert entgegen und testet ihn. Nehmen wir an, daß der Anwender den Wert 3 eingegeben hat. Daraufhin erzeugt Zeile 215 ein neues Data-Objekt, das Zeile 216 in die Liste einfügt.

Auch hier übergibt die Liste in Zeile 197 die Daten an ihren Kopfknoten (HeadNode). Die Methode HeadNode::Insert() übergibt ihrerseits den neuen Wert an den Knoten, auf den myNext momentan zeigt. Wie wir wissen, verweist dieser Zeiger jetzt auf den Knoten mit dem Data-Objekt, dessen Wert gleich 15 ist. Es schließt sich der Aufruf der Methode InternalNode::Insert() aus Zeile 96 an.

In Zeile 100 veranlaßt InternalNode über den eigenen Zeiger myData, daß das gespeicherte Data-Objekt (mit dem Wert 15) seine Methode Compare() aufruft und dabei das neue Data-Objekt (mit dem Wert 3) übergeben wird. Das führt zum Aufruf der in Zeile 41 dargestellten Methode Compare().

Die Methode Compare() vergleicht beide Werte. Da myValue gleich 15 und der Wert theOtherData.myValue gleich 3 ist, gibt die Methode den Wert kIsLarger zurück. Daraufhin verzweigt das Programm zu Zeile 109.

Für das neue Data-Objekt wird ein neuer InternalNode erzeugt. Der neue Knoten zeigt auf das aktuelle InternalNode-Objekt, und die Methode InternalNode::Insert() gibt die Adresse des neuen InternalNode an HeadNode zurück. Dementsprechend wird der neue Knoten, dessen Objektwert kleiner als der Objektwert des aktuellen Knotens ist, in die Liste eingefügt. Die Liste entspricht nun Abbildung 12.8.

Abbildung 12.8:  Die verkettete Liste nach Einfügen des zweiten Knotens

Für den dritten Schleifendurchlauf nehmen wir an, daß der Anwender den Wert 8 eingegeben hat. Dieser Wert ist größer als 3, aber kleiner als 15 und sollte demnach zwischen den beiden vorhandenen Knoten eingefügt werden. Der Ablauf entspricht zunächst genau dem vorherigen Beispiel. Der Vergleich liefert jetzt aber kIsSmaller statt kIsLarger (weil das Objekt mit dem Wert 3 kleiner als das neue Objekt mit dem Wert 8 ist).

Daraufhin verzweigt die Methode InternalNode::Insert() zu Zeile 116. Statt einen neuen Knoten zu erzeugen und einzufügen, übergibt InternalNode einfach die neuen Daten an die Methode Insert() desjenigen Objekts, auf das der Zeiger myNext gerade zeigt. In diesem Fall erfolgt der Aufruf von InsertNode() für den InternalNode, dessen Data-Objektwert gleich 15 ist.

Nach einem erneuten Vergleich wird ein neuer InternalNode erzeugt. Dieser zeigt auf den InternalNode, dessen Data-Objektwert gleich 15 ist. Die Adresse des eingefügten internen Knotens reicht das Programm an den InternalNode weiter, dessen Data-Objektwert 3 ist (Zeile 116).

Unterm Strich wird der neue Knoten an der richtigen Stelle in die Liste eingefügt.

Wenn Sie die Möglichkeit haben, können Sie schrittweise das Einfügen von Knoten mit Ihrem Debugger nachvollziehen. Dabei läßt sich beobachten, wie diese Methoden einander aufrufen und die Zeiger geeignet angepaßt werden.

Was haben wir gelernt?

Das hier vorgestellte Konzept unterscheidet sich grundlegend von der prozeduralen Programmierung. Bei einem herkömmlichen prozeduralen Programm würde eine steuernde Funktion die Daten untersuchen und die betreffenden Funktionen aufrufen.

In diesem objektorientierten Ansatz erhält jedes Objekt einen genau festgelegten Satz von Verantwortlichkeiten. Die verkettete Liste ist für die Verwaltung des Kopfknotens zuständig. Der Kopfknoten übergibt sofort die neuen Daten dorthin, wohin er gerade zeigt, ohne sich darum zu kümmern, wo das wohl sein könnte.

Sobald der Endknoten Daten erhält, erzeugt er einen neuen Knoten und fügt ihn ein. Der Endknoten handelt nach der Devise: »Wenn diese Daten zu mir gelangen, muß ich sie direkt vor mir einfügen.«

Interne Knoten sind kaum komplizierter. Sie fordern das gespeicherte Objekt auf, sich selbst mit dem neuen Objekt zu vergleichen. Je nach Ergebnis fügen sie entweder das neue Objekt ein oder geben es einfach weiter.

Beachten Sie, daß der interne Knoten keine Ahnung hat, wie der Vergleich auszuführen ist. Die Realisierung des Vergleichs bleibt dem Objekt selbst überlassen. Der interne Knoten fordert die Objekte lediglich zum Vergleich auf und erwartet eine von drei möglichen Antworten. Bei einer bestimmten Antwort wird das Objekt eingefügt, andernfalls reicht es der Knoten weiter und weiß nicht, wohin es schließlich gelangt.

Wer ist also verantwortlich? In einem gut konzipierten objektorientierten Programm ist niemand verantwortlich. Jedes Objekt werkelt still vor sich hin, und im Endeffekt hat man eine gut funktionierende Maschine vor sich.

Array-Klassen

Das Aufsetzen eigener Array-Klassen ist in vieler Hinsicht vorteilhafter, als die vordefinierten Arrays zu verwenden. Zum einen kann man damit das Überlaufen des Array verhindern. Vielleicht erwägen Sie auch, eine Array-Klasse mit dynamischer Größenanpassung zu implementieren: Zur Zeit seiner Erzeugung hat es nur ein Element, deren Anzahl kann aber im Laufe des Programms zunehmen.

Vielleicht wollen Sie die Elemente des Array sortieren oder anderweitig anordnen. Oder Sie überlegen sich eine ganze Reihe von Array-Varianten. Zu den populärsten gehören:

Durch das Überladen des Index-Operators ([]) können Sie eine verkettete Liste in eine geordnete Kollektion umwandeln. Durch Ausschluß von doppelten Einträgen, können Sie eine Kollektion in eine Menge umwandeln. Wenn die Objekte in der Liste aus Wertepaaren bestehen, können Sie eine verkettete Liste dazu verwenden, ein Wörterbuch oder ein sparsames Array aufzubauen.

Zusammenfassung

In diesem Kapitel haben Sie gelernt, wie man in C++ Arrays erzeugt. Ein Array ist eine in der Größe festgelegte Sammlung von Objekten, die alle vom gleichen Typ sind.

Arrays führen keine Bereichsprüfung durch. Daher ist es zulässig, über das Ende eines Array hinauszulesen oder -zuschreiben. Allerdings kann das verheerende Folgen haben. Die Indizierung von Arrays beginnt bei 0. Ein häufiger Fehler besteht darin, bei einem Array mit n Elementen in das Element mit dem Index n zu schreiben.

Arrays können eindimensional oder mehrdimensional sein. Für beide Typen von Arrays lassen sich die Elemente des Arrays initialisieren, sofern das Array vordefinierte Typen wie int oder Objekte einer Klasse mit Standardkonstruktor enthält.

Arrays und ihre Inhalte kann man auf dem Heap oder auf dem Stack anlegen. Wenn man ein Array im Heap löscht, sollte man die eckigen Klammern im Aufruf von delete nicht vergessen.

Array-Namen sind konstante Zeiger auf das jeweils erste Element des Array. Zeiger und Arrays arbeiten mit Zeigerarithmetik, um das nächste Element in einem Array zu lokalisieren.

Sie können verkettete Listen erstellen, um Kollektionen zu verwalten, deren Größe Sie zur Kompilierzeit noch nicht kennen. Aufbauend auf einer verketteten Liste können Sie eine beliebige Anzahl von komplexen Datenstrukturen erzeugen.

Strings sind Arrays von Zeichen oder Variablen vom Typ char. C++ stellt spezielle Funktionen für die Verwaltung von char-Arrays bereit. Dazu gehört die Initialisierung der Strings mit Zeichenfolgen, die in Anführungszeichen eingeschlossen sind.

Fragen und Antworten

Frage:
Was passiert, wenn ich in das Element 25 eines 24-elementigen Array schreibe?

Antwort:
Der Schreibvorgang findet in einem außerhalb des Array liegenden Speicherbereich statt und kann damit verheerende Folgen im Programm haben.

Frage:
Was ist in einem nicht initialisierten Array-Element gespeichert?

Antwort:
Jeweils das, was sich gerade im Speicher befindet. Die Ergebnisse bei der Verwendung dieses Elements ohne vorherige Zuweisung eines Wertes sind nicht vorhersehbar.

Frage:
Kann ich Arrays zusammenfassen?

Antwort:
Ja. Einfache Arrays kann man mit Hilfe von Zeigern zu einem neuen, größeren Array kombinieren. Bei Strings kann man mit den integrierten Funktionen wie zum Beispiel strcat() arbeiten, um Zeichenfolgen zu verketten.

Frage:
Warum soll ich eine verkettete Liste erzeugen, wenn auch ein Array funktioniert?

Antwort:
Ein Array muß eine feste Größe haben, während sich eine verkettete Liste dynamisch zur Laufzeit in der Größe ändern kann.

Frage:
Warum sollte ich jemals vordefinierte Arrays verwenden, wenn ich selbst eine bessere Array-Klasse implementieren kann?

Antwort:
Vordefinierte Arrays lassen sich schnell und problemlos einsetzen.

Frage:
Muß eine String-Klasse einen char * verwenden, um den Inhalt eines Strings aufzunehmen?

Antwort:
Nein. Sie kann jeden beliebigen vom Entwickler vorgesehenen Speicherplatz verwenden.

Workshop

Der Workshop enthält Quizfragen, die Ihnen helfen sollen, Ihr Wissen zu festigen, und Übungen, die Sie anregen sollen, das eben Gelernte umzusetzen und eigene Erfahrungen zu sammeln. Versuchen Sie, das Quiz und die Übungen zu beantworten und zu verstehen, bevor Sie die Lösungen in Anhang D lesen und zur Lektion des nächsten Tages übergehen.

Quiz

  1. Wie lauten die ersten und letzten Elemente von EinArray[25]?
  2. Wie deklariert man ein mehrdimensionales Array?
  3. Initialisieren Sie die Elemente des Array aus Frage 2.
  4. Wie viele Elemente enthält das Array EinArray[10][5][20]?
  5. Wie lautet die maximale Anzahl von Elementen, die Sie einer verketteten Liste hinzufügen können?
  6. Können Sie die Index-Notation für eine verkettete Liste verwenden?
  7. Wie lautet das letzte Zeichen in dem String »Barbie ist eine nette Lady«?

Übungen

  1. Deklarieren Sie ein zweidimensionales Array, das ein Tic-Tac-Toe-Brett darstellt.
  2. Schreiben Sie einen Code, der alle Elemente in dem Array aus Übung 1 mit 0 initialisiert.
  3. Schreiben Sie die Deklaration für eine Node-Klasse, die Integer-Werte aufnimmt.
  4. FEHLERSUCHE: Was ist falsch an folgendem Codefragment?
    unsigned short EinArray[5][4];
    for (int i = 0; i<4; i++)
    for (int j = 0; j<5; j++)
    EinArray[i][j] = i+j;
  5. FEHLERSUCHE: Was ist falsch an folgendem Codefragment?
    unsigned short EinArray[5][4];
    for (int i = 0; i<=5; i++)
    for (int j = 0; j<=4; j++)
    EinArray[i][j] = 0;



vorheriges KapitelInhaltsverzeichnisStichwortverzeichnisFeedbacknächstes Kapitel


© Markt&Technik Verlag, ein Imprint der Pearson Education Deutschland GmbH