Infix-Operatoren überladen

Infix Operatoren stehen zwischen zwei Argumenten, wie zum Beispiel das '+' zwischen den beiden zu addierenden Werten plaziert wird. Es findet eine Verknüpfung zwischen zwei Werten statt und wie verknüpft wird, entscheidet der jeweilige Infix-Operator. Die beiden Argumente werden 'Linker Wert', bzw. 'Rechter Wert' genannt, verbreitet ist die Schreibweise, die Argumente im Quelltext 'lhs' (left hand side) und 'rhs' (right hand side) zu nennen.

Hier einige Beispiele für binäre Operatoren:

int a = 4, b = 5
 
a + b;
a += b;
a = b;
a == b;
a && b;

Die Mehrzahl der Operatoren in C++ sind binär. Allerdings gibt es einige Unterschiede im Ergebnis der Operatoren, die berücksichtigt werden muss:

  • ergebnisliefernde Operatoren (Getter)
    darunter fallen die arithmentischen, bitmanipulierenden, vergleichenden und logischen binären Operatoren, sowie auch Index, Zugriffe, Functor, Speichermanagement und die Konvertierung.
    Diese Operatoren greifen in der Regel ausschließlich lesend auf die Argumente zu. Dieser Grundsatz sollte auch für selbstgeschriebene Operatordefinitionen gelten. Dies sollte klargestellt werden, in dem lediglich const-Parameter verwendet werden.
  • zuweisende Operatoren (Setter)
    Setter veränderen in C++ grundsätzlich den linken Wert. Der rechte Wert in C++ nicht verändert. Auch hier sollte man sich mit eigenen Operatoren an diese Regel halten und das rechte Argument als const-Parameter annehmen

Wie bei den unären Operatoren gibt es zwei Möglichkeiten einen binären Operator zu überladen: Definition innerhalb der betreffenden Klasse

  • als globale Definition
  • Bei der ersten Möglichkeit sehe ich den zusätzlichen Vorteil, dass sich die Operatordefintion leicht in den Quelltexten wiederfinden lässt.

Operatordefinition innerhalb der Klasse

Bei folgendem Beispiel wird der *= Operator überladen.

class Fraction
{
  private:
    int Numerator;
    int Denominator;
  public:
    Fraction( int num, int den ) : Numerator( num ), Denominator( den ) {}
    Fraction & operator *=( Fraction & rhs );
};
Fraction & Fraction::operator *=( Fraction & rhs )
{
  Numerator   *= rhs.Numerator;
  Denominator *= rhs.Denominator;
  return *this;
}

Ein Beispielprogramm:

Fraction a( 1, 2 ), b( 2, 3 );
 
a *= b;

Das Ergebnis ist, dass a nun den Wert 2/6 repräsentiert.

Globale Operatordefintion

Das gleiche Beispiel wie zuvor nun noch einmal. Da wir uns nun nicht mehr innerhalb einer Klasse befinden, können wir den this-Pointer nicht mehr verwenden. Stattdessen lassen wir uns die Referenz auf das Objekt einfach übergeben. Müssen wir auf durch 'privat' oder 'protected' geschützte Daten zugreifen, so muss der Operator als 'friend' definiert werden.

class Fraction
{
  friend Fraction & operator *=( Fraction & lhs, Fraction & rhs );
 
  private:
    int Numerator;
    int Denominator;
  public:
    Fraction( int num, int den ) : Numerator( num ), Denominator( den ) {}
};
 
Fraction & operator *=( Fraction & lhs, Fraction const & rhs )
{
  lhs.Numerator   *= rhs.Numerator;
  lhs.Denominator *= rhs.Denominator;
  return lhs;
}

Das Resultat ist gleichwertig. Aufzupassen ist lediglich, dass Operatoren auch innerhalb von Namensräumen neu deklariert werden. In dem Fall sind sie auch nur dann sichtbar, wenn der Namensraum ebenfalls sichtbar ist.

Der Zusweisungsoperator

Einen Operator, nämlich den Zuweisungsoperator (=), definiert C++ für jede Klasse automatisch. Dabei wird Bit für Bit kopiert. Dies gilt auch für Zeiger und Referenzen. So kann es passieren, dass zwei Objekte versuchen, den gleichen Speicherbereich freizugeben, was natürlich nur dem Ersten erfolgreich gelingt. Daraus folgt, dass Klassen, die Referenzen oder Zeiger besitzen, entweder den automatisch erzeugten Zuweisungs-Operator überschreiben oder als Privat deklarieren, so dass das Objekt nicht kopiert werden kann.

Argumente unterschiedlicher Typen

Häufig gibt es nah verwandte Datentypen, die man ebenfalls verknüpfen möchte. So ist in C++ keine Umwandlung von int nach double erforderlich, wenn man diese Datentypen miteinander verknüpfen möchte.

In unserem Beispiel lässt sich eine Verwandtschaft zwischen Integern und unserer Klasse Fraction leicht feststellen: Integers sind Brüche, die den Nenner 1 besitzen.

Folgender Code soll kompiliert werden:

Fraction a( 1, 2 );
 
a *= 2;

Um dies zu leisten, gibt es zwei Möglichkeiten:

  • Ein Integer wird automatisch durch den Compiler zu einem Fraction konvertiert, schließlich anschließend wird der bereits definierte operator *= ( Fraction &, Fraction & ) aufgerufen.
  • Wir definieren einen Operatoren, um Fractions mit Integern zu verknüpfen.

Beginnen wir mit der automatischen Konvertierung: Dafür erweitern wir die Klasse 'Fraction' um einen zusätzlichen Konstruktor:

class Fraction
{
  public:
    Fraction( int num ) : Numerator( num ), Denominator( 1 ) {}
};

Hiermit weiß der C-Compiler, wie er aus einem Integer auf direktem Wege ein gleichwertige Instanz der Klasse Fraction erzeugt und kann anschließend den bereits definierten Operator verwenden.

Dieser Ansatz ist sehr praktisch, wenn man Konvertierungen verstecken möchte. Allerdings hat er auch den Nachteil, dass so eventuell unbemerkt Konvertierungen vorgenommen werden, die so gar nicht gewünscht waren. Um dies zu vermeiden lasst sich der Konstruktor als explicit kennzeichnen, das heißt, dass eine Konvertierung nur dann erfolgt, wenn der Autor dies ausdrücklich wünscht:

class Fraction
{
  public:
    explicit Fraction( int num ) : Numerator( num ), Denominator( 1 ) {}
};

Nun findet keine implizite Konvertierung mehr statt, wir müssten unser Programm also wie folgt ändern:

Fraction a( 1, 2 );
 
a *= Fraction( 2 );

Das Programm sollte aber nicht geändert werden, also schauen wir uns die zweite Möglichkeit an: Wir definieren einen zusätzlichen Operator:

Fraction & Fraction::operator *=( int rhs )
{
  Numerator   *= rhs;
  return *this;
}

Dieser Operator ist nun darauf spezialisiert, ein Fraction mit einem Integer zu multiplizieren. Hierfür ist allerdings ein wenig mehr Schreibarbeit erforderlich. Dafür ist dieser Operator wesentlich schneller als die Lösung über die implizite Konvertierung. Wir sparen die Multiplikation mit dem Nenner und - viel wichtiger - es wird kein temporäres Fraction-Objekt erzeugt, um die Multiplikation mit der Operator-*=-Version für zwei Fractions zu lösen.

Ergebnisliefernde Operatoren (Getter)

Bisher haben wir uns nur Setter angesehen, die mit ihrem Ergebnis eines ihrer Arguemente überschreiben. Das hat einen großen Vorteil, da man sich in diesem Fall nicht darum zu kümmern braucht, wo das Ergebnis eigentlich gespeichert wird. Das sieht bei den Gettern ganz anders aus: Hier wird etwas neues produziert.

Bei den Vergleichsoperatoren ist das Problem sehr einfach gelöst, es wird ein Boolean 'produziert' und das wird auch auch so zurückgeliefert. Überladen wir dafür als Beispiel einfach den Vergleichsoperator:

bool Fraction::operator ==( int rhs )
{
  return ( Numerator / Denominator ) == ( rhs.Numerator / rhs.Denumerator );
}

Auch hier ist es natürlich möglich, unterschiedliche Datentypen zu verknüpfen:

bool Fraction::operator ==( int rhs )
{
  return ( Numerator / Denominator ) == rhs;
}

Was die Sache hier so einfach macht ist, dass die Rückgabetyp sehr klein ist. Es spielt bezogen auf die Größe keine Rolle, ob man wie beim *=-Operator eine Referenz oder wie hier ein Boolean zurück gibt. Also gibt man auch direkt den passenden Datentyp liefern und umschifft damit praktischerweise das folgende Problem:

Getter erlauben keine Referenzen!

Wenn ganze Klasseninstanzen kopiert werden müssen bedeutet das schon einigen Aufwand. Also versucht man dies in der Regel so zu vermeiden, dass man lediglich Referenzen übergibt. Das führt bei Operatoren leider zu einem Problem, da sich temporäre Variablen nicht referenzieren lassen.Wie es richtig geht, sieht man wenn wir nun den Multiplikations-Operator überladen. Beide Operatordefinitionen sind vom Aufbau gleichwertig, die zweite Variante ist lediglich kürzer.

Fraction Fraction::operator *( int rhs )
{
  Fraction result( *this );
  result *= rhs;
  return result;
}
Fraction Fraction::operator *( Fraction & rhs )
{
  return Fraction( *this ) *= rhs;
}

Was passiert? Wir erzeugen mit Hilfe des Copy-Konstruktors eine Kopie der aktuellen Fraction-Instanz. Die Kopie wird anschließend mit den bereits definierten *=-Operatoren mit rhs multipliziert.

Den *=-Operator hier wieder zu verwenden hat den Vorteil, dass man bei komplizierteren Klassen keinen Fehler durch Kopieren des Codes einbauen kann und sich die Operatoren * und *= garantiert gleiche Ergebniswerte liefern. Der Code bleibt leichter zu warten, da Änderungen nur im *=-Operator durchgeführt werden müssen.

Obwohl es in der Regel deutlich mehr Aufwand ist, eine komplette Klasseninstanz zu kopieren, als nur die Referenz auf die Klasse, ist es wichtig, dass diese Operatoren komplette Instanzen übergeben. Warum? Nachdem die Operator-Funktion verlassen wird, werden auch alle in der Funktion enthaltenen temporären Variablen zerstört. In der Version operator *( int rhs ) sieht man die temporäre Variable 'result' deutlich, die in der kürzeren Variante operator *( Fraction & rhs ) ohne Namen und daher etwas versteckt ist.

Gibt man hier eine Referenz ( Fraction & ) oder einen Zeiger ( Fraction * ) zurück, so weisen beide auf nicht mehr existierende Instanzen: Das Ergebnis ist undefiniert, das Programmverhalten damit nicht vorhersagbar. Damit ist das Programm falsch.

Man könnte auch selber Speicher beschaffen, aber wer möchte nach jedem Operator erstmal wieder Speicher freigeben. Der Aufwand würde damit eher größer, denn das Objekt muss in jedem Fall kopiert werden. Zusätzlich müsste noch Speicher beschafft werden und am Ende muss der Entwickler auch daran denken, dass er den Speicher auch wieder freigibt.

Es muss tatsächlich die vollständige Klasseninstanz kopiert werden. Tricksereien sind möglich aber es sind (mir) keine bekannt, die grundsätzlich funktionieren. Eine erschöpfende Diskussion zu diesen Tricksereien findet sich in Scott Meyers 'Effizient C++ programmieren'.

Auch operator << und operator >> sind Infix-Operatoren, haben jedoch einige Besonderheiten.