Zum Inhalt springen

C++-Programmierung/ Eigene Datentypen definieren

Aus Wikibooks
Eigene Datentypen definieren

Zielgruppe:

Anfänger

Lernziel:
Das Arbeiten mit Klassen verstehen.


Das Klassenkonzept [Bearbeiten]

In einem späteren Abschnitt werden Sie etwas über objektorientierte Programmierung erfahren. Die notwendige Basis dafür werden wir in diesem Abschnitt erarbeiten. Sie haben inzwischen einiges über die verschiedenen Basisdatentypen gelernt und auch erfahren, wie Sie diese Daten mithilfe von Funktionen manipulieren können. Im Prinzip könnten Sie mit diesen Mitteln bereits jedes beliebige Programm schreiben. Allerdings werden Programme, die mit diesen Mitteln geschrieben wurden oft unübersichtlich, sobald eine gewisse Größe überschritten wird. Aus diesem Grund wurde ein neues Konzept erdacht, das für mehr Übersicht sorgt. Anstatt einfach Daten (Variablen) zu haben, die von Funktionen manipuliert werden, fasst man logisch zusammengehörende Daten und die darauf arbeitenden Funktionen zu einem neuen Konstrukt zusammen. Dieses Konstrukt nennt sich „Klasse“.

Eine Klasse hat üblicherweise einen Namen und besteht aus Variablen und Funktionen, welche als Klassenmember (zu deutsch etwa Mitglieder) bezeichnet werden. Ein Funktionsmember bezeichnet man auch als Methode der Klasse. Ein wesentlicher Vorteil der Verwendung von Klassen besteht darin, dass ein Nutzer der Klasse üblicherweise nicht wissen muss, wie die Klasse intern arbeitet. Andererseits kann man Klassen so gestalten, dass man von außen nur auf bestimmte klasseneigene Variablen und Funktionen zugreifen kann. Dann können Sie die interne konkrete Implementierung (abgesehen von diesen Variablen- und Funktionsnamen) der Klasse jederzeit ändern, ohne dass der Code, in dem die Klasse verwendet wird, geändert werden muss. Der Code außerhalb der Klasse muss nur dann geändert werden, wenn die Variablen- und Funktionennamen der Klasse, auf die man von außen zugreifen kann, geändert wurden. Meistens werden übrigens die Variablen einer Klasse nicht sichtbar gemacht, und man verwendet stattdessen Funktionen, um die Variablen zu verändern. Dabei kommt es durchaus häufig vor, dass eine Funktion nichts anderes tut, als eine Variable zu verändern oder sie auszulesen. Dabei spricht man auch von "Getter"- und "Setter"-Funktionen.

Eine Klasse ist ein benutzerdefinierter Datentyp. Somit können Sie also, genau wie bei einem Basisdatentyp, Variablen vom Typ der Klasse (= deren Name) erstellen. Eine Variable vom Typ einer Klasse beinhaltet alle in der Klasse deklarierten Variablen. Somit verbraucht eine Klassenvariable soviel Speicherplatz wie die Variablen, die in ihr deklariert wurden. Natürlich hat der Übersetzer hier einige Freiräume, sodass Sie nicht einfach davon ausgehen können, dass eine Klassenvariable genau soviel Speicherplatz belegt, wie die Summe ihrer Variablenmember. Wenn der Übersetzer es aus Optimierungsgründen für sinnvoll hält, etwas mehr Speicherplatz zu nutzen, um etwa die internen Variablen so im Speicher anzuordnen, dass auf sie schnell zugegriffen werden kann, darf er dies tun. Wenn Sie die genaue Größe wissen möchten, die eine Variable einer Klasse im Speicher belegt, können Sie den sizeof-Operator auf die Klasse oder eine Variable vom Typ der Klasse anwenden.

Der Inhalt einer solchen Variable ist ein „Objekt“ der Klasse. Ebenso wie es vom Typ 'int' viele Variablen mit unterschiedlichen Werten in einem Programm geben kann, so kann es viele Objekte mit unterschiedlichen Werten vom selben Klassen-Typ in einem Programm geben.

Eine Klasse ist wie ein Bauplan für Objekte. Klassen haben zwei spezielle Methoden, die beim Erstellen bzw. Zerstören eines Objektes vom Typ der Klasse aufgerufen werden. Erstere bezeichnet man als Konstruktor und die zweite als Destruktor der Klasse.

Ähnlich wie Funktionen ihre lokalen Variablen haben können, auf die „von außen“ nicht zugegriffen werden kann, gibt es innerhalb einer Klasse verschiedene Sichtbarkeitsarten für Variablen, die darüber entscheiden, ob eine Membervariable nur innerhalb der Klasse oder auch von außerhalb verwendet werden kann. Später werden wir sehen, dass man diese Sichtbarkeitsart durch spezielle Ausdrücke wie etwa public oder private festlegt.

Es gibt einige allgemeine Operatoren für Klassen und Objekte. Es ist möglich, einige dieser Operatoren für eine Klasse zu überladen, sodass diese dann auf Klassenobjekte angewendet werden können.

Das waren jetzt wieder sehr viele Informationen auf wenig Raum, aber machen Sie sich keine Sorgen, wenn Sie sich nicht alles merken konnten: In den nächsten Kapiteln wird auf die verschiedenen Eigenschaften von Klassen noch näher eingegangen, sodass Sie die Gedanken dahinter nachvollziehen können.

Ein eigener Datentyp

[Bearbeiten]

Nun wird es aber Zeit, dass wir auch mal eine eigene Klasse schreiben. Das folgende Beispiel soll die prinzipielle Arbeitsweise einer Klasse demonstrieren.

#include <iostream>

class Auto{
public:
    Auto(int tankgroesse, float tankinhalt, float verbrauch);

    void info()const;

    void fahren(int km);
    void tanken(float liter);

private:
    int   tankgroesse_;
    float tankinhalt_;
    float verbrauch_;
};

Auto::Auto(int tankgroesse, float tankinhalt, float verbrauch):
    tankgroesse_(tankgroesse),
    tankinhalt_(tankinhalt),
    verbrauch_(verbrauch)
    {}

void Auto::info()const{
    std::cout << "In den Tank passen " << tankgroesse_ << " Liter Treibstoff.\n";
    std::cout << "Aktuell sind noch " << tankinhalt_ << " Liter im Tank.\n";
    std::cout << "Der Wagen verbraucht " << verbrauch_ << " Liter pro 100 km.\n";
    std::cout << std::endl;
}

void Auto::fahren(int km){
    std::cout << "Fahre " << km << " km.\n";
    tankinhalt_ -= verbrauch_*km/100;

    if(tankinhalt_ < 0.0f){
        tankinhalt_ = 0.0f;

        std::cout << "Mit dem aktuellen Tankinhalt schaffen Sie die Fahrt leider nicht.\n";
        std::cout << "Der Wagen ist unterwegs liegengeblieben, Zeit zu tanken!\n";
    }

    std::cout << std::endl;
}

void Auto::tanken( float liter ) {
    std::cout << "Tanke " << liter << " Liter.\n";
    tankinhalt_ += liter;

    if( tankinhalt_ > tankgroesse_ ) {
        tankinhalt_ = tankgroesse_;

        std::cout << "Nicht so übereifrig! Ihr Tank ist jetzt wieder voll.\n";
        std::cout << "Sie haben aber einiges daneben gegossen!\n";
    }

    std::cout << std::endl;
}

Diese Klasse nutzt vieles, was Sie im Laufe dieses Abschnittes noch kennenlernen werden. Für den Moment sollten Sie wissen, dass diese Klasse drei verschiedene Daten beinhaltet. Diese Daten sind die drei Variablen, deren Namen auf einen Unterstrich (_) enden. Vier Funktionen arbeiten auf diesen Daten.

Sie haben nun gesehen, wie die Klasse aufgebaut ist, und in den folgenden Kapiteln wird dieser Aufbau genauer erläutert. Jetzt sollen Sie jedoch erst einmal den Vorteil einer Klasse verstehen, denn um eine Klasse zu benutzen, müssen Sie keine Ahnung haben, wie diese Klasse intern funktioniert.


int main(){
    Auto wagen(80, 60.0f, 5.7f);

    wagen.info();

    wagen.tanken(12.4f);
    wagen.info();

    wagen.fahren(230);
    wagen.info();

    wagen.fahren(12200);
    wagen.info();

    wagen.tanken(99.0f);
    wagen.info();
}
Ausgabe:
In den Tank passen 80 Liter Treibstoff.
Aktuell sind noch 60 Liter im Tank.
Der Wagen verbraucht 5.7 Liter pro 100 km.

Tanke 12.4 Liter.

In den Tank passen 80 Liter Treibstoff.
Aktuell sind noch 72.4 Liter im Tank.
Der Wagen verbraucht 5.7 Liter pro 100 km.

Fahre 230 km.

In den Tank passen 80 Liter Treibstoff.
Aktuell sind noch 59.29 Liter im Tank.
Der Wagen verbraucht 5.7 Liter pro 100 km.

Fahre 12200 km.
Mit dem aktuellen Tankinhalt schaffen Sie die Fahrt leider nicht.
Der Wagen ist unterwegs liegengeblieben, Zeit zu tanken!

In den Tank passen 80 Liter Treibstoff.
Aktuell sind noch 0 Liter im Tank.
Der Wagen verbraucht 5.7 Liter pro 100 km.

Tanke 99 Liter.
Nicht so übereifrig! Ihr Tank ist jetzt wieder voll.
Sie haben aber einiges daneben gegossen!

In den Tank passen 80 Liter Treibstoff.
Aktuell sind noch 80 Liter im Tank.
Der Wagen verbraucht 5.7 Liter pro 100 km.

In der ersten Zeile von main() wird ein Auto-Objekt mit dem Namen wagen erstellt. Anschließend werden Methoden dieses Objekts aufgerufen, um die Daten darin zu verwalten. Von den Daten innerhalb des Objekts kriegen Sie beim Arbeiten mit dem Objekt überhaupt nichts mit. Lediglich die Ausgabe verrät, dass die drei Methoden untereinander über diese Daten „kommunizieren“.

Die vierte Methode (jene, die mit dem Klassennamen identisch ist) wird übrigens auch aufgerufen. Gleich in der ersten Zeile von main() wird diese Methode genutzt, um das Objekt zu erstellen. Es handelt sich also um den Konstruktor der Klasse.

Erstellen und Zerstören [Bearbeiten]

In C++-Klassen gibt es zwei besondere Arten von Methoden: Konstruktoren und den Destruktor. Ein Konstruktor wird beim Anlegen eines Objektes ausgeführt, der Destruktor vor der „Zerstörung“ desselben. Der Name des Konstruktors ist immer gleich dem Klassennamen, der Destruktor entspricht ebenfalls dem Klassennamen, jedoch mit einer führenden Tilde (~).

Konstruktoren und Destruktoren haben keinen Rückgabetyp, auch nicht void. Der Konstruktor kann nicht als Methode aufgerufen werden, beim Destruktor ist dies hingegen möglich (aber nur selten nötig, dazu später mehr).

Konstruktor

[Bearbeiten]

Jede Klasse hat einen oder mehrere Konstruktoren. Ein solcher Konstruktor dient zur Initialisierung eines Objektes. Im Folgenden wird eine Klasse Bruch angelegt, die über den Konstruktor die Membervariablen zaehler_ und nenner_ initialisiert.

class Bruch{
public:
    Bruch(int z, int n):
        zaehler_(z), // Initialisierung von zaehler_
        nenner_(n)   // Initialisierung von nenner_
        {}

private:
    int zaehler_;
    int nenner_;
};

int main(){
    Bruch objekt(7, 10); // Initialisierung von objekt
}

Wie Sie sehen, ist der Methodenrumpf von Bruch::Bruch leer. Die Initialisierung findet in den beiden Zeilen über dem Rumpf statt. Nach dem Prototyp wird ein Doppelpunkt geschrieben, darauf folgt eine Liste der Werte, die initialisiert werden sollen. Vielleicht erinnern Sie sich noch, dass es zur Initialisierung zwei syntaktische Varianten gibt:

int wert(8);
int wert = 8;

Innerhalb der Initialisierungsliste ist nur die erste Variante zulässig. Auch in der Hauptfunktion main() findet eine Initialisierung statt. Hier könnte die zweite Variante verwendet werden, jedoch besitzt der Konstruktor zwei Parameter, daher ist die Variante mit dem Gleichheitszeichen nicht sofort offensichtlich. Dieses Thema wird am Ende des Kapitels genauer besprochen, da zum Verständnis dessen ein wenig Hintergrundwissen nötig ist, das im Verlauf des Kapitels vermittelt wird.

Hinweis

Beachten Sie, dass die Initialisierung der Variablen einer Klasse in der Reihenfolge erfolgt, in der sie in der Klasse deklariert wurden. Die Initialisierungsreihenfolge ist unabhängig von der Reihenfolge, in der sie in der Initialisierungsliste angegeben wurden. Viele Compiler warnen, wenn man so etwas macht, da derartiger Code möglicherweise nicht das tut, was man erwartet.

Natürlich ist es wie bei Funktionen möglich und in der Regel zu empfehlen, die Methodendeklaration von der Methodendefinition zu trennen. Die Definition sieht genau so aus, wie bei einer normalen Funktion. Der einzige auffällige Unterschied besteht darin, dass dem Methodennamen der Klassenname vorangestellt wird, getrennt durch den Bereichsoperator (::).

class Bruch{
public:
    Bruch(int z, int n);     // Deklaration

private:
    int zaehler_;
    int nenner_;
};

Bruch::Bruch(int z, int n): // Definition
    zaehler_(z),            // Initialisierung von zaehler_
    nenner_(n)              // Initialisierung von nenner_
    {}

int main(){
    Bruch objekt(7, 10);     // Der Bruch 7/10 oder auch 0.7
}

Wie bereits erwähnt, hat der Konstruktor keinen Rückgabetyp, daher wird auch in der Definition keiner angegeben. Bei den Basisdatentypen ist es bezüglich der Performance übrigens egal, ob Sie diese initialisieren oder zuweisen. Sie könnten also den gleichen Effekt erzielen, wenn Sie statt der Initialisierungsliste eine Zuweisung im Funktionsrumpf benutzen. Allerdings gilt dies wirklich nur für die Basisdatentypen, bei komplexen Datentypen ist die Initialisierung oft deutlich schneller. Außerdem können konstante Variablen ausschließlich über die Initialisierungsliste einen Wert erhalten. Nun aber noch mal ein einfaches Beispiel, in dem der Funktionsrumpf des Konstruktors zur Anfangswertzuweisung dient:

class Bruch{
public:
    Bruch(int z, int n); // Deklaration

private:
    int zaehler_;
    int nenner_;
};

Bruch::Bruch(int z, int n){  // Definition
    zaehler_ = z; // Zuweisung
    nenner_  = n; // Zuweisung
}

Wie Sie sehen, entfällt der Doppelpunkt, wenn Sie die Initialisierungsliste nicht nutzen. Natürlich können Sie auch Initialisierungsliste und Funktionsrumpf parallel benutzen.

Irgendwann werden Sie sicher einmal in die Verlegenheit kommen, ein Array als Membervariable innerhalb Ihrer Klasse zu deklarieren. Leider gibt es keine Möglichkeit, Arrays zu initialisieren, sie müssen immer im Konstruktorrumpf mittels Zuweisung ihren Anfangswert erhalten. Entsprechend ist es auch nicht möglich, ein Memberarray mit konstanten Daten zu erstellen. Wenn Sie also zwingend eine Initialisierung benötigen, müssen Sie wohl oder übel Einzelvariablen erstellen oder auf eine array-ähnliche Klasse zurückgreifen. std::vector aus der Standard-Headerdatei vector ist meist eine gute Alternative. Um Performanceeinbußen zu verhindern, müssten Sie das std::vector-Objekt zunächst mittels der Methode reserve(n) anweisen, für n Elemente Speicher zu alloziieren und anschließend die Elemente mittels der Methode push_back(element) hinzufügen. Beides passiert natürlich im Konstruktorrumpf, in der Initialisierungsliste müssen Sie keine Angaben machen.

Defaultparameter

[Bearbeiten]

Sie können einem Konstruktor ebenso Defaultparameter vorgeben, wie einer gewöhnlichen Funktion. Die syntaktischen Regeln sind identisch.

class Bruch{
public:
    Bruch(int z, int n = 1); // Deklaration mit Defaultwert

private:
    int zaehler_;
    int nenner_;
};

Bruch::Bruch(int z, int n):  // Definition
    zaehler_(z),
    nenner_(n)
    {}

Mehrere Konstruktoren

[Bearbeiten]

Auch das Überladen des Konstruktors funktioniert wie das Überladen einer Funktion. Deklarieren Sie mehrere Konstruktoren innerhalb der Klasse und schreiben für jeden eine Definition:

class Bruch{
public:
    Bruch(int z);        // Deklaration
    Bruch(int z, int n); // Deklaration

private:
    int zaehler_;
    int nenner_;
};

Bruch::Bruch(int z):        // Definition
    zaehler_(z),
    nenner_(1)
    {}

Bruch::Bruch(int z, int n): // Definition
    zaehler_(z),
    nenner_(n)
    {}

Wenn mehrere Konstruktoren das gleiche tun, ist es oft sinnvoll, diese gleichen Teile in eine eigene Methode (üblicherweise mit dem Namen init()) zu schreiben. Das ist kürzer, übersichtlicher, meist schneller und hilft auch noch bei der Vermeidung von Fehlern. Denn wenn Sie den Code nachträglich ändern, dann müssten Sie diese Änderungen für jeden Konstruktor vornehmen. Nutzen Sie eine init()-Methode, müssen Sie den Code nur in dieser ändern.

class A{
public:
    A(double x);
    A(int x);

private:
    void init();

    int x_;
    int y_;
};

A::A(double x):
    x_(x) {
    init();
    }

A::A(int x):
    x_(x) {
    init();
    }

void A::init(){
    y_ = 7000;
}

Dieses Beispiel ist zugegebenermaßen ziemlich sinnfrei, aber das Prinzip der init()-Funktion wird deutlich. Wenn Sie sich nun irgendwann entscheiden, y_ den Anfangswert 3333 zuzuweisen, dann müssen Sie dies nur in der init()-Funktion ändern. Die Konstruktoren bleiben unverändert.

Standardkonstruktor

[Bearbeiten]

Als Standardkonstruktor bezeichnet man einen Konstruktor, der keine Parameter erwartet. Ein Standardkonstruktor für unsere Bruch-Klasse könnte folgendermaßen aussehen:

class Bruch{
public:
    Bruch();      // Deklaration Standardkonstruktor

private:
    int zaehler_;
    int nenner_;
};

Bruch::Bruch():   // Definition Standardkonstruktor
    zaehler_(0),
    nenner_(1)
    {}

Natürlich könnten wir in diesem Beispiel den Konstruktor überladen, um neben dem Standardkonstruktor auch die Möglichkeiten zur Initialisierung mit beliebigen Werten zu haben. Es bietet sich hier jedoch an, dafür Defaultparameter zu benutzen. Das folgende kleine Beispiel erlaubt die Initialisierung mit einer ganzen Zahl und mit einer gebrochenen Zahl (also zwei Ganzzahlen: Zähler und Nenner). Außerdem wird auch gleich noch der Standardkonstruktor bereitgestellt, welcher den Bruch mit 0 initialisiert, also den Zähler auf 0 und den Nenner auf 1 setzt.

class Bruch{
public:
    // Erlaubt 3 verschiedene Aufrufmöglichkeiten, darunter die des Standardkonstruktors
    Bruch(int z = 0, int n = 1);

private:
    int zaehler_;
    int nenner_;
};

Bruch::Bruch(int z, int n): // Definition des Konstruktors
    zaehler_(z),
    nenner_(n)
    {}

Im Kapitel „Leere Klassen?“ werden Sie noch einiges mehr über den Standardkonstruktor erfahren.

Kopierkonstruktor

[Bearbeiten]

Neben dem Standardkonstruktor gibt es einen weiteren speziellen Konstruktor, den Kopierkonstruktor: er erstellt ein Objekt anhand eines bereits vorhandenen Objektes. Der Parameter des Kopierkonstruktors ist also immer eine Referenz auf ein konstantes Objekt derselben Klasse. Der Kopierkonstruktor unserer Bruch-Klasse hat folgende Deklaration:

Bruch::Bruch(Bruch const& bruch_objekt);

Beachten Sie, dass ein Kopierkonstruktor immer eine Referenz auf ein Objekt (meist ein konstantes Objekt) übernimmt. Würde an dieser Stelle eine „call-by-value“-Übergabe stehen, müsste für die Objektübergabe an den Kopierkonstruktor ja selbst das Objekt zunächst (mittels Kopierkonstruktor) kopiert werden. Dies führt zu einer Endlosrekursion beim Kompilieren und ist somit verboten.

Wenn wir keinen eigenen Kopierkonstruktor schreiben, erstellt der Compiler einen für uns. Dieser implizite Kopierkonstruktor initialisiert alle Membervariablen mit den entsprechenden Werten der Membervariablen im übergebenen Objekt. Für den Moment ist diese vom Compiler erzeugte Variante ausreichend. Später werden Sie Gründe kennenlernen, die es notwendig machen, einen eigenen Kopierkonstruktor zu schreiben. Wenn Sie die Nutzung des Kopierkonstruktors verbieten wollen, dann schreiben Sie seine Deklaration in den private-Bereich der Klassendeklaration. Das bewirkt einen Kompilierfehler, wenn eine nicht-Klassen-Funktion versucht den Kopierkonstruktor aufzurufen.

Neben dem Kopierkonstruktor erzeugt der Compiler übrigens auch noch eine Kopierzuweisung, auch zu dieser werden Sie später noch mehr erfahren. Auch diese können Sie verbieten, indem Sie sie im private deklarieren. Wie dies geht, erfahren Sie im Kapitel zu Operatorüberladung.

Destruktor

[Bearbeiten]

Im Gegensatz zum Konstruktor, gibt es beim Destruktor immer nur einen pro Klasse. Das liegt daran, dass ein Destruktor keine Parameter übergeben bekommt. Sein Aufruf erfolgt in der Regel implizit durch den Compiler bei der Zerstörung eines Objektes. Für den Anfang werden Sie mit dem Destruktor wahrscheinlich nicht viel anfangen können, da es mit den Mitteln, die Sie bis jetzt kennen, kaum nötig werden kann, dass bei der Zerstörung eines Objektes Aufräumarbeiten ausgeführt werden. Das folgende kleine Beispiel enthält einen Destruktor, der einfach gar nichts tut:

class A{
public:
    ~A(); // Deklaration Destruktor
};

A::~A(){  // Definition Destruktor
    // Hier könnten "Aufräumarbeiten" ausgeführt werden
}

Wenn Sie den Abschnitt über Speicherverwaltung gelesen haben, werden Sie wissen, wie nützlich der Destruktor ist. Im Moment reicht es, wenn Sie mal von ihm gehört haben. Auch über den Destruktor werden Sie im Kapitel „Leere Klassen?“ weitere Informationen erhalten.

Beispiel mit Ausgabe

[Bearbeiten]

Um noch einmal deutlich zu machen, an welchen Stellen Konstruktor und Destruktor aufgerufen werden, geben wir einfach innerhalb der Methoden eine Nachricht aus:

#include <iostream>

class A{
public:
    A();          // Deklaration Konstruktor
    ~A();         // Deklaration Destruktor

    void print(); // Deklaration einer Methode
};

A::A(){           // Definition Konstruktor
    std::cout << "Innerhalb des Konstruktors" << std::endl;
}

A::~A(){          // Definition Destruktor
    std::cout << "Innerhalb des Destruktors" << std::endl;
}

void A::print(){  // Definition der print()-Methode
    std::cout << "Innerhalb der Methode print()" << std::endl;
}

int main(){
    A objekt;       // Anlegen des Objekts == Konstruktoraufruf
    objekt.print(); // Aufruf der Methode print()
    return 0;
}                   // Ende von main(), objekt wird zerstört == Destruktoraufruf
Ausgabe:
Innerhalb des Konstruktors
Innerhalb der Methode print()
Innerhalb des Destruktors

Explizite Konstruktoren

[Bearbeiten]

Bevor wir den Begriff des expliziten Konstruktors klären, soll kurz auf die oben angesprochene zweite syntaktische Möglichkeit zum Konstruktoraufruf eingegangen werden.

class Bruch{
public:
    Bruch(int z = 0, int n = 1);
// ...
};

int main(){
    Bruch objekt1 = Bruch(7, 10); // Bruch objekt1(7, 10);
    Bruch objekt2 = Bruch(7);     // Bruch objekt2(7);
    Bruch objekt3 = Bruch();      // Bruch objekt3;
}

Diese Variante, einen Konstruktor aufzurufen, ist absolut gleichwertig zu der, die im Kommentar steht. Beachten Sie an dieser Stelle, dass hier keineswegs ein Kopierkonstruktor aufgerufen wird, es handelt sich tatsächlich nur um eine andere syntaktische Möglichkeit. Im folgenden Beispiel wird eine Klasse String definiert, welche über 2 Konstruktoren verfügt:

#include <iostream>

class String{
public:
    String(char const* zeichenkette);
    String(int anzahl, char zeichen = '*');

    char feld[80];
};

String::String(char const* zeichenkette){
    char* i = feld;
    while(*zeichenkette != '\0'){
        *i++ = *zeichenkette++;
    }
    *i = '\0';
}

String::String(int anzahl, char zeichen){
    for(int i = 0; i < anzahl; ++i)
        feld[i] = zeichen;
    feld[anzahl] = '\0';
}

int main(){
    String str1 = "Ein Text";
    String str2 = String(5, 'Z');
    String str3 = 'A';

    std::cout << str1.feld << std::endl;
    std::cout << str2.feld << std::endl;
    std::cout << str3.feld << std::endl;
}
Ausgabe:
Ein Text
ZZZZZ
*****************************************************************

Auf die Implementierung der Konstruktoren soll an dieser Stelle nicht eingegangen werden und natürlich wäre feld in einer echten Klasse privat. Der erste Konstruktor erzeugt ein Objekt anhand einer Zeichenkette. Der zweite Konstruktor kann mit einem oder mit zwei Parametern aufgerufen werden. Er erzeugt einen String, der aus anzahl zeichen besteht.

Sicher wird Ihnen auffallen, dass für die Initialisierung von str1 und str3 zwar das Gleichheitszeichen verwendet wird, dahinter jedoch kein Objekt der Klasse String folgt. Für diese syntaktische Möglichkeit muss der Compiler eine sogenannte implizite Konvertierung durchführen. Falls die Klasse über einen Konstruktor mit einem Parameter verfügt, der zu dem des übergebenen Objekts kompatibel ist, wird dieser Konstruktor verwendet. Kompatibel bedeutet, dass sich das übergebene Objekt in maximal einem Schritt in ein Objekt mit dem Typ des Parameters umwandeln lässt. Bei str3 wird ein Objekt vom Typ char const übergeben. Der Compiler findet in der Klasse einen Konstruktor, der mit einem int aufgerufen werden kann. Ein char lässt sich direkt in einen int umwandeln. Daher wählt der Compiler diesen Konstruktor aus. Analog wird für str1 ein Objekt vom Typ char const[9] übergeben und dieses ist zu char const* kompatibel.

Es ist im Falle von str3 jedoch sehr wahrscheinlich, dass eigentlich ein String definiert werden sollte, der aus dem einzelnen Zeichen 'A' besteht und der Programmierer nur übersehen hat, dass die Klasse gar nicht über einen entsprechenden Konstruktor verfügt. Das Problem wäre gelöst, wenn die implizite Konvertierung nicht stattfinden würde und genau das ist bei expliziten Konstruktoren der Fall. Das Schlüsselwort für die Deklaration lautet explicit.

#include <iostream>

class String{
public:
    String(char const* zeichenkette);
    explicit String(int anzahl, char zeichen = '*');

    char feld[80];
};

int main(){
    String str1 = "Ein Text"; // implizite Konvertierung
    String str3 = 'A';        // Fehler, keine implizite Konvertierung möglich
}

In vielen Fällen ist, wie bei str1, eine implizite Konvertierung gewünscht, da sie zur besseren Lesbarkeit des Quellcodes beitragen kann. Beachten Sie auch, dass die folgenden beiden Zeilen explizite Konstruktoraufrufe sind und entsprechend durchgeführt werden:

String str4('A');          // ist explizit
String str5 = String('A'); // ist explizit

Die Möglichkeit, einen Konstruktor mittels impliziter Konvertierung aufzurufen, sollte immer dann genutzt werden, wenn das Objekt, das Sie definieren wollen, den übergebenen Wert repräsentiert. In unserem String-Beispiel ist dies bei str1 der Fall. Das erzeugte Objekt repräsentiert den übergebenen String. Im Falle von str2 wird hingegen ein Objekt nach den Vorgaben der übergebenen Parameter erstellt. Der Unterschied ist rein sprachlicher Natur, im ersten Fall wird eben bereits ein String (in Form eines C-Strings) übergeben. Technisch gesehen wird natürlich in beiden Fällen ein Objekt anhand der Parameter konstruiert.

Wenn Sie nun beabsichtigen, einen String zu erstellen, der aus dem Zeichen 'A' besteht, würde das erstellte Objekt das Zeichen 'A' repräsentieren. Wenn Sie sich infolgedessen für die Anwendung einer impliziten Konvertierung wie bei str3 entscheiden, wird der Compiler dies dank explizitem Konstruktor mit einer Fehlermeldung quittieren. Wenn Sie beabsichtigen einen String mit 'A' vielen Zeichen zu konstruieren, dann wählen Sie die explizite Syntax wie bei str4 oder str5. Dies wird Ihr Compiler anstandslos übersetzen.

Privat und öffentlich [Bearbeiten]

Mit den Schlüsselwörtern public und private wird festgelegt, von wo aus auf eine Eigenschaft oder Methode einer Klasse zugegriffen werden kann. Auf jene, die als public deklariert werden, kann von überall aus zugegriffen werden, sie sind also öffentlich verfügbar. private-deklarierte Eigenschaften oder Methoden lassen sich nur innerhalb der Klasse ansprechen, also nur innerhalb von Methoden derselben Klasse. Typischerweise werden Variablen private deklariert, während Methoden public sind. Eine Ausnahme bilden Hilfsmethoden, die gewöhnlich im private-Bereich deklariert sind, wie etwa die init()-Methode, die von verschiedenen Konstruktoren aufgerufen wird, um Codeverdopplung zu vermeiden.

Standardmäßig sind die Member einer Klasse private. Geändert wird der Sichtbarkeitsbereich durch die beiden Schlüsselwörter gefolgt von einem Doppelpunkt. Alle Member, die darauffolgend deklariert werden, fallen in den neuen Sichtbarkeitsbereich.

class A{
    // private Member

public:
    // öffentliche Member

private:
    // private Member
};

Zwischen den Sichtbarkeitsbereichen kann somit beliebig oft gewechselt werden. Der implizit private Bereich sollte aus Gründen der Übersichtlichkeit nicht genutzt werden. In diesem Buch folgen wir der Regel, als erstes die öffentlichen Member zu deklarieren und erst danach die privaten. Die umgekehrte Variante findet jedoch ebenfalls eine gewisse Verbreitung. Entscheiden Sie sich für eine Variante und wenden Sie diese konsequent an.

In Zusammenhang mit Vererbung werden Sie noch einen weiteren Sichtbarkeitsbereich kennen lernen. Dieser ist für unsere Zwecke aber noch nicht nötig und wird daher auch erst später erläutert.

Strukturen

[Bearbeiten]

Neben den Klassen gibt es in C++ auch noch die aus C stammenden „Strukturen“. Eine Struktur, auch Datenverbund genannt, fasst mehrere Variablen zu einem Typ zusammen. Im Gegensatz zu den C-Strukturen sind C++-Strukturen allerdings vollständig kompatibel zu Klassen. Sie können in einer Struktur also problemlos Methoden deklarieren oder die Zugriffsrechte über die Schlüsselworte public, protected (wird später erläutert) und private festlegen.

Der einzige wirkliche Unterschied zwischen Strukturen und Klassen ist in C++, dass in Klassen implizit private gilt, während in Strukturen implizit public gilt. Da dieser implizite Zugriffsrechtebereich aber für gewöhnlich nicht genutzt werden sollte, ist dieser Unterschied fast unbedeutend. Das folgende kleine Beispiel zeigt die Definition einer Struktur im Vergleich zu der einer Klasse.

struct A{                  | class B{
    // öffentliche Member  |     // private Member
                           |
public:                    | public:
    // öffentliche Member  |     // öffentliche Member
                           |
private:                   | private:
    // private Member      |     // private Member
};                         | };
Thema wird später näher erläutert…

Im Kapitel über Vererbung werden Sie noch sehen, dass Sie eine Klasse problemlos von einer Struktur ableiten können und umgekehrt.

Methoden [Bearbeiten]

Auch für Klassen gilt üblicherweise: Verwenden Sie const, wann immer es möglich ist. Wie Sie bereits wissen, sollte const immer verwendet werden, wenn eine Variable nach der Initialisierung nicht mehr verändert werden soll.

Da Klassen Datentypen sind, von denen Instanzen (also Variablen) erstellt werden können, ist es natürlich möglich ein Objekt zu erstellen, das konstant ist. Da der Compiler jedoch davon ausgehen muss, dass jede Methode der Klasse die Daten (und somit das Objekt) verändert, sind Methodenaufrufe für konstante Objekte nicht möglich. Eine Ausnahme bilden jene Methoden, die ebenfalls als const gekennzeichnet sind. Eine solche Methode kann zwar problemlos Daten aus dem Objekt lesen, aber niemals darauf schreiben und auch für Dritte keine Möglichkeit bereitstellen, objektinterne Daten zu verändern.

Konstante Methoden

[Bearbeiten]

Unsere Beispielklasse Auto enthält die konstante Methode info(), sie greift nur lesend auf die Membervariablen zu, um ihre Werte auszugeben. Wenn Sie ein konstantes Auto Objekt erstellen, können Sie info() problemlos aufrufen. Versuchen Sie jedoch fahren() oder tanken() aufzurufen, wird Ihr Compiler dies mit einer Fehlermeldung quittieren.

class Auto{
// ...
    void info()const;

    bool fahren(int km);
    void tanken(float liter);
// ...
};

Wie Sie an diesem Beispiel sehen, lässt sich eine Methode als konstant auszeichnen, indem nach der Parameterliste das Schlüsselwort const angegeben wird. Diese Auszeichnung folgt also auch der einfachen Regel: const steht immer rechts von dem, was konstant sein soll, in diesem Fall die Methode. Da const zum Methodenprototyp zählt, muss es natürlich auch bei der Definition der Methode angegeben werden.

Es sei noch einmal explizit darauf hingewiesen, dass sich die Konstantheit einer Methode lediglich auf die Membervariablen der zugehörigen Klasse auswirkt. Es ist problemlos möglich, eine als Parameter übergebene Variable zu modifizieren.

Eine konstante Methode kann ausschließlich andere konstante Methoden des eigenen Objektes aufrufen, denn der Aufruf einer nicht-konstanten Methode könnte ja Daten innerhalb des Objektes ändern.

Sinn und Zweck konstanter Objekte

[Bearbeiten]

Vielleicht haben Sie sich bereits gefragt, wofür es gut sein soll, ein Objekt als konstant auszuzeichnen, wenn der Zweck eines Objektes doch darin besteht, mit den enthaltenen Daten zu arbeiten. Beim Erstellen eines konstanten Objektes können Sie es einmalig über den Konstruktor mit Werten belegen. In diesem Fall haben Sie von einem konstanten Objekt das gleiche, wie bei konstanten Variablen von Basisdatentypen.

Oft ist es jedoch nicht möglich, alle Einstellungen zu einem Objekt über den Konstruktoraufruf festzulegen. Es fördert die Übersichtlichkeit schließlich nicht, wenn man etwa 20 verschiedene Konstruktoren mit je etwa 50 Parametern hat. Der Ansatz, Klassen so zu gestalten, dass man immer alle Werte über den Konstruktor festlegen kann, hat also seine Grenzen. In diesem Fall hat es einfach keinen Sinn, ein Objekt bei der Erstellung konstant zu machen, denn die Einstellungen werden erst nach dem Erstellen des Objektes vorgenommen.

Wenn Sie ein so erstelltes Objekt nun allerdings an eine Funktion übergeben und diese Funktion keine Veränderungen an dem Objekt vornimmt, ist die Wahrscheinlichkeit groß, dass der Parameter ein konstantes Objekt ist. Innerhalb einer solchen Funktion wäre das Objekt also konstant.

Zugriffsmethoden

[Bearbeiten]

Zugriffsmethoden sollten eigentlich vermieden werden, aber manchmal sind sie nützlich. Eine Zugriffsmethode macht nichts anderes, als eine Membervariable lesend oder schreibend zugreifbar zu machen:

class A{
public:
    void SetWert(int wert) { m_wert = wert; }
    int  GetWert()const    { return m_wert; }

private:
    int m_wert;
};

Get-Methoden sind immer konstant, da sie den Wert ja nur lesend zugreifbar machen sollen. Eine Set-Methode kann dagegen nie mit einem konstanten Objekt benutzt werden. Im Normalfall sollten Sie jedoch keine „Getter“ oder „Setter“ benötigen, wenn doch, müssen Sie sich Gedanken darüber machen, ob das Objekt die Logik möglicherweise nicht ausreichend kapselt.

Solche Einzeiler werden normalerweise einfach direkt in die Funktionsdeklaration geschrieben, dadurch sind sie auch gleich automatisch als inline ausgezeichnet. Dazu müssen Sie nur das Semikolon durch den Funktionsrumpf ersetzen. Sollten Sie dennoch lieber eine eigene Definition für solche Methoden machen wollen, dann achten Sie darauf, diese bei der Definition als inline zu kennzeichnen. Falls Sie mit Headerdateien arbeiten, dann beachten Sie, dass der Funktionsrumpf bei inline-Methoden während des Kompilierens bekannt sein muss. Die Definition muss also mit in die Headerdatei, nicht wie gewöhnlich in die Quelldatei.

Überladen

[Bearbeiten]

Sie haben bereits gelernt, dass Funktionen überladen werden können, indem für den gleichen Funktionsnamen mehrere Deklarationen mit verschiedenen Parametern gemacht werden. Auch bei Klassenkonstruktoren haben Sie Überladung bereits kennengelernt. Für gewöhnliche Memberfunktionen ist eine Überladung ebenfalls nach den Ihnen bereits bekannten Kriterien möglich. Zusätzlich können Sie Memberfunktionen aber anhand des eben vorgestellten const-Modifizierers überladen.

class A{
public:
    void methode();      // Eine Methode
    void methode()const; // Die überladene Version der Methode für konstante Objekte
};

Natürlich können auch Methoden mit Parametern auf diese Weise überladen werden. Die nicht-konstante Version wird immer dann aufgerufen, wenn Sie mit einem nicht-konstanten Objekt der Klasse arbeiten. Analog dazu wird die konstante Version aufgerufen, wenn Sie mit einem konstanten Objekt arbeiten. Wenn Sie nur eine konstante Version deklarieren, wird immer diese aufgerufen.

Sinnvoll ist diese Art der Überladung vor allem dann, wenn Sie einen Zeiger oder eine Referenz auf etwas innerhalb des Objekts zurückgeben. Wie Sie sich sicher erinnern, kann eine Überladung nicht anhand des Rückgabetyps einer Funktion (oder Methode) gemacht werden. Das folgende Beispiel wird Ihnen zeigen, wie Sie eine const-Überladung nutzen können, um direkte Manipulation von Daten innerhalb eines Objekts nur für nicht-konstante Objekte zuzulassen.

#include <iostream>

class A{
public:
    A():m_b(7) {} // m_b mit 7 initialisieren

    int&       B()      { return m_b; } // Zugriff lesend und schreibend
    int const& B()const { return m_b; } // Zugriff nur lesend

private:
    int m_b; // Daten
};

int main(){
    A       objekt;       // Ein Objekt von A
    A const objekt_const; // Ein konstantes Objekt von A

    std::cout << objekt.B()       << std::endl; // Gibt 7 aus
    std::cout << objekt_const.B() << std::endl; // Gibt 7 aus

    objekt.B() = 9; // setzt den Wert von m_b auf 9
    // objekt_const.B() = 9; // Produziert einen Kompilierfehler

    std::cout << objekt.B()       << std::endl; // Gibt 9 aus
    std::cout << objekt_const.B() << std::endl; // Gibt 7 aus
}

Im Kapitel über Operatoren überladen werden Sie noch ein Beispiel zu dieser Technik kennenlernen, welches in der Praxis oft zu sehen ist.

Code-Verdopplung vermeiden

[Bearbeiten]

Im Beispiel von eben geben die beiden Funktionen lediglich eine Referenz auf eine Membervariable innerhalb des Objekts zurück. In der Regel wird eine solche Funktion natürlich noch etwas mehr tun, zum Beispiel den Zugriff protokollieren und den aktuellen Wert auf dem Bildschirm ausgeben. Dann wäre es nötig, zwei Funktionen zu schreiben, die den gleichen Code enthalten. Das wiederum ist ausgesprochen schlechter Stil. Stellen Sie sich nur vor, Sie möchten die Funktion später aus irgendwelchen Gründen ändern, dann müssten Sie alle Änderungen an zwei Stellen im Code vornehmen.

Daher ist es sinnvoll, wenn eine Variante die andere aufruft. Hierfür sind einige Tricks nötig, da Sie einer der beiden Varianten beibringen müssen, eine Methode aufzurufen, die eigentlich nicht zum const-Modifizierer des aktuellen Objekts passt. Die konstante Variante verspricht, niemals eine Änderung am Objekt vorzunehmen, sie ist in ihren Möglichkeiten also stärker eingeschränkt. Die nicht konstante Version darf hingegen alles, was auch die konstante Version darf. Somit ist es sinnvoll, die konstante Version von der nicht-konstanten aufrufen zu lassen; so kann die Methode, die mehr darf, (später) erweitert werden um Aktivitäten, die nur sie darf, ohne die Aufrufstruktur umstellen zu müssen.

// nicht-konstante Version von B()
int& A::B(){
    // Konstantheit hinzucasten
    A const* obj_myself_but_const = static_cast< A const* >(this);
    // konstante Methodenversion aufrufen
    int const& result = obj_myself_but_const->B();
    // Konstantheit vom Rückgabewert wegcasten
    return const_cast< int& >(result);

Erläuterung:

Um nun der nicht-konstanten Version beizubringen, dass sie ihr konstantes Äquivalent aufrufen soll, müssen wir zunächst einmal aus dem aktuellen Objekt ein konstantes Objekt machen. Jede Klasse enthält eine spezielle Variable, den sogenannten this-Zeiger, der innerhalb einer Membervariable einen Zeiger auf das aktuelle Objekt repräsentiert. Diesen this-Zeiger casten wir in einen Zeiger auf ein konstantes Objekt.

Nun haben wir einen Zeiger auf das Objekt, über den wir nur konstante Methoden aufrufen können. Das Problem ist nun, dass die aufgerufene Methode natürlich auch eine Referenz auf eine konstante Variable aufruft.

Da wir ja wissen, dass das aktuelle Objekt eigentlich gar nicht konstant ist, können wir die Konstantheit für die zurückgegebene Referenz guten Gewissens entfernen. Allerdings ist der static_cast, den Sie bereits kennen, nicht dazu in der Lage. Um Konstantheit zu entfernen, benötigen Sie den const_cast. Beachten Sie jedoch, dass dieser Cast wirklich nur auf Variablen angewendet werden darf, die eigentlich nicht konstant sind!

Wenn Sie diese Anweisungen in einer zusammenfassen, sieht Ihre Klasse nun folgendermaßen aus.

class A{
public:
    // m_b mit 7 initialisieren
    A():m_b(7) {}

    // Ruft B()const auf
    int&       B()      { return const_cast< int& >( (static_cast< A const* >(this)) -> B() ); }
    int const& B()const { return m_b; }

private:
    int m_b; // Daten
};

Wie schon gesagt, sieht diese Technik in unserem Beispiel überdimensioniert aus. Aber auch an dieser Stelle möchte ich Sie auf das Beispiel im Kapitel zur Operatorüberladung verweisen, wo sie, aufgrund der umfangreicheren konstanten Version, bereits deutlich angenehmer erscheint. Mit Performanceeinbußen haben Sie an dieser Stelle übrigens nicht zu rechnen. Ihr Compiler wird die casting-Operationen vermutlich wegoptimieren.

Call by reference

[Bearbeiten]

Im Gegensatz zu den vorangegangenen Kapiteln dieses Abschnitts geht es diesmal nicht darum, wie man Objekte aufbaut, sondern wie man mit ihnen arbeitet. Im Kapitel über Funktionen hatten Sie schon ersten Kontakt mit der Wertübergabe als Referenz. Wie dort bereits erwähnt, ist es für Klassenobjekte effizienter, sie als Referenz an eine Funktion zu übergeben.

Bei der Übergabe einer Variablen als Wert muss von dieser Variable erst eine Kopie angefertigt werden. Zusätzlich führt der Kopierkonstruktor an dieser Stelle möglicherweise noch irgendwelche zusätzlichen Operationen aus, die Zeit kosten. Bei einer Übergabe als Referenz muss hingegen nur die Speicheradresse des Objekts kopiert werden. Wenn wir dann noch dafür sorgen, dass die Referenz auf ein konstantes Objekt verweist, haben wir eine fast kostenlose Übergabe und gleichzeitig die Sicherheit, dass die übergebene Variable innerhalb der Funktion nicht verändert wird.

class A{
public:
    int a, b, c, d;
};

class B{
public:
    // Das übergebene A-Objekt ist innerhalb der Methode konstant
    void methode(A const& parameter_name);
};

Die Methode von B kann auf die 4 Variablen von A lesend zugreifen, sie aber nicht verändern. Die Übergabe als Wert ist dann sinnvoll, wenn innerhalb einer Methode ohnehin eine Kopie der Variablen benötigt wird.

class B{
public:
    // Es wird eine Kopie des A-Objekts übergeben
    void methode(A parameter_name);
};

Nun können auf das A-Objekt beliebige lesende oder schreibende Operationen angewendet werden. Da sie auf einer Kopie ausgeführt werden, bleibt auch hier das Originalobjekt unverändert, aber eben zu dem Preis, dass zusätzlich Zeit und Speicherplatz benötigt werden, um eine Kopie des Objekts zu erstellen. In den meisten Fällen ist es nicht nötig, innerhalb einer Methode Schreiboperationen auszuführen. Verwenden Sie daher nach Möglichkeit die „Call by reference“-Variante.

Bei Rückgabewerten sollten Sie natürlich auf Referenzen verzichten, es sei denn, Sie wissen wirklich, was Sie tun. Andernfalls kann es schnell passieren, dass Sie eine Referenz auf eine Variable zurückgeben, die außerhalb der Methode gar nicht mehr existiert. Das wiederum führt zufallsbedingt zu Laufzeitfehlern und Programmabstürzen.

Wir empfehlen inline

[Bearbeiten]

In den Kapiteln über Funktionen und Headerdateien haben Sie bereits einiges über inline-Funktionen erfahren. An dieser Stelle soll dieses Wissen nun auf den Gebrauch von inline-Klassenmethoden übertragen werden. Das folgende kleine Beispiel zeigt eine Klasse mit einer inline-Methode.

class A{
public:
    int methode()const;

private:
    int a_;
};

inline int A::methode()const{
    return a_;
}

Das Schlüsselwort inline steht vor der Methodendefinition, es kann auch in der Deklaration innerhalb der Klasse angegeben werden. Letzteres ist jedoch unübersichtlich und daher nicht zu empfehlen. Damit der Compiler den Methodenaufruf auch durch den Methodenrumpf ersetzen kann, steht die Methodendefinition ebenso wie die Klassendefinition in der Headerdatei und nicht in einer, eventuell zugehörigen, Implementierungsdatei. Wenn Sie eine Funktion direkt innerhalb einer Klassendefinition definieren, ist sie implizit inline, da solche Funktionen nahezu immer sehr kurz sind und sich somit gut zur Ersetzung eignen.

class A{
public:
    // methode ist implizit inline
    int methode()const { return a_; }

private:
    int a_;
};
Tipp

Einige moderne Compilersuiten sorgen, je nach Einstellungen, dafür, dass Methoden inline verwendet werden, auch wenn sie nicht als inline deklariert wurden! In vielen Fällen können die Compiler ohnehin am besten selbst entscheiden, welche Funktionen gute inline-Kandidaten sind und welche nicht.

Freunde [Bearbeiten]

Normalerweise können Member einer Klasse, die als private deklariert wurden, nicht von außen zugegriffen werden. In einigen Fällen kann es aber sinnvoll sein, wenn bestimmte Funktionen und Klassen (direkten) Zugriff auf die Member einer Klasse haben. Anwendungsbeispiele finden sich oft bei der Überladung von Operatoren, die als Nicht-Member implementiert werden müssen, da der erste Parameter kein Objekt der Klasse selbst sein soll.

Funktionen als Freunde

[Bearbeiten]

Eine Funktion kann als Freund deklariert werden, indem in der Klassendefinition das Schlüsselwort friend gefolgt vom Prototyp der Funktion angegeben wird. Für Methoden einer anderen Klasse muss dem Funktionsnamen, durch den Bereichsoperator (::) getrennt, der Klassenname vorangestellt werden. Analoges gilt für Funktionen aus einem anderen Namensraum. Für die Angabe von friend ist es unerheblich, in welchem Zugriffsbereich (public oder private) die Funktion deklariert wurde. Üblicherweise werden friend-Deklarationen am Anfang oder Ende der Klassendefinition vorgenommen.

#include <iostream>

class A;                    // Klasse A deklariert

namespace super{
    void f2(A const& a);    // Deklaration
}

class B{                    // Klasse B definiert
public:
    B(A const& a):a_(a){}

    void f3();              // Deklaration

private:
    A const& a_;
};

class A{                    // Klasse A definiert
public:
    A():x_(5){}             // Standardkonstruktor

private:
    int x_;

    // "Meine Freunde sind:"
friend void f1(A const&);          // die nicht zu dieser Klasse gehörende, globale Funktion 'f1',
friend void super::f2(A const&);   // die Funktion 'f2' aus dem Namespace 'super',
friend void B::f3();               // die Methode 'f3' der Klasse 'B'.
};

// Definitionen (A musste erst definiert werden)
void f1(A const& a){
    std::cout << "f1: " << a.x_ << std::endl;
}

namespace super{
    void f2(A const& a){
        std::cout << "f2: " << a.x_ << std::endl;
    }
}

void B::f3(){
    std::cout << "f3: " << a_.x_ << std::endl;
}

int main(){
    A a;
    B b = a;
    f1(a);
    super::f2(a);
    b.f3();
}
Ausgabe:
f1: 5
f2: 5
f3: 5

Wie Sie sehen muss einiger Aufwand getrieben werden, um die verschiedenen Deklarationen und Definitionen in die richtige Reihenfolge zu bringen. Da alle drei Funktionen (f1, f2, f3) auf einen Member von A zugreifen, muss als Erstes eine Deklaration der Klasse A vorgenommen werden. Ohne eine vollständige Definition von A kann man jedoch noch nicht auf die Member zugreifen, da diese noch nicht bekannt sind. Eine Definition von A ist an dieser Stelle wiederum noch nicht möglich, da für friend-Deklarationen in der Regel eine vorherige Deklaration der Funktionen nötig ist. Die Ausnahme bildet in diesem Fall die Funktion f1, die im gleichen Namensbereich (dem globalen) wie A deklariert wird. Hier ist eine implizite Deklaration bei der friend-Deklarationen zulässig. Folglich müssen also zunächst die Deklarationen von f2 im Namensbereich super und von f3 als Teil der Definition der Klasse B erfolgen. Anschließend ist eine Definition von A möglich und danach auch die Definition der drei Funktionen.

Eine Stelle, wo friends oft vorkommen, sind die Überladungen für die Ausgabe-/Eingabeoperatoren, sprich << und >>. Sie müssen meistens als Freunde deklariert werden.

Klassen als Freunde

[Bearbeiten]

Wenn eine ganze Klasse als Freund deklariert werden soll, wird friend gefolgt von dem Schlüsselwort class und anschließend dem Klassennamen angegeben. Alle Methoden einer befreundeten Klasse sind somit als friend deklariert. Es ist aber nur selten sinnvoll dies zu tun, da nur die nötigsten externen Funktionen Zugriff auf das Innenleben einer Klasse haben sollten. Wie im echten Leben ist Freundschaft nicht automatisch beidseitig. Wenn also in einer Klasse eine andere als friend deklariert wird, heißt dies keineswegs, dass auch die andere Klasse der ersten friend-Zugriffe einräumt.

#include <iostream>

class B; // Deklaration von B

class A{ // Definition von A
public:
    A():a_(5){}

    void f(B const& b); // Deklaration

private:
    int a_;
};

class B{ // Definition von B
public:
    B():b_(42){}

    void f(A const& a); // Deklaration

private:
    int b_;

friend class A;
};

void A::f(B const& b){
    std::cout << "A: B glaubt, ich sei sein Freund: ";
    std::cout << b.b_ << std::endl;    // OK: A ist Freund von B
}

void B::f(A const& a){
    std::cout << "B: A betrachtet mich nicht als Freund." << std::endl;
//  std::cout << a.a_ << std::endl; // Fehler: B ist kein Freund von A
}

int main(){
    A a;
    B b;

    a.f(b);
    b.f(a);
}
Ausgabe:
A: B glaubt, ich sei sein Freund: 42
B: A betrachtet mich nicht als Freund.

Würde eine Methode von B versuchen, auf einen privaten Member von A zuzugreifen, würde der Compiler einen Fehler melden. Umgekehrt wurde in B explizit gestattet, dass Methoden von A auf private Member von B zugreifen dürfen.

Operatoren überladen [Bearbeiten]

Im Kapitel über Zeichenketten haben Sie gelernt, dass es sich bei std::string um eine Klasse handelt. Dennoch war es Ihnen möglich, mehrere std::string-Objekte über den +-Operator zu verknüpfen oder einen String mittels = bzw. += an einen std::string zuzuweisen bzw. anzuhängen. Der Grund hierfür ist, dass diese Operatoren für die std::string-Klasse überladen wurden. Das heißt, ihnen wurde in Zusammenhang mit der Klasse std::string eine neue Bedeutung zugewiesen.

Vorweg sollten Sie auf jeden Fall eines beherzigen. Überladen Sie Operatoren nur, wenn beim Operatoraufruf intuitiv klar ist, was passiert. Ist dies nicht der Fall, dann schreiben Sie besser eine einfache Methode mit einem aussagekräftigen Namen. Der Sinn der Operatorüberladung ist die Erhöhung der Übersichtlichkeit des Quellcodes. Wenn man erst in der Dokumentation nachschlagen muss, um herauszufinden, was ein überladener Operator tut, dann haben Sie dieses Ziel verfehlt. Es muss natürlich nicht im Detail klar sein, welchen Effekt der Operator hat, aber man sollte intuitiv eine ungefähre Vorstellung haben, wenn man Code liest, in dem ein überladener Operator verwendet wurde. Beispielweise kann für eine Klasse, die eine komplexe Zahl repräsentiert, nicht sofort eindeutig klar sein, wie diese als Text ausgegeben wird, aber man sollte immer am Methodennamen oder eben am Operator erkennen, dass hier eine Ausgabe stattfindet.

Um Operatoren überladen zu können, müssen Sie zunächst einmal wissen, dass diese in C++ im Prinzip nur eine spezielle Schreibweise für Funktionen sind. Das folgende kleine Beispiel demonstriert den Aufruf der überladenen std::string in ihrer Funktionsform:

#include <iostream>
#include <string>

int main(){
    std::string a = "7", b = "3", c;

    c = a + b;
    std::cout << c << std::endl;

    c.operator=(operator+(b,a));
    std::cout << c << std::endl;
}
Ausgabe:
73
37

Wie Ihnen beim Lesen des Beispiels vielleicht schon aufgefallen ist, wurde der Zuweisungsoperator als Methode von std::string überladen. Der Verkettungsoperator ist hingegen als gewöhnliche Funktion überladen. Dieses Vorgehen ist üblich, aber nicht zwingend. Die Deklarationen der beiden Operatoren sehen folgendermaßen aus:

class std::string{
    // Zuweisungsoperator als Member von std::string
    std::string& operator=(std::string const& assign_string);
};

// Verkettungsoperator als globale Funktion
std::string operator+(std::string const& lhs, std::string const& rhs);

Da es sich bei std::string um ein typedef von einer Templateklasse handelt, sehen die Deklarationen in der C++-Standardbibliothek noch etwas anders aus, das ist für unser Beispiel aber nicht relevant. Auf die Rückgabetypen der verschiedenen Operatoren wird später in diesem Kapitel noch näher eingegangen.

Wenn Sie den Zuweisungsoperator als globale Funktion überladen wollten, müssten Sie ihm zusätzlich als ersten Parameter eine Referenz auf ein std::string-Objekt übergeben. Würden Sie den Verkettungsoperator als Methode der Klasse deklarieren wollen, entfiele der erste Parameter. Er würde durch das Objekt ersetzt, für das der Operator aufgerufen wird. Da die Operatorfunktion nichts an dem Objekt ändert, für das sie aufgerufen wird, ist sie außerdem konstant. Allerdings hätten Sie an dieser Stelle ein Problem, sobald Sie einen std::string zum Beispiel mit einem C-String verknüpfen wollen.

Bei einer Operatorüberladung als Methode ist der erste Operand immer ein Objekt der Klasse. Für den Zuweisungsoperator ist das in Ordnung, da ja immer etwas an einen std::string zugewiesen werden soll. Wenn Sie also folgende Operatorüberladungen in der Klasse vornahmen, werden Sie bei der Nutzung der Klasse schnell auf Probleme stoßen.

class std::string{
    // Zuweisung eines anderen std::string's
    std::string& operator=(std::string const& assign_string);
    // Zuweisung eines C-Strings
    std::string& operator=(char const assign_string[]);

    // Verkettung mit anderen std::string's
    std::string operator+(std::string const& rhs)const;
    // Verkettung mit C-Strings
    std::string operator+(char const rhs[])const;
};

int main(){
    std::string std_str_1 = "std_1";
    std::string std_str_2 = "std_2";
    char        c_str_1[80] = "c_str";

    std_str_1 = std_str_2; // Geht
    std_str_1 = c_str_1;   // Geht
//  c_str_1   = std_str_1; // Geht nicht (soll es auch nicht!)
    std_str_1 + std_str_2; // Geht
    std_str_1 + c_str_1;   // Geht
//  c_str_1   + std_str_2; // Geht nicht (sollte es aber)
}

Die in der Klasse deklarierten Formen lassen sich problemlos nutzen. Wenn wir aber wollen, dass man auch „C-String + std::string“ schreiben kann, müssen wir noch eine weitere Operatorüberladung global deklarieren. Da dies jedoch uneinheitlich aussieht und obendrein die Klassendeklaration unnötig aufbläht, deklariert man alle Überladungen des Verkettungsoperators global. Das Ganze sieht dann folgendermaßen aus.

std::string operator+(std::string const& lhs, std::string const& rhs);
std::string operator+(std::string const& lhs, char const rhs[]);
std::string operator+(char const lhs[],       std::string const& rhs);

Definition der überladenen Operatoren

[Bearbeiten]

Im Folgenden werden Sie einige beispielhafte Implementierungen für verschiedene Operatoren kennenlernen, wobei wir uns weiterhin an der std::string-Klasse orientieren möchten. Vergegenwärtigen wir uns zunächst, welche Operatoren für diese Klasse überladen sind. Bereits erwähnt wurden der Zuweisungsoperator (=) und der Verknüpfungsoperator (+), infolge dessen ist auch die verknüpfende Zuweisung (+=) überladen. Weiterhin stehen die Vergleichsoperatoren (==, !=, <, <=, > und >=) und ebenso wie der Ein- (>>) und Ausgabeoperator (<<) bereit. Außerdem steht der Zugriffsoperator in einer konstanten und einer nicht-konstanten Version zur Verfügung.

Für die folgenden Beispiele nehmen wir an, dass unser String in einem Array von chars mit 1024 Elementen verwaltet wird. Dementsprechend werden wir als Klassenname ab sofort nicht mehr std::string, sondern MyString verwenden. Nachdem Sie die dynamische Speicherverwaltung kennengelernt haben, können Sie auch mal versuchen, eine eigene Stringklasse zu schreiben, welche nicht auf eine festgelegte Zeichenmenge begrenzt ist. Der Einfachheit halber werden wir im Folgenden keine Grenzprüfung für unser char-Array vornehmen. Wenn Sie also vorhaben, Ihre Objekte mit langen Strings zu füllen, dann achten Sie darauf, dass Ihr Array hinreichend groß ist.

Als Erstes müssen wir uns überlegen, ob wir die Operatoren als Klassenmember oder extern deklarieren. Allgemein gilt für Zuweisungsoperatoren, ob nun zusammengesetzt oder nicht, dass Sie als Klassenmember deklariert sind. Vergleichs- und Verknüpfungsoperatoren werden als Klassenmember deklariert, wenn Sie ausnahmslos mit Objekten der gleichen Klasse benutzt werden, andernfalls werden Sie extern deklariert. Ein Zugriffsoperator liefert üblicherweise einen Wert aus der Klasse oder erlaubt sogar direkten Zugriff, somit ist er natürlich ein Klassenmember. Ein- und Ausgabeoperatoren sind extern, aber darauf wird später, wie schon erwähnt, noch explizit eingegangen. Im Folgenden werden einmal alle Deklarationen aufgeführt, die wir für unser Beispiel implementieren möchten, inklusive der (sehr kurz gefassten) Klassendefinition von MyString.

#include <cstddef>

class MyString{
public:
    static std::size_t const max_length = 1024;

    MyString();
    MyString(MyString const& string);
    MyString(char const*     c_string);

    MyString& operator=(MyString const& string);
    MyString& operator=(char const*     c_string);
    MyString& operator+=(MyString const& string);
    MyString& operator+=(char const*     c_string);

    char const& operator[](std::size_t index)const;
    char&       operator[](std::size_t index);

    std::size_t length()const{ return length_; }

private:
    char        data_[max_length];
    std::size_t length_;

    void selfcopy(MyString const& string);
    void selfcopy(char const*     c_string);
};

MyString operator+(MyString const& lhs, MyString const& rhs);
MyString operator+(MyString const& lhs, char const rhs[]);
MyString operator+(char const lhs[],    MyString const& rhs);

bool operator==(MyString const& lhs, MyString const& rhs);
bool operator==(MyString const& lhs, char const rhs[]);
bool operator==(char const lhs[],    MyString const& rhs);

bool operator!=(MyString const& lhs, MyString const& rhs);
bool operator!=(MyString const& lhs, char const rhs[]);
bool operator!=(char const lhs[],    MyString const& rhs);

bool operator<(MyString const& lhs, MyString const& rhs);
bool operator<(MyString const& lhs, char const rhs[]);
bool operator<(char const lhs[],    MyString const& rhs);

bool operator<=(MyString const& lhs, MyString const& rhs);
bool operator<=(MyString const& lhs, char const rhs[]);
bool operator<=(char const lhs[],    MyString const& rhs);

bool operator>(MyString const& lhs, MyString const& rhs);
bool operator>(MyString const& lhs, char const rhs[]);
bool operator>(char const lhs[],    MyString const& rhs);

bool operator>=(MyString const& lhs, MyString const& rhs);
bool operator>=(MyString const& lhs, char const rhs[]);
bool operator>=(char const lhs[],    MyString const& rhs);

Als Erstes werden wir uns kurz Gedanken um unsere Datenstruktur machen. Die Zeichen werden, wie gesagt, in der Membervariable data_ gespeichert. length_ speichert die Anzahl der momentan in data_ gültigen Zeichen. Der Standardkonstruktor setzt einfach length_ auf 0 und erstellt somit einen leeren String. Der Kopierkonstruktor übernimmt die Stringlänge von dem an ihn übergebenen Argument und nutzt std::memcpy, um die benötigten Arrayelemente zu kopieren. Natürlich könnte man die Arrays auch in einer for-Schleife durchlaufen und die Elemente einzeln kopieren, aber std::memcpy ist um einiges schneller, da hier gleich ganze Blöcke von Elementen kopiert werden. Der verbleibende Konstruktor erhält einen C-String (nullterminierter String, wird also durch ein Nullzeichen abgeschlossen) und verfährt somit fast analog, mit dem Unterschied, dass ganz am Anfang erst einmal die Stringlänge ermittelt werden muss.

#include <cstring>

MyString::MyString():length_(0){}

MyString::MyString(MyString const& string):
    length_(string.length_)
{
    std::memcpy(data_, string.data_, length_*sizeof(data_[0]));
}

MyString::MyString(char const* c_string){
    length_ = max_length - 1;
    for(std::size_t i = 0; i < max_length; ++i){
        if(c_string[i] != '\0'){
            continue;
        }

        length_ = i;
        break;
    }

    std::memcpy(data_, c_string, length_*sizeof(data_[0]));
}

Die Funktion std::memcpy() erwartet 3 Argumente, als Erstes die Zielspeicheradresse, auf die geschrieben werden soll, als Zweites die Quelladresse, von der gelesen werden soll und schließlich die Anzahl der Bytes, die kopiert werden sollen. Die Zeigertypen werden hierbei implizit nach void* umgewandelt, daher ist die Funktion mit einiger Umsicht zu verwenden. Die Multiplikation von length_ mit sizeof(data_[0]) ist eigentlich nicht nötig, da ein char immer die Größe 1 Byte hat. Es wird empfohlen in Zusammenhang mit der Funktion memcpy dennoch immer die Datentypgröße durch Multiplikation von sizeof(Variablenname) zu berücksichtigen, da sich auf diese Weise der Typ von Variable ändern kann, ohne dass eine Änderung des folgenden Codes nötig ist. So könnte in diesem Beispiel problemlos der Typ von char auf wchar_t geändert werden. Beachten Sie, dass wirklich die Variable und nicht deren Typ an sizeof übergeben wird.

Möglicherweise wundern Sie sich ein wenig über den Schleifenaufbau im letzten Konstruktor. Den Rest der Schleife zu überspringen, sobald eine Bedingung erfüllt ist, ist eine übliche Designvariante um Quellcode übersichtlicher zu gestalten. Es vermeidet eine zusätzlich Codeeinrückung und drückt deutlich aus, dass dies eine Abbruchbedingung ist und sofort mit dem nächsten Schleifendurchlauf fortgefahren wird.

Da die Konstruktoren in diesem Beispiel das gleiche tun wie die Zuweisungsoperatoren und in den Konstruktoren weder etwas besonderes zu beachten, noch großartige Optimierungsmöglichkeiten bestehen, werden wir den Code für beide zusammenfassen. Dies geschieht in einer privaten Hilfsfunktion. Der Rückgabewert der Zuweisungsoperatoren ist immer eine Referenz auf das aktuelle Objekt, was wir durch ein return *this realisieren.

#include <cstring>

MyString::MyString(MyString const& string){
    selfcopy(string);
}

MyString::MyString(char const* c_string){
    selfcopy(c_string);
}

MyString& MyString::operator=(MyString const& string){
    selfcopy(string);
    return *this;
}

MyString& MyString::operator=(char const* c_string){
    selfcopy(c_string);
    return *this;
}

void MyString::selfcopy(MyString const& string){
    length_ = string.length_;
    std::memcpy(data_, string.data_, length_*sizeof(data_[0]));
}

void MyString::selfcopy(char const* c_string){
    length_ = max_length - 1;
    for(std::size_t i = 0; i < max_length; ++i){
        if(c_string[i] != '\0'){
            continue;
        }

        length_ = i;
        break;
    }

    std::memcpy(data_, c_string, length_*sizeof(data_[0]));
}

Beachten Sie, dass Sie nicht versuchen sollten, einen Konstruktor durch den Aufruf des Zuweisungsoperators oder umgekehrt zu implementieren. Speziell für unser Beispiel würde zwar beides funktionieren, wobei Sie die Syntax für letzteres noch nicht kennen, aber sobald die Klasse in irgendeiner Weise erweitert wird, könnte sich dadurch undefiniertes Verhalten ergeben. Die Auslagerung der gemeinsamen Arbeit erhöht außerdem die Lesbarkeit des Quellcodes und ist ein allgemein übliches Vorgehen um Codeverdopplung zu vermeiden.

Für die verknüpfende Zuweisung implementieren wir zunächst die Version, die wieder ein Objekt unserer Klasse übernimmt. Wir kopieren den Inhalt des übergebenen Strings an das derzeitige Ende (also die durch length_ gekennzeichnete Stelle) des aktuellen Strings und addieren anschließend die Länge des übergeben zu der des aktuellen Strings.

Die andere verknüpfende Zuweisung übernimmt einen C-String als Argument. Hier können und sollten wir uns die Implementierung wieder leicht machen. Wir wandeln den C-String in einen MyString um, indem wir ein temporäres Objekt durch einen Aufruf des entsprechenden Konstruktors erstellen. Anschließend rufen wir mit diesem temporären Objekt die eben erstellte verknüpfende Zuweisung auf. Wir nutzen also den vorhandenen Code zweier anderer Methoden der Klasse und vermeiden so Codeverdopplung. Gleichzeitig hat das noch den angenehmen Nebeneffekt, dass wir weniger Schreibarbeit haben. Der Rückgabewert ist bei beiden Versionen wieder eine Referenz auf das aktuelle Objekt.

#include <cstring>

MyString& MyString::operator+=(MyString const& string){
    std::memcpy(
        data_ + length_,                  // Adresse des Datenfeldes + Anzahl vorhandener Zeichen
        string.data_,                      // Adresse des Datenfeldes des Arguments
        string.length_*sizeof(data_[0]) // Anzahl der Zeichen im Datenfeld des Arguments
    );
    length_ += string.length_;            // Längen addieren

    return *this;
}

MyString& MyString::operator+=(char const* c_string){
    return *this += MyString(c_string);
}

Wie Sie sehen ist die Implementierung der zweiten Operatorvariante mit einer Zeile wirklich sehr kompakt. Da die Rückgabe der anderen verknüpfenden Zuweisung mit jener Version identisch ist, kann diese durch ein return vor dem Aufruf einfach weitergegeben werden.

Da wir nun die verknüpfende Zuweisung zur Verfügung haben, wenden wir uns als Nächstes dem Verknüpfungsoperator an sich zu. Da bei diesem der erste Operand nicht zwingendermaßen ein Objekt von MyString sein muss, ist dieser Operator außerhalb der Klasse deklariert. Daraus ergibt sich für uns leider auch ein Nachteil, denn wir haben in den Operatorimplementierungen keinen Zugriff auf private- und protected-Member der Klasse. Allerdings benötigen wir diesen letztlich auch gar nicht, denn wir werden wiederum vorhandenen Code für die Implementierung benutzen.

Der Rückgabewert dieses Operators ist das aus der Verknüpfung resultierende Objekt der Klasse MyString. Die Strategie, die wir für die Implementierung benutzen, sieht so aus, dass wir den Verknüpfungsaufruf durch einen Aufruf der verknüpfenden Zuweisung ausdrücken. Da bei dieser aber immer das „erste Argument“ (also das Objekt, für das die Operatormethode aufgerufen wird) modifiziert wird, müssen wir zunächst eine Kopie unseres ersten Arguments erstellen. Für diese Kopie können wir dann die verknüpfende Zuweisung mit unserem zweiten Argument als Argument aufrufen. Da dieser Aufruf wiederum eine Referenz auf unsere Kopie zurück gibt, können wir diese sofort weitergeben, denn die Kopie ist nun das resultierende Objekt unserer Verknüpfung.

MyString operator+(MyString const& lhs, MyString const& rhs){
    return MyString(lhs) += rhs;
}

MyString operator+(MyString const& lhs, char const rhs[]){
    return MyString(lhs) += rhs;
}

MyString operator+(char const lhs[], MyString const& rhs){
    return MyString(lhs) += rhs;
}

Wie Sie sehen ist die Implementierung wesentlich einfacher, als die langatmige Erklärung eben vielleicht vermuten ließ. Die Aufrufhierarchie sieht nun folgendermaßen aus:

Zugriffsoperator

[Bearbeiten]

Den Zugriffsoperator gibt es in einer konstanten und einer nicht-konstanten Version. In Abhängigkeit davon, ob das konkrete Objekt für das er aufgerufen wird konstant ist oder nicht, entscheidet der Compiler, welche Version aufgerufen wird. Die beiden Versionen unterscheiden sich ausschließlich im Rückgabetyp. Die konstante Version liefert eine Referenz auf ein konstantes Zeichen, während die nicht-konstante Version eine Referenz auf ein Zeichen liefert und somit schreibenden Zugriff auf das gewählte Zeichen bietet. Wir werden uns zunächst um die konstante Version kümmern.

Der Zugriffsoperator übernimmt üblicherweise einen Index. In unserem Fall ist dies eine positive ganze Zahl, über welche Zugriff auf das entsprechende Zeichen unserer Zeichenkette gewährt wird. Wir überprüfen ob der Index innerhalb unserer Zeichenkette liegt. Da der Index positiv sein soll, brauchen wir den negativen Bereich nicht zu prüfen, lediglich wenn der Index größer ist, als der MyString lang, müssen wir etwas unternehmen. Üblicherweise würde man in einem solchen Fall eine Exception werfen, da dies aber noch nicht behandelt wurde, werden wir stattdessen das letzte Zeichen unseres MyStrings zurückliefern. Ist der MyString leer, so liefern wir das Zeichen an der Position 0, obgleich dieses eigentlich nicht gültig ist.

char const& MyString::operator[](std::size_t index)const{
    if(length_ == 0)
        return data_[0];

    if(index >= length_)
        return data_[length_ - 1];

    return data_[index];
}

Wie bereits erwähnt, ist die Implementierung für die nicht-konstante Version identisch. Daher wäre es sinnvoll, wenn wir diese konstante Version aufrufen und anschließend den Rückgabetyp modifizieren lassen könnten. Tatsächlich ist dies möglich, da dies jedoch ein wenig Wissen über die verschiedenen Möglichkeiten zur Typumwandlung in C++ erfordert, wird die Technik hier lediglich verwendet und nicht erläutert. Wenn Sie mehr darüber erfahren möchten, lesen Sie das Kapitel „Casts“.

char& MyString::operator[](std::size_t index){
    return const_cast< char& >(
        static_cast< MyString const& >(*this)[index]
    );
}

Vergleichsoperatoren

[Bearbeiten]

Die 6 Vergleichsoperatoren sollen MyString-Objekte untereinander, sowie in Kombination mit C-Strings vergleichen. Somit werden Sie außerhalb der Klasse deklariert, da der erste Parameter eines Vergleichs ein C-String sein kann. Es sind insgesamt 18 Überladungen (6 Operatoren, mit je 3 Prototypen) erforderlich um alle möglichen Vergleiche abzudecken. Wie eben schon gezeigt, werden wir auch in diesem Fall Codeverdopplung soweit es geht vermeiden, indem sich die Operatoren gegenseitig aufrufen. Alle Vergleichsoperatoren liefern einen logischen Wert (true oder false) zurück.

Als Erstes beschäftigen wir uns mit dem Gleichheitsoperator ==. Zwei MyStrings sollen gleich sein, wenn Sie die gleiche Länge haben und alle enthaltenen Zeichen gleich sind. Die Implementierung überprüft entsprechend diese beiden Bedingungen, wobei ein Vergleich der einzelnen Zeichen natürlich nur nötig (und möglich) ist, wenn die Länge übereinstimmt. Dementsprechend ist die Überprüfung dieser Bedingung natürlich auch der erste Schritt.

Die Versionen die einen C-String übernehmen, erstellen aus dem C-String ein temporäres MyString-Objekt und rufen anschließend die Überladung auf, die zwei MyString-Objekte übernimmt. Nur für diese eine Version werden wir den vergleichenden Code schreiben.

bool operator==(MyString const& lhs, MyString const& rhs){
    if(lhs.length() != rhs.length()){
        return false;
    }

    for(std::size_t i = 0; i < lhs.length(); ++i){
        if(lhs[i] != rhs[i]){
            return false;
        }
    }

    return true;
}

bool operator==(MyString const& lhs, char const rhs[]){
    return lhs == MyString(rhs);
}

bool operator==(char const lhs[], MyString const& rhs){
    return MyString(lhs) == rhs;
}

Der Ungleichheitsoperator != liefert als Umkehrfunktion immer den genau den gegenteiligen Wert des Gleichheitsoperators. Entsprechend werden wir einfach immer den Gleichheitsoperator aufrufen und das Ergebnis negieren.

bool operator!=(MyString const& lhs, MyString const& rhs){
    return !(lhs == rhs);
}

bool operator!=(MyString const& lhs, char const rhs[]){
    return !(lhs == rhs);
}

bool operator!=(char const lhs[], MyString const& rhs){
    return !(lhs == rhs);
}

Als Nächstes müssen wir uns überlegen, wann ein MyString kleiner als ein anderer sein soll. Die Länge ist dafür zunächst einmal uninteressant, wir schauen uns stattdessen die einzelnen Zeichen an. Wenn das erste Zeichen des ersten MyStrings kleiner als das erste Zeichen des zweiten MyStrings ist, so ist das gesamte erste MyString-Objekt kleiner als das Zweite. Ist das erste Zeichen größer, so ist entsprechend das gesamte erste MyString-Objekt größer. Sind beide Zeichen gleichgroß, so sehen wir uns entsprechend das jeweils zweite Zeichen der beiden Objekte an und vollführen den gleichen Vergleich. Dies setzen wir solange fort, bis in einem der beiden MyStrings keine Zeichen mehr zur Verfügung stehen. Sind bis zu dieser Position alle Zeichen gleich, so ist der kürzere String der kleinere. Sind beide MyStrings gleich lang, so sind sie insgesamt gleich.

Ist der erste MyString nicht kleiner, so ist er entsprechend größer oder gleich dem zweiten MyString. Die Umkehroperation zum Kleiner-Operator < ist dementsprechend der Größer-Gleich-Operator >=. Äquivalent dazu sind auch der Größer-Operator > und der Kleiner-Gleich-Operator <= entgegengesetzte Funktionen.

bool operator<(MyString const& lhs, MyString const& rhs){
    std::size_t min_length = std::min(lhs.length(), rhs.length());

    for(std::size_t i = 0; i < min_length; ++i){
        if(lhs[i] < rhs[i]){
            return true;
        }
        if(lhs[i] > rhs[i]){
            return false;
        }
    }

    if(lhs.length() >= rhs.length()){
        return false;
    }

    return true;
}

bool operator<(MyString const& lhs, char const rhs[]){
    return lhs < MyString(rhs);
}

bool operator<(char const lhs[], MyString const& rhs){
    return MyString(lhs) < rhs;
}


bool operator>=(MyString const& lhs, MyString const& rhs){
    return !(lhs < rhs);
}

bool operator>=(MyString const& lhs, char const rhs[]){
    return !(lhs < rhs);
}

bool operator>=(char const lhs[], MyString const& rhs){
    return !(lhs < rhs);
}


bool operator<=(MyString const& lhs, MyString const& rhs){
    return !(lhs > rhs);
}

bool operator<=(MyString const& lhs, char const rhs[]){
    return !(lhs > rhs);
}

bool operator<=(char const lhs[], MyString const& rhs){
    return !(lhs > rhs);
}

Was jetzt noch fehlt, ist die Implementierung für den Größer-Operator, auf den wir eben bereits in der Implementierung für den Kleiner-Gleich-Operator zurückgegriffen haben. Es gibt für die Implementierung zwei Möglichkeiten, zum einen könnten wir ihn als Kombination aus den anderen Operatoren implementieren, zum anderen könnten wir aber auch eine Implementierung schreiben, welche dem Kleiner-Operator ähnlich ist. Ersteres vermeidet Codeverdopplung, letzteres ist dafür deutlich performanter.

#include <algorithm> // Für std::min()

// Performante Implementierung
bool operator>(MyString const& lhs, MyString const& rhs){
    std::size_t min_length = std::min(lhs.length(), rhs.length());

    for(std::size_t i = 0; i < min_length; ++i){
        if(lhs[i] > rhs[i]){
            return true;
        }
        if(lhs[i] < rhs[i]){
            return false;
        }
    }

    if(lhs.length() <= rhs.length()){
        return false;
    }

    return true;
}

// Codeverdopplung vermeidenede Implementierung
bool operator>(MyString const& lhs, MyString const& rhs){
    return !(lhs < rhs || lhs == rhs);
}

Die Implementierungen für die C-String-Varianten rufen in jedem Fall wieder die eben implementierte auf, um so Codeverdopplung zu vermeiden.

bool operator>(MyString const& lhs, char const rhs[]){
    return lhs > MyString(rhs);
}

bool operator>(char const lhs[], MyString const& rhs){
    return MyString(lhs) > rhs;
}

Ein-/Ausgabeoperator

[Bearbeiten]

Für den Ein- bzw. Ausgabeoperator müssen wir uns zunächst darüber im Klaren sein, wie die Ein- und Ausgabe in C++ typischerweise funktioniert. Alle Ein- und Ausgaben werden über Streams vorgenommen, für die Standardein-/ausgabe gibt es die Objekte std::cin (Klasse std::istream), std::cout, std::cerr und std::clog (alle Klasse std::ostream). Wie genau diese Objekte die Ein-/Ausgabe letztlich verarbeiten, muss uns an dieser Stelle nicht kümmern. Objekte der Klassen std::ifstream bzw. std::ofstream kümmern sich um die Ein-/Ausgabe bezüglich Dateien und mit std::istringstream und std::ostringstream stehen uns zwei Klassen für temporäre Speicherbereiche zur Verfügung. Die 4 letztgenannten Klassen sind von std::istream oder std::ostream abgeleitet, daher müssen wir uns nicht explizit um sie kümmern.

Als Nächstes muss uns klar sein, wie die entsprechenden Prototypen für die Operatoren aussehen müssen. Was wir erreichen wollen, ist eine Ein-/Ausgabe mit folgender Syntax:

MyString string_1, string_2;
std::cin >> string_1 >> string_2;               // Eingabe zweier MyStrings
std::cout << string_1 << string_2 << std::endl; // Ausgabe zweier MyStrings + Zeilenumbruch

Es werden gleich 2 Objekte gelesen bzw. geschrieben, um auch eine sinnvolle Aussage über den Rückgabedatentyp treffen zu können. Wenn wir Klammern für die beiden Ausdrücke setzen, wird offensichtlich, welche Typen die binären Operatoren als Parameter erhalten müssen.

MyString string_1, string_2;
((std::cin >> string_1) >> string_2);
(((std::cout << string_1) << string_2) << std::endl);

Wie in der Mathematik wird die innerste Klammer zuerst aufgelöst. Der operator>> erhält als Parametertypen also einen std::istream und einen MyString. Da wir etwas von std::istream lesen wollen und es dabei aus dem Eingabepuffer entfernt werden muss, damit es später nicht erneut eingelesen wird, müssen wir eine Referenz auf std::istream übergeben. MyString muss bei der Eingabe ebenfalls verändert werden, somit ist auch das MyString-Objekt als Referenz zu übergeben. Wenn wir uns nun der zweiten Klammer zuwenden, kennen wir bereits die beiden Parametertypen. Da der erste Parameter die Rückgabe der ersten Klammer ist, folgt logisch, dass unser Ausgabeoperator als Rückgabetyp den Typ des ersten Parameters (Referenz auf std::istream) haben muss.

Für die Ausgabe gilt ähnliches, allerdings soll MyString hierbei nicht verändert werden. Wir können somit wahlweise eine Kopie oder eine Referenz auf const MyString übergeben. Da Letzteres effizienter ist, wählen wir dies. In unserem Beispiel oben gibt es für die Ausgabe noch eine dritte Klammer. Diese erhält als zweiten Parameter kein MyString-Objekt sondern std::endl. Folglich muss es auch mit diesen Prototypen eine Überladung geben, sie befindet sich in der Headerdatei ostream welche von iostream eingebunden wird.

Allgemein lässt sich somit also für die Ein-/Ausgabe sagen, der erste Parameter ist ebenso wie der Rückgabetyp immer eine Referenz auf std::istream bzw. std::ostream und der zweite Parameter hat den Typ, für den die Operatoren überladen werden sollen. Für die Eingabe wird als zweiter Parameter eine Referenz übergeben, für die Ausgabe eine Kopie oder typischerweise eine Referenz auf const. In unserem konkreten Fall sehen die Prototypen somit so aus:

#include <iostream> // Am Beginn der Headerdatei einbinden
std::istream& operator>>(std::istream& is, MyString& rhs);
std::ostream& operator<<(std::ostream& os, MyString const& rhs);
Tipp

Wenn Sie die Deklarationen in einer Headerdatei und die Funktionsdefinitionen in einer Quelldatei machen, so genügt es im Header die Datei iosfwd einzubinden und erst in der Quelldatei iostream. Erstere enthält nur die (Forward-)Deklarationen von std::istream und std::ostream, was die Compilierzeit beim Einbinden ihrer Headerdatei verringert.

Für die konkrete Ein-/Ausgabe nutzen wir nun die Member unserer MyString-Klasse. Da die Ausgabe sich etwas einfacher gestaltet, wird sie zuerst erläutert. Die Methode length() liefert uns die Anzahl der gültigen Zeichen in unserem String und operator[] liefert uns per Index ein Zeichen vom Datentyp char. Für diesen ist ein Ausgabeoperator deklariert, also geben wir einfach in einer Zählschleife length() viele Zeichen aus. Am Ende geben wir noch unseren std::ostream zurück.

std::ostream& operator<<(std::ostream& os, MyString const& rhs){
    for(std::size_t i = 0; i < rhs.length(); ++i){
        os << rhs[i];
    }
    return os;
}

Für die Eingabe haben wir keine Information wie lang der zu lesende String werden soll. Der std::string verhält sich so, dass bis zum nächsten Leerraumzeichen eingelesen wird. Wir werden immer bis zum nächsten Zeilenumbruch lesen, maximal jedoch MyString::max_length - 1 viele Zeichen. Praktischerweise bietet uns std::istream mit der Methode getline() genau das, was wir brauchen. getline() bekommt als ersten Parameter einen Zeiger auf char, was als Zeiger auf die Anfangsadresse eines Arrays von chars zu verstehen ist. Als zweiter Parameter wird die Größe des Arrays übergeben. Die Funktion getline() setzt das letzte geschriebene Zeichen dabei immer auf '\0'. Falls im Eingabestream also MyString::max_length oder mehr Zeichen enthalten sind, werden nur MyString::max_length - 1 dieser Zeichen in den Puffer geschrieben und das letzte Zeichen wird auf '\0' gesetzt. Die Methode gcount() liefert uns im Anschluss die Information, wie viele Zeichen nun wirklich aus dem Eingabestream gelesen wurden. Danach geben wir eine Referenz auf den Eingabestream zurück.

std::istream& operator>>(std::istream& is, MyString& rhs){
    is.getline(rhs.data_, MyString::max_length);
    rhs.length_ = is.gcount();
    return is;
}

Wie Ihnen zweifellos auffällt, wird hier auf private Klassenmember zugegriffen. Dies ist für Ein- und Ausgabeoperatoren oft sinnvoll, aber nur möglich wenn sie zuvor auch innerhalb der Klasse als friend deklariert wurden. Ergänzen Sie daher die Klassendefinition folgendermaßen:

class MyString{
// ...
friend std::istream& operator>>(std::istream& is, MyString& rhs);
};

Dass die Zeile nicht eingerückt wurde, soll andeuten, dass es für eine friend-Deklaration unerheblich ist, in welchem Sichtbarkeitsbereich sie stattfindet.

Bei den Ein-/Ausgabeoperatoren handelt es sich eigentlich um den Rechtsshift-operator>> und den Linksshift-operator. Eine solche Entfremdung bezüglich der Funktionalität eines Operators sollte eigentlich nicht stattfinden. Wenn es allerdings konsequent umgesetzt wird, wie es beim Ein-/Ausgabesystem von C++ der Fall ist, kann es nach einer kurzen Einlernphase für den Nutzer (Sie als Programmierer) die Übersichtlichkeit erheblich steigern.

Tipp

Ein weiteres gutes Beispiel für eine Bibliothek die Operatoren sinnvollerweise zweckentfremdet ist Boost::Spirit. Mit den Boost Bibliotheken sollten Sie sich übrigens früher oder später vertraut machen. Spirit ist beispielsweise speziell für die Ein-/Ausgabe extrem nützlich. Sie finden die Boost-Bibliotheken unter boost.org.

Cast-Operatoren

[Bearbeiten]

Wie Ihnen bereits bekannt ist, kann ein Datentyp in einen anderen konvertiert werden, wenn letzter einen Konstruktor bereitstellt der ein Objekt des ersten übernimmt. Oft kommt es jedoch vor, dass Sie auf den Datentyp, in den umgewandelt werden soll, keinen Zugriff haben, sprich keinen zusätzlichen Konstruktor einführen können. Bei den elementaren Datentypen ist dies beispielsweise grundsätzlich der Fall.

Aus diesem Grund gibt es die Cast-Operatoren. Wenn Sie nun, etwa mittels static_cast, eine Umwandlung vornehmen, wird entweder der passende Konstruktor aufgerufen oder ein passender Cast-Operator, wobei nur eines von beiden deklariert sein darf, weil sich der Compiler sonst über Mehrdeutigkeit beschwert. Ein Cast-Operator wird immer in der Klasse deklariert, die man umwandeln möchte. Die Syntax lautet wie folgt:

Syntax:
operator «Zieldatentyp»();
«Nicht-C++-Code», »optional«

Auffällig ist, dass für diese „Funktionen“ kein Rückgabetyp angegeben wird. Diese wurde (analog zum Konstruktor) weggelassen, da der Rückgabetyp offensichtlich ist. Ein Konstruktor liefert ein Objekt seiner Klasse und die Typumwandungsoperator gibt natürlich ein Objekt des Zieldatentyps zurück.

Im folgenden möchten wir unserer MyString-Klasse noch einen Cast-Operator nach double spendieren. Die Deklaration sieht folgendermaßen aus:

class MyString{
public:
    // ...
    operator double()const;
// ...
};

Wie Sie sehen, wurde der Operator als konstanter Member deklariert. Dies ist für die meisten Typumwandlungsoperatoren sinnvoll, denn typischerweise wollen wir das Originalobjekt ja nicht verändern. Analog dazu werden einem Konstruktor typischerweise Referenzen auf konstante Objekte übergeben. Die Definition kann beispielsweise folgendermaßen aussehen:

#include <sstream>

MyString::operator double()const{
    std::stringstream stream;
    stream << *this;  // Text temporär ausgeben
    double result;
    stream >> result; // Als Zahl wieder einlesen
    return result;
}

Nun können Sie MyString-Objekt implizit und explizit in doubles umwandeln.

// Klassendefinition

int main(){
    MyString zahl = "5.5";
    double test = 3.2;
    test += zahl;                           // implizite Typumwandlung
    std::cout << test << std::endl;
    test = 5 + static_cast< double >(zahl); // explizite Typumwandlung
    std::cout << test << std::endl;
}
Ausgabe:
8.7
10.5

In diesem Fall wäre die explizite Typumwandlung nicht zwingend nötig gewesen, der Compiler hätte auch implizit (ohne static_cast< double >) das richtige getan, es gibt jedoch reichlich Fälle in denen eine explizite Umwandlung nötig ist.

Spaß mit „falschen“ Überladungen

[Bearbeiten]

Mit der Operatorüberladung lässt sich auch einiger Unsinn anstellen. Die folgende kleine Klasse verhält sich wie ein normaler int, allerdings rechnet der Plusoperator minus, und umgekehrt. Gleiches gilt für die Punktoperationen. Natürlich sollten Sie solch unintuitives Verhalten in ernst gemeinten Klassen unbedingt vermeiden, aber an dieser Stelle demonstriert es noch einmal schön die Syntax zur Überladung.

// Zum Überladen von Ein-/Ausgabe
#include <iostream>

class Int{
public:
    // Konstruktor (durch Standardparameter gleichzeitig Standardkonstruktor)
    Int(int value = 0): m_value(value) {}

    // Überladene kombinierte Rechen-/Zuweisungsoperatoren
    Int const& operator+=(Int const& rhs) { m_value -= rhs.m_value; return *this; }
    Int const& operator-=(Int const& rhs) { m_value += rhs.m_value; return *this; }
    Int const& operator*=(Int const& rhs) { m_value /= rhs.m_value; return *this; }
    Int const& operator/=(Int const& rhs) { m_value *= rhs.m_value; return *this; }

private:
    int m_value;

// Friend-Deklarationen für Ein-/Ausgabe
friend std::istream& operator>>(std::istream& is, Int& rhs);
friend std::ostream& operator<<(std::ostream& os, Int const& rhs);
};

// Überladene Rechenoperatoren rufen kombinierte Versionen auf
Int operator+(Int const& lhs, Int const& rhs) { return Int(lhs) += rhs; }
Int operator-(Int const& lhs, Int const& rhs) { return Int(lhs) -= rhs; }
Int operator*(Int const& lhs, Int const& rhs) { return Int(lhs) *= rhs; }
Int operator/(Int const& lhs, Int const& rhs) { return Int(lhs) /= rhs; }

// Definition Ein-/Ausgabeoperator
std::istream& operator>>(std::istream& is, Int& rhs) { return is >> rhs.m_value; }
std::ostream& operator<<(std::ostream& os, Int const& rhs) { return os << rhs.m_value; }

Präfix und Postfix

[Bearbeiten]

Für einige Klassen kann es sinnvoll sein, den Increment- und den Decrement-Operator zu überladen. Da es sich um unäre Operatoren handelt, sie also nur einen Parameter haben, werden sie als Klassenmember überladen. Entsprechend übernehmen sie überhaupt keine Parameter mehr, da dieser ja durch das Klassenobjekt, für das sie aufgerufen werden ersetzt wird. Als Beispiel nutzen wir noch einmal unsere Int-Klasse, diesmal aber ohne die Operatoren mit der falschen Funktionalität zu überladen.

class Int{
public:
// ...

    Int& operator++() { ++m_value; return *this; }
    Int& operator--() { --m_value; return *this; }

// ...
};

// ...

int main(){
    Int value(5);

    std::cout << value << std::endl;   // value ist 5, Ausgabe ist 5

    std::cout << ++value << std::endl; // value ist 6, Ausgabe ist 6
    std::cout << --value << std::endl; // value ist 5, Ausgabe ist 5
}

So weit, so gut, aber wir können nicht value++ oder value-- schreiben. Diese Variante überlädt also nur die Präfix-Version der Operatoren, aber wie schreibt man nun die Postfix-Überladung? Wie Sie wissen, kann eine Funktion nur anhand ihrer Parameter oder, im Fall einer Methode, auch ihres const-Qualifizieres überladen werden. Aber weder die Präfix- noch die Postfixvariante übernehmen einen Parameter und auch der const-Qualifizier ist in keinem Fall gegeben, weil die Operatoren ja immer das Objekt verändern.

Aus diesem Grund hat man sich eine Kompromisslösung ausdenken müssen. Die Postfix-Operatoren übernehmen einen int, der jedoch innerhalb der Funktionsdefinition nicht verwendet wird. Er dient einzig, um dem Compiler mitzuteilen, dass es sich um eine Postfix-Operatorüberladung handelt.

class Int{
public:
// ...

    // Präfix (also ++Variable)
    Int& operator++() { ++m_value; return *this; }
    Int& operator--() { --m_value; return *this; }

    // Postfix (also Variable++)
    Int const operator++(int) { Int tmp = *this; ++m_value; return tmp; }
    Int const operator--(int) { Int tmp = *this; --m_value; return tmp; }

// ...
};

// ...

int main(){
    Int value(5);

    std::cout << value << std::endl;   // value ist 5, Ausgabe ist 5

    std::cout << ++value << std::endl; // value ist 6, Ausgabe ist 6
    std::cout << --value << std::endl; // value ist 5, Ausgabe ist 5

    std::cout << value++ << std::endl; // value ist 6, Ausgabe ist 5
    std::cout << value-- << std::endl; // value ist 5, Ausgabe ist 6

    std::cout << value << std::endl;   // value ist 5, Ausgabe ist 5
}

Im folgenden wird auf den Inkrementoperator (++) Bezug genommen, für den Dekrementoperator (--) gilt natürlich das Gleiche, mit dem Unterschied, dass die Werte erniedrigt werden.

In diesem Beispiel sehen Sie auch schön den Unterschied zwischen Präfix- und Postfix-Variante. Bei der Präfix-Variante wird der Wert um 1 erhöht und anschließend zurückgegeben. Der Postfix-Operator erhöht den Wert zwar um 1, gibt anschließend aber den Wert zurück, den das Objekt vor der Erhöhung hatte. Entsprechend sind auch die Rückgabewerte der Operatoren. Bei Präfix wird eine Referenz auf sich selbst zurückgeliefert, bei Postfix hingeben ein konstantes temporäres Objekt, das den alten Wert des Objektes enthält, für das der Operator aufgerufen wurde. Die Implementierung ist entsprechend. Die Präfix-Version erhöht den Wert und liefert sich dann selbst zurück, während die Postfix-Variante zunächst eine Kopie der aktuellen Objektes erstellt, dann den Wert erhöht und schließlich den zwischengespeicherten, alten Wert zurückgibt.

Wie früher schon einmal erwähnt, ist es effizienter den Präfix-Operator zu benutzen, wenn prinzipiell beide Varianten möglich wären. Bei den Basisdatentypen, kann der Compiler einen Performance-Nachteil wegoptimieren. Bei Klassen für die diese Operatoren überladen wurden, kann sich der Compiler jedoch nicht darauf verlassen, dass die Präfix-Variante das gleiche macht, wie die Postfix-Variante. Daher ist er bei Klassen nicht oder zumindest nur sehr eingeschränkt in der Lage an dieser Stelle zu optimieren. Aus Gründen der Einheitlichkeit sollte daher, auch bei Basisdatentypen, die Präfix-Variante bevorzugt werden.

Der Rückgabewert der Postfix-Variante ist übrigens aus gutem Grund konstant. Den Präfix-Operator können Sie beliebig oft hintereinander für das gleiche Objekt aufrufen. Auch wenn in den meisten Fällen der Aufruf der Plus-Zuweisung (+=) möglich und effizienter ist. Für die Postfix-Variante ist ein mehrfacher Aufruf nicht sinnvoll, wie im folgenden demonstriert.

Int i = 5;

++++++i; // i ist 8
// Effizienter wäre "i += 3;"

i++++++; // i ist 9
// Lässt sich nur übersetzen, wenn Rückgabewert nicht const

Beim mehrfachen Aufruf der Postfix-Variante werden bis auf den ersten, alle Aufrufe für temporäre Objekte aufgerufen. Hinzu kommt noch, dass der Rückgabewert ja immer der alte Wert ist. Somit ist die Operation für jeden Folgeaufruf die Gleiche, während der berechnete Wert, als temporäres Objekt, sogleich verworfen wird. Ist der Rückgabetyp konstant, kann kein Nicht-Konstanter-Operator für das Objekt aufgerufen werden und eine solch unsinnige Postfix-Operatorkette resultiert in einem Compilierfehler.

Klassen als Datenelemente einer Klasse [Bearbeiten]

Analog zu Basistyp-Variablen, können Sie auch Klassenobjekte als Member einer Klasse deklarieren. Dies funktioniert genau so, wie Sie es schon kennen.

class A{
    int zahl;
};

class B{
    A a_objekt;
};

Nun enthält die Klasse B ein Objekt der Klasse A.

Verweise untereinander

[Bearbeiten]

Manchmal ist es nötig, dass zwei Klassen einen Verweis auf die jeweils andere enthalten. In diesem Fall muss die Klassendeklaration von der Klassendefinition getrennt werden. Wie auch bei Funktionen gilt, dass jede Definition immer auch eine Deklaration ist und dass Deklarationen beliebig oft gemacht werden können, während Definition nur einmal erfolgen dürfen.

// Deklaration von A
class A;

// Definition von A
class A{
    int zahl;
};

Um ein Klassenobjekt zu deklarieren, muss die gesamte Klassendefinition bekannt sein. Wenn Sie hingegen nur einen Zeiger oder eine Referenz auf ein Klassenobjekt deklarieren möchten, genügt es, wenn die Klasse deklariert wurde.

class A; // Deklaration von A

class B{
    // B enthält eine Referenz auf ein A-Objekt
    A& a_objekt;
};

class A{
    // A enthält ein B-Objekt
    B b_objekt;
};

Falls Ihnen jetzt die Frage im Hinterkopf herumschwirrt, wieso B nur eine Referenz und kein Objekt von A enthält: Die Größe eines Objektes der Klasse A wäre dann

      [Größe von B]+[Größe von restlichen Membern]

Sagen wir mal, die restlichen Member sind 1 groß: Die Größe von B wäre:

      [Größe von A] (B enthält keine weiteren Member)

Wenn Sie jetzt ein wenig herumrechnen, werden Sie feststellen, dass A und B unendlich groß wären, da sie sich selbst unendlich oft enthalten. Dasselbe Problem tritt auf, wenn eine Klasse direkt ein Objekt von sich selbst enthält.

Leere Klassen? [Bearbeiten]

Natürlich soll es uns in diesem Kapitel nicht so sehr darum gehen, was in einer Klasse ohne Inhalt alles existiert, sondern vielmehr darum, welche Member der Compiler automatisch erzeugt. Eine Klasse, die Sie ohne einen einzigen Member angelegen, kann zwar unter bestimmten Umständen auch nützlich sein. Darauf werden wir aber erst bei der Behandlung fortgeschrittener Programmiertechniken zu sprechen kommen. Um es vorweg kurz zusammenzufassen, der Compiler erstellt unter bestimmten Umständen automatisch:

  • einen Standardkonstruktor
  • einen Kopierkonstruktor
  • einen Destruktor
  • eine Kopierzuweisung

Alle diese Methoden werden natürlich nur dann vom Compiler generiert, wenn sie im Laufe des Programms auch irgendwo aufgerufen werden. Welche Eigenschaften diese vier Methoden haben und unter welchen Umständen der Compiler sie nicht erstellen kann, werden Sie im Folgenden sehen.

Compilergenerierte Funktionen explizit verbieten

[Bearbeiten]

Diese vier Methoden sind für die meisten Klassen gewünscht und würden auch meist in der vom Compiler erzeugten Weise implementiert werden. Somit erspart er Ihnen hier eine Menge Arbeit. Leider gibt es aber auch Klassen, für die beispielsweise überhaupt keine Kopierzuweisung gewünscht ist. In diesem Fall muss es eine Möglichkeit geben, die Erstellung zu verhindern. Die Lösung ist einfach die Deklaration der entsprechenden Methode im private-Bereich der Klasse vorzunehmen. Eine Definition ist nicht erforderlich, da sie ohnehin nie aufgerufen wird.

Standardkonstruktor

[Bearbeiten]

Falls kein einziger Konstruktor in einer Klasse deklariert wurde, erstellt der Compiler einen Standardkonstruktor, der für alle Membervariablen der Klasse den Standardkonstruktor zur Initialisierung aufruft. Das implizite Erstellen eines solchen Konstruktors ist dem Compiler somit nur möglich, falls alle Membervariablen über einen Standardkonstruktor verfügen. Für die Basisdatentypen ist das Verhalten leider wieder komplizierten Regeln unterworfen und im Falle von Klassen wirkt sich dies ausgesprochen verworren aus. Abhängig davon, wo ein Objekt der Klasse deklariert wird, wird eine solche Variable mit 0 initialisiert oder eben nicht. Sehen Sie sich etwa folgendes Beispiel an.

#include <iostream>

std::size_t const count = 1000000;

class A{
public:
    int data[count];
};

A global; // data wird komplett mit 0 initialisiert

int main(){
    A lokal; // data wird nicht initialisiert

    for(std::size_t i = 0; i < count; ++i){
        if(global.data[i] != 0){
            std::cout << "global" << std::endl;
            break;
        }
    }
    for(std::size_t i = 0; i < count; ++i){
        if(lokal.data[i] != 0){
            std::cout << "lokal" << std::endl;
            break;
        }
    }
}

Dieses Programm wird höchstwahrscheinlich die Zeile „lokal“ ausgeben. Sollte dies bei Ihnen nicht der Fall sein, dann standen an der Stelle des Speichers, den lokal.data belegt, zufällig gerade überall Nullen. Damit solche Effekte nicht auftreten, sollten Klassen, die Basisdatentypen enthalten, immer einen selbstgeschriebenen Konstruktor besitzen, der die entsprechenden Variablen explizit mit 0 initialisiert. Für alle nicht-Basisdatentyp-Variablen, die nicht in der Initialisierungsliste auftauchen, wird implizit der Standardkonstruktor aufgerufen. Bei Klassen, die keine Basisdatentypen enthalten, können Sie getrost die compilergenerierte Version verwenden.

Kopierkonstruktor und Kopierzuweisungsoperator

[Bearbeiten]

Es wird ein Kopierkonstruktor erstellt, der für alle Membervariablen den Kopierkonstruktor aufruft. Offensichtlich müssen also hier für die implizite Erstellung alle Membervariablen über einen Kopierkonstruktor verfügen. In äquivalenter Weise erstellt der Compiler einen Kopierzuweisungsoperator. Für die Basisdatentypen werden in beiden Fällen einfach die Bitfolgen kopiert. Das folgende Beispiel zeigt eine Klasse B, für die beide Methoden implizit erstellt werden, allerdings nur, weil beide in der main()-Funktion auch aufgerufen werden. Die Klasse A deklariert beide Methoden explizit. Dies wäre für dieses Beispiel nicht zwingend notwendig, zeigt jedoch, dass der Compiler tatsächlich auch explizit deklarierte Methoden aufrufen lassen kann.

#include <iostream>

class A{
public:
    A(int v):
        value(v)
        {}

    int value;

    // Expliziter Kopierkonstruktor (äquivalent zum compilergenerierten)
    A(A const& a):
        value(a.value)
        {}

    // Explizite Kopierzuweisung (äquivalent zur compilergenerierten)
    A& operator=(A const& a){value = a.value; return *this;}
};

class B{
public:
    B(int v):
        data(v)
        {}

    A data;
};

int main(){
    B b1(5);
    B b2(10);
    B b3(b1); // Kopierkonstruktor (compilergeneriert)
    std::cout << b3.data.value << std::endl;
    b3 = b2;  // Kopierzuweisung (compilergeneriert)
    std::cout << b3.data.value << std::endl;
}
Ausgabe:
5
10

Wenn Sie eine der beiden Methoden in A im private-Bereich deklarieren und trotzdem versuchen, die entsprechende compilergenerierte Methode in B aufzurufen, dann wird der Compiler dies mit einer Fehlermeldung quittieren.

Für diese beiden Funktionen werden Sie wahrscheinlich meist die compilergenerierte Version verwenden. Eine handgeschriebene Funktion ist immer dann zwingend erforderlich, wenn Member nicht einfach binär kopiert werden dürfen. Enthält Ihre Klasse beispielsweise einen Zeiger, der auf einen dynamisch angeforderten Speicherbereich verweist, welcher im Destruktor wieder freigegeben wird, so wäre es nicht ratsam, diesen Zeiger einfach zu kopieren. Das Resultat wäre, dass anschließend 2 Objekte auf den gleichen Speicherbereich verweisen, doch damit nicht genug. Die Zerstörung des ersten Objektes würde im Destruktor ordnungsgemäß den Speicherbereich wieder freigeben, sobald jedoch das zweite Objekt zerstört wird, würde dessen Destruktion erneut versuchen den Speicher freizugeben, was in den meisten Fällen zu einem Programmabsturz führt.

Es gibt natürlich auch noch eine reichliche Menge anderer Situationen, in denen es erforderlich ist, die beiden Funktionen manuell zu implementieren. Überlegen Sie immer genau, ob das Verhalten der compilergenerierten Standardversionen genau dem entspricht, was Sie auch händisch implementieren würden. Ist dies der Fall, sollten Sie auch die Standardversionen verwenden, denn Ihre Klasse wird dadurch übersichtlicher und leichter wartbar.

Destruktor

[Bearbeiten]

Der Destruktor wird vom Compiler so generiert, dass er für alle Membervariablen, die über einen Destruktor verfügen, diesen aufruft. Beachten Sie, dass der Compiler diese Aufrufe auch automatisch tätigt, wenn Sie explizit einen Destruktor deklarieren. In diesem Fall wird zunächst der Funktionsrumpf Ihres expliziten Destruktors ausgeführt und anschließend werden die Destruktoren der Membervariablen, in umgekehrter Reihenfolge zu ihrer Deklaration, aufgerufen. Es ist also nicht sinnvoll in diesem Zusammenhang explizit Destruktoren aufzurufen. Je nach Funktion des aufgerufen Destruktors, kann ein doppelter Aufruf (einmal manuell, einmal vom Compiler) sogar zu Programmabstürzen führen. Das folgende Beispiel verdeutlicht was passiert.

#include <iostream>

struct A{ ~A(){std::cout << "~A();" << std::endl;} };
struct B{ ~B(){std::cout << "~B();" << std::endl;} };

struct C{ // Impliziter Destruktor, nur compilergenerierte Aufrufe
    A a; B b;
};

struct D{ // Expliziter Destruktor, gefolgt von impliziten Aufrufen des Compilers
    A a; B b;
    ~D(){a.~A();}
};

int main(){
    std::cout << "Destruktor von C:" << std::endl; {C c;}
    std::cout << "Destruktor von D:" << std::endl; {D d;}
}
Ausgabe:
Destruktor von C:
~B();
~A();
Destruktor von D:
~A();
~B();
~A();

Eine wichtige Eigenschaft des compilergenerierten Destruktors werden Sie in Zusammenhang mit Vererbung kennen lernen. Sie besteht darin, dass dieser Destruktor nicht virtuell ist, es sei denn, eine Basisklasse verfügt über einen virtuellen Destruktor. Da Vererbung jedoch noch nicht behandelt wurde, wird dieses Thema später besprochen.

Gemeinsame Hindernisse

[Bearbeiten]

Konstante Membervariablen und auch Referenz-Membervariablen verhindern das implizite Erstellen eines Standardkonstruktors und einer Kopierzuweisung. Dass es keine allgemeingültig sinnvolle Version einer Kopierzuweisung gibt, leuchtet in beiden Fällen sofort ein. Wie sollte sich die Funktion denn verhalten? Ein konstantes Objekt darf ja nicht geändert werden und im Falle einer Referenz ist es in jedem Fall unmöglich, sie zu ändern. Dass der Standardkonstruktor nicht erstellt werden kann, wenn eine der Membervariablen eine Referenz ist, ist ebenfalls offensichtlich. Referenzen müssen immer initialisiert werden, aber woher sollte ein Konstruktor wissen womit. Für konstante Variablen könnte man argumentieren, dass der Standardkonstruktor des jeweiligen Datentyps aufgerufen werden könnte. Da jedoch für Basisdatentypen kein Konstruktor existiert wäre dieses Vorgehen nicht allgemeingültig anwendbar.

Für den Kopierkonstruktor gelten diese Einschränkungen nicht, denn in beiden Fällen ist es durchaus legitim und auch sinnvoll, die Variablen mit den Werten des zu kopierenden Objekts zu initialisieren.

Zusammenfassung [Bearbeiten]

Deklaration und Definition

[Bearbeiten]

Die Deklaration einer Klasse besteht nur aus ihrem Namen, welchem das Schlüsselwort class vorangestellt wird. Die Definition einer Klasse beinhaltet die Deklaration aller Methoden und Variablen, welche Teil der Klasse sind; sie werden als Member (Mitglieder) der Klasse bezeichnet. Auch in diesem Fall gilt natürlich, eine Definition ist gleichzeitig eine Deklaration.

Syntax:
class «Klassenname»; // Deklaration

class «Klassenname»{ // Definition
    // Klassenmember
};
«Nicht-C++-Code», »optional«

Alle Methoden einer Klasse haben Zugriff auf sämtliche Member. Eine Klasse kann mehrere Konstruktoren und einen Destruktor enthalten, beides sind spezielle Methoden. Konstruktoren werden aufgerufen, wenn ein Objekt der Klasse erstellt wird. Der Destruktor wird aufgerufen, wenn ein Objekt der Klasse zerstört wird. Der Name der Konstruktoren ist immer gleich dem Klassennamen, beim Destruktor wird dem Klassennamen noch eine Tilde (~) vorangestellt. Weder der Konstruktor noch der Destruktor haben einen Rückgabetyp. Konstruktoren besitzen außerdem eine sogenannte Initialisierungsliste, in welcher die Variablenmember eines Objekts initialisiert werden können. Im Funktionsrumpf eines Konstruktors sind keine Initialisierungen von Klassenmembern mehr möglich, hier lassen sich lediglich noch Zuweisungen vornehmen.

Syntax:
class «Klassenname»{
    «Klassenname»() // Konstruktor (Definition)
        :a(5)       // Initialisierungsliste
        {}          // leerer Funktionsrumpf

    «Klassenname»(int b) // weiterer Konstruktor (Definition)
        :a(b)            // Initialisierungsliste
        {}               // leerer Funktionsrumpf

    ~«Klassenname»(); // Destruktor (Deklaration)

    «Rückgabetyp» «Methodenname»(); // eine Methode (Deklaration)

    int a; // Membervariable
};

«Klassenname»::~«Klassenname»(){} // Definition des Destruktors

// Definition einer normalen Methode
«Rückgabetyp» «Klassenname»::«Methodenname»(){}
«Nicht-C++-Code», »optional«

Zugriffsbereiche

[Bearbeiten]

Innerhalb von Klassen gibt es verschiedene Zugriffsbereiche. Öffentliche Member (public) können von überall aus aufgerufen und verändert werden. Private Member (private) lassen sich nur von Methoden der Klasse aus ansprechen. Ein dritter Bereich (protected) wird später noch vorgestellt werden. Der Standardbereich einer Klasse ist privat. Es kann mittels der Schlüsselwörter innerhalb der Klassendefinition beliebig oft zwischen den verschiedenen Bereichen gewechselt werden.

Syntax:
class «Klassenname»{
    // Alles hier ist privat
    public: // Ab hier ist alles öffentlich
    private: // Ab hier ist alles privat
};
«Nicht-C++-Code», »optional«

Freunde

[Bearbeiten]

Manchmal ist es sinnvoll, auch einer Funktion außerhalb einer Klasse Zugriff auf deren private Member zu gewähren. Dafür muss diese innerhalb der Klassendefinition als friend deklariert werden. Auch die Deklaration einer ganzen Klasse als friend ist möglich, wenngleich selten sinnvoll. Freundschaft zwischen Klassen beruht im allgemeinen nicht auf Gegenseitigkeit. Die Deklaration eines Freundes ist nicht vom Zugriffsbereich abhängig.

Syntax:
class «Klassenname»{
    friend «Funktionsdeklaration»;
    friend «Klassendeklaration»;
};
«Nicht-C++-Code», »optional«

Verwendung

[Bearbeiten]

Objekte einer Klasse werden genau so erzeugt, wie dies bei Basisdatentypen der Fall ist. Falls ein Konstruktor nur einen Parameter hat, kann anstatt der runden Klammern, genau wie bei Basisdatentypen, auch das Gleichheitszeichen verwendet werden. Der Compiler ruft in jedem Fall einen Konstruktor auf, wenn ein Objekt erzeugt wird, und den Destruktor, wenn ein Objekt zerstört wird. Auf öffentliche Member können, durch einen Punkt vom Objekt getrennt (object.member), zugegriffen werden. Für Zeiger auf Klassenobjekte kann die Dereferenzierung mit anschließendem Zugriff ((*objectzeiger).member) mit dem dereferenzierenden Zugriffsoperator (objectzeiger->member) ausgeführt werden.

Operatorüberladung

[Bearbeiten]

In C++ können die meisten Operatoren für eigene Datentypen überladen werden. Dies kann als Member einer Klasse geschehen oder auch außerhalb. Falls die Überladung als Member erfolgt, so ist der erste Parameter ein Objekt der entsprechenden Klasse. Die Operatorfunktionen sehen genau so aus wie gewöhnliche Funktionen, ihr Name setzt sich aus dem Schlüsselwort operator gefolgt von der jeweiligen Zeichenfolge des gewünschten Operators.

Eine Operatorüberladung ist immer dann sinnvoll, wenn dadurch die Lesbarkeit des Programms erhöht wird. Es muss also intuitiv erkennbar sein, was der Operator tut. In begründeten Ausnahmefällen kann es auch sinnvoll sein, einen Operator mit einer anderen Funktionalität auszustatten, als er dies bei den Basisdatentypen hat. Ein gutes Beispiel hierfür ist die Verwendung der Shiftoperatoren zur Ein- bzw. Ausgabe.

Die Cast-Operatoren sind eine spezielle Form der Operatorüberladung. Ähnlich einem Konstruktor, der ein Objekt einer anderen Klasse übernimmt, kann die Umwandlung auch durch einen Cast-Operator erfolgen. Der Name einer entsprechenden Funktion setzt sich aus dem Schlüsselwort operator gefolgt vom gewünschten Datentyp zusammen. Eine Besonderheit dieser Funktionen ist, dass sie keinen Rückgabetyp angeben dürfen, denn dieser ist ja durch den Funktionsnamen bereits festgelegt.

Compilergenerierte Methoden

[Bearbeiten]

Unter bestimmten Umständen wird der Compiler für Sie einen Standardkonstruktor, einen Kopierkonstruktor, einen Destruktor und eine Kopierzuweisung generieren. In vielen Fällen sind die compilergenerierte Methoden geeignet. Deklarieren Sie eine dieser Methoden selbst, wird Ihre Variante verwendet. Falls Sie eine der Methoden explizit nicht für Ihre Klasse wünschen, dann deklarieren Sie sie im privaten Bereich. Der Compiler wird dann einen Fehler erzeugen, wenn jemand versucht die Methode aufzurufen.