Überladen

Willemers Informatik-Ecke

Überladen von Elementfunktionen

Das Überladen wurde beim Thema Funktionen bereits behandelt. An dieser Stelle wird das Thema noch einmal aufgefrischt, um zunächst das Überladen von Elementfunktionen zu betrachten.

Bruchrechnung

Als Beispiel betrachten wir die Klasse tBruch, die es möglich macht, mit Brüchen zu rechnen. Es werden zwei Elementfunktionen namens Addiere() angelegt. Die eine addiert ganzzahlige Werte zu dem Bruch, die andere verwendet einen Bruch als Parameter.

[Bruchrechnung mit überladener Funktion (bruch.cpp)]

class tBruch
{
public:
    tBruch() {zaehler=0; nenner=1;}
    long GetNenner()  {return nenner;}
    long GetZaehler()  {return zaehler;}
    void SetNenner(long p) {if (p!=0) nenner=p;}
    void SetZaehler(long p)  {zaehler=p;}
    void Addiere(long);
    void Addiere(tBruch);
    void Show();
private:
    long zaehler;
    long nenner;
};

void tBruch::Addiere(long summand)
{
    zaehler+=summand*nenner;
}

void tBruch::Addiere(tBruch summand)
{
    zaehler = zaehler*summand.GetNenner()
            + summand.GetZaehler()*nenner;
    nenner  = nenner * summand.GetNenner();
}

Welche der Additionsfunktionen verwendet wird, entscheidet der Compiler anhand der verwendeten Parameter beim Aufruf.

Kür: Überladen von Operatoren

operator

Viel eleganter sieht es natürlich aus, wenn man zwei Objekte vom Typ tBruch direkt mit dem Pluszeichen addieren kann. In C++ gibt es die Möglichkeit, Operatoren zu implementieren. Dazu wird eine Funktion geschrieben, deren Namen mit dem Schlüsselwort operator beginnt. Daran wird der Operator angehängt, den man nachbilden möchte. Folgende Operatoren können verwendet werden:
new        +   %   ~   >    /=   |=    <<=   >=   --   ()
delete     -   ^   !   +=   %=   <<    ==    &&   ,    []
new[]      *   &   =   -=   ^=   >>    !=    ||   ->*
delete[]   /   |   <   *=   &=   >>=   <=    ++   -> 

Kompatibilität

Dadurch, dass Sie die Operatoren selbst implementieren, ändert sich nichts an dem Kontext der Operatoren. Priorität, Anzahl der Operanden und Assoziativität -- also ob der Operator rechts- oder linksgebunden ist -- bleiben erhalten. Auch können die Operatoren der zum Sprachumfang gehörigen Typen nicht geändert werden. Mindestens einer der beiden Operanden muss einen selbst definierten Typ haben.

Global oder als Element

Eine Operatorfunktion muss nicht zwingend zu einer Klasse gehören. Sie kann auch als globale Funktion angelegt werden. Ohne Anbindung an die Klasse können aber bestimmte Operatoren nicht überladen werden. Das sind der Zuweisungsoperator =, der Funktionsaufruf (), der Index [] und der Zeigeroperator ->. Gehört die Operatorfunktion zur Klasse, stellt das Objekt der Klassen den ersten Operanden dar. Im folgenden Beispiel wird der Plus-Operator überladen. Da die Operatorfunktion zur Klasse tBruch gehört, stellt sie die Addition dar, bei der der erste Operand vom Typ tBruch ist.
tBruch tBruch::operator+(long summand);   // Elementfunktion
Bruch = Bruch.operator+(Long);            // Aufruf
Bruch = Bruch + Long;                     // Aufruf

Benötigt der Operator einen zweiten Operanden, wird er als Parameter übergeben. Das Beispiel zeigt eine Addition. Diese hat immer zwei Operanden. Also wird der zweite Operand als Parameter der Funktion operator+() übergeben, hier also vom Typ long. Der Anwender einer Addition wird erwarten, dass er die beiden Parameter beliebig tauschen kann. Dann wäre der erste Operand allerdings long und damit ein Standardtyp für den es natürlich keine Klasse gibt. In solch einem Fall muss eine globale Operatorfunktion geschrieben werden. Diese Funktion hat dann zwei Parameter:

tBruch operator+(long, const tBruch& o2); // globale Funktion
Bruch = operator+(Long, Bruch);           // Aufruf
Bruch = Long + Bruch;                     // Aufruf

Liebgewonnene Zusammenhänge gelten nicht automatisch, wenn Operatoren überladen werden. So ergibt sich aus dem Überladen des Operators + nicht, dass sich += aus dem Aufaddieren des zweiten Operanden ergibt. Wollen Sie, dass diese Regel auch für Ihren Datentyp gilt, so müssen Sie explizit den +=-Operator implementieren. Das Gleiche gilt für ++.

Stimmigkeit

Das Überladen von Operatoren hat seine Tücken, wenn der Anwender die Funktionalität mit dem Operator nicht intuitiv in Verbindung bringen kann. Trifft die Assoziation des Operators nicht wirklich perfekt auf den überladenen Operator, ist es besser, wenn Sie eine normale Funktion schreiben. Der Operator kann ansonsten mehr Verwirrung stiften als nützen. Solche Erfahrungen haben wohl dazu geführt, dass in Java das Überladen von Operatoren nicht eingeführt wurde. Wenn beispielsweise der Gleichheitsoperator definiert ist, der Ungleichheitsoperator aber nicht, oder wenn der eine Operator nicht die Negation des anderen ist, dann wird die Verwendung der Operatoren unstimmig. Sobald sich die Operatoren Ihrer Klasse deutlich anders verhalten als in den Basistypen, werden Sie den Anwender Ihrer Klasse schnell verwirrt haben. Er wird vermutlich einige Zeit brauchen, bis er herausfindet, dass Ihre Funktion nichts mit dem Operator zu tun hat. Erst dann hat er eine Chance zu begreifen, was Sie tun.

Addition

Als Erstes betrachten wir den Additionsoperator. Wie bei der Funktion Addiere(), an der das Überladen von Funktionen gezeigt wurde, soll es je eine Operatorfunktion geben: eine für Brüche und eine für ganzzahlige Werte. Als Beispielklasse für das Überladen von Operatoren bietet sich tBruch einfach an. Für die Addition hatten wir schon eine Funktion in die Klasse eingebaut. Nun soll die Addition durch den Operator + implementiert werden. Dazu wird zunächst eine normale Funktion gebildet, die mit operator+() lediglich einen originellen Namen hat.

[Bruchklasse mit überladenen Operatoren]

#include <iostream>
using namespace std;

class tBruch
{
public:
    tBruch() {zaehler=0; nenner=1;}
    long GetNenner()  {return nenner;}
    long GetZaehler()  {return zaehler;}
    void SetNenner(long p) {if (p!=0) nenner=p;}
    void SetZaehler(long p)  {zaehler=p;}
    tBruch operator+(long);
    tBruch operator+(tBruch);
    void Show();
private:
    long zaehler;
    long nenner;
};

tBruch tBruch::operator+(long summand)
{
    zaehler+=summand*nenner;
    return *this;
}

tBruch tBruch::operator+(tBruch summand)
{
    zaehler = zaehler*summand.GetNenner()
              + summand.GetZaehler()*nenner;
    nenner  = nenner * summand.GetNenner();
    return *this;
}

Sie können die Funktion durchaus direkt aufrufen. Dazu benötigen Sie eine Objekte vom Typ tBruch, die das Ergebnis aufnimmt, ein Objekt vom Typ tBruch, über die die Funktion aufgerufen wird, und ein Objekt vom Typ tBruch oder long, die als zweiter Operand dient.

tBruch Summe, Summand1, Summand2;

Summe = Summand1.operator+(Summand2);
Summe = Summand1 + Summand2;

Die beiden letzten Zeilen sind äquivalent. Der erste Operand wird durch die Klasse bestimmt, deren Elementfunktion operator+() ist. Der zweite Operand wird durch den Typ des Parameters bestimmt. Der Rückgabetyp der Funktion operator+() legt den Typ der Variablen fest, der das Ergebnis zugewiesen wird.

Beispiel Datum

Im folgenden Beispiel wird für die Klasse tDatum das Pluszeichen definiert. Es macht keinen Sinn, zwei Tagesdaten zusammenzuzählen, darum wird es auch nicht definiert. Aber man kann zu einem Datum eine Anzahl von Tagen addieren, um ein anderes Datum zu bekommen. Die Deklaration und Definition sieht so aus:

[Überladen des Operators]

class tDatum  
{
public:
        tDatum operator+(int Tage);
};

tDatum tDatum::operator+(int Tage)
{
        // Berechnung des Datums
        return *this;
}

Die Addition liefert ein neues Datum zurück, und als rechter Operand ist eine ganze Zahl zulässig. So kann das Datum durch einfache Addition um 14 Tage weitergeschoben werden.

[Verwendung des Operators]

tDatum heute;

   heute = heute + 14;

Vielleicht ist es Ihnen auch so gegangen, dass Sie spontan überlegt hatten, statt dieser Zeile heute += 14 zu schreiben. Der Operator += ist aber gar nicht definiert. Das wäre jedoch konsequenterweise zu fordern, wenn bereits der Operator + definiert ist. Dasselbe gilt natürlich auch für den Operator ++.

Globale Operatorfunktionen

Für die Bruch-Klasse ist eine Addition mit einer long-Variablen implementiert worden. Damit ist es möglich, auf der linken Seite des Pluszeichens einen Bruch und auf der rechten Seite einen ganzzahligen Wert zu verwenden. Möchten Sie es auch ermöglichen, beide Operanden auszutauschen, dann haben Sie das Problem, dass der linke Operator ja durch die Klasse bestimmt ist. Sie müssten also eine Operatorfunktion der Klasse long schreiben. Das geht natürlich nicht, da long ja ein Basistyp ist und keine Klasse. Aber Sie können stattdessen eine globale Operatorfunktion anlegen. Diese hat dann zwei Parameter, und es ist natürlich kein Problem, den ersten Parameter als long zu definieren.
tBruch operator+(long o1, const tBruch& o2)
{
tBruch summe;
    summe.SetZaehler(o2.GetZaehler()+o1*o2.GetNenner());
    summe.SetNenner(o2.GetNenner());
    return summe;
}

friend

In solchen Fällen bietet es sich an, die globale Funktion als Freund zu deklarieren, damit auf die Elemente direkt zugegriffen werden kann. Das ist im folgenden Listing zu sehen:
class tBruch
{
    ...
    // die globale Funktion operator+ darf auf 
    // Elementvariablen zugreifen
    friend tBruch operator+(long o1, const tBruch& o2);
    ...
};

tBruch operator+(long o1, const tBruch& o2)
{
    tBruch summe;
    summe.zaehler = o2.zaehler+o1*o2.nenner;
    summe.nenner = o2.nenner;
    return summe;
}

Inkrementieren und Dekrementieren

Das Inkrementieren und Dekrementieren gibt es in je zwei Erscheinungsformen. In der einen Form steht der Operator links und in der anderen Form rechts von der Variablen. Steht der Operator links, spricht man vom Präfix. Dann wird zunächst der Operator ausgeführt und dann erst die Variable ausgewertet. In der Postfix-Form steht der Operator rechts von der Variablen. Hier wird zuerst ausgewertet und dann der Operator ausgeführt.

a = 5;                  a = 5;
b = ++a;                b = a++;
// b==6                 // b==5

Um Präfix und Postfix in der operator++-Definition zu unterscheiden, erhält die Postfix-Variante zusätzlich einen Integer-Parameter. Dieser wird aber tatsächlich überhaupt nicht ausgewertet. Die Postfix-Operation benötigt immer eine lokale Variable zum Sichern des alten Stands, der ja nach Erhöhung der Variablen noch zurückgegeben werden muss. Daraus ergibt sich eine geringfügig bessere Performance der Präfix-Variante.

[Inkrementieren mit Präfix und Postfix (bruch.cpp)]

class tBruch
{
    tBruch& operator++();    // Präfix
    tBruch operator++(int); // Postfix
    ...
};

tBruch& tBruch::operator++()
// Praefix-Inkrement
{
    // berechne den neuen Bruch
    // geht auch: *this = *this + 1;
    zaehler += nenner;
    return *this;
}

tBruch tBruch::operator++(int)
// Postfix-Inkrement
{
    tBruch oldBruch =*this; // alten Stand sichern
    zaehler += nenner;      // Variable erhöhen
    return oldBruch;        // return alten Stand
}

Der Rückgabewert der Funktion operator++ ist das Ergebnis der Auswertung. Der Postfix-Operator darf keine Referenz zurückgeben, da ansonsten eine Referenz auf eine lokale Variable entstünde, die sofort nach dem Verlassen der Funktion ungültig würde.

Der Zuweisungsoperator

Der Zuweisungsoperator wird immer dann aufgerufen, wenn auf der linken Seite der Zuweisung ein Objekt der Klasse steht, in der er implementiert ist. Wenn eine Klasse keinen Zuweisungsoperator implementiert, wird ein Objekt bei der Zuweisung Bit für Bit kopiert. Das funktioniert auch wunderbar, sofern die Klasse keinen Zeiger enthält, der auf externe Datenbestände verweist. In diesem Fall wird nur der Zeiger kopiert. Das hat zur Folge, dass beide Objekte nach der Zuweisung auf den gleichen externen Datenbereich zeigen. Soweit könnte Ihnen die Situation noch vom
Kopierkonstruktor bekannt vorkommen).

Hier steht im Buch die Abbildung "Kopieren von Objekten" (abbobjkopie2).

Ziel existiert

Der Hauptunterschied zwischen Kopierkonstruktor und Zuweisungsoperator besteht darin, dass bei einer Zuweisung das Zielobjekt bereits existiert. Das heißt, das Zielobjekt hatte vor der Zuweisung bereits einen externen Datenbereich, der über seinen Zeiger zugreifbar war. Dieser Zeiger wird aber bei einer Bit-für-Bit-Kopie durch die Zuweisung überschrieben und zeigt anschließend auf den Datenbereich des Quellobjekts. Abbildung (abbobjkopie2) zeigt die Problematik deutlich. Beim Kopieren wird der Zeiger mitkopiert. Da der Zeiger des Originals die Adresse von Daten1 enthält, wird der Zeiger des Ziels nachher auch auf Daten1 zeigen. Der ursprüngliche Datenbereich Daten2 bleibt referenzlos im Speicher liegen. Da beide Objekte auf denselben Datenbereich zeigen, wird das Objekt, dessen Destruktor zuerst aufgerufen wurde, dem anderen die Daten entziehen.

Aufgabenstellung

Damit hat der Zuweisungsoperator folgende Aufgaben:
  • Der externe Speicher des Zielobjektes muss freigegeben werden.
  • Für das Zielobjekt muss externer Speicher angefordert werden, in den die externe Daten des Quellobjekts passen.
  • Die externen Daten des Quellobjekts müssen in den externen Speicher des Zielobjekts kopiert werden.
  • Alle Elementvariablen, die keine Zeiger sind, müssen kopiert werden.
Wenn der externe Speicher bei Quelle und Ziel gleich groß sind, kann das Freigeben und Neuanlegen natürlich entfallen. Dann reicht es, den Datenbereich der Quelle in den externen Speicher des Zielobjekts zu kopieren.

Aufruf

Um zu wissen, wann der Zuweisungsoperator aufgerufen wird, müssen Sie genau zwischen Zuweisung und Initialisierung unterscheiden. Eine Initialisierung steht immer in Verbindung mit einer Deklaration. In solch einem Fall wird der Kopierkonstruktor aufgerufen. Auch bei Parameterübergaben und bei der Rückgabe per Wert einer Funktion wird der Kopierkonstruktor aufgerufen. Wird der zurückgegebene Wert allerdings anschließend einem Objekt zugewiesen, wird der Zuweisungsoperator aufgerufen. Das folgende Beispiel betrachtet eine kleine Klasse tAuto, die ein Kennzeichen enthält. Dieses wird über einen char-Zeiger verwaltet. Das Beispiel hat sowohl einen Kopierkonstruktor als auch einen Zuweisungsoperator. Das Hauptprogramm führt ein paar Tests durch.

[Zuweisungsoperator definieren (zuweis.cpp)]

#include <iostream>
using namespace std;

class tAuto
{
public:
    tAuto() { Kennzeichen=0; Raeder=4; }
    ~tAuto() { delete Kennzeichen; Kennzeichen = 0; }
    void SetKennzeichen(char *a);
    char *GetKennzeichen() const { return Kennzeichen; }
    tAuto(const tAuto& k);           // Kopierkonstruktor
    tAuto &operator=(const tAuto &k); // Zuweisungsoperator

private:
    // Zwei private Funktionen für den Umgang mit den Strings
    int StringLaenge(char *s)
    {
        int len=0;
        while (*s++) len++;
        return len+1;
    }
    void StringKopie(char *Quelle)
    {
        char *Ziel = Kennzeichen;
        while (*Quelle) *Ziel++ = *Quelle++;
        *Ziel=0; // Abschluss-Null
    }
    char *Kennzeichen; // Zeiger auf externen Speicher
    int Raeder;        // Normale Elementvariable
};

// Normale Belegung des Kennzeichens
void tAuto::SetKennzeichen(char *Kz)
{ 
    if (Kennzeichen) delete Kennzeichen; // Speicher freigeben
    int len = StringLaenge(Kz);  // Größe bestimmen
    Kennzeichen = new char[len]; // Neuen Speicher anfordern
    StringKopie(Kz);             // Daten übernehmen
}

// Kopierkonstruktor
tAuto::tAuto(const tAuto& k)
{
    cout << "Kopierkonstruktor" << endl;
    int len = StringLaenge(k.GetKennzeichen()); // Größe?
    Kennzeichen = new char[len]; // Neuen Speicher anfordern
    StringKopie(k.GetKennzeichen()); // Daten übernehmen
    Raeder = k.Raeder; // Alle anderen Daten kopieren
}

// Zuweisungsoperator
tAuto &tAuto::operator=(const tAuto &k)
{
    if (this != &k) // wenn es keine Zuweisung an sich selbst ist
    {
        cout << "Zuweisung:" << k.GetKennzeichen() << endl;
        delete Kennzeichen; // lösche bisherige Daten
        int len = StringLaenge(k.GetKennzeichen()); // Größe?
        Kennzeichen = new char[len]; // Neuen Speicher anfordern
        StringKopie(k.GetKennzeichen()); // Daten übernehmen
        Raeder = k.Raeder; // Alle anderen Daten kopieren
    }
    return *this; // zurück mit dem aktuellen Objekt
}

// Zum Test von Kopierkonstruktor und Zuweisungsoperator
tAuto Uebergebe(tAuto para)
{
    cout << "Funktion:" << para.GetKennzeichen() << endl;
    return para;
}

int main()
{
    tAuto Objekt;
    tAuto Ziel;
    Objekt.SetKennzeichen("HG-AW 409");
    Ziel = Uebergebe(Objekt);
    cout << Objekt.GetKennzeichen() << endl;
    cout << Ziel.GetKennzeichen() << endl;
}

Selbstzuweisung

Gleich zu Anfang prüft der Zuweisungsoperator, ob es eine Zuweisung an sich selbst handelt. In diesem Falle wäre die Ausführung fatal. Der Zuweisungsoperator gibt ja als erstes die externen Daten des Zielobjekts frei. Wenn Ziel und Quelle aber identisch sind, sind mit dem ersten Schritt die externen Daten verloren. Nach dieser Prüfung, führt der Zuweisungsoperator die üblichen Schritte durch: Er gibt den alten Speicherfrei, fordert neuen Speicher an, kopiert die externen Daten und schließlich alle Elementvariablen.

Rückgabe

Sie sehen im Beispiel, dass die Rückgabe über eine Referenz erfolgt. Das ist erforderlich, damit nicht noch einmal zusätzlich der Kopierkonstruktor aufgerufen wird.

Parameter

Der Zuweisungsoperator hat immer einen Parameter. Es können mehrere Zuweisungsoperatoren in einer Klasse definiert werden. Dabei unterscheiden sich die Zuweisungsoperatoren dann durch den Parametertyp. Der Zuweisungsoperator kann nur durch eine Elementfunktion und nicht durch eine globale Operatorfunktion implementiert werden.

Die Vergleichsoperatoren

Die Vergleichsoperatoren geben naturgemäß einen booleschen Wert zurück. Wie bei allen Operatoren, die als Elementfunktionen implementiert werden, entspricht der Typ der Klasse dem linken Operanden. Der Typ des rechten Operanden wird durch den Parameter der Funktion bestimmt.

Gleichheit

Auch ohne weitere Vorkehrungen können zwei Objekte mit Hilfe des Gleichheitsoperators verglichen werden. C++ prüft dann, ob die beiden Objekte Bit für Bit gleich sind. Diese Art des Vergleichs führt aber in einigen Fällen zu falschen Ergebnissen. Die Brüche 1/2 und 2/4 sind beispielsweise gleich, auch wenn die Standardmethode das nicht erkennt. Beim Vergleichen zweier Objekte der Klasse tDatum muss der Wochentag aus dem Vergleich herausgelassen werden. Schließlich unterscheiden sich zwei Tage nur durch die Elemente Tag, Monat und Jahr und nicht dadurch, ob der Wochentag derzeit berechnet ist oder nicht. In Fällen, in denen Klassen Zeiger enthalten, die auf ausgelagerte Daten zeigen, werden zwei nicht identische, aber inhaltlich gleiche Objekte niemals als gleich erkannt werden, da die Zeiger immer auf unterschiedliche Speicherbereiche zeigen. Eine selbst geschriebene Gleichheitsfunktion würde nicht den Zeiger, sondern die Daten, auf die er zeigt, vergleichen. Das folgende Beispiel zeigt den Gleichheitsoperator für die Klasse tBruch. Sie sorgt dafür, dass die beteiligten Brüche vor dem Vergleich gekürzt werden.

[Gleichheitsoperator bei tBruch]

bool tBruch::operator==(tBruch vgl)
{
    kuerze();
    vgl.kuerze();
    return (zaehler==vgl.zaehler && nenner==vgl.nenner);
}

Für die Klasse tDatum würde einfach nur verglichen, ob Tag, Monat und Jahr übereinstimmen. Bei einer Klasse mit Zeigern würde der Inhalt der Speicher verglichen, auf den die Zeiger verweisen.

Kleiner/größer

Die Vergleichsoperatoren < und > werden im gleichen Strickmuster erstellt. Von besonderer Bedeutung ist der Operator für die Kleiner-Relation. So verwenden beispielsweise das Sortierverfahren der STL und der Map-Container, der Objekte sortiert ablegt, das Kleiner-Zeichen, um die Reihenfolge festzulegen. Eine Klasse, die dafür verwendbar sein soll, muss also das Kleiner-Zeichen definieren.

Ungleichheit

Der Gleichheitsoperator sollte immer im Doppelpack mit dem Ungleichheitsoperator implementiert werden. Das ist auch nicht weiter aufwändig, da die Ungleichheit leicht aus der Gleichheit abzuleiten ist:

[Ungleichheit]

class tBruch
{
    ...
    bool operator==(tBruch op2);
    bool operator!=(tBruch op2)
        {
            return !(*this == op2);
        }
    ...
};

Auf ähnliche Weise lässt sich der Operator >= aus der Negation des Operators < erzeugen.

Der Ausgabeoperator

Der Ausgabeoperator wird üblicherweise nicht als Elementfunktion implementiert, sondern als globale Operatorfunktion. Der Grund ist, dass nicht das Objekt der Klasse sondern das Stream-Objekt auf der linken Seite steht.

Beispiel

Das folgende Beispiel zeigt den Ausgabeoperator für die Klasse tBruch. Dabei wird zuerst der Zähler, dann ein Schrägstrich und schließlich der Nenner auf das Ausgabeobjekt umgeleitet.

[Ausgabe eines Bruchs (bruch.cpp)]

ostream& operator<<(ostream& Stream, const tBruch& B)
{
    return Stream << B.GetZaehler() << "/" << B.GetNenner();
}

Konstante Funktion

Da der Parameter B als konstant deklariert wird, dürfen innerhalb der Funktion keine Datenelemente verändert werden. Darüber hinaus dürfen nur als konstant deklarierte Funktionen aufgerufen werden. Nur so kann der Compiler dem Aufrufer garantieren, dass der übergebene Wert innerhalb der Funktion nicht verändert wird, ohne dass er in die Implementation der Klasse tBruch hineinschauen muss. Die Funktionen GetZaehler() und GetNenner() werden wie folgt als konstant deklariert:

[Konstante Funktionen (bruch.cpp)]

class tBruch
{
    ...
    long GetNenner() const  {return nenner;}
    long GetZaehler() const  {return zaehler;}
    ...
};

friend

Alternativ können die Operatorfunktionen als Freunde der Klasse deklariert werden. Dann dürfen sie auch auf die privaten Elemente zugreifen. Darüber hinaus wird dokumentiert, dass die Umleitungsoperatoren eigentlich auch zur Klasse gehören.

[Konstante Funktionen (bruch.cpp)]

class tBruch
{
    ...
    friend ostream& operator<<(ostream&, const tBruch&);
    friend istream& operator>>(istream&, tBruch&);
    ...
};

ostream& operator<<(ostream& Stream, const tBruch& B)
{
    return Stream << B.zaehler << '/' << B.nenner;
}

Der Indexoperator

Eckige Klammern

Als Indexoperator werden die eckigen Klammern bezeichnet, die beim Array verwendet werden, um auf ein bestimmtes Element zuzugreifen. Der Parameter der Operatorfunktion enthält den Wert, den der Anwender zwischen die eckigen Klammern setzt. Der Rückgabewert ist naheliegenderweise der Wert, auf den verwiesen wird. Der Indexoperator kann nur als Elementfunktion implementiert werden, nicht als globale Operatorfunktion. Das folgende Beispiel zeigt die Definition eines Indexoperators in einer Klasse, die eine Zeichenkette namens SafeString implementiert. Der Indexoperator soll gegen Überschreitungen der Puffergrenzen abgesichert sein.

[Safer String (safestr.cpp)]

#include <iostream>
using namespace std;

class tSafeString // String-Klasse mit abgesichertem Index
{
public:
    tSafeString(int len) // Max. Länge muss vorgegeben werden
    {
        maxLen = len;
        safestr = new char[len]; // Externe Speicheranforderung
        mist = 0; // Dummy
    }
    ~tSafeString()
    {
        delete[] safestr;
        safestr=0;
    }
    char& operator[](int i);
    // In einer realen Klasse müsste auch ein Kopierkonstruktor
    // und ein Zuweisungsoperator implementiert werden.
private:
    char *safestr; // der String ist extern!
    char mist;     // Dummy fuer Fehlzugriffe
    int maxLen;
};

char& tSafeString::operator[](int i)
{
    if (i<maxLen && i>=0)
    {
        return *(safestr+i); // Buchstabe gefunden!
    }
    return mist; // return 0 wegen Referenz nicht möglich!
}

int main()
{
    tSafeString str(6); // 6 Zeichen, von 0 bis 5
    str[5] = 'A';       // Schreiben
    char c = str[5];    // Lesen
    cout << c << endl;  // Test, ob es wirklich ein 'A' ist
}

Sofern der Index korrekt ist, liefert die Operatorfunktion eine Referenz auf das passende Element des internen Strings safestr. Dieses Element kann dann ausgelesen oder belegt werden, je nachdem, auf welcher Seite des Zuweisungsoperators der Ausdruck steht. Für den Fall, dass der Index außerhalb des zulässigen Bereichs liegt, wird die Elementvariable mist verwendet. Die Funktion kann nicht einfach 0 zurückgeben, weil der zurückgegebenen Referenz gegebenenfalls ein Wert zugewiesen werden soll.

Rückgabe als Referenz

Der Rückgabewert der Operatorfunktion muss als Referenz zurückgegeben werden, weil der Operator nur so auf der linken Seite einer Zuweisung stehen kann. In diesem Fall wird die Referenz auf das korrekte Zeichen in der privaten Zeichenkette safestr zurückgegeben. Da die Referenz ein Stellvertreter für das Objekt darstellt, kann es auch beschrieben werden. Würde die Rückgabe per Wert erfolgen, würde in eine lokale Kopie geschrieben.

String als Index

Der Parameter von operator[]() muss nicht zwingend ein ganzzahliger Wert sein. Sie können auch beispielsweise eine Zeichenkette verwenden. Damit könnten Zugriffe auf Adressverzeichnisse realisiert werden. Sie können auch Zeichenketten als Index akzeptieren, um über Kürzel auf Inhalte zuzugreifen. Das entspräche den Hashvariablen in Perl. Solche Zugriffe sind sehr anschaulich, wie das folgende Beispiel zeigt.

KfzKennzeichen["HH"] = "Hansestadt Hamburg";

Auf diese Weise funktioniert auch der STL-Container map.

Der Aufrufoperator ()

Der Aufrufoperator ermöglicht es, ein Objekt der Klasse als Funktion aufzurufen. Was passieren soll, wenn ein Objekt, wie etwa ein Tagesdatum oder ein Bruch, aufgerufen wird, ist zunächst nur schwer nachzuvollziehen. Stroustrup spricht in diesem Zusammenhang von Funktionsobjekten. (vgl. Stroustrup, Bjarne: Die C++ Programmiersprache. Addison--Wesley, München. S.~305.) Solche Funktionsobjekte werden eingesetzt, wenn eine Funktion nicht ausreicht. Beispielsweise besitzt in der STL (Standard Template Library) die Funktion find_if() einen Parameter, die eine Funktion aufruft, die einen booleschen Rückgabewert hat. Damit kann sehr flexibel nach einem Objekt gesucht werden. Die Bedingung wird durch die Funktion festgelegt. Wenn aber diese Funktion ein Gedächtnis haben muss, beispielsweise den bisherigen Verlauf der Abfragen, dann stößt die Funktion an ihre Grenzen. Mit dem Funktionsobjekt kann eine Klasse wie eine Funktion auftreten und diese Lücke füllen. Die folgende Klasse tRufMich stellt ein solches Funktionsobjekt dar.

[Aufrufoperator]

class tRufMich
{
public:
    int operator() (int)
        { /* tu was */ }
    ...
};

Die Verwendung dieses Operators sieht erwartungsgemäß wie ein Funktionsaufruf auf ein Objekt aus. Hat die Funktion keine Rückgabewerte, könnte man den Aufruf auch mit einem Konstruktoraufruf verwechseln. Von Letzterem unterscheidet sich der Aufrufoperator vor allem darin, dass er nicht bei der Initialisierung des Objekts aufgerufen wird. Das folgende Beispiel zeigt den Aufruf eines Konstruktors und eines Aufrufoperators.

[Aufrufoperator]

tRufMich ruf(23); // Aufruf des Konstruktors
...
ruf(23);          // Aufruf des Funktionsoperators

Der Konvertierungsoperator

Um einen fremden Typ in ein Objekt der eigenen Klasse zu konvertieren, verwenden Sie einen Konvertierungskonstruktor, wie er bereits beschrieben wurde.

Syntax

Eine Konvertierungsfunktion hat keinen deklarierten Rückgabewert, sondern beginnt mit dem Schlüsselwert operator. Durch ein Leerzeichen abgetrennt, folgt der Typ, in den konvertiert werden soll, und danach steht ein leeres Klammernpaar, da eine Konvertierungsfunktion keinen Parameter hat. Die Funktion gibt einen Ausdruck vom Zieltyp zurück.

Beispiel

So wäre es durchaus sinnvoll, für die Klasse tBruch eine Konvertierungsfunktion zu schreiben, die aus dem Bruch eine Fließkommazahl erzeugt:

class tBruch
{
public:
    operator double()
    {
        return double(zaehler)/double(nenner);
    }
    ...


Informatik-Ecke Einstieg in C++ (C) Copyright 2005 Arnold Willemer