C++ Arrays, Zeiger und Referenzen

In diesem Unterpunkt werden die wesentlichen Grundlagen zu eindimensionalen C++ Arrays vorgestellt. Da die interne Array-Implementierung der Programmiersprache C++ eng mit dem C++ Zeiger Konzept verknüpft ist, werden wir zunächst die Begriffe Zeiger, Adresse und Referenz an mehreren Beispielen vorstellen, um danach die eindimensionalen Arrays am Beispiel des Zeichen-Arrays des "HelloWorld" - Programmes zu illustrieren. Der Bezeichner (Variablenname) des Arrays kann hierbei als Zeiger auf sein erstes Element verstanden werden. Bei der Deklaration eines Arrays muss sein Name, Datentyp und die Anzahl der Elemente spezifiziert werden (in einem C++ Array dürfen keine Elemente unterschiedlichen Typs auftreten). Möchte man ein Array an eine Funktion als Argument übergeben, sollte man dies nicht über die einzelnen Werte des Arrays machen, da man dann die Array-Einträge nicht verändern kann. Stattdessen übergibt man ein Array als Zeiger auf sein erstes Element mit einem zusätzlichen Vermerk zu seiner Dimension. Mehrdimensionale C++ Arrays sind im Prinzip als ein Array von Arrays dargestellt und beschreiben den Übergang zu eines Matrix-ähnlichen integrierten Datentyp. Diese werden jedoch erst in der nächsten Vorlesung behandelt.

Zeiger, Adressen und Referenzen

In diesem Teilkapitel werden wir die grundlegenden integrierten Sprachmechanismen kennenlernen, die für den Zugriff auf den Hauptspeicher des Computers vorgesehen sind. Mittels des Variablennamens können wir in einem C++ Programm auf den in der Variable abgelegten Wert zugreifen. Diese abstrakte Variablenebene eines C++ Quelltextes besitzt eine physikalische Identität im Hauptspeicher des Computers, wo der Wert der Variable in Form von Nullen und Einsen in mehreren Bits gespeichert ist (siehe Unterpunkt Datentypen und Variablen und Computerarithmetik mit Python). Dies bedeutet, dass es zu jeder deklarierten Variable eines bestimmten Datentyps eine Adresse im Hauptspeicher gibt und wir werden nun beschreiben, wie man eine solche Adresse/Referenz ermittelt und die dafür benötigten C++ Sprachkonstrukte des Zeigers und der Referenz vorstellen.

Betrachten wir z.B. eine Variable, die den Wert einer Gleitkommazahl in doppelter Maschinengenauigkeit speichern soll - eine sogenannte double-Variable mit dem Namen "zahl". Für den Wert dieser Variable wurde beim Deklarationsprozess ein Platz von 8 Bytes im Hauptspeicher reserviert. Die Adresse im Hauptspeicher, wo der Wert der Variable binär abgelegt ist, kann man mittels des Referenzoperators (&zahl: ein der Variable vorgestelltes &) ermitteln. In der Sprache C++, sind speziell für den Zweck des Hauptspeicherzugriffs, zwei eigene Datentypen definiert, mittels deren man die Adresse der Variable eines bestimmten Datentyps T speichern kann. Der Datentyp "Zeiger auf T" wird mit einem nachgestellten Sternsymbol gekennzeichnet (T*) und eine Variable dieses neuen Datentyps kann die Adresse eines Typs T speichern. Man sollte eine Zeiger-Variable des Typs T* am besten sofort beim Deklarationsprozess mit einer Adresse initialisieren, da es sonst geschehen kann, dass der Zeiger auf ein nicht existentes Objekt im Hauptspeicher zeigt. Möchte man dennoch einen nicht-intitialisierten Zeiger verwenden, so wird es angeraten diesen mit dem Nullzeiger "nullptr" zu initialisieren (z.B. int* a = nullptr;). Ein weiterer neuer Datentyp ist die "Referenz eines Typs T" und diese wird bei der Deklaration des Typennamens mit einem nachgestellten &-Symbol gekennzeichnet (T&). Eine Referenz ist im Prinzip gleichbedeutend mit der Adresse des Objektes, wobei im Unterschied zum Zeigerkonstrukt eine automatische Umwandlung (Dereferenzierung) der Adresse in den Wert der Variable geschieht. Eine Referenz ist demnach eine Art von Zeiger, der bei jeder Verwendung im Programm dereferenziert wird. Bei einer Zeigervariable Z des Typs T erhält man den Wert mittels eines vorgestellten Sternsymbols "*" (*Z: Dereferenzieren eines Zeigers, Inhaltsoperator "*" angewandt auf den Zeiger Z), wobei bei einer Referenz das Dereferenzieren automatisch geschieht. Eine Referenz bezieht sich immer auf das Objekt, mit dem sie initialisiert wurde und es gibt keine Null-Referenzen im Gegensatz zum Null-Zeiger.

Die unten abgebildete Box zeigt die Verwendung von Zeigern, Adressen und Referenzen am Beispiel des Datentyps double.

Zeiger, Adresse und Referenz am Beispiel des Datentyps T $\rightarrow$ double

Deklaration einer double-Variable und Initialisierung mit einer Gleitkommazahl:
double zahl = 2.47654673;

Definition des Zeigers auf die Adresse der double-Variable:
double* zeiger_zahl = &zahl;

Definition der Referenz der double-Variable
double& ref_zahl = zahl;

Dereferenzierung des Zeigers (vorgestelltes Sternzeichen) liefert den Wert der double-Variable:
double stern_zeiger_zahl = *zeiger_zahl;

Das folgende C++ Programm illustriert die Verwendung der besprochenen Größen am Beispiel der Datentypen T $\rightarrow$ char, int und double

Zeiger_1.cpp
#include <iostream>                              // Ein- und Ausgabebibliothek

int main(){                                      // Hauptfunktion
    char zeichen = 'H';                          // Definition einer Variable vom Datentyp char (zeichen) und Initialisierung auf den Buchstaben 'H'
    char* zeiger_zeichen = &zeichen;             // Definition des Zeigers auf die Adresse der char Variable zeichen (&zeichen)
    char& ref_zeichen = zeichen;                 // Definition der Referenz(Adresse) der char Variable zeichen 
    char stern_zeiger_zeichen = *zeiger_zeichen; // char Variable zum Überprüfen des Dereferenzierungsoperators angewandt auf den Zeiger (*zeiger_zeichen)

    int ganze_zahl = 234767;                          // Definition einer Variable vom Datentyp int (ganze Zahl) und Initialisierung 
    int* zeiger_ganze_zahl = &ganze_zahl;             // Definition des Zeigers auf die Adresse der int Variable ganze_zahl (&ganze_zahl)
    int& ref_ganze_zahl = ganze_zahl;                 // Definition der Referenz(Adresse) der int Variable ganze_zahl
    int stern_zeiger_ganze_zahl = *zeiger_ganze_zahl; // int Variable zum Überprüfen des Dereferenzierungsoperators angewandt auf den Zeiger (*zeiger_ganze_zahl)
    
    double zahl = 2.47654673;                // Definition einer Variable vom Datentyp double (zahl) und Initialisierung 
    double* zeiger_zahl = &zahl;             // Definition des Zeigers auf die Adresse der double Variable zahl (&zahl)
    double& ref_zahl = zahl;                 // Definition der Referenz(Adresse) der double Variable zahl
    double stern_zeiger_zahl = *zeiger_zahl; // double Variable zum Überprüfen des Dereferenzierungsoperators angewandt auf den Zeiger (*zeiger_zahl)
    
    printf("Wert der Variable 'zeichen' ist: %c \n", zeichen);                            // Ausgabe des Wertes der Variable
    printf("Wert der Variable 'zeiger_zeichen' ist: %p \n", zeiger_zeichen);              // Ausgabe des Zeigers auf die Adresse der Variable
    printf("Wert der Variable 'stern_zeiger_zeichen' ist: %c \n", stern_zeiger_zeichen);  // Ausgabe der Dereferenzierung des Zeigers
    printf("Wert der Variable 'ref_zeichen' ist: %c \n", ref_zeichen);                    // Ausgabe der Referenz (konstanter Zeiger, der direkt dereferenziert wird)
    printf("-------------------------------\n");
    printf("Wert der Variable 'ganze_zahl' ist: %i \n", ganze_zahl);                      // Ausgabe des Wertes der Variable
    printf("Wert der Variable 'zeiger_ganze_zahl' ist: %p \n", zeiger_ganze_zahl);        // Ausgabe des Zeigers auf die Adresse der Variable
    printf("Wert der Variable 'stern_zeiger_ganze_zahl' ist: %i \n", stern_zeiger_ganze_zahl); // ....
    printf("Wert der Variable 'ref_ganze_zahl' ist: %i \n", ref_ganze_zahl);
    printf("-------------------------------\n");
    printf("Wert der Variable 'zahl' ist: %f \n", zahl);
    printf("Wert der Variable 'zeiger_zahl' ist: %p \n", zeiger_zahl); 
    printf("Wert der Variable 'stern_zeiger_zahl' ist: %f \n", stern_zeiger_zahl);
    printf("Wert der Variable 'ref_zahl' ist: %f \n", ref_zahl); 
}

Arrays und Zeiger

Alle die bis zu diesem Abschnitt besprochenen Datentypen verbinden den Namen des deklarierten Typs mit einem einzelnen Wert. Um die in der Physik und Mathematik definierten Größen wie Vektoren und Matrizen in einem Computerprogramm adäquat abbilden zu können, gibt es die sogenannten Datenarrays. Für einen Typ T ist "T Name[N];" die Deklaration eines Typs "Array mit N Elementen vom Typ T", wobei die Elemente von 0 bis N-1 indiziert werden. Diese Konstruktion ist der mathematischen Definition eines N-dimensionalen Vektors $ \vec{v} = \left( v_0, v_1, ..., v_{N-1} \right) $ nicht unähnlich, wobei ein C++ Array natürlich von viel allgemeinerer Struktur ist, da der Typ des Arrays ja nicht nur auf Zahlen-Arrays limitiert ist. C++ Daten-Arrays und das Konstrukt des Zeigers sind eng miteinander verwandt und der Name eines Arrays kann als Zeiger auf sein erstes Element verstanden werden. Diese Eigenschaft wollen wir, am Beispiel eines eindimensionalen int-Arrays, im Folgenden näher betrachten.

In dem folgenden C++ Programm wird der Vektor $ \vec{v} = \left( 1,4,6,8,9,5,3 \right) $, der Dimension N=7 mittels eines eindimensionalen int-Arrays deklariert und initialisiert (int v[7] = {1,4,6,8,9,5,3};). Danach definieren wir eine Variable des Typs int* mit dem Namen "zeiger_v", die den Zeiger auf das definierte Array speichern soll (int* zeiger_v = v;). Das Array "v" ist, im Gegensatz zu dem Null-dimensionalen, skalaren Datentyp int, nicht der Wert der Variable, sondern "v" entspricht dem Zeicher auf das erste Element des Arrays. Dies ist auch der Grund, dass wir beim Initialisieren der entsprechenden Zeigervariable nicht die Adresse des Arrays benutzen, sondern die Variable "v" des Arrays selbst. Das Array "v" zeigt auf die Adresse im Hauptspeicher, wo sein erstes Element abgelegt ist, und der gesamte Speicherbedarf eines eindimensionalen N-dimensionalen Arrays entspricht der Summe des Speicherbedarfs seiner einzelnen Elemente. Die Dimension eines eindimensionen Arrays lässt sich somit mittels des folgenden Quotienten berechnen: int dim_v = sizeof(v)/sizeof(v[0]); (dim_v=$\frac{28}{4}=7$).

Array_1_zeiger.cpp
#include <iostream>                           // Ein- und Ausgabebibliothek
using namespace std;                          // Benutze den Namensraum std

int main(){                                   // Hauptfunktion
    int v[7] = {1,4,6,8,9,5,3};               // Definition eines Integer-Arrays mit sieben Einträgen
    int* zeiger_v = v;                        // Definition des Zeigers auf das Integer-Array v
    int dim_v = sizeof(v)/sizeof(v[0]);       // Dimension des Arrays 
    
    cout << "Das Array v ist ein Zeiger auf sein erstes Element: v=" << v << " und &v[0]=" << &v[0] << endl;
    cout << "Die Dimension unseres Arrays v ist: dim(v)=" << dim_v << endl << endl;
    
    printf("%20s %20s %20s %25s %30s %30s \n", "Index i Array", "Wert v[i]", "Referenz &v[i]", "Zeiger zeiger_v+i", "Deref. Zeiger *(zeiger_v+i)", "Deref. Adresse *(&v[i])");
    
    for(int i=0; i<dim_v; ++i){         // Schleifen Anfang ueber alle 
        printf("%20i ", i);             // Ausgabe des Indexes i
        printf("%20i ", v[i]);          // Ausgabe des i-ten Wertes des Arrays
        printf("%20p ", &v[i]);         // Ausgabe der Referenz (Addresse) des i-ten Eintrages im Array
        printf("%25p ", zeiger_v+i);    // Ausgabe des Zeigers auf den i-ten Eintrag im Array
        printf("%30i ", *(zeiger_v+i)); // Dereferenzierung des Zeigers auf den i-ten Eintrag im Array
        printf("%30i \n", *(&v[i]));    // Dereferenzierung der Addresse des i-ten Eintrages im Array
    }                                   // Ende der Schleife 
    printf("\nEs gilt z.B. für i=3:\nv[3] = %i \n*(v+3) = %i \n*(&v[0]+3) = %i \n", v[3], *(v+3), *(&v[0]+3));
}

Nun folgen im Programm einige Terminalausgaben, die teils mittels cout und zeim Teil mit printf(...) realisiert werden. Die untere Abbildung zeigt die gesamte Terminalausgabe des Programms.

Zunächst wird überprüft, ob die Array-Variable "v" wirklich mit dem Zeiger auf sein erstes Objekt übereinstimmt. Die Adresse des ersten Objektes des Integer-Arrays wird dabei mittels des vorgestellten Referenzoperators "&", angewandt auf das erste Array-Element ( &v[0]) bestimmt. Danach wird die Dimension des Arrays ausgegeben und diese stimmt mit N=7 überein. Nun werden mittels einer for-Schleife mehrere Größen der jeweiligen Elemente des Arrays ausgegeben. Für jedes Element "v[i]" des Arrays, wird sein Index "i", sein Wert "v[i]", seine Referenz/Adresse &v[i], sein individueller Zeiger "zeiger_v+i", die Dereferenzierung des Zeigers "*(zeiger_v+i)" und die Dereferenzierung seiner Adresse "*(&v[i])" ausgegeben. Hierbei ist zu beachten, dass das Navigieren in Arrays im Prinzip fundamental auf einer Zeigerebene erfolgt und es gelten dabei die folgenden äquivalenten Formulierungen, um den Wert des i-ten Eintrages im Array zu erhalten: v[i] =*(v+i) = *(&v[0]+i) . Diese Eigenschaft wird in den vier letzten Zeilen der Terminalausgabe für $i=3$ überprüft.

Strings als Arrays von Zeichen

Obwohl wir es im Großteil der Vorlesung mit Zahlen-Arrays zu tun haben werden, wollen wir die Verwendung von anderen C++ Array-Typen auch einmal am Beispiel eines Arrays bestehend aus Zeichen (ein String, ein eindimensionales Array vom Typ char) verdeutlichen. Ein String ist eine Abfolge (ein Array) von mehreren Zeichen. Nehmen wir z.B. den Satz "Hallo, du schoene Welt!". Dieser String stellt ein Array des Typs char dar, wobei seine Dimension N die Anzahl der Zeichen (hier speziell N=23) ist. Im folgenden Programm wurde dieses char-Array mittels der Anweisung char satz[] = {'H','a', ... ,'!'}; deklariert und sofort mit den einzelnen Zeichen initialisiert. Initialisiert man ein Array direkt bei seiner Deklaration, muss man die Dimension des Arrays (hier N=23) nicht explizit angeben. In gleicher Weise wie im vorigen Beispiel des int-Arrays, wird der Zeiger auf das Array definiert, die Dimension des Arrays berechnet und mehrere Größen der jeweiligen Elemente des Arrays ausgegeben.

HelloWorld_1_zeiger.cpp
#include <iostream>                        // Ein- und Ausgabebibliothek

int main(){                                // Hauptfunktion
    char satz[] = {'H','a','l','l','o',',',' ','d','u',' ','s','c','h','o','e','n','e',' ','W','e','l','t','!'}; // Deklaration des eindimensionalen Arrays von Zeichentypen und Initialisierung
    char* zeiger_satz = satz;                       // Deklaration des Zeigers auf das eindimensionalen Arrays von Zeichentypen
    int anz_zeichen = sizeof(satz)/sizeof(satz[0]); // Dimension des eindimensionalen Arrays (dim= 23 = 92/4 = (Byte fuer den gesamten Satz)/(Byte für ein Zeichen)
    
    printf("%20s %20s %20s %25s %35s %35s \n", "Index i Array", "Zeichen satz[i]", "Referenz &satz[i]", "Zeiger zeiger_satz+i", "Deref. Zeiger *(zeiger_satz+i)", "Deref. Adresse *(&satz[i])");
    
    for(int i=0; i<anz_zeichen; ++i){      // Schleifen Anfang ueber alle Zeichen im Satz
        printf("%20i ", i);                // Ausgabe des Indexes i
        printf("%20c ", satz[i]);          // Ausgabe des i-ten Wertes des Arrays
        printf("%20p ", &satz[i]);         // Ausgabe der Referenz (Addresse) des i-ten Eintrages im Array
        printf("%25p ", zeiger_satz+i);    // Ausgabe des Zeigers auf den i-ten Eintrag im Array
        printf("%35c ", *(zeiger_satz+i)); // Dereferenzierung des Zeigers auf den i-ten Eintrag im Array
        printf("%35c \n", *(&satz[i]));    // Dereferenzierung der Addresse des i-ten Eintrages im Array
    }                                      // Ende der Schleife 
}

Die untere Abbildung zeigt die gesamte Terminalausgabe des Programms.

Führt man das Programm mehrmals aus, so erkennt man, dass sich die physikalischen Adressen (an welcher Stelle im Hauptspeicher der jeweilige Wert des Zeichens des Satzes abgespeichert ist) ständig verändern. Die nebenstehende Abbildung verdeutlicht die Adressenstruktur des Arrays char satz[23]. Das Bild wurde mittels eines C++-Python Shell-Skriptes erstellt, wobei das C++ Programm HelloWorld_1_zeiger_python.cpp zunächst die Adressenstruktur des Strings in eine Python Liste schreibt und dann das Python Programm HelloWorld_1_zeiger_python.py diese Adressen in das Leitbild der Vorlesung illustration_DeborahMoldawski.jpg einfügt. Mittels des C++-Python Shell-Skriptes plot_bild.sh werden die einzelnen Komponenten des C++-Python Programmes zusammengefasst ausführbar. Sie können z.B. nun selbst betrachten, wie auf ihrem Computer die zugrundeliegende Adressenstruktur des "Hello World"-Satzes aussieht. Laden Sie sich dazu am besten den .zip file "plot_bild.zip" herunter, entpacken ihn, überprüfen ob die Datei "plot_bild.sh" ausführbar ist und führen dann das C++-Python Shell-Skript mittels des Befehls "./plot_bild.sh" im Terminal aus.

In diesem Unterkapitel wurde bisher in das C++ Zeiger Konzept eingeführt und die enge Verwandtschaft von Zeigern und eindimensionalen typenspezifischen Arrays aufgezeigt. Auf den Themenbereich der mehrdimensionalen Arrays werden wir in der nächsten Vorlesung eingehen und auf das C++ Container Konzept, welches unter anderem den wichtigen Container vector der Standardbibliothek bereitstellt, werden wir in der übernächsten Vorlesung zu sprechen kommen.

Arrays an Funktionen übergeben

Möchte man ein Array an eine Funktion als Argument übergeben, sollte man dies nicht über die einzelnen Werte des Arrays machen, da man dann die Array-Einträge nicht verändern kann. Stattdessen übergibt man ein Array (dies gilt auch für mehrdimensionale Arrays) als Zeiger auf sein erstes Element mit einem zusätzlichen Vermerk zu seiner Dimension.

Array_funktionen_1.cpp
#include <iostream>                            // Ein- und Ausgabebibliothek

/** Funktion ohne Rueckgabewert, die als Argument den Zeiger auf 
 * ein eindimensionales int-Array und seine Dimension hat.
 * Falls die Dimension groesser als 5 ist, vertauscht sie den vierten
 * Eintrag im Array (v[3]) mit dem sechsten (v[5])
 **/
void Tausche_35(int* pv, int dim){
    if( dim > 5 ){
        printf("Vertausche v[3] mit v[5] \n"); 
        int tmp = *(pv+3);                     // Speichert den Wert von v[3]
        *(pv+3) = *(pv+5);                     // Schreibt den Wert von v[5] in v[3]
        *(pv+5) = tmp;                         // Schreibt den alten Wert von v[3] in v[5]
    }
}

int main(){                                    // Hauptfunktion
    int v[] = {1,4,6,8,5,3,8,9,3};             // Definition eines Integer-Arrays mit neun Einträgen
    int dim_v = sizeof(v)/sizeof(v[0]);        // Dimension des Arrays 

    //Ausgabe von v mittels einer Index-basierten for-Schleife
    printf("v = ( %2i", v[0]); 
    for(int i=1; i<dim_v; ++i){
        printf(" ,%2i", v[i]);
    }
    printf(" )\n");
    
    Tausche_35(v, dim_v);
    
    //Ausgabe von v mittels einer Bereichsbasierten for-Schleife
    printf("v = ( "); 
    for(int n : v){         // Man haette an dieser Stelle auch "for(auto n : v){" schreiben koennen
        printf("%2i, ", n);
    }
    printf("\b\b )\n");
}

Als ein einfaches Beispiel betrachten wir uns die folgende Programmieraufgabe. Sie sollen eine C++ Funktion schreiben, die den 4. und 6. Eintrag eines eindimensionalen Arrays vertauscht. Nehmen wir z.B. den Vektor $\vec{v} = \left( v_0,v_1,v_2,v_3,v_4,v_5,v_6,v_7,v_8 \right) = \left( 1,4,6,8,5,3,8,9,3 \right)$ und vertauschen seinen 4. und 6. Eintrag. Ein solches Vertauschen ist gleichbedeutend mit dem Tausch der Werte von $v_3$ mit $v_5$ und das Ergebnis wäre der Vektor $\vec{v} = \left( 1,4,6,3,5,8,8,9,3 \right)$. Die zu entwerfende Tausch-Funktion sollte natürlich nur funktionieren, wenn die Dimension des Arrays mindestens 6 ist. Das nebenstehende C++ Programm stellt einen solchen Komponentenaustausch des Vektors dar und verwendet dabei eine definierte Tausch-Funktion. Die konstruierte Tausch-Funktion "void Tausche_35(int* pv, int dim){ ... }" hat zwar keinen Rückgabewert, dennoch verändert sie die Komponenten des int-Arrays, indem sie auf Zeigerebene die Werte des Arrays abändert und vertauscht. Im main()-Programm wird zunächst das Array definiert, dann mittels einer normalen (Index-basierten) for-Schleife im Terminal ausgegeben, danach vertauscht und dann nochmals im Terminal ausgegeben. Die zweite Terminalausgabe des Vektors benutzt dabei eine Bereichsbasierte for-Schleife.