C++-Programmierung/ Weitere Grundelemente

Aus Wikibooks

Wechseln zu: Navigation, Suche
Weitere Grundelemente

Zielgruppe:

Anfänger, diesmal zusammen

Lernziel:
Grundelemente in C++

Inhaltsverzeichnis

Prozeduren und Funktionen

Unter einer Funktion (function, in anderen Programmiersprachen auch Prozedur oder Subroutine genannt) versteht man ein Unterprogramm, das eine bestimmte Aufgabe erfüllt. Funktionen sind unter anderem sinnvoll, um sich oft wiederholende Befehle zu kapseln, so dass diese nicht jedesmal neu geschrieben werden müssen. Zudem verbessert es die Übersichtlichkeit der Quellcode-Struktur erheblich, wenn der Programmtext logisch in Abschnitte unterteilt wird.

[Bearbeiten] Parameter und Rückgabewert

Die spezielle Funktion main() ist uns schon mehrfach begegnet. In C++ lassen sich Funktionen nach folgenden Kriterien unterscheiden:

  • Eine Funktion kann Parameter besitzen, oder nicht.
  • Eine Funktion kann einen Wert zurückgeben, oder nicht.

Dem Funktionsbegriff der Mathematik entsprechen diejenigen C++-Funktionen, die sowohl Parameter haben als auch einen Wert zurückgeben. Dieser Wert kann im Programm weiter genutzt werden, um ihn z.B. einer Variablen zuzuweisen.

Crystal Clear app terminal.png
int a = f(5); // Aufruf einer Funktion

Damit diese Anweisung fehlerfrei kompiliert wird, muss vorher die Funktion f() deklariert worden sein. Bei einer Funktion bedeutet Deklaration die Angabe des Funktionsprototyps. Das heißt, der Typ von Parametern und Rückgabewert muss angegeben werden. Das folgende Beispiel deklariert bspw. eine Funktion, die einen Parameter vom Typ int besitzt und einen int-Wert zurückgibt.

Crystal Clear app terminal.png
int f (int x); // Funktionsprototyp == Deklaration

Soll eine Funktion keinen Wert zurückliefern, lautet der Rückgabetyp formal void.

Nach dem Compilieren ist das Linken der entstanden Objektdateien zu einem ausführbaren Programm nötig. Der Linker benötigt die Definition der aufzurufenden Funktion. Eine Funktionsdefinition umfasst auch die Implementation der Funktion, d.h. den Code, der beim Aufruf der Funktion ausgeführt werden soll. In unserem Fall wäre das:

Crystal Clear app terminal.png
int f(int x);     // Funktionsdeklaration

int main(){
    int a = f(3); // Funktionsaufruf
    // a hat jetzt den Wert 9
}

int f(int x){    // Funktionsdefinition
    return x * x;
}
Symbol opinion vote.svg
Hinweis
Innerhalb eines Programms dürfen Sie eine Funktion beliebig oft (übereinstimmend!) deklarieren, aber nur einmal definieren.

Der Compiler muss die Deklaration kennen, um eventuelle Typ-Unverträglichkeiten abzufangen. Würden Sie die obige Funktion z.B. als int a = f(2.5); aufrufen, käme die Warnung, dass f() ein ganzzahliges Argument erwartet und keine Fließkommazahl. Eine Definition ist für den Compiler auch immer eine Deklaration, das heißt Sie müssen nicht explizit eine Deklaration einfügen um eine Funktion aufzurufen, die zuvor definiert wurde.

Die Trennung von Deklaration und Definition kann zu übersichtlicher Code-Strukturierung bei größeren Projekten genutzt werden. Insbesondere ist es sinnvoll, Deklarationen und Definitionen in verschiedene Dateien zu schreiben. Oft will man, wenn man fremden oder alten Code benutzt, nicht die Details der Implementierung einer Funktion sehen, sondern nur das Format der Parameter o.ä. und kann so in der Deklarationsdatei (header file, üblicherweise mit der Endung .hpp, z.T. auch .h oder .hh) nachsehen, ohne durch die Funktionsrümpfe abgelenkt zu werden. (Bei proprietärem Fremdcode bekommt man die Implementation in der Regel gar nicht zu Gesicht!)

Bei einer Deklaration ist es nicht nötig, die Parameternamen mit anzugeben, denn diese sind für den Aufruf der Funktion nicht relevant. Es ist allerdings üblich die Namen dennoch mit anzugeben um zu verdeutlichen was der Parameter darstellt. Der Compiler ignoriert die Namen in diesem Fall einfach, weshalb es auch möglich ist den Parametern in der Deklaration und der Definition unterschiedliche Namen zu geben. Allerdings wird davon aus Gründen der Übersichtlichkeit abgeraten.

Mit der Anweisung return gibt die Funktion einen Wert zurück, in unserem Beispiel x * x, wobei die Variable x als Parameter bezeichnet wird. Als Argument bezeichnet man eine Variable oder einen Wert, mit denen eine Funktion aufgerufen wird. Bei Funktionen mit dem Rückgabetyp void schreiben Sie einfach return; oder lassen die return-Anweisung ganz weg. Nach einem return wird die Funktion sofort verlassen, d.h. alle nachfolgenden Anweisungen des Funktionsrumpfs werden ignoriert.

Erwartet eine Funktion mehrere Argumente, so werden die Parameter durch Kommata getrennt. Eine mit

Crystal Clear app terminal.png
int g(int x, double y);

deklarierte Funktion könnte z.B. so aufgerufen werden:

Crystal Clear app terminal.png
int a = g(123, -4.44);

Für eine leere Parameterliste schreiben Sie hinter dem Funktionsnamen einfach ().

Crystal Clear app terminal.png
int h(){     // Deklaration von h()
  // Quellcode ...
}

int a = h(); // Aufruf von h()
Symbol opinion vote.svg
Hinweis
Vergessen Sie beim Aufruf einer Funktion ohne Parameter nicht die leeren Klammern. Andernfalls erhalten Sie nicht den von der Funktion zurückgelieferten Wert, sondern die Adresse der Funktion. Dies ist ein „beliebter“ Anfängerfehler, daher sollten Sie im Falle eines Fehlers erst einmal überprüfen, ob Sie nicht vielleicht irgendwo eine Funktion ohne Parameter falsch aufgerufen haben.

[Bearbeiten] Übergabe der Argumente

C++ kennt zwei Varianten, wie einer Funktion die Argumente übergeben werden können: call-by-value und call-by-reference.

[Bearbeiten] call-by-value

Bei call-by-value (Wertübergabe) wird der Wert des Arguments in einen Speicherbereich kopiert, auf den die Funktion mittels Parametername zugreifen kann. Ein Werteparameter verhält sich wie eine lokale Variable, die „automatisch“ mit dem richtigen Wert initialisiert wird. Der Kopiervorgang kann bei Klassen (Thema eines späteren Kapitels) einen erheblichen Zeit- und Speicheraufwand bedeuten!

Crystal Clear action button cancel.png
#include <iostream>

void f1(const int x) {
    x = 3 * x;       // ungültig, weil Konstanten nicht überschrieben werden dürfen
    std::cout << x << std::endl;
}

void f2(int x) {
    x = 3 * x;
    std::cout << x << std::endl;
}

int main() {
   int a = 7;
   f2(a);          // Ausgabe: 21
   f2(5);          // Ausgabe: 15
   std::cout << x; // Fehler! x ist hier nicht definiert
   std::cout << a; // a hat immer noch den Wert 7
}

Wird der Parameter als const deklariert, so darf ihn die Funktion nicht verändern (siehe erstes Beispiel). Im zweiten Beispiel kann die Variable x verändert werden. Die Änderungen betreffen aber nur die lokale Kopie und sind für die aufrufende Funktion nicht sichtbar.

[Bearbeiten] call-by-reference

Die Sprache C kennt nur call-by-value. Sollen die von einer Funktion vorgenommen Änderungen auch für das Hauptprogramm sichtbar sein, müssen sogenannte Zeiger verwendet werden. C++ stellt ebenfalls Zeiger zur Verfügung. C++ gibt Ihnen aber auch die Möglichkeit, diese Zeiger mittels Referenzen zu umgehen. Beide sind jedoch noch Thema eines späteren Kapitels.

Im Gegensatz zu call-by-value wird bei call-by-reference die Speicheradresse des Arguments übergeben, also der Wert nicht kopiert. Änderungen der (Referenz-)Variable betreffen zwangsläufig auch die übergebene Variable selbst und bleiben nach dem Funktionsaufruf erhalten. Um call-by-reference anzuzeigen, wird der Operator & verwendet, wie Sie gleich im Beispiel sehen werden. Wird keine Änderung des Inhalts gewünscht, sollten Sie den Referenzparameter als const deklarieren um so den Speicherbereich vor Änderungen zu schützen. Fehler, die sich aus der ungewollten Änderung des Inhaltes einer übergebenen Referenz ergeben, sind in der Regel schwer zu finden.

Die im folgenden Beispiel definierte Funktion swap() vertauscht ihre beiden Argumente. Weil diese als Referenzen übergeben werden, überträgt sich das auf die Variablen mit denen die Funktion aufrufen wurde:

Crystal Clear app terminal.png
#include <iostream>

void swap(int &a, int &b) {
    int tmp = a; // "temporärer" Variable den Wert von Variable a zuweisen
    a = b;       // a mit dem Wert von Variable b überschreiben
    b = tmp;     // b den Wert der "temporären" Variable zuweisen (Anfangswert von Variable a)
}

int main() {
    int x = 5, y = 10;

    swap(x, y);

    std::cout << "x=" << x << " y=" << y << std::endl;

    return 0;
}
Crystal Clear app kscreensaver.png
Ausgabe:
x=10 y=5
Symbol kept vote.svg
Tipp
Ob das kaufmännische Und (&) genau nach int (int& a, ...), oder genau vor der Variablen a (int &a, ...), oder dazwischen (int & a, ...) steht, ist für den Compiler nicht von Bedeutung. Auch möglich wäre (int&a, ...).

Nicht-konstante Referenzen können natürlich nur dann übergeben werden, wenn das Argument tatsächlich eine Speicheradresse hat, sprich eine Variable bezeichnet. Ein Literal z.B. 123 oder gar ein Ausdruck 1 + 2 wäre hier nicht erlaubt. Bei const-Referenzen wäre das möglich, der Compiler würde dann eine temporäre Variable anlegen.


Symbol move vote.svg
Thema wird später näher erläutert…
Wir werden die oben angesprochenen Zeiger in einem späteren Kapitel ausführlich kennen lernen. Sie ermöglichen z.B. das Arbeiten mit Arrays. Soll eine Funktion mit einem Array umgehen, werden ihr die Startadresse des Arrays (ein Zeiger) und seine Größe als Parameter übergeben. Wenn das Array nur gelesen werden soll, deklariert man die Werte, auf die der Zeiger zeigt, als const. Das Zeichen * signalisiert, dass es sich um einen Zeiger handelt.
Crystal Clear app terminal.png
#include <iostream>

void print(int const* array, int const arrayGroesse) {
    std::cout << "Array:" << std::endl;
    for (int i = 0; i < arrayGroesse; ++i) {
        std::cout << array[i] << std::endl;
    }
}

int main() {
    int array[] = { 3, 13, 113 };
    int n = sizeof(array) / sizeof(array[0]);

    print(array, n);
}
Crystal Clear app kscreensaver.png
Ausgabe:
Array:
3
13
113

[Bearbeiten] Default-Parameter

Default-Parameter dienen dazu, beim Aufruf einer Funktion nicht alle Parameter explizit angeben zu müssen. Die nicht angegebenen Parameter werden mit einer Voreinstellung (default) belegt. Parameter, die bei einem Aufruf einer Funktion nicht angegeben werden müssen, werden auch als „fakultative Parameter“ bezeichnet.

Crystal Clear app terminal.png
int summe(int a, int b, int c = 0, int d = 0) {
    return a + b + c + d;
}
 
int main() {
    int x = summe(2, 3, 4, 5); //x == 14
    x = summe(2, 3, 4);        //x == 9, es wird d=0 gesetzt
    x = summe(2, 3);           //x == 5, es wird c=0, d=0 gesetzt  
}

Standardargumente werden in der Deklaration einer Funktion angegeben, da der Compiler sie beim Aufruf der Funktion kennen muss. Im obigen Beispiel wurde die Deklaration durch die Definition gemacht, daher sind die Parameter hier in der Definition angegeben. Bei einer getrennten Schreibung von Deklaration und Definition könnte das Beispiel so aussehen:

Crystal Clear app terminal.png
int summe(int a, int b, int c = 0, int d = 0); // Deklaration

int main() {
    int x = summe(2, 3, 4, 5); //x == 14
    x = summe(2, 3, 4);        //x == 9, es wird d=0 gesetzt
    x = summe(2, 3);           //x == 5, es wird c=0, d=0 gesetzt  
}

int summe(int a, int b, int c, int d) {        // Definition
    return a + b + c + d;
}

[Bearbeiten] Funktionen überladen

Überladen (overloading) von Funktionen bedeutet, dass verschiedene Funktionen unter dem gleichen Namen angesprochen werden können. Damit der Compiler die Funktionen richtig zuordnen kann, müssen die Funktionen sich in ihrer Funktionssignatur unterscheiden. In C++ besteht die Signatur aus dem Funktionsnamen und ihren Parametern, der Typ des Rückgabewerts gehört nicht dazu. So ist es nicht zulässig, eine Funktion zu überladen, die den gleichen Namen und die gleiche Parameterliste wie eine bereits existierende Funktion besitzt und sich nur im Typ des Rückgabewerts unterscheidet. Das obige Beispiel lässt sich ohne Default-Parameter so formulieren:

Crystal Clear app terminal.png
int summe(int a, int b, int c, int d) {
    return a + b + c + d;
}

int summe(int a, int b, int c) {
    return a + b + c;
}

int summe(int a, int b) {
    return a + b;
}

int main() {
    // ...
}

[Bearbeiten] Funktionen mit beliebig vielen Argumenten

Wenn die Zahl der Argumente nicht von vornherein begrenzt ist, wird als Parameterliste die sog. Ellipse ... angegeben. Der Funktion werden die Argumente dann in Form einer Liste übergeben, auf die mit Hilfe der (in der Headerdatei cstdarg definierten) va-Makros zugegriffen werden kann.

Crystal Clear app terminal.png
#include <cstdarg>

int summe(int a, ...) {
    int summe = 0;
    int i = a;

    va_list Parameter;              // Zeiger auf Argumentliste
    va_start(Parameter, a);         // gehe zur ersten Position der Liste

    while (i != 0){                 // Schleife, solange Zahl nicht 0 (0 ist Abbruchbedingung)
        summe += i;                 // Zahl zur Summe addieren
        i = va_arg(Parameter, int); // nächstes Argument an i zuweisen, Typ int
    }

    va_end(Parameter);              // Liste löschen

    return summe;
}

int main() {
    int x;

    x = summe(2, 3, 4, 0);          // x = 9
    x = summe(2, 3, 0);             // x = 5

    x = summe(1,1,0,1,0);           // x = 2 (da die erste 0 in der while-Schleife für Abbruch sorgt)

    return x;
}

Obwohl unspezifizierte Argumente manchmal verlockend aussehen, sollten Sie sie nach Möglichkeit vermeiden. Das hat zwei Gründe:

  • Die va-Befehle können nicht erkennen, wann die Argumentliste zu Ende ist. Es muss immer mit einer expliziten Abbruchbedingung gearbeitet werden. In unserem Beispiel muss als letztes Argument eine 0 stehen, ansonsten gibt es, je nach Compiler unterschiedliche, „interessante“ Ergebnisse.
  • Die va-Befehle sind Makros, d.h. eine strenge Typüberprüfung findet nicht statt. Fehler werden - wenn überhaupt - erst zur Laufzeit bemerkt.

[Bearbeiten] Inline-Funktionen

Um den Aufruf einer Funktion zu beschleunigen, kann in die Funktionsdeklaration das Schlüsselwort inline eingefügt werden. Dies ist eine Empfehlung (keine Anweisung) an den Compiler, beim Aufruf dieser Funktion keine neue Schicht auf dem Stack anzulegen, sondern den Code direkt auszuführen - den Aufruf sozusagen durch den Funktionsrumpf zu ersetzen.

Da dies – wie eben schon erwähnt – nur eine Empfehlung an den Compiler ist, wird der Compiler eine Funktion nur dann tatsächlich inline einbauen, wenn es sich um eine kurze Funktion handelt. Ein typisches Beispiel:

Crystal Clear app terminal.png
inline int max(int a, int b) {
    return a > b ? a : b;
}
Symbol move vote.svg
Thema wird später näher erläutert…
Das Schlüsselwort inline wird bei der Definition angegeben. Allerdings muss der Compiler beim Aufruf den Funktionsrumpf kennen, wenn er den Code direkt einfügen soll. Für den Aufruf einer inline-Funktion genügt also wie immer die Deklaration. Über diese Eigenheit von inline-Funktionen erfahren Sie im Kapitel „Headerdateien“ mehr. Auch die Bedeutung von Deklaration und Definition wird Ihnen nach diesem Kapitel klarer sein.

Lebensdauer und Sichtbarkeit von Variablen

In diesem Kapitel werden Sie zuerst lernen, wie lange eine Variable im Speicher unseres Programmes existiert (Lebensdauer). Danach wird behandelt, an welchen Stellen wir auf eine bestimmte Variable zugreifen können (Sichtbarkeit).

[Bearbeiten] Lebensdauer

[Bearbeiten] Globale Variablen

Variablen, die auf der äußersten Ebene deklariert werden, nennt man global, weil an jeder Stelle des Programmes auf sie zugegriffen werden kann. Dabei darf die Variable nicht innerhalb einer Funktion ( main() eingeschlossen) oder einer Klasse deklariert werden.

Diese Variablen werden meist beim Starten initialisiert und beim Beenden wieder zerstört. Damit ist klar, dass der Speicher, den sie benötigen, für die gesamte Laufzeit des Programmes belegt ist und nicht anders verwendet werden kann.

Crystal Clear app terminal.png
int var = 234; //Beispiel für eine globale Variable

int main() {
    // ...
}

[Bearbeiten] Lokale Variablen

Im Gegensatz zu globalen, werden lokale Variablen in einem bestimmten Anweisungsblock (z.B. Schleifen, if-Abfragen oder Funktionen) deklariert. Ihre Existenz endet wenn dieser Block wieder verlassen wird.

Crystal Clear app terminal.png
void foo(int a) {
    int lok1 = 0; // lok1 ist eine Variable im Anweisungsblock von void foo(int a)

    if (a < 0) {
        int lok2 = 1; // lok2 ist eine Variable im if-Block
    } // hier wird lok2 aus dem Speicher gelöscht...
} // ...und hier lok1
Symbol opinion vote.svg
Hinweis
Bei Schleifen ist zu beachten dass Variablen, deren Deklaration im Schleifenrumpf steht, bei jedem Durchlauf neu initialisiert werden
Crystal Clear app terminal.png
while (1) {
    int var = 0;
    std::cout << var << std::endl; // die Ausgabe ist hier immer 0
    ++var;
}

[Bearbeiten] Statische Variablen

Statische Variablen (auch statische Klassenmember) werden wie globale zu Beginn des Programmes im Speicher angelegt und bei seinem Ende wieder daraus entfernt. Der Unterschied zu einer globalen Variable wird weiter unten auf dieser Seite im Teil über Sichtbarkeit geklärt.

[Bearbeiten] Dynamisch erzeugte Variablen

Eine Variable, die mittels dem new Operator angefordert wird, heißt dynamisch. Sie existiert so lange bis sie durch einen Aufruf von delete wieder gelöscht wird.

Symbol opinion vote.svg
Hinweis
Der Aufruf von delete ist die einzige Möglichkeit den von einer dynamisch erzeugten Variable belegten Speicher wieder frei zu geben. Geschieht dies nicht so kann es leicht zu einem Speicherleck kommen. Lesen sie bitte auch das Kapitel über den dynamischen Speicher um Informationen über new bzw. delete zu erhalten

[Bearbeiten] Objekte und Membervariablen

Objekte werden wie normale Variablen gehandhabt, d. h. sie können global, lokal, statisch oder dynamisch erzeugt sein. Ihre Member haben die gleiche Lebensdauer wie sie selbst. Eine Ausnahme bilden statische Klassenvariablen, die von Anfang bis Ende des Programmablaufes im Speicher vorhanden sind.

[Bearbeiten] Sichtbarkeit

[Bearbeiten] Allgemein

Um Überhaupt die Chance zu haben mit einer Variablen zu arbeiten, muss diese im Quelltext bereits deklariert worden sein. Folgendes Codestück ist also falsch und würde zu einem Fehler führen.

Crystal Clear action button cancel.png
// ...
var = 34; // Fehler z.B. "symbol var not found"
int var;
// ...
Symbol move vote.svg
Thema wird später näher erläutert…
Das Prinzip der Datenkapselung in der objektorientierten Programmierung finden Sie im Abschnitt über Klassen.

[Bearbeiten] Gültigkeitsbereiche und deren Schachtelung

Jede Variable gehört zu einem bestimmten Gültigkeitsbereich (engl. scope). Diese legen fest wann eine Variable von uns „gesehen“ und damit benutzt werden kann. Vereinfacht gesagt bildet jedes Paar aus geschweiften Klammern ( {}) einen eigenen Definitionsbereich.Dazu gehören beispielsweise if, else, Schleifen und Funktionen. Diese unterschiedlichen Bereiche sind nun ineinander geschachtelt ähnlich wie die berühmten Matrjoschka-Puppen mit dem Unterschied, dass die Definitionsbereiche nicht „kleiner“ werden und dass es mehrere „nebeneinander“ geben kann.

Crystal Clear app terminal.png
// das hier gehört zum globalen Bereich

int func() { // hier beginnt der Bereich der Funktion func...
    return 0;
    // ... und ist hier auch schon wieder beendet
}

int bar(int val) {  // val gehört zum Definitionsbereich "bar"
    if (val == 7) { // diese if-Anweisung hat auch ihren eigenen Gültigkeitsbereich...
       int ich_gehoer_zum_if;
    } //... der hier zu Ende ist
} // bar ende

[Bearbeiten] Welche Variablen sind sichtbar?

Jetzt ist es leicht zu bestimmen mit welchen Variablen wir an einer bestimmten Stelle im Programmcode arbeiten können: Es sind diejenigen, die entweder dem derzeitigen oder einem Gültigkeitsbereich auf einer höheren Ebene angehören. Wenn wir uns erneut das Beispiel der Puppen vor Augen halten, so wird klar was hiermit gemeint ist.

Beispiel:

Crystal Clear app terminal.png
int out;
{
    int inner_1;
}

{  
    int inner_2;
    //an dieser Stelle könnten wir sowohl auf out als auch auf inner_2 zugreifen, nicht jedoch auf inner_1;
}

Zusätzlich gilt noch, dass von zwei Variablen gleichen Namens nur auf die weiter inner liegende zugegriffen werden kann.

Crystal Clear app terminal.png
int var=9;

{  
    int var=3;
    std::cout << var << std::endl;//die Ausgabe ist 3
}

Schleifen mal anders – Rekursion

Jede Funktion kann sowohl andere Funktionen als auch sich selbst aufrufen. Ein solcher Selbstaufruf wird auch rekursiver Aufruf genannt. Das dahinter stehende Konzept bezeichnet man entsprechend als Rekursion.

Eine Ausnahme von dieser Regel bildet wiedereinmal die Funktion main(). Sie darf ausschließlich vom Betriebssystem aufgerufen werden, also weder von einer anderen Funktion, noch aus sich selbst heraus.

Eine rekursive Problemlösung ist etwas langsamer und speicheraufwendiger als eine iterative Variante (also mit Schleifen). Dafür ist der Code allerdings auch kompakter und ein „intelligenter“ Compiler ist meist in der Lage, eine Rekursion in eine Iteration umzuwandeln um somit die Nachteile aufzuheben. Sie sollten also keine Scheu haben ein Problem mit Rekursion zu lösen, wenn Sie so schneller ein richtiges Ergebnis erhalten als beim Schreiben einer iterativen Variante. Sollten dadurch im Laufe der Entwicklung eines Programms Geschwindigkeits- oder Speichernachteile auftreten, so können Sie die Funktion immer noch durch eine iterativ arbeitende ersetzen.

[Bearbeiten] Fakultät

Als erstes einfaches Beispiel einer rekursiven Problemlösung nehmen wir die Berechnung der Fakultät. Da die Fakultät für negative und nicht ganze Zahlen nicht definiert ist, benutzen wir als Datentyp unsigned int:

Crystal Clear app terminal.png
#include <iostream>       // Für std::cin und std::cout

unsigned int fakultaet(unsigned int zahl) {
    if (zahl <= 1) {
        return 1; // Die Fakultät von 0 und 1 ist als 1 definiert.
    }

    return fakultaet(zahl - 1) * zahl;
}

int main() {
    unsigned int zahl;

    std::cout << "Bitte Zahl eingeben: ";
    std::cin >> zahl;                           // Zahl einlesen
    std::cout << "Die Fakultät von " << zahl << // Antwort ausgeben
        " ist " << fakultaet(zahl) << "!" << endl;
}
Crystal Clear app kscreensaver.png
Ausgabe:
Bitte Zahl eingeben: <eingabe>4</eingabe>
Die Fakultät von 4 ist 24!

Genau wie bei einer Schleife, ist auch bei einer Rekursion eine Abbruchbedingung definiert (also erforderlich) und genau wie bei einer Schleife würde ohne Abbruchbedingung eine Endlosrekursion auftreten, analog zur Endlosschleife. So eine Endlosschleife bezeichnet man auch als infiniten Regress. Wenn der Wert der Variablen zahl kleiner, oder gleich eins ist, so wird eins zurückgegeben, andernfalls wird weiter rekursiv aufgerufen. Eine iterative Variante für das gleiche Problem könnte folgendermaßen aussehen:

Crystal Clear app terminal.png
unsigned int fakultaet(unsigned int zahl) {
    unsigned int wert = 1;

    for (unsigned int i = 2; i <= zahl; ++i) {
        wert *= i;
    }

    return wert;
}

[Bearbeiten] Fibonacci-Zahlen

Als zweites Beispiel wollen wir Fibonacci-Zahlen ausrechnen.

Crystal Clear app terminal.png
#include <iostream>

unsigned int fibonacci(unsigned int zahl) {
    if (zahl == 0) { // Die Fibonacci-Zahl von null ist null
        return 0;
    }

    if (zahl == 1) { // Die Fibonacci-Zahl von eins ist eins
        return 1;
    }

    // Ansonsten wird die Summe der zwei vorherigen Fibonacci-Zahlen zurückgegeben
    return fibonacci(zahl - 1) + fibonacci(zahl - 2);
}

int main() {
    unsigned int zahl;

    std::cout << "Bitte Zahl eingeben: ";
    std::cin >> zahl;                                 // Zahl einlesen
    std::cout << "Die Fibonacci-Zahl von " << zahl << // Antwort ausgeben
        " ist " << fibonacci(zahl) << "!" << endl;
}
Crystal Clear app kscreensaver.png
Ausgabe:
Bitte Zahl eingeben: <eingabe>12</eingabe>
Die Fibonacci-Zahl von 12 ist 144!

Die iterative Entsprechung sieht folgendermaßen aus:

Crystal Clear app terminal.png
unsigned int fibonacci(unsigned int zahl) {
    if (zahl == 0) { // Die Fibonacci-Zahl von null ist null
        return 0;
    }

    if (zahl == 1) { // Die Fibonacci-Zahl von eins ist eins
        return 1;
    }

    unsigned int ret;
    unsigned int h1 = 0;
    unsigned int h2 = 1;

    for (unsigned int i = 1; i < zahl; ++i) {
        ret = h1 + h2; // Ergebnis ist die Summe der zwei vorhergehenden Fibonacci-Zahlen
        h1 = h2;       // und den beiden Hilfsvariablen die
        h2 = ret;      // neuen vorhergehenden Fibonacci-Zahlen
    }

    return ret;
}

Bei vielen komplexen Problemen eignet sich Rekursion oft besser zur Beschreibung, als eine iterative Entsprechung. Aus diesem Grund trifft man das Konzept der Rekursion in der Programmierung recht häufig an.

Zeiger

[Bearbeiten] Grundlagen zu Zeigern

Zeiger (engl. pointer) sind Variablen, die als Wert die Speicheradresse einer anderen Variablen enthalten. Zeiger können auf eine Variable eines Typs zeigen, oder auf einen Speicherbereich ohne Typ, deklariert mit dem Schlüsselwort void.

Jede Variable wird in C++ an einer bestimmten Position im Hauptspeicher abgelegt. Diese Position nennt man Speicheradresse (memory address). C++ bietet die Möglichkeit, die Adresse jeder Variable zu ermitteln. Solange eine Variable gültig ist, bleibt sie an ein und derselben Stelle im Speicher.

Am einfachsten vergegenwärtigt man sich dieses Konzept anhand der globalen Variablen. Diese werden außerhalb aller Funktionen und Klassen deklariert und sind überall gültig. Auf sie kann man von jeder Klasse und jeder Funktion aus zugreifen. Über globale Variablen ist bereits zur Compilerzeit bekannt, wo sie sich innerhalb des Speichers befinden (also kennt das Programm ihre Adresse).

Zeiger sind nichts anderes als normale Variablen. Sie werden deklariert (und definiert), besitzen einen Gültigkeitsbereich, eine Adresse und einen Wert. Dieser Wert, der Inhalt der Zeigervariablen, ist aber nicht wie in unseren bisherigen Beispielen eine Zahl, sondern die Adresse einer anderen Variablen oder eines Speicherbereichs. Bei der Deklaration einer Zeigervariable wird der Typ der Variablen festgelegt, auf den sie verweisen soll. Dieser Typ ist fest und kann nicht verändert werden.

Crystal Clear app terminal.png
#include <iostream>

int main() {
    int   Wert;      // eine int-Variable
    int *pWert;      // eine Zeigervariable, zeigt auf einen int
    int *pZahl;      // ein weiterer "Zeiger auf int"

    Wert = 10;       // Zuweisung eines Wertes an eine int-Variable

    pWert = &Wert;   // Adressoperator '&' liefert die Adresse einer Variablen
    pZahl = pWert;   // pZahl und pWert zeigen jetzt auf dieselbe Variable

Der Adressoperator & kann auf jede Variable angewandt werden und liefert deren Adresse, die man einer (dem Variablentyp entsprechenden) Zeigervariablen zuweisen kann. Wie im Beispiel gezeigt, können Zeiger gleichen Typs einander zugewiesen werden. Zeiger verschiedenen Typs bedürfen einer Typumwandlung. Die Zeigervariablen pWert und pZahl sind an verschiedenen Stellen im Speicher abgelegt, nur die Inhalte sind gleich.

Wollen Sie auf den Wert zugreifen, der sich hinter der im Zeiger gespeicherten Adresse verbirgt, so verwenden Sie den Dereferenzierungsoperator *.

Crystal Clear app terminal.png
    *pWert += 5;
    *pZahl += 8;

    std::cout << "Wert = " << Wert << std::endl;
}
Crystal Clear app kscreensaver.png
Ausgabe:
Wert = 23

Man nennt das den Zeiger dereferenzieren. Im Beispiel erhalten Sie die Ausgabe Wert = 23, denn pWert und pZahl verweisen ja beide auf die Variable Wert.

Um es noch einmal hervorzuheben: Zeiger auf Integer ( int) sind selbst keine Integer. Den Versuch, einer Zeigervariablen eine Zahl zuzuweisen, beantwortet der Compiler mit einer Fehlermeldung oder mindestens einer Warnung. Hier gibt es nur eine Ausnahme: die Zahl 0 darf jedem beliebigen Zeiger zugewiesen werden. Ein solcher Nullzeiger zeigt nirgendwohin. Der Versuch, ihn zu dereferenzieren, führt zu einem Laufzeitfehler.

Symbol move vote.svg
Thema wird später näher erläutert…
Der Sinn von Zeigern erschließt sich vor allem Anfängern nicht unmittelbar. Das ändert sich allerdings schnell, sobald der dynamische Speicher besprochen wird.

[Bearbeiten] Zeiger und const

Das Schlüsselwort const kann auf zweierlei Arten in Verbindung mit Zeigern genutzt werden:

  • Um den Wert, auf den der Zeiger zeigt, konstant zu machen
  • Um den Zeiger selbst konstant zu machen

Im ersteren Fall kann der Zeiger im Laufe seines Lebens auf verschiedene Objekte zeigen, diese Werte können dann allerdings (über diesen Zeiger) nicht geändert werden. Im zweiten Fall kann der Zeiger nicht auf eine andere Adresse "umgebogen" werden. Der Wert an dieser Stelle kann allerdings verändert werden. Natürlich sind auch beide Varianten in Kombination möglich.

Crystal Clear action button cancel.png
int               Wert1;           // eine int-Variable
int               Wert2;           // noch eine int-Variable
int const *       p1Wert = &Wert1; // Zeiger auf konstanten int
int * const       p2Wert = &Wert1; // konstanter Zeiger auf int
int const * const p3Wert = &Wert1; // konstanter Zeiger auf konstanten int

p1Wert  = &Wert2; // geht
*p1Wert = Wert2;  // geht nicht, int konstant

p2Wert  = &Wert2; // geht nicht, Zeiger konstant
*p2Wert = Wert2;  // geht

p3Wert  = &Wert2; // geht nicht, int konstant
*p3Wert = Wert2;  // geht nicht, Zeiger konstant

Wie Sie sich sicher noch erinnern, gehört const immer zu dem was links von ihm steht. Es sei denn links steht nichts mehr, dann gehört es zu dem was rechts davon steht.

[Bearbeiten] Zeigerarithmetik

Zeiger sind keine Zahlen. Deshalb sind einige arithmetischen Operationen auf Zeiger nicht anwendbar und für die übrigen gelten andere Rechenregeln als in der Zahlenarithmetik. C++ kennt die Größe des Speicherbereichs, auf den ein Zeiger verweist. Inkrementieren (oder Dekrementieren) verändert die referenzierte Adresse unter Berücksichtigung dieser Speichergröße. Das folgende Beispiel soll den Unterschied zwischen Zahlen und Zeigerarithmetik verdeutlichen:

Crystal Clear app terminal.png
#include <iostream>

int main() {
    std::cout << "Zahlenarithmetik" << std::endl;

    int a = 1;   // a wird 1
    std::cout << "a: " << a << std::endl;

    a++;         // a wird 2
    std::cout << "a: " << a << std::endl;

    std::cout << "Zeigerarithmetik" << std::endl;

    int *p = &a; // Adresse von a an p zuweisen

    std::cout << "p verweist auf: " << p << std::endl;
    std::cout << " Größe von int: " << sizeof(int) << std::endl;

    p++;
    std::cout << "p verweist auf: " << p << std::endl;
}
Crystal Clear app kscreensaver.png
Ausgabe:
Zahlenarithmetik
a: 1
a: 2
Zeigerarithmetik
p verweist auf: 0x7fff3aa60090
 Größe von int: 4
p verweist auf: 0x7fff3aa60094
Die Ausgabe sieht bei Ihnen vermutlich etwas anders aus.

Wie Sie sehen, erhöht sich der Wert des Zeigers nicht um eins, sondern um vier, was genau der Größe des Typs entspricht auf den er zeigt: int. Auf einer Platform auf der int eine andere Größe hat würde natürlich entsprechend dieser Größe gezählt werden. Im nächsten Beispiel sehen Sie, wie ein Zeiger auf eine weitere Zeigervariable verweist, welche ihrerseits auf einen int-Wert zeigt.

Crystal Clear app terminal.png
#include <iostream>

int main() {
    int   a  = 1;
    int  *p  = &a; // Adresse von a an p zuweisen
    int **pp = &p; // Adresse von p an pp zuweisen
    std::cout << "pp verweist auf: " << pp << std::endl;
    std::cout << " Größe von int*: " << sizeof(int*) << std::endl;
    ++pp;
    std::cout << "pp verweist auf: " << pp << std::endl;
}
Crystal Clear app kscreensaver.png
Ausgabe:
pp verweist auf: 0x7fff940cb6f0
 Größe von int*: 8
pp verweist auf: 0x7fff940cb6f8
Die Ausgabe sieht bei Ihnen vermutlich etwas anders aus.

Wie Sie sehen hat ein Zeiger auf int im Beispiel eine Größe von 8 Byte. Die Größe von Datentypen ist allerdings architektur-, compiler- und systembedingt. pp ist vom Typ „Zeiger auf Zeiger auf int“, was sich dann in C++ als int** schreibt. Um also auf die Variable "hinter" diesen beiden Zeigern zuzugreifen, muss man **pp schreiben.

Es spielt keine Rolle, ob man in Deklarationen int* p; oder int *p; schreibt. Einige Programmierer schreiben den Stern direkt hinter den Datentyp ( int* p), andere schreiben ihn direkt vor Variablennamen ( int *p) und wieder andere lassen zu beidem ein Leerzeichen ( int * p). In diesem Buch wird die Konvention verfolgt, den Stern direkt vor Variablennamen zu schreiben wenn einer vorhanden ist ( int *p), andernfalls wird er direkt nach dem Datentyp geschrieben ( int*).

[Bearbeiten] Negativbeispiele

Zur Verdeutlichung zwei Beispiele, die nicht funktionieren, weil sie vom Compiler nicht akzeptiert werden:

Crystal Clear action button cancel.png
int * pWert;
int Wert;
pWert = Wert;     // dem Zeiger kann kein int zugewiesen werden
Wert  = pWert;    // umgekehrt natürlich auch nicht

Zeigervariablen erlauben als Wert nur Adressen auf Variablen. Daher kann einer Zeigervariable wie in diesem Beispiel kein Integer-Wert zugewiesen werden.

Im Folgenden wollen wir den Wert, auf den pWert zeigt, inkrementieren. Einige der Beispiele bearbeiten die Adresse, die pWert enthält. Erst das letzte Beispiel verändert tatsächlich den Wert, auf den pWert zeigt. Beachten Sie, dass jede Codezeile ein Einzelbeispiel ist.

Crystal Clear app terminal.png
int Wert = 0;
int *pWert = &Wert; // pWert zeigt auf Wert

pWert += 5;         // ohne Dereferenzierung (*pWert) verändert man die Adresse, auf die  
                    // der Zeiger verweist, und nicht deren Inhalt
std::cout << pWert; // es wird nicht die Zahl ausgegeben, auf die pWert
                    // zeigt, sondern deren (veränderte) Adresse
printf("Wert enthält: %d", pWert);    // gleiche Ausgabe

pWert++;            // Diese Operation verändert wiederum die Adresse, da nicht dereferenziert wird.
*pWert++;           // Auf diese Idee kommt man als nächstes. Doch auch das hat nicht den
                    // gewünschten Effekt. Da der (Post-)Inkrement-Operator vor dem Dereferenzierungs-
                    // operator ausgewertet wird, verändert sich wieder die Adresse.
(*pWert)++;         // Da der Ausdruck in der Klammer zuerst ausgewertet wird, erreichen wir
                    // diesmal den gewünschten Effekt: Eine Änderung des Wertes.

[Bearbeiten] void-Zeiger (anonyme Zeiger)

Eine besondere Rolle spielen die „Zeiger auf void“, die so genannten generischen Zeiger. Einem Zeiger vom Typ void* kann jeder beliebige Zeiger zugewiesen werden. void-Zeiger werden in der Sprache C z.B. bei der dynamischen Speicherverwaltung verwendet. In C++ kommt man weitgehend ohne sie aus. Vermeiden Sie Zeiger auf void wenn Sie eine andere Möglichkeit haben.

Eine Variable kann nicht vom Typ void sein. Daher würde folgende Zeile zu einem Fehler führen:

Crystal Clear action button cancel.png
void variable;

Sie können einer Variable, die auf void zeigt, einen beliebigen Zeiger zuweisen. Deshalb werden solche Variablen meist für Zeiger verwendet, dessen Typ noch nicht feststeht und sich erst im Laufe des Programmes ergibt oder aber als temporärer Speicher mit wechselnden Zeigertypen.

Crystal Clear app terminal.png
#include <iostream>

int main() {
    int intValue    = 1;
    int *intPointer = &intValue; // zeigt auf intValue

    void *voidPointer;
    voidPointer = intPointer; // voidPointer zeigt auf intPointer

Sie können jetzt nicht ohne Weiteres auf *voidPointer zugreifen, um an die Adresse von intValue zu bekommen. Da es sich um einen Zeiger, vom Typ void handelt, muss man diesen erst casten. In diesem Fall nach int*.

Crystal Clear app terminal.png
    std::cout << *reinterpret_cast<int*>(voidPointer) << std::endl;
}

Ablauf im Detail:

  1. Zeiger *voidPointer ist vom Typ void und zeigt auf intPointer
  2. reinterpret_cast<int*>, Zeiger ist vom Typ int*
  3. Zeiger dereferenzieren ( *), um Wert zu erhalten

[Bearbeiten] Zeiger und Funktionen

Wenn Sie einen Zeiger als Parameter an eine Funktion übergeben, können Sie den Wert an der übergebenen Adresse ändern. Eine Funktion, welche die Werte zweier Variablen vertauscht, könnte folgendermaßen implementiert werden:

Crystal Clear app terminal.png
#include <iostream>

void swap(int *wert1, int *wert2) {
    int tmp;
    tmp    = *wert1;
    *wert1 = *wert2;
    *wert2 = tmp;
}

int main() {
    int a = 7, b = 9;

    std::cout << "a: " << a << ", b: " << b << std::endl;
    swap(&a, &b);
    std::cout << "a: " << a << ", b: " << b << std::endl;
}
Crystal Clear app kscreensaver.png
Ausgabe:
a: 7, b: 9
a: 9, b: 7

Diese Funktion hat natürlich einige Schwachstellen. Beispielsweise stürtzt sie ab, wenn ihr ein Nullzeiger übergeben wird. Aber sie zeigt, dass es mit Zeigern möglich ist, den Wert einer Variable außerhalb der Funktion zu verändern. In Kürze werden Sie sehen, dass sich dieses Beispiel besser mit Referenzen lösen lässt.

[Bearbeiten] Funktionszeiger

Zeiger können nicht nur auf Variablen, sondern auch auf Funktionen verweisen.

Crystal Clear app terminal.png
int (*pFunc)(int);   // Anlegen eines Funktionszeigers
int (Klasse::*pFunc)(int);   // Anlegen eines Funktionszeigers auf eine Memberfunktion

Um einen Zeiger auf eine Funktion zu deklarieren, benutzen Sie den Prototyp der Funktion und ersetzen dort den Funktionsnamen durch (*Variablenname). Das ganze wird gelesen als: pFunc ist ein Zeiger auf eine Funktion, die einen int übernimmt und einen int zurückgibt. Ohne die Klammern um den Variablennamen müsste man lesen: pFunc ist eine Funktion, die einen int übernimmt und einen Zeiger auf int zurückgibt.

Dem Zeiger kann die Adresse einer typentsprechenden Funktion zugewiesen werden. Typentsprechend heißt so viel wie: Übernimmt einen int-Wert als Argument und gibt einen int-Wert zurück. Die Funktion muss also einen ihrer Zeigervariablen entsprechenden Prototyp besitzen.

Crystal Clear app terminal.png
int sqr(int x) {   // Prototyp der Funktion f
    return x * x;
}

pFunc = & sqr;   // Adresse der Funktion sqr() der Variable pFunc zuweisen
pFunc = & Klasse::sqr;   // bei Verwendung für Memberfunktionen muss hier die Klasse angegeben werden

Um die Adresse der Funktion zu erhalten, müssen Sie den Adressoperator auf den Funktionsnamen anwenden. Beachten Sie, dass die Klammern, die Sie zum Aufruf einer Funktion immer setzen müssen, hier keinesfalls gesetzt werden dürfen. &sqr() würde Ihnen die Adresse, des von sqr() zurückgelieferten Objekts beschaffen. Es sei auch darauf hingewiesen, dass der Adressoperator nicht zwingend zum Ermitteln der Funktionsadresse notwendig ist. Sie sollten Ihn aus Gründen der Übersicht allerdings immer mitschreiben.

Crystal Clear app terminal.png
int x;
x = (*pFunc)(2); // ruft sqr() auf und weist x den Rückgabewert zu
x = pFunc(2);    // alternative (nicht empfohlene) Syntax

x = (*this.*pFunc)(2) // auch hier die Variante für eine Memberfunktion

Auch für das Aufrufen einer Funktion über einen Funktionszeiger gibt es zwei Möglichkeiten. Sie können den Zeiger erst dereferenzieren, dann benötigen Sie Klammern um die Dereferenzierung, damit nicht das zurückgegebene Objekt dereferenziert wird, oder Sie rufen die Funktion mit der gleichen Syntax auf, über die Sie dies bei einem direkten Aufruf der Funktion tun würden.

Die Syntax der zweiten Variante ist einfacher, allerdings wird dabei nicht deutlich, dass eine Funktion über einen Zeiger aufgerufen wird. Hier gehen die Meinungen darüber, welche Variante besser ist, auseinander. Fakt ist jedoch, dass die erste Variante eindeutiger das Geschehen dokumentiert.

Typisches Beispiel für den Einsatz von Funktionszeigern stellt eine Sortierroutine dar, der man die Vergleichsfunktion als Argument übergibt. Ein ausführliches (englischsprachiges) Tutorial über Funktionszeiger finden Sie unter http://www.newty.de/fpt/index.html.

[Bearbeiten] Zeiger und Referenzen und Klassen

Symbol move vote.svg
Thema wird später näher erläutert…
Im Zusammenhang mit Klassen werden uns weitere Arten von Zeigern begegnen:
  • Zeiger auf statische Datenelemente
  • Zeiger auf Elementfunktionen.
Beide werden Sie zu einem späteren Zeitpunkt noch kennenlernen.

[Bearbeiten] Löschen von Zeigern

Zeiger müssen prinzipiell nicht gelöscht werden, weil diese nur Verweise auf Adressbereiche sind. Es ist dennoch möglich, mit delete einen Zeiger zu löschen. Dabei sollte man beachten, dass der Zeiger seinen Gültigkeitsbereich verliert. Man gibt nur den Speicher frei, auf den der Zeiger verweist. Man kann dem Zeiger aber trotzdem noch Werte zuweisen. delete sollte nicht aufgerufen werden, wenn ein Zeiger auf einen nicht-vorhandenen Bereich zeigt, da dies das Programm zum Absturz bringen kann. Daher sollte nach dem Aufruf von delete, der Wert des Zeigers auf 0 gesetzt werden, weil delete bei Null-Zeigern keinen Fehler erzeugt.

Referenzen

[Bearbeiten] Grundlagen zu Referenzen

Referenzen sind interne Zeiger auf Variablen. Sie werden also genau so verwendet wie gewöhnliche Variablen, verweisen jedoch auf das Objekt, mit dem sie initialisiert wurden. Die Zeigerverwendung wird vor dem Programmierer verborgen.

Crystal Clear app terminal.png
int  a = 1;  // eine Variable
int &r = a;  // Referenz auf die Variable a

std::cout << "a: " << a << " r: " << r << std::endl;
++a;
std::cout << "a: " << a << " r: " << r << std::endl;
++r;
std::cout << "a: " << a << " r: " << r << std::endl;
Crystal Clear app kscreensaver.png
Ausgabe:
a: 1 r: 1
a: 2 r: 2
a: 3 r: 3

Wie Sie im Beispiel sehen, sind a und r identisch. Gleiches können Sie natürlich auch mit einem Zeiger erreichen, auch wenn bei einem Zeiger die Syntax etwas anders ist als bei einer Referenz.

Im Beispiel wurde die Referenz auf int r, mit dem int a initialisiert. Beachten Sie, dass die Initialisierung einer Referenzvariablen nur beim Anlegen erfolgen kann, danach kann ihr Wert nur noch durch eine Zuweisung geändert werden. Daraus folgt, dass eine Referenz immer initialisiert werden muss und es nicht möglich ist, eine Referenzvariable auf ein neues Objekt verweisen zu lassen:

Crystal Clear app terminal.png
int  a = 10; // eine Variable
int  b = 20; // noch eine Variable
int &r = a;  // Referenz auf die Variable a

std::cout << "a: " << a << " b: " << b << " r: " << r << std::endl;
++a;
std::cout << "a: " << a << " b: " << b << " r: " << r << std::endl;
r = b;       // r zeigt weiterhin auf a, r (und somit a) wird 20 zugewiesen
std::cout << "a: " << a << " b: " << b << " r: " << r << std::endl;
Crystal Clear app kscreensaver.png
Ausgabe:
a: 10 b: 20 r: 10
a: 11 b: 20 r: 11
a: 20 b: 20 r: 20

Wie Sie sehen, ist es nicht möglich, r als Alias für b zu definieren, nachdem es einmal mit a initialisiert wurde. Die Zuweisung bewirkt genau das, was auch eine Zuweisung von b an a bewirkt hätte. Dass eine Referenz wirklich nichts weiter ist als ein Aliasname wird umso deutlicher, wenn man sich die Adressen der Variablen aus dem ersten Beispiel ansieht:

Crystal Clear app terminal.png
int  a = 1;  // eine Variable
int &r = a;  // Referenz auf die Variable a

std::cout << "a: " << &a << " r: " << &r << std::endl;
Crystal Clear app kscreensaver.png
Ausgabe:
a: 0x7fffbf623c54 r: 0x7fffbf623c54
Die Ausgabe sieht bei Ihnen vermutlich etwas anders aus.

Wie Sie sehen, sind die Adressen identisch.

[Bearbeiten] Anwendung von Referenzen

Vielleicht haben Sie sich bereits gefragt, wofür Referenzen nun eigentlich gut sind, schließlich könnte man ja auch einfach die Originalvariable benutzen.

[Bearbeiten] Selbstdefinierte Referenzen

Referenzen bieten in einigen Anwendungsfällen eine Beschleunigung und bessere Lesbarkeit der Quelltexte. Sie müssen sofort initialisiert werden.

Crystal Clear app terminal.png
int main (void)
{
        const size_t x = 9, y = 15, z = 45;
        unsigned int ar_Zahlen[x][y][z];
        for (size_t a=0;a<x;++a)
        {
                for (size_t b=0;b<y;++b)
                {
                        for (size_t c=1;c<z;++c)
                        {
                                unsigned int & s = ar_Zahlen[a][b][c];
                                // Mehrfache Verwendung der Referenz
                        }
                }
        }
        return 0;
};

Bei umfangreicher, mehrfacher Verwendung, kann die Referenz viel Tipparbeit ersparen.

Außerdem erhöht diese Vorgehensweise die Performanz, da der Zugriff auf Daten in verschachtelten Klassen, großen Feldern, durch eine Referenzdefinition vereinfacht wird. Bei obigem Beispiel wird zur Laufzeit, im Arbeitsspeicher, bei jeder Verwendung von ar_Zahlen[a][b][c] zuerst der Speicherort der einzelnen Zahl berechnet, dabei müssen die Inhalts-,Feldgrößen und Offsets innerhalb des Felds berücksichtigt werden. An anderen Stellen mag dies alles mit STL-Kontainerklassen und verschachtelten Methoden- und Operatoraufrufen erfolgen. Dies alles können Sie dem Prozessor nicht ersparen. Sie können aber dafür sorgen, dass es bei obiger Iteration, pro Schritt, nur einmal vorkommt. Die Verwendung einer Referenz macht daher Sinn, sobald Sie ar_Zahlen[a][b][c] mehr als einmal verwenden. Eine Referenz ist intern mit einem Zeiger implementiert.

[Bearbeiten] Call-By-Reference

Möglicherweise erinnern Sie sich aber auch noch, dass im Kapitel „Prozeduren und Funktionen“ die Wertübergabe als Referenz (call-by-reference) vorgestellt wurde. Darauf wird nun genauer eingegangen.

Referenzen bieten genau wie Zeiger die Möglichkeit, den Wert einer Variable außerhalb der Funktion zu ändern. Im Folgenden sehen Sie die oben vorgestellte Funktion swap() mit Referenzen:

Crystal Clear app terminal.png
#include <iostream>

void swap(int &wert1, int &wert2) {
    int tmp;
    tmp   = wert1;
    wert1 = wert2;
    wert2 = tmp;
}

int main() {
    int a = 7, b = 9;

    std::cout << "a: " << a << ", b: " << b << "\n";

    swap(a, b);

    std::cout << "a: " << a << ", b: " << b << "\n";
    return (0);
}
Crystal Clear app kscreensaver.png
Ausgabe:
a: 7, b: 9
a: 9, b: 7

Diese Funktion bietet gegenüber der Zeigervariante einige Vorteile. Die Syntax ist einfacher und es ist nicht möglich, so etwas wie einen Nullzeiger zu übergeben. Um diese Funktion zum Absturz zu bewegen, ist schon einige Mühe nötig.

[Bearbeiten] const-Referenzen

Referenzen auf konstante Variablen spielen in C++ eine besondere Rolle. Eine Funktion die eine Variable übernimmt, kann genauso gut auch eine Referenzen auf eine konstante Variablen übernehmen. Folgendes Beispiel soll dies demonstrieren:

Crystal Clear app terminal.png
#include <iostream>

void ausgabe1(int wert) {
    std::cout << "wert: " << wert << "\n";
}

void ausgabe2(int const &wert) {
    std::cout << "wert: " << wert << "\n";
}

int main() {
    ausgabe1(5);
    ausgabe2(5);
    return (0);
}
Crystal Clear app kscreensaver.png
Ausgabe:
ausgabe1 wert: 5
ausgabe2 wert: 5

Die beiden Ausgabefunktionen sind an sich identisch, lediglich die Art der Parameterübergabe unterscheidet sich. ausgabe1() übernimmt einen int, ausgabe2() eine Referenz auf einen konstanten int. Beide Funktionen lassen sich auch vollkommen identisch aufrufen. Würde ausgabe2() eine Referenz auf einen nicht-konstanten int übernehmen, wäre ein Aufruf mit einer Konstanten, wie dem int-Literal 5 nicht möglich.

In Verbindung mit Klassenobjekten ist die Übergabe als Referenz auf ein konstantes Objekt sehr viel schneller, dazu erfahren Sie aber zu gegebener Zeit mehr. Für die Ihnen bereits bekannten Basisdatentypen ist tatsächlich die Übergabe als Wert effizienter.

[Bearbeiten] Referenzen als Rückgabetyp

Referenzen haben als Rückgabewert die gleichen Vorteile wie bei der Wertübergabe. Allerdings sind Sie in diesem Zusammenhang wesentlich gefährlicher. Es kann schnell passieren, dass Sie versehentlich eine Referenz auf eine lokale Variable zurückgeben. Diese Variable ist außerhalb der Funktion allerdings nicht mehr gültig, daher ist das Resultat, wenn Sie außerhalb der Funktion darauf zugreifen, undefiniert. Aus diesem Grund sollten Sie Referenzen als Rückgabewert nur verwenden wenn Sie wirklich wissen, was Sie tun.

Crystal Clear app terminal.png
#include <iostream>

// gibt die Referenz des Parameters x zurück
int &zahl(int &x) {
    return x;
}

int main() {
    int y = 3;
    zahl(y) = 5;
    std::cout << y; // Ausgabe: 5
    return y;
}

Felder

In C++ lassen sich mehrere Variablen desselben Typs zu einem Array (im Deutschen bisweilen auch Feld oder Vektor genannt) zusammenfassen. Auf die Elemente des Arrays wird über einen Index zugegriffen. Bei der Definition sind der Typ der Elemente und die Größe des Arrays anzugeben. Folgende Möglichkeiten stehen zum Anlegen eines Array zur Verfügung:

Crystal Clear app terminal.png
int feld[10];                          // Anlegen ohne Initialisierung
int feld[]   = {1,2,3,4,5,6,7,8,9,10}; // Mit Initialisierung (automatisch 10 Elemente)
int feld[10] = {1,2,3,4,5,6,7,8,9,10}; // 10 Elemente, mit Initialisierung

Soll das Array initialisiert werden, verwenden Sie eine Aufzählung in geschweiften Klammern, wobei der Compiler die Größe des Arrays selbst ermitteln kann. Es wird empfohlen, diese automatische Größenerkennung nicht zu nutzen. Wenn die Größenangabe explizit gemacht wurde, gibt der Compiler einen Fehler aus, falls die Anzahl der Intitiallisierungselemente nicht mit der Größenangabe übereinstimmt.

Wie bereits erwähnt, kann auf die einzelnen Elemente eines Arrays mit dem Indexoperator [] zugegriffen werden. Beim Zugriff auf Arrayelemente beginnt die Zählung bei 0. Das heißt, ein Array mit 10 Elementen enthält die Elemente 0 bis 9. Ein Arrayindex ist immer ganzzahlig.

Crystal Clear app terminal.png
#include <iostream>

int main() {
    int feld[10] = {1,2,3,4,5,6,7,8,9,10}; // 10 Elemente, mit Initalisierung

    for(int i = 0; i < 10; ++i) {
        std::cout << feld[i] << " ";   // Elemente 0 bis 9 ausgeben
    }
}
Crystal Clear app kscreensaver.png
Ausgabe:
1 2 3 4 5 6 7 8 9 10

Mit Arrayelementen können alle Operationen wie gewohnt ausgeführt werden.

Crystal Clear app terminal.png
feld[4] = 88;
feld[3] = 2;
feld[2] = feld[3] - 5 * feld[7 + feld[3]];

if(feld[0] < 1)
    ++feld[9];

for(int n = 0; n < 10; ++n)
    std::cout << feld[n] << std::endl;


Symbol opinion vote.svg
Hinweis
Beachten Sie, dass der Compiler keine Indexprüfung durchführt. Wenn Sie ein nicht vorhandenes Element, z.B. feld[297] im Programm verwenden, kann Ihr Programm bei einigen Durchläufen unerwartet abstürzen. Zugriffe über Arraygrenzen erzeugen undefiniertes Verhalten. In modernen Desktop-Betriebssystemen kann das Betriebssystem einige dieser Bereichsüberschreitungen abfangen und das Programm abbrechen („segmentation fault“). Manchmal überschreibt man auch einfach nur den eigenen Speicherbereich in anderen Variablen, was zu schwer zu findenden Bugs führt.

[Bearbeiten] Zeiger und Arrays

Der Name eines Arrays wird vom Compiler (ähnlich wie bei Funktionen) als Adresse des Arrays interpretiert. In Folge dessen haben Sie neben der Möglichkeit über den Indexoperator auch die Möglichkeit, mittels Zeigerarithmetik auf die einzelnen Arrayelemente zuzugreifen.

Crystal Clear app terminal.png
#include <iostream>

int main() {
    int feld[10] = {1,2,3,4,5,6,7,8,9,10};

    std::cout << feld[3]   << "\n"; // Indexoperator
    std::cout << *(feld + 3) << "\n"; // Zeigerarithmetik
}
Crystal Clear app kscreensaver.png
Ausgabe:
4
4

Im Normalfall werden Sie mit der ersten Syntax arbeiten, es ist jedoch nützlich zu wissen, wie Sie ein Array mittels Zeigern manipulieren können. Es ist übrigens nicht möglich, die Adresse von feld zu ändern. feld verhält sich also in vielerlei Hinsicht wie ein konstanter Zeiger auf das erste Element des Arrays. Einen deutlichen Unterschied werden Sie allerdings bemerken, wenn Sie den Operator sizeof() auf die Arrayvariable anwenden. Als Rückgabe bekommen Sie die Größe des gesamten Arrays. Teilen Sie diesen Wert durch die Größe eines beliebigen Arrayelements, erhalten Sie die Anzahl der Elemente im Array.

Crystal Clear app terminal.png
#include <iostream>

int main() {
    int feld[10];

    std::cout << "   Array Größe: " << sizeof(feld)                   << "\n";
    std::cout << " Element Größe: " << sizeof(feld[0])                << "\n";
    std::cout << "Element Anzahl: " << sizeof(feld) / sizeof(feld[0]) << "\n";
}
Crystal Clear app kscreensaver.png
Ausgabe:
Array Größe: 40
 Element Größe: 4
Element Anzahl: 10

[Bearbeiten] Mehrere Dimensionen

Auch mehrdimensionale Arrays sind möglich. Hierfür werden einfach Größenangaben für die benötigte Anzahl von Dimensionen gemacht. Das folgende Beispiel legt ein zweidimensionales Array der Größe 4 × 8 an, das 32 Elemente enthält. Theoretisch ist die Anzahl der Dimensionen unbegrenzt.

Crystal Clear app terminal.png
int feld[4][8];    // Ohne Initialisierung

int feld[4][8] = { // Mit Initialisierung
    {  1,  2,  3,  4,  5,  6,  7,  8},
    {  9, 10, 11, 12, 13, 14, 15, 16},
    { 17, 18, 19, 20, 21, 22, 23, 24},
    { 25, 26, 27, 28, 29, 30, 31, 32}
};

Wie Sie sehen, können auch mehrdimensionalen Arrays initialisiert werden. Die äußeren geschweiften Klammern beschreiben die erste Dimension mit 4 Elementen. Die inneren geschweiften Klammern beschreiben dementsprechend die zweite Dimension mit 8 Elementen. Beachten Sie, dass die inneren geschweiften Klammern lediglich der Übersicht dienen, sie sind nicht zwingend erforderlich. Dementsprechend ist es für mehrdimensionale Arrays immer nötig, die Größe aller Dimensionen anzugeben.

Genaugenommen wird eigentlich ein Array von Arrays erzeugt. In unserem Beispiel ist feld ein Array mit 4 Elementen vom Typ „Array mit 8 Elementen vom Typ int“. Dementsprechend sieht auch der Aufruf mittels Zeigerarithmetik auf einzelne Elemente aus.

Crystal Clear app terminal.png
#include <iostream>

int main(){
    // Anlegen des Arrays mit Initialisierung von eben

    std::cout << feld[2][5]         << "\n"; // Indexoperator
    std::cout << *(*(feld + 2) + 5) << "\n"; // Zeigerarithmetik
}
Crystal Clear app kscreensaver.png
Ausgabe:
22
22

Es sind ebenso viele Dereferenzierungen wie Dimensionen nötig. Um sich vor Augen zu führen, wie die Zeigerarithmetik für diese mehrdimensionalen Arrays funktioniert, ist es nützlich, sich einfach die Adressen bei Berechnungen anzusehen:

Crystal Clear app terminal.png
#include <iostream>

int main(){
    int feld[4][8];


    std::cout << "Größen\n";
    std::cout << "int[4][8]: " << sizeof(feld)     << "\n";
    std::cout << "   int[8]: " << sizeof(*feld)    << "\n";
    std::cout << "      int: " << sizeof(**feld)   << "\n";

    std::cout << "Adressen\n";
    std::cout << "       feld: " << feld        << "\n";
    std::cout << "   feld + 1: " << feld + 1    << "\n";
    std::cout << "(*feld) + 1: " << (*feld) + 1 << "\n";
}
Crystal Clear app kscreensaver.png
Ausgabe:
Größen
int[4][8]: 128
   int[8]: 32
      int: 4
Adressen
       feld: 0x7fff2be5d400
   feld + 1: 0x7fff2be5d420
(*feld) + 1: 0x7fff2be5d404

Wie Sie sehen, erhöht feld + 1 die Adresse um den Wert 32 (Hexadezimal 20), was sizeof(int[8]) entspricht. Also der Größe aller verbleibenden Dimensionen. Die erste Dereferenzierung liefert wiederum eine Adresse zurück. Wird diese um 1 erhöht, so steigt der Wert lediglich um 4 ( sizeof(int)).

Symbol opinion vote.svg
Hinweis
Beachten Sie, dass auch für mehrdimensionale Arrays keine Indexprüfung erfolgt. Greifen Sie nicht auf ein Element zu, dessen Grenzen außerhalb des Arrays liegen. Beim Array int[12][7][9] können Sie auf die Elemente [0..11][0..6][0..8] zugreifen. Die Zählung beginnt also auch hier immer bei 0 und endet dementsprechend 1 unterhalb der Dimensionsgröße.

[Bearbeiten] Arrays und Funktionen

Arrays und Funktionen arbeiten in C++ nicht besonders gut zusammen. Sie können keine Arrays als Parameter übergeben und auch keine zurückgeben lassen. Da ein Array allerdings eine Adresse hat (und der Arrayname diese zurückliefert), kann man einfach einen Zeiger übergeben. C++ bietet (ob nun zum besseren oder schlechteren) eine alternative Syntax für Zeiger bei Funktionsparametern an.

Crystal Clear app terminal.png
void funktion(int *parameter);
void funktion(int parameter[]);
void funktion(int parameter[5]);
void funktion(int parameter[76]);

Jeder dieser Prototypen ist gleichwertig. Die Größenangaben beim dritten und vierten den Beispiel werden vom Compiler ignoriert. Innerhalb der Funktion können Sie wie gewohnt mit dem Indexoperator auf die Elemente zugreifen. Beachten Sie, dass Sie die Arraygröße innerhalb der Funktion nicht mit sizeof(arrayname) feststellen können. Bei diesem Versuch würden Sie stattdessen die Größe eines Zeigers auf ein Array-Element erhalten.

Aufgrund dieses Verhaltens könnte man die Schreibweise des ersten Prototypen auswählen. Andere Programmierer argumentieren, dass bei der zweiten Schreibweise deutlich wird, dass der Parameter ein Array repräsentiert. Eine Größenangabe bei Arrayparametern ist manchmal anzutreffen, wenn die Funktion nur Arrays dieser Größe bearbeiten kann. Um es noch einmal zu betonen: Diese Größenangaben sind nur ein Hinweis für den Programmierer; Der Compiler wird ohne Fehler und Warnung Ihren Array mit allen Elementen übernehmen, auch wenn die beim Funktionsparameter angegebene Größe nicht mit Ihrem Array übereinstimmt.

Bei mehrdimensionalen Arrays sehen die Regeln ein wenig anders aus, da diese Arrays vom Typ Array sind. Wie Sie wissen ist es zulässig, Zeiger als Parameter zu übergeben. Entsprechend ist es natürlich auch möglich, einen Zeiger auf einen Array zulässig. Die folgenden Prototypen zeigen, wie die Syntax bei mehrdimensionalen Arrays aussieht.

Crystal Clear app terminal.png
void funktion(int (*parameter)[8]);
void funktion(int parameter[][8]);
void funktion(int parameter[4][8]);

Alle diese Prototypen haben einen Parameter vom Typ „Zeiger auf Array mit acht Elementen vom Typ int“. Ab der zweiten Dimension geben Sie also tatsächlich Arrays an, somit müssen Sie natürlich auch die Anzahl der Elemente zwingend angeben. Daher können Sie sizeof() in der Funktion verwenden, um die Größe zu ermitteln. Dies ist allerdings nicht notwendig, da Sie bereits im Vorfeld wissen, wie groß der Array ist und vom welchem Typ er ist. Die Größe berechnet sich wie folgt: sizeof(Typ) * Anzahl der Elemente. In unserem Beispiel entspricht dies 4 * 8 = 32. Auch ein zweidimensionales Array können Sie innerhalb der Funktion mit dem normalen Indexoperator zugreifen.

Beachten Sie, dass beim ersten Prototypen die Klammern zwingend notwendig sind, andernfalls hätten die eckigen Klammern auf der rechten Seite des Parameternamen Vorrang. Somit würde der Compiler dies wie oben gezeigt als einen Zeiger behandeln, natürlich unabhängig von der Anzahl der angegebenen Elemente. Ohne diese Klammern würden Sie also einen Zeiger auf einen Zeiger auf int deklarieren.

[Bearbeiten] Lesen komplexer Datentypen

Sie kennen nun Zeiger, Referenzen und Arrays, sowie natürlich die grundlegenden Datentypen. Es kann Ihnen passieren, dass Sie auf Datentypen treffen, die all das in Kombination nutzen. Im Folgenden werden Sie lernen, solche komplexen Datentypen zu lesen und zu verstehen, wie man Sie schreibt.

Symbol kept vote.svg
Tipp
Als einfache Regel zum Lesen von solchen komplexeren Datentypen können Sie sich merken:
  • Es wird ausgehend vom Namen gelesen.
  • Steht etwas rechts vom Namen, wird es ausgewertet.
  • Steht rechts nichts mehr, wird der Teil auf der linken Seite ausgewertet.
  • Mit Klammern kann die Reihenfolge geändert werden.

Die folgenden Beispiele werden zeigen, dass diese Regeln immer gelten:

Crystal Clear app terminal.png
int i;            // i ist ein int
int *j;           // j ist ein Zeiger auf int
int k[6];         // k ist ein Array von sechs Elementen des Typs int
int *l[6];        // l ist ein Array von sechs Elementen des Typs Zeiger auf int
int (*m)[6];      // m ist ein Zeiger auf ein Array von sechs Elementen des Typs int
int *(*&n)[6];    // n ist eine Referenz auf einen Zeiger auf ein Array von
                  // sechs Elementen des Typs Zeiger auf int
int *(*o[6])[5];  // o ist ein Array von sechs Elementen des Typs Zeiger auf ein
                  // Array von fünf Elementen des Typs Zeiger auf int
int **(*p[6])[5]; // p ist Array von sechs Elementen des Typs Zeiger auf ein Array
                  // von fünf Elementen des Typs Zeiger auf Zeiger auf int

Nehmen Sie sich die Zeit, die Beispiele nachzuvollziehen. Wenn Sie keine Probleme damit haben, sehen Sie sich das nächste sehr komplexe Beispiel an. Es soll die allgemeine Gültigkeit dieser Regel noch einmal demonstrieren:

Crystal Clear app terminal.png
int (*(*(**pFunc[5])(int*, double&))[6])();

pFunc ist ein Array mit fünf Elementen, das Zeiger auf Zeiger auf Funktionen enthält, die einen Zeiger auf int und eine Referenz auf double übernehmen und einen Zeiger auf Arrays mit sechs Elementen vom Typ Zeiger auf Funktionen, ohne Parameter, mit einem int als Rückgabewert zurückgeben. Einem solchen Monstrum werden Sie beim Programmieren wahrscheinlich selten bis nie begegnen, aber falls doch, können Sie es mit den obengenannten Regeln ganz einfach entschlüsseln. Wenn Sie nicht in der Lage sind, dem Beispiel noch zu folgen, brauchen Sie sich keine Gedanken zu machen: Nur wenige Menschen sind in der Lage, sich ein solches Konstrukt überhaupt noch vorzustellen. Wenn Sie es nachvollziehen können, kommen Sie sehr wahrscheinlich mit jeder Datentypdeklaration klar.

Zeichenketten

[Bearbeiten] Einleitung

In C++ gibt es keinen eingebauten Datentyp für Zeichenketten, lediglich einen für einzelne Zeichen. Da es in C noch keine Klassen gab, bediente man sich dort der einfachsten Möglichkeit, aus Zeichen Zeichenketten zu bilden: Man legte einfach einen Array von Zeichen an. C++ bietet eine komfortablere Lösung an: Die C++-Standardbibliothekenthält eine Klasse namens string. Um diese Klasse nutzen zu können, müssen Sie die gleichnamige Headerdatei string einbinden.

Crystal Clear app terminal.png
#include <iostream>
#include <string>

int main() {
    std::string zeichenkette;
    zeichenkette = "Hallo Welt!";
    std::cout << zeichenkette << std::endl;
}
Crystal Clear app kscreensaver.png
Ausgabe:
Hallo Welt!

Wir werden uns in diesem Kapitel mit der C++-Klasse string auseinandersetzen. Am Ende des Kapitels beleuchten wir den Umgang mit C-Strings (also char-Arrays) etwas genauer. Natürlich liegt auch string, wie alle Teile der Standardbibliothek, im Namensraum std.

[Bearbeiten] Wie entsteht ein string-Objekt?

Zunächst sind einige Worte zur Notation von Zeichenketten in doppelten Anführungszeichen nötig. Wie Ihnen bereits bekannt ist, werden einzelne Zeichen in einfachen Anführungszeichen geschrieben. Dieser Zeichenliteral ist dann vom Typ char. Die doppelten Anführungszeichen erzeugen hingegen eine Instanz eines char-Arrays. "Hallo Welt!" ist zum Beispiel vom Typ char[12].

Es handelt sich also um eine Kurzschreibweise, zum Erstellen von char-Arrays, damit Sie nicht {'H', 'a', 'l', 'l', 'o', ' ', 'W', 'e', 'l', 't', '!', '\0'} schreiben müssen, um eine einfache Zeichenkette zu erstellen. Was ist das '\0' und warum ist das Array 12 chars lang ist, obwohl es nur 11 Zeichen enthält? Wie bereits erwähnt, ist ein C-String ein Array von Zeichen. Da ein solcher C-String natürlich im Programmablauf Zeichenketten unterschiedlicher Längen enthalten konnte, beendete man die Zeichenkette durch ein Endzeichen: '\0' (Zahlenwert 0). Somit musste ein Array von Zeichen in C immer ein Zeichen länger sein, als die längste Zeichenkette, die im Programmverlauf darin gespeichert wurde.

Diese Kurzschreibweise kann aber noch mehr, als man auf den ersten Blick vermuten würde. Die eben genannte lange Notation zur Initialisierung eines Arrays funktioniert im Quelltext nur, wenn der Compiler auch weiß, von welchem Datentyp die Elemente des Arrays sein sollen. Da Zeichenliterale jedoch implizit in größere integrale Typen umgewandelt werden können, kann er den Datentyp nicht vom Typ der Elemente, die für die Initialisierung genutzt wurden ableiten:

Crystal Clear action button cancel.png
#include <string>

int main() {
    // char-Array mit 12 Elementen
    char a[] = {'H', 'a', 'l', 'l', 'o', ' ', 'W', 'e', 'l', 't', '!', '\0'};

    // int-Array mit 12 Elementen
    int  b[] = {'H', 'a', 'l', 'l', 'o', ' ', 'W', 'e', 'l', 't', '!', '\0'};

    // char-Array mit 12 Elementen
    std::string z = {'H', 'a', 'l', 'l', 'o', ' ', 'W', 'e', 'l', 't', '!', '\0'};
}

Bei der Notation mit geschweiften Klammern ist dagegen immer bekannt, dass es sich um ein char-Array handelt. Entsprechend ist die Initialisierung eines int-Arrays damit nicht möglich. Folgendes dagegen schon:

Crystal Clear action apply.png
#include <string>

int main() {
    // char-Array mit 12 Elementen
    char a[] = "Hallo Welt!";

    // char-Array mit 12 Elementen
    std::string z = "Hallo Welt!";
}

Bei der Erzeugung eines string-Objekts wird eine Funktion aufgerufen, die sich Konstruktor nennt. Was genau ein Konstruktor ist, erfahren Sie im Kapitel über Klassen. In unserem Fall wird also der Konstruktor für das string-Objekt z aufgerufen. Als Parameter erhält er das char-Array "Hallo Welt!". Wie Ihnen bereits bekannt ist, können an Funktionen keine Arrays übergeben werden. Stattdessen wird natürlich ein Zeiger vom Arrayelementtyp (also char) übergeben. Dabei geht aber die Information verloren, wie viele Elemente dieses Array enthält und an dieser Stelle kommt das '\0'-Zeichen (Nullzeichen) ins Spiel. Anhand dieses Zeichens kann auch innerhalb der Zeichenkette erkannt werden, wie lang die übergebene Zeichenkette ist.

Damit wissen Sie nun, wie aus dem einfachen char-Array das fertige string-Objekt wird. Jetzt ist es an der Zeit zu erfahren, was Sie mit diesem Objekt alles machen können

[Bearbeiten] string und andere Datentypen

Wie Sie bereits im Beispiel von eben gesehen haben, lässt sich die string-Klasse problemlos mit anderen Datentypen und Klassen kombinieren. Im ersten Beispiel dieses Kapitels wurde zunächst eine Zuweisung eines char-Arrays vorgenommen. Anschließend wurde das string-Objekt über cout ausgegeben. Auch die Eingabe einer Zeichenkette über cin ist mit einem string-Objekt problemlos möglich:

Crystal Clear app terminal.png
#include <iostream>
#include <string>

int main() {
    std::string zeichenkette;

    std::cin >> zeichenkette;
    std::cout << zeichenkette;
}

Diese Art der Eingabe erlaubt es lediglich, bis zum nächsten Whitespace einzulesen. Es kommt jedoch häufig vor, dass man eine Zeichenkette bis zum Zeilenende oder einem bestimmten Endzeichen einlesen möchte. In diesem Fall ist die Funktion getline hilfreich. Sie erwartet als ersten Parameter einen Eingabestream und als zweiten ein string-Objekt.

Crystal Clear app terminal.png
#include <iostream>
#include <string>

int main() {
    std::string zeichenkette;

    // Liest bis zum Zeilenende
    std::getline(std::cin, zeichenkette);
    std::cout << zeichenkette;
}

Als optionalen dritten Parameter kann man das Zeichen angeben, bis zu dem man einlesen möchte. Im Fall von eben wurde als der Default-Parameter '\n' (Newline-Zeichen) benutzt. Im folgenden Beispiel wird stattdessen bis zum ersten kleinen y eingelesen.

Crystal Clear app terminal.png
#include <iostream>
#include <string>

int main() {
    std::string zeichenkette;

    // Liest bis zum nächsten y
    std::getline(std::cin, zeichenkette, 'y');
    std::cout << zeichenkette;
}

[Bearbeiten] Zuweisen und Verketten

Genau wie die Basisdatentypen, lassen sich auch strings einander zuweisen. Für die Verkettung von strings wird der +-Operator benutzt und das Anhängen einer Zeichenkette ist mit += möglich.

Crystal Clear app terminal.png
#include <iostream>
#include <string>

int main() {
    std::string string1, string2, string3;

    string1 = "ich bin ";
    string2 = "doof";
    string3 = string1 + string2;
    std::cout << string3 << std::endl;

    string3 += " - " + string1 + "schön";
    std::cout << string3 << std::endl;

    std::cout << string1 + "schön " + string2 << std::endl;
}
Crystal Clear app kscreensaver.png
Ausgabe:
ich bin doof
ich bin doof - ich bin schön
ich bin schön doof

Spielen Sie einfach ein wenig mit den Operatoren, um den Umgang mit ihnen zu lernen.

[Bearbeiten] Nützliche Methoden

Die string-Klasse stellt einige nützliche Methoden bereit. Etwa um den String mit etwas zu füllen, ihn zu leeren oder über verschiedene Eigenschaften Auskunft zu bekommen. Eine Methode wird mit folgender Syntax aufgerufen:

Crystal Clear app tutorials.png
Syntax:
«stringname».«methodenname»(«parameter...»);

Die Methoden size() und length() erwarten keine Parameter und geben beide die aktuelle Länge der gespeicherten Zeichenkette zurück. Diese Doppelung in der Funktionalität existiert, da string in Analogie zu den anderen Containerklassen der C++-Standardbibliothek size() anbieten muss, der Name length() für die Bestimmung der Länge eines Strings aber natürlicher und auch allgemein üblich ist. empty() gibt true zurück falls der String leer ist, andernfalls false.

Mit clear() lässt sich der String leeren. Die resize()-Methode erwartet ein oder zwei Parameter. Der erste ist die neue Größe des Strings, der zweite das Zeichen, mit dem der String aufgefüllt wird, falls die angegebene Länge größer ist, als die aktuelle. Wird der zweite Parameter nicht angegeben, wird der String mit '\0' (Nullzeichen) aufgefüllt. In der Regel werden Sie dieses Verhalten nicht wollen, geben Sie also ein Füllzeichen an, falls Sie sich nicht sicher sind, was Sie tun. Ist die angegebene Länge geringer, als die des aktuellen Strings, wird am Ende abgeschnitten.

Um den Inhalt zweier Strings auszutauschen existiert die swap()-Methode. Sie erwartet als Parameter den String mit dem ihr Inhalt getauscht werden soll. Dies ist effizienter, als das Vertauschen über eine dritte, temporäre string-Variable.

Crystal Clear app terminal.png
#include <iostream>
#include <string>

int main() {
    std::string zeichenkette1 = "Ich bin ganz lang!";
    std::string zeichenkette2 = "Ich kurz!";

    std::cout << zeichenkette1 << std::endl;
    std::cout << zeichenkette2 << std::endl;

    zeichenkette1.swap(zeichenkette2);

    std::cout << zeichenkette1 << std::endl;
    std::cout << zeichenkette2 << std::endl;
}
Crystal Clear app kscreensaver.png
Ausgabe:
Ich bin ganz lang!
Ich kurz!
Ich kurz!
Ich bin ganz lang!

[Bearbeiten] Zeichenzugriff

Genau wie bei einem Array können Sie den []-Operator (Zugriffsoperator) verwenden, um auf einzelne Zeichen im String zuzugreifen. Allerdings wird, ebenfalls genau wie beim Array, nicht überprüft, ob der angegebene Wert noch innerhalb der enthaltenen Zeichenkette liegen.

Alternativ existiert die Methode at(), die den Index als Parameter erwartet und eine Grenzprüfung ausführt. Im Fehlerfall löst sie eine out_of_range-Exception aus. Da Sie den Umgang mit Exceptions wahrscheinlich noch nicht beherrschen, sollten Sie diese Methode vorerst nicht einsetzen und stattdessen genau darauf achten, dass Sie nicht versehentlich über die Stringlänge hinaus zugreifen.

Crystal Clear app terminal.png
#include <iostream>
#include <string>

int main() {
    std::string zeichenkette = "Ich bin ganz lang!";

    std::cout << zeichenkette[4] << std::endl;
    std::cout << zeichenkette.at(4) << std::endl;

    std::cout << zeichenkette[20] << std::endl;    // Ausgabe von Datenmüll
    std::cout << zeichenkette.at(20) << std::endl; // Laufzeitfehler
}
Crystal Clear app kscreensaver.png
Ausgabe:
b
b

terminate called after throwing an instance of 'std::out_of_range'
  what():  basic_string::at
Abgebrochen
Die Fehlerausgabe kann bei Ihrem Compiler anders aussehen.
Symbol opinion vote.svg
Hinweis

Beachten Sie beim Zugriff, dass das erste Zeichen den Index 0 hat. Das letzte Zeichen hat demzufolge den Index zeichenkette.length() - 1.

[Bearbeiten] Manipulation

[Bearbeiten] Suchen

Die Methode find() sucht das erste Vorkommen eines Strings und gibt die Startposition (Index) zurück. Der zweite Parameter gibt an, ab welcher Position des Strings gesucht werden soll.

Crystal Clear app terminal.png
#include <iostream>
#include <string>

int main() {
    std::string str = "Zeichenkette";
    std::string find = "k";
    std::cout << str.find(find, 0);
}
Crystal Clear app kscreensaver.png
Ausgabe:
7

Wird ein Substring nicht gefunden, gibt find() den Wert std::string::npos zurück.

Das Gegenstück zu find() ist rfind(). Es ermittelt das letzte Vorkommen eines Strings. Die Parameter sind die gleichen wie bei find().

[Bearbeiten] Ersetzen

Sie können replace() verwenden, um Strings zu ersetzen. Dafür benötigen Sie die Anfangsposition und die Anzahl der Zeichen, die anschließend ersetzt werden sollen.

Crystal Clear app terminal.png
#include <iostream>
#include <string>

int main() {
    std::string str = "Zeichenkette";
    str.replace(str.find("k"), std::string("kette").length(), "test");
    std::cout << str << std::endl;
}
Crystal Clear app kscreensaver.png
Ausgabe:
Zeichentest

Wie Sie sehen, verwenden wir find(), um die Startposition zu ermitteln. Der zweite Parameter gibt die Länge an. Hier soll die alternative Schreibweise verdeutlicht werden; Sie müssen nicht eine zusätzliche Variable deklarieren, sondern können die std::string-Klasse wie eine Funktion verwenden und über Rückgabewert auf die Methode length() zugreifen. Im dritten Parameter spezifizieren Sie den String, welcher den ursprünglichen String zwischen der angegebenen Startposition und Startposition + Länge ersetzt.

[Bearbeiten] Einfügen

Die Methode insert() erlaubt es Ihnen, einen String an einer bestimmten Stelle einzufügen.

Crystal Clear app terminal.png
#include <iostream>
#include <string>

int main() {
    std::string str = "Hallo Welt.";
    str.insert(5, " schöne");
    std::cout << str << std::endl;
}
Crystal Clear app kscreensaver.png
Ausgabe:
Hallo schöne Welt

[Bearbeiten] Kopieren

Mit der Methode substr() kann man sich einen Substring zurückgeben lassen. Der erste Parameter gibt den Startwert an. Zusätzlich kann man mit dem zweiten Parameter noch die Endposition festlegen. Wird die Methode nur mit dem Startwert aufgerufen, gibt sie alle Zeichen ab dieser Position zurück.

Crystal Clear app terminal.png
#include <iostream>
#include <string>

int main() {
    std::string str = "Hallo Welt.";
    std::cout << str.substr(0, str.find(' ')) << std::endl;
}
</source>
Crystal Clear app kscreensaver.png
Ausgabe:
Hallo

[Bearbeiten] Vergleiche

C++-Strings können Sie, genau wie Zahlen, miteinander vergleichen. Was Gleichheit und Ungleichheit bei einem String bedeutet, wird Ihnen sofort klar sein. Sind alle Zeichen zweier Strings identisch, so sind beide gleich, andernfalls nicht. Die Operatoren <, >>, <= und >= geben da schon einige Rätsel mehr auf.

Im Grunde kennen Sie die Antwort bereits. Zeichen sind in C++ eigentlich Zahlen. Sie werden zu Zeichen, indem den Zahlen entsprechende Symbole zugeordnet werden. Der Vergleich erfolgt also einfach mit den Zahlen, welche die Zeichen kodieren. Das erste Zeichen der Strings, das sich unterscheidet, entscheidet darüber, welcher der Strings größer bzw. kleiner ist.

Die meisten Zeichenkodierungen beinhalten in den ersten 7 Bit den ASCII-Code, welchen die nachfolgende Tabelle zeigt.

ASCII-Codetabelle, Nummerierung im Hexadezimalsystem (Teil 1)
Code …0 …1 …2 …3 …4 …5 …6 …7
0… NUL SOH STX ETX EOT ENQ ACK BEL
1… DLE DC1 DC2 DC3 DC4 NAK SYN ETB
2… SP ! " # $ % & '
3… 0 1 2 3 4 5 6 7
4… @ A B C D E F G
5… P Q R S T U V W
6… ` a b c d e f g
7… p q r s t u v w
ASCII-Codetabelle, Nummerierung im Hexadezimalsystem (Teil 2)
Code …8 …9 …A …B …C …D …E …F
0… BS HT LF VT FF CR SO SI
1… CAN EM SUB ESC FS GS RS US
2… ( ) * + , - . /
3… 8 9 : ; < = > ?
4… H I J K L M N O
5… X Y Z [ \ ] ^ _
6… h i j k l m n o
7… x y z { | } ~ DEL
Crystal Clear app terminal.png
#include <string>

int main(){
    std::string gross = "Ich bin ganz groß!";
    std::string klein = "Ich bin ganz klein!";

    gross == klein; // ergibt false ('g' != 'k')
    gross != klein; // ergibt true  ('g' != 'k')
    gross <  klein; // ergibt true  ('g' <  'k')
    gross >  klein; // ergibt false ('g' <  'k')
    gross <= klein; // ergibt true  ('g' <  'k')
    gross >= klein; // ergibt false ('g' <  'k')
}

[Bearbeiten] Zahl zu string und umgekehrt

In C++ gibt es, im Gegensatz zu vielen anderen Programmiersprachen, keine Funktion, um direkt Zahlen in Strings oder umgekehrt umzuwandeln. Es ist allerdings nicht besonders schwierig, eine solche Funktion zu schreiben. Wir haben für die Umwandlung zwei Möglichkeiten:

  • Die C-Funktionen atof(), atoi(), atol() und sprintf()
  • C++-String-Streams

Die C-Variante wird in Kürze im Zusammenhang mit C-Strings besprochen. Für den Moment wollen wir uns der C++-Variante widmen. Stringstreams funktionieren im Grunde genau wie die Ihnen bereits bekannten Ein-/Ausgabestreams cin und cout mit dem Unterschied, dass sie ein string-Objekt als Ziel benutzen.

Crystal Clear app terminal.png
#include <iostream> // Standard-Ein-/Ausgabe
#include <sstream>  // String-Ein-/Ausgabe

int main() {
    std::ostringstream strout; // Unser Ausgabe-Stream
    std::string str;           // Ein String-Objekt
    int var = 10;              // Eine ganzzahlige Variable

    strout << var;             // ganzzahlige Variable auf Ausgabe-Stream ausgeben
    str = strout.str();        // Streaminhalt an String-Variable zuweisen

    std::cout << str << std::endl; // String ausgeben
}

Der vorliegende Code wandelt eine Ganzzahl in einen String um, indem die Ganzzahl auf dem Ausgabe-Stringstream ausgegeben und dann der Inhalt des Streams an den String zugewiesen wird. Die umgekehrte Umwandlung funktioniert ähnlich. Natürlich verwenden wir hierfür einen Eingabe-Stringstream ( istringstream statt ostringstream) und übergeben den Inhalt des Strings an den Stream, bevor wir ihn von diesem auslesen.

Crystal Clear app terminal.png
#include <iostream> // Standard-Ein-/Ausgabe
#include <sstream>  // String-Ein-/Ausgabe

int main() {
    std::istringstream strin; // Unser Eingabe-Stream
    std::string str = "17";   // Ein String-Objekt
    int var;                  // Eine ganzzahlige Variable

    strin.str(str);           // Streaminhalt mit String-Variable füllen
    strin >> var;             // ganzzahlige Variable von Eingabe-Stream einlesen

    std::cout << var << std::endl; // Zahl ausgeben
}

Statt istringstream und ostringstream können Sie übrigens auch ein stringstream-Objekt verwenden, welches sowohl Ein-, als auch Ausgabe erlaubt, allerdings sollte man immer so präzise wie möglich angeben, was der Code machen soll. Daher ist die Verwendung eines spezialisierten Streams zu empfehlen, wenn Sie nur die speziellen Fähigkeiten (Ein- oder Ausgabe) benötigen.

Sicher sind Sie jetzt bereits in der Lage, zwei Funktionen zu schreiben, welche diese Umwandlung durchführt. Allerdings stehen wir in dem Moment, wo wir andere Datentypen als int in Strings umwandeln wollen vor einem Problem. Wir können der folgenden Funktion zwar ohne weiteres eine double-Variable übergeben, allerdings wird dann der Nachkommateil einfach abgeschnitten. Als Lösung kommt Ihnen nur eventuell in den Sinn, einfach eine double-Variable von der Funktion übernehmen zu lassen.

Crystal Clear app terminal.png
#include <iostream> // Standard-Ein-/Ausgabe
#include <sstream>  // String-Ein-/Ausgabe

std::string zahlZuString(double wert) {
    std::ostringstream strout; // Unser Ausgabe-Stream
    std::string str;           // Ein String-Objekt

    strout << wert;            // Zahl auf Ausgabe-Stream ausgeben
    str = strout.str();        // Streaminhalt an String-Variable zuweisen

    return str;                // String zurückgeben
}

int main() {
    std::string str;
    int    ganzzahl  = 19;
    double kommazahl = 5.55;

    str = zahlZuString(ganzzahl);
    std::cout << str << std::endl;

    str = zahlZuString(kommazahl);
    std::cout << str << std::endl;
}
Crystal Clear app kscreensaver.png
Ausgabe:
19
5.55

Nun, so weit so gut. Das funktioniert. Leider gibt es da aber auch noch die umgekehrte Umwandlung und obgleich es möglich ist, sie auf ähnliche Weise zu lösen, wird Ihr Compiler sich dann ständig mit einer Warnung beschweren, wenn das Ergebnis ihrer Umwandlung an eine ganzzahlige Variable zugewiesen wird.

Besser wäre es, eine ganze Reihe von Funktionen zu erzeugen, von denen jede für einen Zahlentyp verantwortlich ist. Tatsächlich können Sie in C++ mehrere Funktionen gleichen Namens erzeugen, die unterschiedliche Parameter(typen) übernehmen. Diese Vorgang nennt sich Überladen von Funktionen. Der Compiler entscheidet dann beim Aufruf der Funktion anhand der übergeben Parameter, welche Version gemeint war während der Programmierer immer den gleichen Namen verwendet.

Im Moment haben wir obendrein einen Sonderfall der Überladung. Alle unsere Funktionen besitzen exakt den gleichen Code. Lediglich der Parametertyp ist unterschiedlich. Es wäre ziemlich zeitaufwendig und umständlich, den Code immer wieder zu kopieren, um dann nur den Datentyp in der Parameterliste zu ändern. Noch schlimmer wird es, wenn wir eines Tages eine Änderung am Funktionsinhalt vornehmen und diese dann auf alle Kopien übertragen müssen.

Glücklicherweise bietet C++ für solche Fälle so genannte Templates, die es uns erlauben, den Datentyp vom Compiler ermitteln zu lassen. Wir teilen dem Compiler also mit, was er tun soll, womit muss er dann selbst herausfinden. Die Funktion zahlZuString() (umbenannt in toString()) sieht als Template folgendermaßen aus:

Crystal Clear app terminal.png
#include <iostream> // Standard-Ein-/Ausgabe
#include <sstream>  // String-Ein-/Ausgabe

template <typename Typ>
std::string toString(Typ wert) {
    std::ostringstream strout; // Unser Ausgabe-Stream
    std::string str;           // Ein String-Objekt

    strout << wert;            // Zahl auf Ausgabe-Stream ausgeben
    str = strout.str();        // Streaminhalt an String-Variable zuweisen

    return str;                // String zurückgeben
}

int main() {
    std::string str;
    int    ganzzahl  = 19;
    double kommazahl = 5.55;
    std::string nochnString = "Blödsinn";

    str = toString(ganzzahl);
    std::cout << str << std::endl;

    str = toString(kommazahl);
    std::cout << str << std::endl;

    str = toString(nochnString);
    std::cout << str << std::endl;
}
Crystal Clear app kscreensaver.png
Ausgabe:
19
5.55
Blödsinn

Die letzte Ausgabe zeigt deutlich warum die Funktion in toString umbenannt wurde, denn sie ist nun in der Lage, jeden Datentyp, der sich auf einem ostringstream ausgeben lässt, zu verarbeiten und dazu zählen eben auch string-Objekte und nicht nur Zahlen. Sie werden später noch lernen, welches enorme Potenzial diese Technik in Zusammenhang mit eigenen Datentypen hat. An dieser Stelle sei Ihnen noch die Funktion zur Umwandlung von Strings in Zahlen (oder besser: alles was sich von einem istringstream einlesen lässt) mit auf den Weg gegeben:

Crystal Clear app terminal.png
#include <iostream> // Standard-Ein-/Ausgabe
#include <sstream>  // String-Ein-/Ausgabe

template <typename Typ>
void stringTo(std::string str, Typ &wert) {
    std::istringstream strin; // Unser Eingabe-Stream

    strin.str(str);           // Streaminhalt mit String-Variable füllen
    strin >> wert;            // Variable von Eingabe-Stream einlesen
}

int main() {
    std::string str = "7.65Blödsinn";
    int    ganzzahl;
    double kommazahl;
    std::string nochnString;

    stringTo(str, ganzzahl);
    std::cout << ganzzahl << std::endl;

    stringTo(str, kommazahl);
    std::cout << kommazahl << std::endl;

    stringTo(str, nochnString);
    std::cout << nochnString << std::endl;
}
Crystal Clear app kscreensaver.png
Ausgabe:
7
7.65
7.65Blödsinn

Die Variable, die mit dem Wert des Strings belegt werden soll, wird als Referenz an die Funktion übergeben, damit der Compiler ihren Typ feststellen kann. Die Ausgabe zeigt, dass immer nur so viel eingelesen wird, wie der jeweilige Datentyp (zweiter Funktionsparameter) fassen kann. Für eine ganzzahlige Variable wird nur die Zahl Sieben eingelesen, die Gleitkommavariable erhält den Wert 7.65 und das string-Objekt kann die gesamte Zeichenkette übernehmen.

Symbol move vote.svg
Thema wird später näher erläutert…

Sie werden Überladung im Kapitel „Überladen…“ (Abschnitt „Eigene Datentypen definieren“) genauer kennen lernen. Templates sind ein sehr umfangreiches Thema, Sie werden ihnen im Abschnitt Templates wiederbegegnen.

[Bearbeiten] C-Strings

Wie bereits erwähnt, handelt es sich bei einem C-String um ein Array von chars. Das Ende eines C-Strings wird durch ein Nullzeichen (Escape-Sequenz '\0') angegeben. Das Arbeiten mit C-Strings ist mühsam, denn es muss immer sichergestellt sein, dass das Array auch groß genug ist, um den String zu beinhalten. Da in C/C++ jedoch auch keine Bereichsüberprüfung durchgeführt wird, macht sich ein Pufferüberlauf (also eine Zeichenkette die größer ist als das Array, das sie beinhaltet) erst durch einen eventuellen Programmabsturz bemerkbar. Allein um dies zu vermeiden sollten Sie, wann immer es Ihnen möglich ist, die C++-string-Klasse verwenden.

Ein weiteres Problem beim Umgang mit C-Strings ist der geringe Komfort beim Arbeiten. Ob Sie einen String mit einem anderen vergleichen wollen, oder ihn an ein anderes Array „zuweisen“ möchten, in jedem Fall benötigen Sie unintuitive Zusatzfunktionen. Diese Funktionen finden Sie in der Standardheaderdatei „cstring“. Wie diese Funktionen heißen und wie man mit ihnen umgeht können Sie im C++-Referenz-Buch nachlesen, falls Sie sie einmal benötigen sollten.

Symbol redirect vote.svg
Buchempfehlung

Wenn Sie sich eingehender mit der Thematik auseinandersetzen möchten, sei Ihnen das Buch C-Programmierung ans Herz gelegt. Wenn Sie in C++ mit der C-Standard-Bibliotheken arbeiten möchten, müssen Sie den Headerdateien ein „c“ voranstellen und das „.h“ weglassen. So wird beispielsweise aus dem C-Header „string.h“ der C++-Header „cstring“.

Vorarbeiter des Compilers

Bevor der Compiler eine C++-Datei zu sehen kriegt, läuft noch der Präprozessor durch. Er überarbeitet den Quellcode, sodass der Compiler daraus eine Objektdatei erstellen kann. Diese werden dann wiederum vom Linker zu einem Programm gebunden. In diesem Kapitel soll es um den Präprozessor gehen, wenn Sie allgemeine Informationen über Präprozessor, Compiler und Linker brauchen, dann lesen Sie das Kapitel „Compiler“.

[Bearbeiten] #include

Die Präprozessordirektive #include haben Sie schon häufig benutzt. Sie fügt den Inhalt der angegebenen Datei ein. Dies ist nötig, da der Compiler immer nur eine Datei übersetzen kann. Viele Funktionen werden aber in verschiedenen Dateien benutzt. Daher definiert man die Prototypen der Funktionen (und einige andere Dinge, die Sie noch kennenlernen werden) in so genannten Headerdateien. Diese Headerdateien werden dann über #include eingebunden, weshalb Sie die Funktionen usw. Aufrufen können.

Symbol move vote.svg
Thema wird später näher erläutert…

Ausführlich Informationen über Headerdateien erhalten Sie im gleichnamigen Kapitel.

#include bietet zwei Möglichkeiten Headerdateien einzubinden:

Crystal Clear app terminal.png
#include "name" // Sucht im aktuellen Verzeichnis und dann in den Standardpfaden des Compilers
#include <name> // Sucht gleich in den Standardpfaden des Compilers

„Aktuelles Verzeichnis“ bezieht sich immer auf das Verzeichnis, in welchem die Datei liegt.

Die erste Syntax funktioniert immer, hat aber den Nachteil, dass dem Compiler nicht mitgeteilt wird, dass es sich um eine Standardheaderdatei handelt. Wenn sich im aktuellen Verzeichnis beispielsweise eine Datei namens iostream befände und Sie versuchten über die erste Syntax, die Standardheaderdatei iostream einzubinden, bänden Sie stattdessen die Datei im aktuellem Verzeichnis ein, was sehr unwahrscheinlich sein sollte, da Sie hoffentlich immer Dateiendungen wie .hpp oder .h für Ihre eigenen Header verwenden. Außerdem verlängert das Einbinden von Standardheadern in Anführungszeichen den Präprozessordurchlauf, je nach Anzahl der Dateien im aktuellen Quellpfad.

Aus diesem Grund ist es wichtig zu wissen, ob der eingebundene Header für eine Bibliotheksfunktionalität in den vordefinierten Pfaden des verwendeten Compilers steht, oder ob es eigener Inhalt ist, oder es sich um Zusatzbibliotheken handelt, deren Include-Verzeichnisse an anderen Stellen zu finden sind. Es sollte die Variante gewählt werden, bei der sich die Headerdatei vom Präprozessor am schnellsten finden lässt.

Symbol kept vote.svg
Tipp

Verwenden Sie für Verweise auf Ihre eigenen Includes immer eine relative Pfadangabe mit normalen Slashes '/' als Verzeichnisseparator, damit Sie Ihre Quellcodeverzeichnisse auch an anderen Stellen und in anderen Entwicklungsumgebungen schneller kompiliert bekommen. Eine Verzeichnisangabe wie "/home/ichuser/code/cpp/projekt/zusatzlib/bla.h" oder "c:\users\manni\Eigene Dateien\code\cpp\projekt\zusatzlib\bla.h" ist sehr unangenehm und sorgt für große Verwirrung. Schreiben Sie es nun in "../zusatzlib/bla.h" um, können Sie später Ihr gesamtes Projekt leichter in anderen Pfaden kompilieren und sparen sich selbst und Ihrem Präprozessor einiges an Ärger und Verwirrung.

[Bearbeiten] #define und #undef

#define belegt eine Textsubstitution mit dem angegebenen Wert, z.B.:

Crystal Clear app terminal.png
#define BEGRUESSUNG "Hallo Welt!\n"
cout << BEGRUESSUNG;

Makros sind auch möglich, z.B.:

Crystal Clear app terminal.png
#define ADD_DREI(x,y,z) x+y+z+3
int d(1),e(20),f(4),p(0);
p = ADD_DREI(d,e,f);              // p = d+e+f+3;

Diese sind allerdings mit Vorsicht zu genießen, da durch die strikte Textsubstitution des Präprozessors ohne jegliche Logikprüfungen des Compilers, fatale Fehler einfließen können und sollten eher durch ( inline)-Funktionen, Konstanten oder sonstige Konzepte realisiert werden. Manchmal lässt es sich allerdings nicht umgehen, ein Makro (weiter) zu verwenden. Beachten Sie bitte immer, dass es sich hierbei um Anweisungen an eine Rohquelltextvorbearbeitungsstufe handelt und nicht um Programmcode.

Da anstelle von einfachen Werten auch komplexere Terme als Parameter für das Makro verwendet werden können und das Makro selbst auch in einen Term eingebettet werden kann, sollten immer Klammern verwendet werden. Bsp.:

Crystal Clear app terminal.png
#define MUL_NOK(x,y) x*y
#define MUL_OK(x,y) ((x)*(y))

resultNok = a * MUL_NOK(b,c+d); // a * b*c+d      = a*b*c+d        <= falsch
resultOk = a * MUL_OK(b,c+d);   // a * ((b)*(c+d)) = a*b*c + a*b*d  <= richtig

#undef löscht die Belegung einer Textsubstitution/Makro, z.B.:

Crystal Clear app terminal.png
#undef BEGRUESSUNG
Symbol redirect vote.svg
Buchempfehlung

Bitte denken Sie bei allen Ideen, auf die Sie nun kommen, dass Sie immer eine Alternative zur Makroprogrammierung haben. Verwenden Sie diese Makros vor allem als Zustandsspeicher für den Präprozessordurchlauf an sich und nicht um Funktionalitäten Ihres Programms zu erweitern.

Einer statischen, konstanten Defintion, sowie Templatefunktionen ist immer der Vorzug zu geben. Wenn Sie dies nicht so sehen, lesen Sie bitte hier weiter: C-Programmierung.

[Bearbeiten] #

Der #-Ausdruck erlaubt es, den einem Makro übergebenen Parameter als Zeichenkette zu interpretieren:

Crystal Clear app terminal.png
#define STRING(s) #s
cout << STRING(Test) << endl;

Das obige Beispiel gibt also den Text "Test" auf die Standard-Ausgabe aus.

[Bearbeiten] ##

Der ##-Ausdruck erlaubt es, in Makros definierte Strings innerhalb des Präprozessorlaufs miteinander zu kombinieren, z.B.:

Crystal Clear app terminal.png
#define A "Hallo"
#define B "Welt"
#define A_UND_B A##B

Als Resultat beinhaltet die Konstante A_UND_B die Zeichenkette "HalloWelt". Der ##-Ausdruck verbindet die Namen der symbolischen Konstanten, nicht deren Werte. Ein weiteres Anwendungsbeispiel:

Crystal Clear app terminal.png
#define MAKE_CLASS( NAME )       \
class NAME                       \
{                                \
  public:                        \
    static void Init##NAME() {}; \
};

...
MAKE_CLASS( MyClass )
...
MyClass::InitMyClass();

[Bearbeiten] #if, #ifdef, #ifndef, #else, #elif und #endif

Direktiven zur bedingten Übersetzung, d.h. Programmteile werden entweder übersetzt oder ignoriert.

Crystal Clear app terminal.png
#if ''Ausdruck1''
// Programmteil 1
#elif ''Ausdruck2''
// Programmteil 2
/*
   ...
 */

#else
// Programmteil sonst
#endif

Die Ausdrücke hinter #if bzw. #elif werden der Reihe nach bewertet, bis einer von ihnen einen von 0 verschiedenen Wert liefert. Dann wird der zugehörige Programmteil wie üblich verarbeitet, die restlichen werden ignoriert. Ergeben alle Ausdrücke 0, wird der Programmteil nach #else verarbeitet, sofern vorhanden.

Als Bedingungen sind nur konstante Ausdrücke erlaubt, d.h. solche, die der Präprozessor tatsächlich auswerten kann. Definierte Makros werden dabei expandiert, verbleibende Namen durch 0L ersetzt. Insbesondere dürfen keine Zuweisungen, Funktionsaufrufe, Inkrement- und Dekrementoperatoren vorkommen. Das spezielle Konstrukt

Crystal Clear app terminal.png
#defined ''Name''

wird durch 1L bzw. 0L ersetzt, je nachdem, ob das Makro Name definiert ist oder nicht.

#ifdef ist eine Abkürzung für #if defined.

#ifndef ist eine Abkürzung für #if ! defined.

Beispiel: Nehmen wir an, dass Sie Daten in zusammengesetzten Strukturen immer an 4-Byte Grenzen ausrichten wollen. Verschiedene Compiler bieten hierfür verschiedene compilerspezifische Pragma-Direktiven. Da diese Konfiguration nicht im C++-Standard definiert ist, kann nicht sichergestellt werden, dass es eine allgemeingültige Anweisung hierfür gibt. Sie definieren daher mit #define in einer Headerdatei, die überall eingebunden ist, welchen Compiler Sie verwenden. Z.B. mit #define FLAG_XY_COMPILER_SUITE in einer Datei namens "compiler-config-all.hpp". Das gibt Ihnen die Möglichkeit, an Stellen, an denen Sie compilerspezifisches Verhalten verwenden wollen, dieses auch auszuwählen.

Crystal Clear app terminal.png
#include "compiler-config-all.hpp"
#ifdef WIN32
#pragma pack(4)
#elif USING_GCC
#pragma align=4
#elif FLAG_XY_COMPILER_SUITE
#pragma ausrichtung(byte4)
#endif

[Bearbeiten] #error und #warning

#error gibt eine Fehlermeldung während des Compilerlaufs aus und bricht den Übersetzungsvorgang ab, z.B.:

Crystal Clear app terminal.png
#error Dieser Quellcode-Abschnitt sollte nicht mit compiliert werden!

#warning ist ähnlich wie #error, mit dem Unterschied, dass das Kompilieren nicht abgebrochen wird. Es ist allerdings nicht Teil von ISO-C++, auch wenn die meisten Compiler es unterstützen. Meistens wird es zum Debuggen eingesetzt:

Crystal Clear app terminal.png
#warning Dieser Quellcode-Abschnitt muss noch überarbeitet werden.

[Bearbeiten] #line

Setzt den Compiler-internen Zeilenzähler auf den angegebenen Wert, z.B.:

Crystal Clear app terminal.png
#line 100

[Bearbeiten] #pragma

Das #pragma-Kommando ist vorgesehen, um eine Reihe von Compiler-spezifischen Anweisungen zu implementieren, z.B.:

Crystal Clear app terminal.png
#pragma comment(lib, "advapi32.lib")

Kennt ein bestimmter Compiler eine Pragma-Anweisung nicht, so gibt er üblicherweise eine Warnung aus, ignoriert diese nicht für ihn vorgesehene Anweisung aber ansonsten.

Um z.B. bei MS VisualC++ eine "störende" Warnung zu unterdrücken, gibt man folgendes an:

Crystal Clear app terminal.png
#pragma warning( disable: 4010 ) // 4010 ist Nummer der Warnung

[Bearbeiten] Vordefinierte Präprozessor-Variablen

  • __LINE__: Zeilennummer
  • __FILE__: Dateiname
  • __DATE__: Datum des Präprozessoraufrufs im Format Monat/Tag/Jahr
  • __TIME__: Zeit des Präprozessoraufrufs im Format Stunden:Minuten:Sekunden
  • __cplusplus: Ist nur definiert, wenn ein C++-Programm verarbeitet wird

Headerdateien

Sie haben bereits mit 2 Headerdateien Bekanntschaft gemacht: iostream und string. Beide sind so genannte Standardheader, das heißt, sie sind in der Standardbibliothek jedes Compilers enthalten.

[Bearbeiten] Was ist eine Headerdatei?

Headerdatei.svg

Headerdateien sind gewöhnliche C++-Dateien, die im Normalfall Funktionsdeklarationen und ähnliches enthalten. Sicher werden Sie sich erinnern: Deklarationen machen dem Compiler bekannt, wie etwas benutzt wird. Wenn Sie also eine Headerdatei einbinden und darin eine Funktionsdeklaration steht, dann weiß der Compiler wie die Funktion aufgerufen wird. Der Compiler weiß zu diesem Zeitpunkt nicht, was die Funktion tut, aber das ist auch nicht nötig um sie aufrufen zu können.

Das einbinden einer Headerdatei erfolgt über die Präprozessordirektive #include. Der Code in der Datei die mit include referenziert wird, wird vom Präprozessor einfach an der Stelle eingefügt, an der das include stand.

In der nebenstehenden Darstellung wird die Headerdatei mal2.hpp von den Quelldateien main.cpp und mal2.cpp eingebunden. Um dieses Beispiel zu übersetzen, müssen die Dateien alle im gleichen Verzeichnis liegen. Sie übergeben die Quelldateien an einen Compiler und rufen anschließend den Linker auf, um die entstanden Objektdateien zu einem Programm zu binden.

Symbol opinion vote.svg
Hinweis

Falls Sie mit der GCC arbeiten, beachten Sie bitte, dass g++ sowohl Compiler als auch Linker ist. Wenn Sie nur die beiden Quelldateien ohne weitere Parameter angeben, wird nach dem Kompilieren automatisch gelinkt. Der zweite Aufruf entfällt somit.

Einige Informationen zu Compilern und Linkern finden Sie übrigens im Kapitel Compiler

[Bearbeiten] Namenskonventionen

Übliche Dateiendungen für C++-Quelltexte sind „.cpp“ oder „.cc“. Headerdateien haben oft die Endungen „.hpp“, „.hh“ und „.h“. Letztere ist allerdings auch die gebräuchlichste Dateiendung für C-Header, weshalb zugunsten besserer Differenzierung empfohlen wird, diese nicht zu benutzen.

Die Standardheader von C++ haben überhaupt keine Dateiendung, wie Sie vielleicht schon anhand der beiden Headerdateien iostream und string erraten haben. In der Anfangszeit von C++ endeten Sie noch auf „.h“, inzwischen ist diese Notation aber nicht mehr gültig, obwohl sie immer noch von vielen Compilern unterstützt wird. Der Unterschied zwischen „iostream“ und „iostream.h“ besteht darin, dass in ersterer Headerdatei alle Deklarationen im Standardnamespace std vorgenommen werden.

Prinzipiell kommt es nicht darauf an, welche Dateiendungen Sie ihren Dateien geben, dennoch ist es sinnvoll, eine übliche Endung zu verwenden, wenn auch nur, um einer möglichen Verwechslungsgefahr vorzubeugen. Wenn Sie sich einmal für eine Dateiendung entschieden haben, ist es vorteilhaft, darin eine gewisse Konstanz zu bewahren, nicht nur vielleicht aus Gemütlichkeit und Zeiteinsparnis, sondern auch im Sinne des schnellen Wiederfindens. Einige Beispiele für Headerdateiendungen finden Sie, wenn Sie sich einfach mal einige weitverbreite C++-Bibliotheken ansehen. Boost verwendet „.hpp“, wxWidgets nutzt „.h“ und Qt orientiert sich an der Standardbibliothek, hat also gar keine Dateiendung.

[Bearbeiten] Schutz vor Mehrfacheinbindung

Da Headerdateien oft andere Headerdateien einbinden, kann es leicht passieren, dass eine Headerdatei mehrfach eingebunden wird. Da viele Header nicht nur Deklarationen, sondern auch Definitionen enthalten, führt dies zu Compiler-Fehlermeldungen, da innerhalb einer Übersetzungseinheit ein Name stets nur genau einmal definiert werden darf (mehrfache Deklarationen, die keine Definitionen sind, sind jedoch erlaubt). Um dies zu vermeiden, wird der Präprozessor verwendet. Am Anfang der Headerdatei wird ein Präprozessor-ifndef ausgeführt, das prüft, ob ein Symbol nicht definiert wurde, ist dies der Fall, wird das Symbol definiert. Am Ende der Headerdatei wird die Abfrage mit einem Präprozessor-endif wieder beendet.

Crystal Clear app terminal.png
mal2.hpp
#ifndef _mal2_hpp_
#define _mal2_hpp_

int mal2(int wert);

#endif

Als Symbol wird üblicherweise aus dem Dateinamen des Headers abgeleitet. In diesem Fall wurde der Punkt durch einen Unterstrich ersetzt und ein weiter Unterstrich vor und nach dem Dateinamen eingefügt. Wenn der Präprozessor nun die Anweisung bekommt, diese Datei in eine andere einzubinden, wird er das anstandslos tun. Findet er eine zweite Anweisung, die Datei einzubinden, wird er alles von #ifndef bis #endif überspringen, da das Symbol _mal2_hpp_ ja nun bereits definiert wurde.

Symbol kept vote.svg
Tipp

Mehr Informationen über Präprozessor-Anweisungen finden Sie im Kapitel Vorarbeiter des Compilers

[Bearbeiten] Inline-Funktionen

Im Kapitel „Prozeduren und Funktionen“ haben Sie bereits erfahren was eine Inline-Funktion ist. Das Schlüsselwort inline empfiehlt dem Compiler, beim Aufruf einer Funktion den Funktionsrumpf direkt durch den Funktionsaufruf zu ersetzen, wodurch bei kurzen Funktionen die Ausführungsgeschwindigkeit gesteigert werden kann. Es bewirkt aber noch mehr. Normalerweise darf eine Definition immer nur einmal gemacht werden. Da für das Inlinen einer Funktion aber die Definition bekannt sein muss, gibt es für Inline-Funktionen eine Ausnahme: Sie dürfen beliebig oft definiert werden, solange alle Definitionen identisch sind. Deshalb dürfen (und sollten) Inline-Funktionen in den Headerdateien definiert werden, ohne dass sich der Linker später über eine mehrfache Definition in verschiedenen Objektdateien beschweren wird.

Symbol opinion vote.svg
Hinweis

Inline-Funktionen dürfen auch in cpp-Dateien definiert werden. Allerdings können sie dann auch nur innerhalb der Objektdatei, die aus der cpp-Datei erzeugt wird, geinlinet werden und das ist in aller Regel nicht beabsichtigt.

Zusammenfassung

Baustelle.svg

Zu diesem Abschnitt existiert leider noch keine Zusammenfassung…


Wenn Sie Lust haben können Sie die Zusammenfassung zum Abschnitt Weitere Grundelemente selbst schreiben oder einen Beitrag dazu leisten.


Persönliche Werkzeuge