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

Aus Wikibooks

Wechseln zu: Navigation, Suche


Im Kapitel über Zeichenketten haben Sie gelernt, das 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, das 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.

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:

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

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

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

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

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:

Crystal Clear app terminal.png
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 eine 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.

Crystal Clear app terminal.png
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.

Crystal Clear app terminal.png
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);

Inhaltsverzeichnis

[Bearbeiten] Definition der überladenen Operatoren

[Bearbeiten] Codeverdopplung vermeiden

[Bearbeiten] Ein-/Ausgabeoperatoren überladen

[Bearbeiten] Spaß mit „falschen“ Überladungen

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.

Crystal Clear app terminal.png
// Zum Überladen von Ein-/Ausgabe
#include <ios>

class Int{
public:
    // Konstruktor (durch Standardparameter gleichzeitig Standardkonstruktor)
    Int(int value = 0): value(m_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::ostream& operator<<(std::ostream& os, Int const& rhs);
friend std::istream& operator>>(std::istream& is, Int& 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::ostream& operator<<(std::ostream& os, Int const& rhs) { return os << rhs.m_value; }
std::istream& operator>>(std::istream& is, Int& rhs) { return is >> rhs.m_value; }

[Bearbeiten] Präfix und Postfix

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.

Crystal Clear app terminal.png
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 Prafix- 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 das es sich um eine Postfix-Operatorüberladung handelt.

Crystal Clear app terminal.png
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 Gleich, 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 denn 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 überlanden 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.

Crystal Clear app terminal.png
Int i = 5;

++++++i; // i ist 8
// Effizienter währe "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.

[Bearbeiten] Übersicht

[Bearbeiten] Rückgabetypen

[Bearbeiten] Casten

Persönliche Werkzeuge