C++-Programmierung/ Eigene Datentypen definieren/ Operatoren überladen

Aus Wikibooks


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.