vorheriges KapitelInhaltsverzeichnisStichwortverzeichnisFeedbacknächstes Kapitel


Woche 2

Tag 10

Funktionen - weiterführende Themen

In Kapitel 5, »Funktionen«, haben Sie die Grundlagen für die Arbeit mit Funktionen kennengelernt. Nachdem Sie nun wissen, wie Zeiger und Referenzen arbeiten, können Sie tiefer in diese Materie eindringen. Heute lernen Sie,

Überladene Elementfunktionen

In Kapitel 5 haben Sie gelernt, wie man Funktionspolymorphie - oder das Überladen von Funktionen - durch Aufsetzen zweier oder mehrerer Funktionen mit demselben Namen aber mit unterschiedlichen Parametern implementiert. Elementfunktionen von Klassen lassen sich ebenfalls überladen.

Die in Listing 10.1 dargestellte Klasse Rectangle enthält zwei Funktionen namens DrawShape(). Eine Funktion hat keine Parameter und zeichnet das Rectangle-Objekt auf der Basis der aktuellen Werte in der Klasse. Die andere Funktion übernimmt zwei Werte, width (Breite) und height (Höhe). Diese Funktion zeichnet das Rectangle-Objekt mit den übergebenen Werten und ignoriert die aktuellen Klassenwerte.

Listing 10.1: Überladen von Elementfunktionen

1:     // Listing 10.1 Ueberladen von Elementfunktionen
2: #include <iostream.h>
3:
4:
5: // Deklaration der Klasse Rectangle
6: class Rectangle
7: {
8: public:
9: // Konstruktoren
10: Rectangle(int width, int height);
11: ~Rectangle(){}
12:
13: // Ueberladene Klassenfunktion DrawShape
14: void DrawShape() const;
15: void DrawShape(int aWidth, int aHeight) const;
16:
17: private:
18: int itsWidth;
19: int itsHeight;
20: };
21:
22: // Implementierung des Konstruktors
23: Rectangle::Rectangle(int width, int height)
24: {
25: itsWidth = width;
26: itsHeight = height;
27: }
28:
29:
30: // Ueberladene Elementfunktion DrawShape - übernimmt
31: // keine Werte. Zeichnet mit aktuellen Datenelementen.
32: void Rectangle::DrawShape() const
33: {
34: DrawShape( itsWidth, itsHeight);
35: }
36:
37:
38: // Ueberladene Elementfunktion DrawShape - uebernimmt
39: // zwei Werte und zeichnet nach diesen Parametern.
40: void Rectangle::DrawShape(int width, int height) const
41: {
42: for (int i = 0; i<height; i++)
43: {
44: for (int j = 0; j< width; j++)
45: {
46: cout << "*";
47: }
48: cout << "\n";
49: }
50: }
51:
52: // Rahmenprogramm zur Demo fuer ueberladene Funktionen
53: int main()
54: {
55: // Ein Rechteck mit 30,5 initialisieren
56: Rectangle theRect(30,5);
57: cout << "DrawShape(): \n";
58: theRect.DrawShape();
59: cout << "\nDrawShape(40,2): \n";
60: theRect.DrawShape(40,2);
61: return 0;
62: }

DrawShape():
******************************
******************************
******************************
******************************
******************************

DrawShape(40,2):
****************************************
****************************************

Listing 10.1 stellt eine abgespeckte Version des Programms aus dem Rückblick zu Woche 1 dar. Der Test auf ungültige Werte und einige der Zugriffsfunktionen wurden aus dem Programm genommen, um Platz zu sparen. Das Hauptprogramm wurde auf ein einfaches Rahmenprogramm ohne Menü reduziert.

Den für uns im Moment interessanten Code finden Sie in den Zeilen 14 und 15, wo die Funktion DrawShape() überladen wird. Die Implementierung dieser beiden überladenen Klassenmethoden ist in den Zeilen 30 bis 50 untergebracht. Beachten Sie, daß die Version von DrawShape(), die keine Parameter übernimmt, einfach die Version aufruft, die zwei Parameter übernimmt, und dabei die aktuellen Elementvariablen übergibt. Man sollte tunlichst vermeiden, den gleichen Code in zwei Funktionen zu wiederholen. Es ist immer schwierig und fehlerträchtig, beide Funktionen synchron zu halten, wenn man Änderungen an der einen oder anderen Funktion vornimmt.

Das Rahmenprogramm in den Zeilen 52 bis 62 erzeugt ein Rectangle-Objekt und ruft dann DrawShape() auf. Beim ersten Mal werden keine Parameter und beim zweiten Aufruf zwei unsigned short ints übergeben.

Der Compiler entscheidet anhand der Anzahl und des Typs der eingegebenen Parameter, welche Methode aufzurufen ist. Man könnte sich eine dritte überladene Funktion namens DrawShape() vorstellen, die eine Abmessung und einen Aufzählungstyp entweder für Breite oder Höhe - je nach Wahl des Benutzers - übernimmt.

Standardwerte

Ebenso wie »klassenlose« Funktionen können auch Elementfunktionen einer Klasse einen oder mehrere Standardwerte haben. Für die Deklaration der Standardwerte gelten dabei stets die gleichen Regeln (siehe Kapitel 5, Abschnitt »Standardparameter«).

Listing 10.2: Standardwerte

1:     // Listing 10.2 Standardwerte in Elementfunktionen
2: #include <iostream.h>
3:
4: int
5:
6: // Deklaration der Klasse Rectangle
7: class Rectangle
8: {
9: public:
10: // Konstruktoren
11: Rectangle(int width, int height);
12: ~Rectangle(){}
13: void DrawShape(int aWidth, int aHeight, bool UseCurrentVals
= false) const;
14:
15: private:
16: int itsWidth;
17: int itsHeight;
18: };
19:
20: //Implementierung des Konstruktors
21: Rectangle::Rectangle(int width, int height):
22: itsWidth(width), // Initialisierungen
23: itsHeight(height)
24: {} // leerer Rumpf
25:
26:
27: // Standardwerte für dritten Parameter
28: void Rectangle::DrawShape(
29: int width,
30: int height,
31: bool UseCurrentValue
32: ) const
33: {
34: int printWidth;
35: int printHeight;
36:
37: if (UseCurrentValue == true)
38: {
39: printWidth = itsWidth; // aktuelle Klassenwerte verwenden
40: printHeight = itsHeight;
41: }
42: else
43: {
44: printWidth = width; // Parameterwerte verwenden
45: printHeight = height;
46: }
47:
48:
49: for (int i = 0; i<printHeight; i++)
50: {
51: for (int j = 0; j< printWidth; j++)
52: {
53: cout << "*";
54: }
55: cout << "\n";
56: }
57: }
58:
59: // Rahmenprogramm zur Demonstration der ueberladenen Funktionen
60: int main()
61: {
62: // Ein Rechteck mit 30,5 initialisieren
63: Rectangle theRect(30,5);
64: cout << "DrawShape(0,0,true)...\n";
65: theRect.DrawShape(0,0,true);
66: cout <<"DrawShape(40,2)...\n";
67: theRect.DrawShape(40,2);
68: return 0;
69: }

DrawShape(0,0,true)...
******************************
******************************
******************************
******************************
******************************

DrawShape(40,2)...
************************************************************
************************************************************

Listing 10.2 ersetzt die überladene Funktion DrawShape() durch eine einzelne Funktion mit Standardparametern. Die Funktion wird in der Zeile 13 deklariert und übernimmt drei Parameter. Die ersten beiden, aWidth und aHeight, sind vom Typ USHORT, der dritte, UseCurrentVals, ist vom Typ BOOL (true oder false) mit dem Standardwert false.

Die Implementierung für diese etwas unhandliche Funktion beginnt in Zeile 28. Die if-Anweisung in Zeile 38 wertet den dritten Parameter, UseCurrentValue, aus. Ist er true, erhalten die lokalen Variablen printWidth und printHeight die Werte der lokalen Elementvariablen itsWidth bzw. itsHeight.

Ist UseCurrentValue gleich false, weil es sich entweder um den Standardwert false handelt oder der Benutzer diesen Wert so festgelegt hat, übernimmt die Funktion für printWidth und printHeight die beiden ersten Parameter.

Wenn UseCurrentValue gleich true ist, werden die Werte der beiden anderen Parameter ignoriert.

Standardwerte oder überladene Funktionen?

Die Programme der Listings 10.1 und 10.2 realisieren die gleiche Aufgabe, wobei aber die überladenen Funktionen in Listing 10.1 einfacher zu verstehen und natürlicher in der Handhabung sind. Wenn man außerdem eine dritte Variation benötigt - wenn vielleicht der Benutzer entweder die Breite oder die Höhe bereitstellen möchte - kann man überladene Funktionen leicht erweitern. Mit Standardwerten ist schnell die Grenze des sinnvoll Machbaren bei neuen Varianten erreicht.

Anhand der folgenden Punkte können Sie entscheiden, ob überladene Funktionen oder Standardwerte im konkreten Fall besser geeignet sind:

Überladene Funktionen verwendet man, wenn

Der Standardkonstruktor

Wie bereits in Kapitel 6, »Klassen«, erwähnt, erzeugt der Compiler, wenn man nicht explizit einen Konstruktor für die Klasse deklariert, einen Standardkonstruktor, der keine Parameter aufweist und keine Aktionen ausführt. Es ist jedoch ohne weiteres möglich, einen eigenen Standardkonstruktor zu erstellen, der ohne Argumente auskommt, aber das Objekt wie gewünscht einrichtet.

Der automatisch bereitgestellte Konstruktor heißt zwar »Standardkonstruktor«, was aber per Konvention auch für alle Konstruktoren gilt, die keine Parameter übernehmen. Normalerweise geht aus dem Kontext hervor, welcher Konstruktor gemeint ist.

Sobald man irgendeinen Konstruktor selbst erstellt, steuert der Compiler keinen Standardkonstruktor mehr bei. Wenn Sie also einen Konstruktor brauchen, der keine Parameter übernimmt, und Sie haben irgendwelche anderen Konstruktoren erstellt, müssen Sie den Standardkonstruktor selbst erzeugen!

Konstruktoren überladen

Ein Konstruktor dient der Erzeugung eines Objekts. Beispielsweise erzeugt der Rectangle -Konstruktor ein Rechteck. Vor Ausführen des Konstruktors gibt es kein Rechteck, lediglich einen Speicherbereich. Nach Abschluß des Konstruktors ist ein vollständiges und sofort einsetzbares Rectangle-Objekt vorhanden.

Wie alle Elementfunktionen lassen sich auch Konstruktoren überladen - eine sehr leistungsfähige und flexible Option.

Nehmen wir zum Beispiel ein Rectangle-Objekt mit zwei Konstruktoren: Der erste Konstruktor übernimmt eine Länge und eine Breite und erstellt ein Rechteck dieser Größe. Der zweite Konstruktor übernimmt keine Werte und erzeugt ein Rechteck mit Standardgröße. Listing 10.3 veranschaulicht dies.

Listing 10.3: Den Konstruktor überladen

1:     // Listing 10.3
2: // Konstruktoren ueberladen
3:
4: #include <iostream.h>
5:
6: class Rectangle
7: {
8: public:
9: Rectangle();
10: Rectangle(int width, int length);
11: ~Rectangle() {}
12: int GetWidth() const { return itsWidth; }
13: int GetLength() const { return itsLength; }
14: private:
15: int itsWidth;
16: int itsLength;
17: };
18:
19: Rectangle::Rectangle()
20: {
21: itsWidth = 5;
22: itsLength = 10;
23: }
24:
25: Rectangle::Rectangle (int width, int length)
26: {
27: itsWidth = width;
28: itsLength = length;
29: }
30:
31: int main()
32: {
33: Rectangle Rect1;
34: cout << "Rect1 Breite: " << Rect1.GetWidth() << endl;
35: cout << "Rect1 Länge: " << Rect1.GetLength() << endl;
36:
37: int aWidth, aLength;
38: cout << "Geben Sie eine Breite ein: ";
39: cin >> aWidth;
40: cout << "\nGeben Sie eine Laenge ein: ";
41: cin >> aLength;
42:
43: Rectangle Rect2(aWidth, aLength);
44: cout << "\nRect2 Breite: " << Rect2.GetWidth() << endl;
45: cout << "Rect2 Laenge: " << Rect2.GetLength() << endl;
46: return 0;
47: }

Rect1 Breite: 5
Rect1 Länge: 10
Geben Sie eine Breite ein: 20

Geben Sie eine Länge ein: 50

Rect2 Breite: 20
Rect2 Länge: 50

Die Zeilen 6 bis 17 deklarieren die Klasse Rectangle. Es werden zwei Konstruktoren deklariert, der »Standardkonstruktor« in Zeile 9 und ein Konstruktor, der zwei Integer- Variablen übernimmt.

Zeile 33 erzeugt mit Hilfe des Standardkonstruktors ein Rechteck, dessen Werte in den Zeilen 34 und 35 ausgegeben werden. Die Zeilen 37 bis 41 bitten den Anwender, Werte für die Breite und Länge einzugeben, und der Aufruf des Konstruktors, der zwei Werte übernimmt, erfolgt in Zeile 43. Schließlich werden die Breite und Höhe des Rechtecks in den Zeilen 44 und 45 ausgegeben.

Wie für jede überladene Funktion, wählt der Compiler anhand Anzahl und Typ der Parameter den richtigen Konstruktor aus.

Objekte initialisieren

Bis jetzt haben wir die Elementvariablen von Objekten immer im Rumpf des Konstruktors eingerichtet. Konstruktoren werden allerdings in zwei Stufen aufgerufen: zuerst in der Initialisierungsphase und dann bei Ausführung des Rumpfes.

Die meisten Variablen lassen sich in beiden Phasen einrichten, entweder durch Initialisierung im Initialisierungsteil oder durch Zuweisung im Rumpf des Konstruktors. Sauberer und meist auch effizienter ist es, die Elementvariablen im Initialisierungsteil zu initialisieren. Das folgende Beispiel zeigt, wie dies geht:

CAT():        // Name und Parameter des Konstruktors
itsAge(5), // Initialisierungsliste
itsWeight(8)
{ } // Rumpf des Konstruktors

Auf die schließende Klammer der Parameterliste des Konstruktors folgt ein Doppelpunkt. Dann schreiben Sie den Namen der Elementvariablen und ein Klammernpaar. In die Klammern kommt der Ausdruck zur Initialisierung dieser Elementvariablen. Gibt es mehrere Initialisierungen, sind diese jeweils durch Komma zu trennen. Listing 10.4 definiert die gleichen Konstruktoren wie Listing 10.3, nur daß diesmal Elementvariablen in der Initialisierungsliste eingerichtet werden.

Listing 10.4: Codefragment zur Initialisierung von Elementvariablen

1:   Rectangle::Rectangle():
2: itsWidth(5),
3: itsLength(10)
4: {
5: }
6:
7: Rectangle::Rectangle (int width, int length):
8: itsWidth(width),
9: itsLength(length)
10: {
11: }

Es gibt keine Ausgabe.

Einige Variablen, zum Beispiel Referenzen und Konstanten, müssen initialisiert werden und erlauben keine Zuweisungen. Sonstige Zuweisungen oder Arbeiten werden im Rumpf des Konstruktors erledigt, denken Sie aber auf jeden Fall daran, so weit es geht Initialisierungen zu verwenden.

Der Kopierkonstruktor

Neben der Bereitstellung eines Standardkonstruktors und -destruktors liefert der Compiler auch einen Standardkopierkonstruktor. Der Aufruf des Kopierkonstruktors erfolgt jedesmal, wenn eine Kopie eines Objekts angelegt wird.

Übergibt man ein Objekt als Wert, entweder als Parameter an eine Funktion oder als Rückgabewert einer Funktion, legt die Funktion eine temporäre Kopie des Objekts an. Handelt es sich um ein benutzerdefiniertes Objekt, wird der Kopierkonstruktor der Klasse aufgerufen, wie Sie gestern in Listing 9.6 feststellen konnten.

Alle Kopierkonstruktoren übernehmen einen Parameter: eine Referenz auf ein Objekt derselben Klasse. Es empfiehlt sich, diese Referenz als konstant zu deklarieren, da der Konstruktor das übergebene Objekt nicht ändern muß. Zum Beispiel:

CAT(const CAT & theCat);

Hier übernimmt der CAT-Konstruktor eine konstante Referenz auf ein existierendes CAT-Objekt. Ziel des Kopierkonstruktors ist das Anlegen einer Kopie von theCat.

Der Standardkopierkonstruktor kopiert einfach jede Elementvariable von dem als Parameter übergebenen Objekt in die Elementvariablen des neuen Objekts. Man spricht hier von einer elementweisen (oder flachen) Kopie. Obwohl das bei den meisten Elementvariablen durchaus funktioniert, klappt das bei Elementvariablen, die Zeiger auf Objekte im Heap sind, schon nicht mehr.

Eine flache oder elementweise Kopie kopiert die Werte der Elementvariablen des einen Objekts in ein anderes Objekt. Zeiger in beiden Objekten verweisen danach auf denselben Speicher. Eine tiefe Kopie überträgt dagegen die auf dem Heap reservierten Werte in neu zugewiesenen Speicher.

Wenn die CAT-Klasse eine Elementvariable itsAge enthält, die auf einen Integer im Heap zeigt, kopiert der Standardkopierkonstruktor die übergebene Elementvariable itsAge von CAT in die neue Elementvariable itsAge von CAT. Die beiden Objekte zeigen dann auf denselben Speicher, wie es Abbildung 10.1 verdeutlicht.

Abbildung 10.1:  Arbeitsweise des Standardkopierkonstruktors

Dieses Verfahren führt zur Katastrophe, wenn eines der beiden CAT-Objekte den Gültigkeitsbereich verliert. Denn, wie Sie in Kapitel 8, »Zeiger«, gelernt haben, ist es die Aufgabe des aufgerufenen Destruktors, den zugewiesenen Speicher aufzuräumen.

Nehmen wir im Beispiel an, daß das originale CAT-Objekt den Gültigkeitsbereich verliert. Der Destruktor dieses Objekts gibt den zugewiesenen Speicher frei. Die Kopie zeigt aber weiterhin auf diesen Speicherbereich. Damit hat man einen vagabundierenden Zeiger erzeugt, der eine reelle Gefahr für das Programm darstellt. Abbildung 10.2 zeigt diesen Problemfall.

Abbildung 10.2:  Einen vagabundierenden Zeiger erzeugen

Die Lösung besteht darin, einen eigenen Kopierkonstruktor zu definieren und für die Kopie eigenen Speicher zu allokieren. Anschließend kann man die alten Werte in den neuen Speicher kopieren. Diesen Vorgang bezeichnet man als tiefe Kopie. Listing 10.5 zeigt ein Programm, das nach diesem Verfahren arbeitet.

Listing 10.5: Kopierkonstruktoren

1:   // Listing 10.5
2: // Kopierkonstruktoren
3:
4: #include <iostream.h>
5:
6: class CAT
7: {
8: public:
9: CAT(); // Standardkonstruktor
10: CAT (const CAT &); // Kopierkonstruktor
11: ~CAT(); // Destruktor
12: int GetAge() const { return *itsAge; }
13: int GetWeight() const { return *itsWeight; }
14: void SetAge(int age) { *itsAge = age; }
15:
16: private:
17: int *itsAge;
18: int *itsWeight;
19: };
20:
21: CAT::CAT()
22: {
23: itsAge = new int;
24: itsWeight = new int;
25: *itsAge = 5;
26: *itsWeight = 9;
27: }
28:
29: CAT::CAT(const CAT & rhs)
30: {
31: itsAge = new int;
32: itsWeight = new int;
33: *itsAge = rhs.GetAge(); // oeffentlicher Zugriff
34: *itsWeight = *(rhs.itsWeight); // privater Zugriff
35: }
36:
37: CAT::~CAT()
38: {
39: delete itsAge;
40: itsAge = 0;
41: delete itsWeight;
42: itsWeight = 0;
43: }
44:
45: int main()
46: {
47: CAT frisky;
48: cout << "Alter von Frisky: " << frisky.GetAge() << endl;
49: cout << "Alter von Frisky auf 6 setzen...\n";
50: frisky.SetAge(6);
51: cout << "Boots aus Frisky erzeugen\n";
52: CAT boots(frisky);
53: cout << "Alter von Frisky: " << frisky.GetAge() << endl;
54: cout << "Alter von Boots: " << boots.GetAge() << endl;
55: cout << "Alter von Frisky auf 7 setzen...\n";
56: frisky.SetAge(7);
57: cout << "Alter von Frisky: " << frisky.GetAge() << endl;
58: cout << "Alter von Boots: " << boots.GetAge() << endl;
59: return 0;
60: }

Alter von Frisky: 5
Alter von Frisky auf 6 setzen...
Boots aus Frisky erzeugen
Alter von Frisky: 6
Alter von Boots: 6
Alter von Frisky auf 7 setzen...
Alter von Frisky: 7
Alter von Boots: 6

Die Zeilen 6 bis 19 deklarieren die Klasse CAT. In Zeile 9 steht die Deklaration eines Standardkonstruktors, in Zeile 10 die Deklaration eines Kopierkonstruktors.

Das Programm deklariert in den Zeilen 17 und 18 zwei Elementvariablen als Zeiger auf int-Werte. Normalerweise gibt es kaum einen Grund, daß eine Klasse Elementvariablen vom Typ int als Zeiger speichert. Hier aber soll dies verdeutlichen, wie man Elementvariablen im Heap verwaltet.

Der Standardkonstruktor in den Zeilen 21 bis 27 reserviert im Heap Platz für zwei int-Variablen und weist ihnen dann Werte zu.

Der Kopierkonstruktor beginnt in Zeile 29. Der Parameter ist wie bei einem Kopierkonstruktor üblich mit rhs benannt, was für right-hand side - zur rechten Seite - steht. (Bei den Zuweisungen, siehe Zeilen 33 und 34, steht das als Parameter übergebene Objekt auf der rechten Seite des Gleichheitszeichens.) Der Kopierkonstruktor arbeitet wie folgt:

In den Zeilen 31 und 32 wird Speicher auf dem Heap reserviert. Dann überträgt der Kopierkonstruktor die Werte aus dem existierenden CAT-Objekt in die neuen Speicherstellen (Zeilen 33 und 34).

Der Parameter rhs ist ein CAT-Objekt, dessen Übergabe an den Kopierkonstruktor als konstante Referenz erfolgt. Als CAT-Objekt verfügt rhs über die gleichen Elementvariablen wie jedes andere CAT-Objekt auch.

Jedes CAT-Objekt kann auf die privaten Elementvariablen aller anderen CAT-Objekte zugreifen. Dennoch ist es guter Programmierstil, möglichst öffentliche Zugriffsmethoden zu verwenden. Die Elementfunktion rhs.GetAge() gibt den Wert aus dem Speicher zurück, auf den die Elementvariable itsAge von rhs zeigt.

Abbildung 10.3 zeigt die Abläufe. Die Werte, auf die das existierende CAT-Objekt verweist, werden in den für das neue CAT-Objekt zugewiesenen Speicher kopiert.

Zeile 47 erzeugt ein CAT-Objekt namens frisky. Zeile 48 gibt das Alter von frisky aus und setzt es dann in Zeile 50 auf den Wert 6. Die Anweisung in Zeile 52 erzeugt mit Hilfe des Kopierkonstruktors das neue CAT-Objekt boots und übergibt dabei frisky als Parameter. Hätte man frisky als Parameter an eine Funktion übergeben, würde der Compiler den gleichen Aufruf des Kopierkonstruktors ausführen.

Die Zeilen 53 und 54 geben das Alter beider CAT-Objekte aus. Wie erwartet hat boots das Alter von frisky (6) und nicht den Standardwert von 5. Zeile 56 setzt das Alter von frisky auf 7, und Zeile 57 gibt erneut das Alter aus. Dieses Mal ist das Alter von frisky gleich 7, während das Alter von boots bei 6 bleibt. Damit ist nachgewiesen, daß sich die Objekte in separaten Speicherbereichen befinden.

Abbildung 10.3:  Tiefe Kopien

Wenn die CAT-Objekte ihren Gültigkeitsbereich verlieren, findet automatisch der Aufruf ihrer Destruktoren statt. Die Implementierung des CAT-Destruktors ist in den Zeilen 37 bis 43 zu finden. Der Aufruf von delete für die beiden Zeiger itsAge und itsWeight gibt den zugewiesenen Speicher an den Heap zurück. Aus Sicherheitsgründen wird beiden Zeigern der Wert NULL zugewiesen.

Operatoren überladen

C++ verfügt über eine Reihe vordefinierter Typen, beispielsweise int, float oder char. Zu jedem dieser Typen gehören verschiedene vordefinierte Operatoren wie Addition (+) und Multiplikation (*). In C++ können Sie diese Operatoren auch in eigene Klassen aufnehmen.

Listing 10.6 erzeugt die neue Klasse Counter, anhand der wir das Überladen von Operatoren umfassend untersuchen werden. Ein Counter-Objekt realisiert Zählvorgänge für Schleifen und andere Konstruktionen, in denen man eine Zahl inkrementieren, dekrementieren oder in ähnlicher Weise schrittweise verändern muß.

Listing 10.6: Die Klasse Counter

1:     // Listing 10.6
2: // Die Klasse Counter
3:
4:
5: #include <iostream.h>
6:
7: class Counter
8: {
9: public:
10: Counter();
11: ~Counter(){}
12: int GetItsVal()const { return itsVal; }
13: void SetItsVal(int x) {itsVal = x; }
14:
15: private:
16: int itsVal;
17:
18: };
19:
20: Counter::Counter():
21: itsVal(0)
22: {};
23:
24: int main()
25: {
26: Counter i;
27: cout << "Wert von i: " << i.GetItsVal() << endl;
28: return 0;
29: }

Wert von i ist 0.

Die in den Zeilen 7 bis 18 definierte Klasse ist eigentlich recht nutzlos. Die einzige Elementvariable ist vom Typ int. Der in Zeile 10 deklarierte und in Zeile 20 implementierte Standardkonstruktor initialisiert die Elementvariable itsVal mit 0.

Im Gegensatz zu einem echten, vordefinierten »Vollblut«-int läßt sich das Counter-Objekt nicht inkrementieren, nicht dekrementieren, nicht addieren und weder zuweisen noch anderweitig manipulieren. Dafür gestaltet sich die Ausgabe seines Wertes wesentlich schwieriger!

Eine Inkrement-Funktion schreiben

Durch das Überladen von Operatoren kann man einen großen Teil der Standardfunktionalität wiederherstellen, die benutzerdefinierten Klassen wie Counter verwehrt bleibt. Listing 10.7 zeigt, wie man eine Inkrement-Methode schreibt.

Listing 10.7: Einen Inkrement-Operator hinzufügen

1:     // Listing 10.7
2: // Die Klasse Counter
3:
4:
5: #include <iostream.h>
6:
7: class Counter
8: {
9: public:
10: Counter();
11: ~Counter(){}
12: int GetItsVal()const { return itsVal; }
13: void SetItsVal(int x) {itsVal = x; }
14: void Increment() { ++itsVal; }
15:
16: private:
17: int itsVal;
18:
19: };
20:
21: Counter::Counter():
22: itsVal(0)
23: {}
24:
25: int main()
26: {
27: Counter i;
28: cout << "Wert von i: " << i.GetItsVal() << endl;
29: i.Increment();
30: cout << "Wert von i: " << i.GetItsVal() << endl;
31: return 0;
32: }

Wert von i ist 0
Wert von i ist 1

Listing 10.7 fügt eine Increment-Funktion hinzu, die in Zeile 14 definiert ist. Das funktioniert zwar, ist aber etwas mühsam. Das Programm schreit förmlich nach einem ++- Operator, der sich im übrigen problemlos realisieren läßt.

Den Präfix-Operator überladen

Präfix-Operatoren lassen sich überladen, indem man Funktionen der folgenden Form deklariert:

rueckgabetyp operator op (parameter)

In diesem Beispiel ist op der zu überladende Operator. Demnach kann der ++-Operator mit folgender Syntax überladen werden:

void operator++ ()

In Listing 10.8 sehen Sie ein Anwendungsbeispiel.

Listing 10.8: operator++ überladen

1:     // Listing 10.8
2: // Die Klasse Counter
3:
4:
5: #include <iostream.h>
6:
7: class Counter
8: {
9: public:
10: Counter();
11: ~Counter(){}
12: int GetItsVal()const { return itsVal; }
13: void SetItsVal(int x) {itsVal = x; }
14: void Increment() { ++itsVal; }
15: void operator++ () { ++itsVal; }
16:
17: private:
18: int itsVal;
19:
20: };
21:
22: Counter::Counter():
23: itsVal(0)
24: {}
25:
26: int main()
27: {
28: Counter i;
29: cout << "Wert von i ist " << i.GetItsVal() << endl;
30: i.Increment();
31: cout << "Wert von i ist " << i.GetItsVal() << endl;
32: ++i;
33: cout << "Wert von i ist " << i.GetItsVal() << endl;
34: return 0;
35: }

Wert von i ist 0
Wert von i ist 1
Wert von i ist 2

Zeile 15 überlädt den operator++, der in Zeile 32 zum Einsatz kommt. Dies entspricht auch viel eher der Syntax, die man für ein Counter-Objekt erwarten würde. An dieser Stelle erwägen Sie vielleicht, die zusätzlichen Aufgaben unterzubringen, für die Counter überhaupt erst erzeugt wurde - beispielsweise den Überlauf des Counters abzufangen.

In unserer Implementierung des Inkrement-Operators gibt es jedoch einen groben Fehler. Wenn Sie den Counter auf die rechte Seite der Zuweisung stellen, funktioniert er nicht. Zum Beispiel

Counter a = ++i;

Dieser Code soll einen neuen Counter a erzeugen und ihm dann den Wert in i nach seiner Inkrementierung zuweisen. Der vordefinierte Kopierkonstruktor ist für die Zuweisung zuständig, aber der aktuelle Inkrement-Operator liefert kein Counter-Objekt, sondern void zurück, und Sie können einem Counter-Objekt kein void-Objekt zuweisen. (Es ist nicht möglich, aus nichts etwas zu machen!)

Rückgabetypen von überladenen Operatorfunktionen

Sie wollen natürlich ein Counter-Objekt zurückliefern, das dann einem anderen Counter -Objekt zugewiesen werden kann. Welches Objekt sollte zurückgegeben werden? Ein Ansatz wäre es, ein temporäres Objekt zu erzeugen und dieses dann zurückzugeben. Listing 10.9 veranschaulicht diesen Ansatz.

Listing 10.9: Ein temporäres Objekt zurückgeben

1:     // Listing 10.9
2: // operator++ gibt ein temporaeres Objekt zurück
3:
4: int
5: #include <iostream.h>
6:
7: class Counter
8: {
9: public:
10: Counter();
11: ~Counter(){}
12: int GetItsVal()const { return itsVal; }
13: void SetItsVal(int x) {itsVal = x; }
14: void Increment() { ++itsVal; }
15: Counter operator++ ();
16:
17: private:
18: int itsVal;
19:
20: };
21:
22: Counter::Counter():
23: itsVal(0)
24: {}
25:
26: Counter Counter::operator++()
27: {
28: ++itsVal;
29: Counter temp;
30: temp.SetItsVal(itsVal);
31: return temp;
32: }
33:
34: int main()
35: {
36: Counter i;
37: cout << "Wert von i ist " << i.GetItsVal() << endl;
38: i.Increment();
39: cout << "Wert von i ist " << i.GetItsVal() << endl;
40: ++i;
41: cout << "Wert von i ist " << i.GetItsVal() << endl;
42: Counter a = ++i;
43: cout << "Wert von a: " << a.GetItsVal();
44: cout << " und von i: " << i.GetItsVal() << endl;
45: return 0;
46: }

Wert von i ist 0
Wert von i ist 1
Wert von i ist 2
Wert von a: 3 und von i: 3

In dieser Version deklariert Zeile 15 einen operator++, der ein Counter-Objekt zurückgibt. Zeile 29 erzeugt eine temporäre Variable temp, deren Wert auf den Wert des aktuellen Objekts gesetzt wird. Diese temporäre Variable wird zurückgeliefert und in Zeile 42 a zugewiesen.

Namenlose temporäre Objekte zurückgeben

Es besteht absolut kein Grund, einen Namen für das temporäre Objekt in Zeile 29 zu vergeben. Wenn Counter einen Konstruktor hätte, der einen Wert übernehmen würde, könnten Sie einfach das Ergebnis dieses Konstruktors als Rückgabewert des Inkrement-Operators zurückliefern. Zum besseren Verständnis gebe ich Ihnen ein Programmbeispiel.

Listing 10.10: Ein namenloses temporäres Objekt zurückliefern

1:     // Listing 10.10 - operator++ liefert
2: // ein namenloses temporaeres Objekt zurück
3:
4: int
5: #include <iostream.h>
6:
7: class Counter
8: {
9: public:
10: Counter();
11: Counter(int val);
12: ~Counter(){}
13: int GetItsVal()const { return itsVal; }
14: void SetItsVal(int x) {itsVal = x; }
15: void Increment() { ++itsVal; }
16: Counter operator++ ();
17:
18: private:
19: int itsVal;
20:
21: };
22:
23: Counter::Counter():
24: itsVal(0)
25: {}
26:
27: Counter::Counter(int val):
28: itsVal(val)
29: {}
30:
31: Counter Counter::operator++()
32: {
33: ++itsVal;
34: return Counter (itsVal);
35: }
36:
37: int main()
38: {
39: Counter i;
40: cout << "Wert von i ist " << i.GetItsVal() << endl;
41: i.Increment();
42: cout << "Wert von i ist " << i.GetItsVal() << endl;
43: ++i;
44: cout << "Wert von i ist " << i.GetItsVal() << endl;
45: Counter a = ++i;
46: cout << "Wert von a: " << a.GetItsVal();
47: cout << " und von i: " << i.GetItsVal() << endl;
48: return 0;
49: }

Wert von i ist 0
Wert von i ist 1
Wert von i ist 2
Wert von a: 3 und von i: 3

Zeile 11 deklariert einen neuen Konstruktor, der einen int-Wert übernimmt. Die Zeilen 27 bis 29 enthalten die Implementierung. Sie initialisiert itsVal mit dem übergebenen Wert.

Die Implementierung von operator++ wird jetzt vereinfacht. Zeile 33 inkrementiert itsVal. Anschließend erzeugt Zeile 34 ein temporäres Counter-Objekt, initialisiert es mit dem Wert in itsVal und liefert das Objekt dann als Ergebnis von operator++ zurück.

Diese Lösung ist wesentlich eleganter. Doch immer noch stellt sich die Frage »Warum überhaupt ein temporäres Objekt erzeugen?« Denken Sie daran, daß jedes temporäre Objekt erst erzeugt und später zerstört werden muß - beides aufwendige Operationen. Außerdem gibt es das Objekt i bereits, und den richtigen Wert hat es auch. Warum nicht das Objekt i zurückliefern? Wir werden das Problem mit Hilfe des this-Zeigers lösen.

Den this-Zeiger verwenden

Der this-Zeiger wird, wie gestern beschrieben, der operator++-Elementfunktion - wie jeder anderen Elementfunktion auch - intern übergeben. Der this-Zeiger zeigt auf i, und wenn er dereferenziert wird, liefert er das Objekt i zurück, das bereits in seiner Elementvariablen itsVal den korrekten Wert enthält. Listing 10.11 veranschaulicht die Rückgabe des dereferenzierten this-Zeigers. Dadurch wird die Erzeugung eines nicht benötigten temporären Objekts vermieden.

Listing 10.11: Rückgabe des this-Zeigers

1:     // Listing 10.11
2: // Rueckgabe des dereferenzierten this-Zeigers
3:
4: int
5: #include <iostream.h>
6:
7: class Counter
8: {
9: public:
10: Counter();
11: ~Counter(){}
12: int GetItsVal()const { return itsVal; }
13: void SetItsVal(int x) {itsVal = x; }
14: void Increment() { ++itsVal; }
15: const Counter& operator++ ();
16:
17: private:
18: int itsVal;
19:
20: };
21:
22: Counter::Counter():
23: itsVal(0)
24: {};
25:
26: const Counter& Counter::operator++()
27: {
28: ++itsVal;
29: return *this;
30: }
31:
32: int main()
33: {
34: Counter i;
35: cout << "Wert von i ist " << i.GetItsVal() << endl;
36: i.Increment();
37: cout << "Wert von i ist " << i.GetItsVal() << endl;
38: ++i;
39: cout << "Wert von i ist " << i.GetItsVal() << endl;
40: Counter a = ++i;
41: cout << " Wert von a: " << a.GetItsVal();
42: cout << " und von i: " << i.GetItsVal() << endl;
43: return 0;
44: }

Wert von i ist 0
Wert von i ist 1
Wert von i ist 2
Wert von a: 3 und von i: 3

Die Implementierung von operator++ in den Zeilen 26 bis 30 wurde dahingehend geändert, daß nun der this-Zeiger dereferenziert und das aktuelle Objekt zurückgegeben wird. Damit erhält man ein Counter-Objekt, das man a zuweisen kann. Wenn die Klasse Counter Speicher für ihre Objekte allokieren würde, hätte man noch den Kopierkonstruktor überschreiben müssen (siehe Erläuterungen weiter oben). Für unser Beispiel reicht der Standardkopierkonstruktor.

Beachten Sie, daß der zurückgelieferte Wert eine Counter-Referenz ist. Damit wird die Erzeugung eines zusätzlichen temporären Objekts vermieden. Wir verwenden eine konstante Referenz, da der Wert nicht von der Funktion, die Counter verwendet, geändert werden soll.

Den Postfix-Operator überladen

Bis jetzt haben wir nur den Präfix-Operator überladen. Was wäre nun, wenn Sie den Inkrement-Operator in der Postfix-Version überladen möchten? Hier hat der Compiler ein Problem. Wie kann er zwischen Präfix und Postfix unterscheiden? Per Konvention nimmt man eine int-Variable als Parameter in die Operator-Deklaration auf. Der Wert des Parameters wird ignoriert - er dient nur als Signal, daß es sich um den Postfix-Operator handelt.

Unterschied zwischen Präfix und Postfix

Bevor man den Postfix-Operator aufsetzen kann, sollte man den Unterschied zum Präfix-Operator kennen. Wir sind im Detail bereits in Kapitel 4, »Ausdrücke und Anweisungen«, darauf eingegangen (siehe Listing 4.3).

Zur Erinnerung, Präfix heißt »inkrementiere und hole dann«, während Postfix »hole und inkrementiere dann« bedeutet.

Demnach kann der Präfix-Operator einfach den Wert inkrementieren und dann das Objekt selbst zurückgeben, während der Postfix-Operator den Wert zurückgeben muß, der vor der Inkrementierung vorhanden war. Dazu ist letztendlich

  1. ein temporäres Objekt zu erzeugen, das den Originalwert aufnimmt,
  2. der Wert des Originalobjekts zu inkrementieren und
  3. der Wert des temporären Objekts zurückzuliefern.

Schauen wir uns das noch einmal genauer an. Schreibt man

a = x++;

und hatte x den Wert 5, enthält a nach dieser Anweisung den Wert 5 und x den Wert 6. Zunächst wird der Wert aus x geholt und an a zugewiesen. Daran schließt sich die Inkrementierung von x an. Wenn x ein Objekt ist, muß der Postfix-Operator den Originalwert (5) in einem temporären Objekt aufbewahren, den Wert von x auf 6 inkrementieren und dann das temporäre Objekt zurückgeben, um dessen Wert an a zuzuweisen.

Da das temporäre Objekt bei Rückkehr der Funktion den Gültigkeitsbereich verliert, ist es als Wert und nicht als Referenz zurückzugeben.

Listing 10.12 demonstriert die Verwendung der Präfix- und Postfix-Operatoren.

Listing 10.12: Präfix- und Postfix-Operatoren

1:     // Listing 10.12
2: // Gibt den dereferenzierten this-Zeiger zurueck
3:
4: int
5: #include <iostream.h>
6:
7: class Counter
8: {
9: public:
10: Counter();
11: ~Counter(){}
12: int GetItsVal()const { return itsVal; }
13: void SetItsVal(int x) {itsVal = x; }
14: const Counter& operator++ (); // Präfix
15: const Counter operator++ (int); // Postfix
16:
17: private:
18: int itsVal;
19: };
20:
21: Counter::Counter():
22: itsVal(0)
23: {}
24:
25: const Counter& Counter::operator++()
26: {
27: ++itsVal;
28: return *this;
29: }
30:
31: const Counter Counter::operator++(int)
32: {
33: Counter temp(*this);
34: ++itsVal;
35: return temp;
36: }
37:
38: int main()
39: {
40: Counter i;
41: cout << "Wert von i ist " << i.GetItsVal() << endl;
42: i++;
43: cout << "Wert von i ist " << i.GetItsVal() << endl;
44: ++i;
45: cout << "Wert von i ist " << i.GetItsVal() << endl;
46: Counter a = ++i;
47: cout << "Wert von a: " << a.GetItsVal();
48: cout << " und von i: " << i.GetItsVal() << endl;
49: a = i++;
50: cout << "Wert von a: " << a.GetItsVal();
51: cout << " und von i: " << i.GetItsVal() << endl;
52: return 0;
53: }

Wert von i ist 0
Wert von i ist 1
Wert von i ist 2
Wert von a: 3 und von i: 3
Wert von a: 4 und von i: 4

Die Deklaration des Postfix-Operators steht in Zeile 15 und die Implementierung in den Zeilen 31 bis 36. Beachten Sie, daß der Aufruf des Präfix-Operators in Zeile 14 keinen int-Parameter x enthält, sondern die normale Syntax verwendet. Der Postfix- Operator zeigt durch seinen int-Parameter x an, daß er der Postfix- und nicht der Präfix-Operator ist. Der Wert x wird nicht weiter benötigt.

Überladung unärer Operatoren

Die Deklaration eines überladenen Operators unterscheidet sich nicht von der einer Funktion. Erst steht das Schlüsselwort operator gefolgt von dem zu überladenden Operator. Unäre Operatoren übernehmen keine Parameter, mit Ausnahme des Postfix-Operators zum Inkrementieren und Dekrementieren, der einen Integer als Flag übernimmt:

Beispiel 1:

    const Counter& Counter::operator++ ();

Beispiel 2:

    Counter Counter::operator-(int);

Der Additionsoperator

Der Inkrement-Operator ist ein unärer Operator, wirkt also nur auf ein Objekt. Dagegen ist der Additionsoperator (+) ein binärer Operator, da er zwei Objekte verknüpft. Wie überlädt man nun den Additionsoperator für Counter?

Man muß in der Lage sein, zwei Counter-Variablen anzugeben und diese zu addieren, wie es folgendes Beispiel zeigt:

Counter varEins, varZwei, varDrei;
varDrei = varEins + varZwei;

Auch hier beginnen wir damit, eine Funktion Add() aufzusetzen, die ein Counter-Objekt als Argument übernimmt, die Werte addiert und dann ein Counter-Objekt mit dem Ergebnis zurückgibt. Listing 10.13 zeigt diese Lösung.

Listing 10.13: Die Funktion Add()

1:     // Listing 10.13
2: // Die Funktion Add
3:
4: int
5: #include <iostream.h>
6:
7: class Counter
8: {
9: public:
10: Counter();
11: Counter(int initialValue);
12: ~Counter(){}
13: int GetItsVal()const { return itsVal; }
14: void SetItsVal(int x) {itsVal = x; }
15: Counter Add(const Counter &);
16:
17: private:
18: int itsVal;
19:
20: };
21:
22: Counter::Counter(int initialValue):
23: itsVal(initialValue)
24: {}
25:
26: Counter::Counter():
27: itsVal(0)
28: {}
29:
30: Counter Counter::Add(const Counter & rhs)
31: {
32: return Counter(itsVal+ rhs.GetItsVal());
33: }
34:
35: int main()
36: {
37: Counter varOne(2), varTwo(4), varThree;
38: varThree = varOne.Add(varTwo);
39: cout << "varOne: " << varOne.GetItsVal()<< endl;
40: cout << "varTwo: " << varTwo.GetItsVal() << endl;
41: cout << "varThree: " << varThree.GetItsVal() << endl;
42:
43: return 0;
44: }

varOne: 2
varTwo: 4
varThree: 6

Die Deklaration der Funktion Add() steht in Zeile 15. Die Funktion übernimmt eine konstante Counter-Referenz. Diese stellt den Wert dar, der dem aktuellen Objekt hinzuaddiert werden soll. Die Funktion gibt ein Counter-Objekt zurück, das als Ergebnis auf der linken Seite von Zuweisungen stehen kann (wie in Zeile 38). Das heißt, varOne ist das Objekt, varTwo ist der Parameter der Funktion Add(), und das Ergebnis weist man an varThree zu.

Um varThree ohne Angabe eines anfänglichen Wertes erzeugen zu können, ist ein Standardkonstruktor erforderlich. Der Standardkonstruktor initialisiert itsVal mit 0, wie es aus den Zeilen 26 bis 28 ersichtlich ist. Da varOne und varTwo mit einem Wert ungleich Null zu initialisieren sind, wurde ein weiterer Konstruktor aufgesetzt (Zeilen 22 bis 24). Eine andere Lösung für dieses Problem wäre die Bereitstellung des Standardwertes 0 für den in Zeile 11 deklarierten Konstruktor.

Den +-Operator überladen

Die Funktion Add() selbst ist in den Zeilen 30 bis 33 zu sehen. Sie funktioniert zwar, ihre Verwendung ist aber eher ungewöhnlich. Das Überladen von +-operator würde eine natürlichere Verwendung der Counter-Klasse ermöglichen. Listing 10.14 zeigt diese Lösung.

Listing 10.14: Der operator+

1:     // Listing 10.14
2: // operator+ ueberladen
3:
4:
5: #include <iostream.h>
6:
7: class Counter
8: {
9: public:
10: Counter();
11: Counter(int initialValue);
12: ~Counter(){}
13: int GetItsVal()const { return itsVal; }
14: void SetItsVal(int x) {itsVal = x; }
15: Counter operator+ (const Counter &);
16: private:
17: int itsVal;
18: };
19:
20: Counter::Counter(int initialValue):
21: itsVal(initialValue)
22: {}
23:
24: Counter::Counter():
25: itsVal(0)
26: {}
27:
28: Counter Counter::operator+ (const Counter & rhs)
29: {
30: return Counter(itsVal + rhs.GetItsVal());
31: }
32:
33: int main()
34: {
35: Counter varOne(2), varTwo(4), varThree;
36: varThree = varOne + varTwo;
37: cout << "varOne: " << varOne.GetItsVal()<< endl;
38: cout << "varTwo: " << varTwo.GetItsVal() << endl;
39: cout << "varThree: " << varThree.GetItsVal() << endl;
40:
41: return 0;
42: }

varOne: 2
varTwo: 4
varThree: 6

Die Deklaration von operator+ finden Sie in Zeile 15 und die Definition in den Zeilen 28 bis 31. Vergleichen Sie das mit der Deklaration und der Definition der Funktion Add() im vorherigen Listing - sie sind nahezu identisch. Die Syntax unterscheidet sich allerdings grundlegend. Gegenüber der Anweisung

varThree = varOne.Add(varTwo);

ist die folgende Formulierung natürlicher:

varThree = varOne + varTwo;

Durch diese kleine Änderung läßt sich das Programm einfacher anwenden und besser verstehen.

Die Techniken zum Überladen von operator++ kann auch auf andere unäre Operatoren, wie zum Beispiel operator-- übertragen werden.

Überladung binärer Operatoren

Binäre Operatoren werden wie unäre Operatoren erzeugt - mit der Ausnahme, daß sie einen Parameter übernehmen. Bei dem Parameter handelt es sich um eine konstante Referenz auf ein Objekt des gleichen Typs.

Beispiel 1:

Counter Counter::operator+ (const Counter & rhs);

Beispiel 2:

Counter Counter::operator- (const Counter & rhs);

Anmerkungen zur Überladung von Operatoren

Überladene Operatoren können, wie in diesem Kapitel beschrieben, in der Form von Elementfunktionen auftreten, aber auch als Nicht-Elementfunktionen. Auf letzteres werde ich noch in Kapitel 14, »Spezielle Themen zu Klassen und Funktionen«, im Zusammenhang mit den friend-Funktionen näher eingehen.

Die einzigen Operatoren, die nur als Klassenelemente definiert werden können, sind die Operatoren für Zuweisung (=), Subskription ([]), Funktionsaufruf (()) und Indirektion (->).

Der Operator [] wird morgen zusammen mit den Arrays erläutert. Das Überladen des Operators -> wird in Kapitel 14 in Verbindung mit den »Intelligenten Zeigern« erklärt.

Einschränkungen beim Überladen von Operatoren

Operatoren von vordefinierten Typen (wie zum Beispiel int) lassen sich nicht überladen. Des weiteren kann man weder die Rangfolge noch die Art - unär oder binär - des Operators ändern. Es lassen sich auch keine neuen Operatoren definieren. Beispielsweise ist es nicht möglich, ** als »Potenz«-Operator zu deklarieren.

Mit der »Art« des Operators ist gemeint, wie viele Operanden der Operator aufweist. Einge C++-Operatoren sind unär und haben nur einen Operanden (meinWert++). Andere Operatoren sind binär und verwenden zwei Operanden (a+b). Es gibt nur einen ternären Operator, und der benötigt drei Operanden: der ?-Operator (a > b ? x : y).

Was überlädt man?

Das Überladen von Operatoren ist eines der Konzepte von C++, das neue Programmierer zu häufig und oft mißbräuchlich anwenden. Es ist zwar verlockend, neue und interessante Einsatzfälle für die ungewöhnlicheren Operatoren auszuprobieren, dies führt aber unweigerlich zu einem Code, der verwirrend und schwer zu lesen ist.

Es kann natürlich lustig sein, den +-Operator zur Subtraktion und den *-Operator zur Addition zu »überreden«. Ein professioneller Programmierer ist aber über derartige Späße erhaben. Die größere Gefahr liegt in der zwar gutgemeinten, aber unüblichen Verwendung eines Operators - zum Beispiel + für die Verkettung einer Zeichenfolge oder / für die Teilung eines Strings. Manchmal mag das sinnvoll sein, trotzdem sollte man hier Vorsicht walten lassen. Rufen wir uns das Ziel beim Überladen von Operatoren ins Bewußtsein: die Brauchbarkeit und Verständlichkeit zu erhöhen.

Was Sie tun sollten

... und was nicht

Überladen Sie Operatoren nur, wenn es das Programm leichter verständlich macht.

Lassen Sie den überladenen Operator ein Objekt der Klasse zurückliefern.

Erzeugen Sie keine kontraproduktiven Operatoren.

Der Zuweisungsoperator

Als vierte und letzte Standardfunktion stellt der Compiler den Zuweisungsoperator (operator=) zur Verfügung, wenn man keinen eigenen spezifiziert.

Der Aufruf dieses Operators erfolgt bei der Zuweisung eines Objekts. Dazu folgendes Beispiel:

CAT ersteKatze(5,7);
CAT zweiteKatze(3,4);
// ... hier steht irgendein Code
zweiteKatze = ersteKatze;

Diese Anweisungen erzeugen ersteKatze und initialisieren itsAge mit 5 und itsWeight mit 7. Es schließt sich die Erzeugung von zweiteKatze mit der Zuweisung der Werte 3 und 4 an.

Nach einiger Zeit werden catTwo die Werte in ersteKatze zugewiesen. Dabei stellen sich zwei Fragen: Was passiert, wenn itsAge ein Zeiger ist und was passiert mit dem Originalwert in zweiteKatze?

Wie man mit Elementvariablen verfährt, die ihre Werte auf dem Heap ablegen, wurde bereits bei der Behandlung des Kopierkonstruktors diskutiert. Hier stellen sich die gleichen Probleme wie sie in den Abbildungen 10.1 und 10.2 illustriert sind.

C++-Programmierer unterscheiden zwischen einer flachen - oder elementweisen - Kopie auf der einen Seite und einer tiefen - oder vollständigen - Kopie auf der anderen. Eine flache Kopie kopiert einfach die Elemente, und beide Objekte zeigen schließlich auf denselben Bereich im Heap. Eine tiefe Kopie reserviert einen neuen Speicherbereich. Sehen Sie sich dazu gegebenenfalls noch einmal Abbildung 10.3 an.

Das gleiche Problem wie beim Kopierkonstruktor tritt auch hier bei der Zuweisung zutage. Hier gibt es allerdings noch eine weitere Komplikation. Das Objekt zweiteKatze existiert bereits und hat Speicher reserviert. Diesen Speicher muß man löschen, wenn man Speicherlücken vermeiden möchte. Was passiert aber, wenn man zweiteKatze an sich selbst wie folgt zuweist:

zweiteKatze = zweiteKatze;

Kaum jemand schreibt so etwas absichtlich, doch das Programm muß diesen Fall behandeln können. Derartige Anweisungen können nämlich auch zufällig entstehen, wenn referenzierte und dereferenzierte Zeiger die Tatsache verdecken, daß die Zuweisung des Objekts auf sich selbst vorliegt.

Wenn man dieses Problem nicht umsichtig behandelt, löscht zweiteKatze die eigene Speicherzuweisung. Steht dann das Kopieren von der rechten Seite der Zuweisung an die linke an, haben wir ein Problem: Der Speicher ist nicht mehr vorhanden.

Zur Absicherung muß der Zuweisungsoperator prüfen, ob auf der rechten Seite das Objekt selbst steht. Dazu untersucht er den Zeiger this. Listing 10.15 zeigt eine Klasse mit einem eigenen Zuweisungsoperator.

Listing 10.15: Ein Zuweisungsoperator

1:      // Listing 10.15
2: // Kopierkonstruktoren
3:
4: #include <iostream.h>
5:
6: class CAT
7: {
8: public:
9: CAT(); // Standardkonstruktor
10: // Aus Platzgruenden auf Kopierkonstruktor und Destruktor verzichtet!
11: int GetAge() const { return *itsAge; }
12: int GetWeight() const { return *itsWeight; }
13: void SetAge(int age) { *itsAge = age; }
14: CAT & operator=(const CAT &);
15:
16: private:
17: int *itsAge;
18: int *itsWeight;
19: };
20:
21: CAT::CAT()
22: {
23: itsAge = new int;
24: itsWeight = new int;
25: *itsAge = 5;
26: *itsWeight = 9;
27: }
28:
29:
30: CAT & CAT::operator=(const CAT & rhs)
31: {
32: if (this == &rhs)
33: return *this;
34: *itsAge = rhs.GetAge();
35: *itsWeight = rhs.GetWeight();
36: return *this;
37: }
38:
39:
40: int main()
41: {
42: CAT frisky;
43: cout << "Alter von Frisky: " << frisky.GetAge() << endl;
44: cout << "Alter von Frisky auf 6 setzen...\n";
45: frisky.SetAge(6);
46: CAT whiskers;
47: cout << "Alter von Whiskers: " << whiskers.GetAge() << endl;
48: cout << "Frisky in Whiskers kopieren...\n";
49: whiskers = frisky;
50: cout << "Alter von Whiskers: " << whiskers.GetAge() << endl;
51: return 0;
52: }

Alter von Frisky: 5
Alter von Frisky auf 6 setzen...
Alter von Whiskers: 5
Frisky in Whiskers kopieren...
Alter von Whiskers: 6

Listing 10.15 enthält die bekannte CAT-Klasse, verzichtet aber aus Platzgründen auf den Kopierkonstruktor und den Destruktor. In Zeile 14 steht die Deklaration des Zuweisungsoperators, in den Zeilen 30 bis 37 die Definition.

Der Test in Zeile 32 prüft, ob das aktuelle Objekt (das heißt, das auf der linken Seite der Zuweisung stehende CAT-Objekt) dasselbe ist, wie das zuzuweisende CAT-Objekt. Dazu vergleicht man die Adresse von rhs mit der im Zeiger this gespeicherten Adresse.

Den Gleichheitsoperator (==) kann man natürlich ebenfalls überladen und damit selbst festlegen, was Gleichheit bei Objekten zu bedeuten hat.

Umwandlungsoperatoren

Was passiert, wenn man eine Variable eines vordefinierten Typs wie etwa int oder unsigned short einem Objekt einer benutzerdefinierten Klasse zuweist? Listing 10.16 bedient sich wieder der Counter-Klasse und versucht, eine Variable vom Typ int an ein Counter-Objekt zuzuweisen.

Listing 10.16 läßt sich nicht kompilieren!

Listing 10.16: Versuch, einem Zähler einen int-Wert zuzuweisen

1:        // Listing 10.16
2: // Dieser Code laesst sich nicht kompilieren!
3:
4:
5: #include <iostream.h>
6:
7: class Counter
8: {
9: public:
10: Counter();
11: ~Counter(){}
12: int GetItsVal()const { return itsVal; }
13: void SetItsVal(int x) {itsVal = x; }
14: private:
15: int itsVal;
16:
17: };
18:
19: Counter::Counter():
20: itsVal(0)
21: {}
22:
23: int main()
24: {
25: int theShort = 5;
26: Counter theCtr = theShort;
27: cout << "theCtr: " << theCtr.GetItsVal() << endl;
28: return 0;
29: }

Compiler-Fehler: 'int' kann nicht in 'class Counter' konvertiert werden

Die in den Zeilen 7 bis 17 deklarierte Klasse Counter hat nur einen Standardkonstruktor und deklariert keine besondere Methode für die Umwandlung eines int in ein Counter-Objekt, so daß Zeile 26 einen Compiler-Fehler produziert. Der Compiler kann nicht erkennen, daß der Wert einer angegebenen int-Variablen an die Elementvariable itsVal zuzuweisen ist, sofern man das nicht ausdrücklich spezifiziert.

Zu diesem Zweck erzeugt die korrigierte Lösung in Listing 10.17 einen Umwandlungsoperator: einen Konstruktor, der einen int übernimmt und ein Counter-Objekt produziert.

Listing 10.17: Konvertierung eines int in ein Counter-Objekt

1:        // Listing 10.17
2: // Konstruktor als Umwandlungsoperator
3:
4: int
5: #include <iostream.h>
6:
7: class Counter
8: {
9: public:
10: Counter();
11: Counter(int val);
12: ~Counter(){}
13: int GetItsVal()const { return itsVal; }
14: void SetItsVal(int x) {itsVal = x; }
15: private:
16: int itsVal;
17:
18: };
19:
20: Counter::Counter():
21: itsVal(0)
22: {}
23:
24: Counter::Counter(int val):
25: itsVal(val)
26: {}
27:
28:
29: int main()
30: {
31: int theShort = 5;
32: Counter theCtr = theShort;
33: cout << "theCtr: " << theCtr.GetItsVal() << endl;
34: return 0;
35: }

theCtr: 5

Als wesentliche Änderung wird in Zeile 11 der Konstruktor überladen, um einen int zu übernehmen. Die Implementierung des Konstruktors, der ein Counter-Objekt aus einem int erzeugt, steht in den Zeilen 24 bis 26.

Mit diesen Angaben kann der Compiler den Konstruktor - der einen int als Argument übernimmt - aufrufen. Und zwar folgendermaßen:

Schritt 1: Ein Counter-Objekt namens theCtr erzeugen

Das ist das gleiche, als wenn man sagt int x = 5; womit man eine Integer-Variable x erzeugt und mit dem Wert 5 initialisiert. In diesem Fall erzeugen wir ein Counter-Objekt theCtr und initialisieren es mit der Integer-Variable theShort vom Typ short.

Schritt 2: theCtr den Wert von theShort zuweisen.

Aber theShort ist vom Typ short und kein Counter! Wir müssen es erst in ein Counter- Objekt umwandeln. Bestimmte Umwandlungen versucht der Compiler automatisch vorzunehmen, Sie müssen ihm jedoch zeigen wie. Teilen Sie dem Compiler mit, wie die Umwandlung zu erfolgen hat, indem Sie einen Konstruktor für Counter erzeugen, der einen einzigen Parameter übernimmt - zum Beispiel vom Typ short:

class Counter 
{
Counter (short int x);
//....
};

Dieser Konstruktor erzeugt Counter-Objekte auf der Grundlage von short-Werten. Zu diesem Zweck erzeugt er ein temporäres und namenloses Counter-Objekt. Stellen Sie sich zur Veranschaulichung vor, daß das aus short erzeugte temporäre Counter-Objekt den Namen wasShort trägt.

Schritt 3: wasShort dem Counter-Objekt theCtr zuweisen, entsprechend

"theCtr = wasShort";

In diesem Schritt steht wasShort (das temporäre Objekt, das erzeugt wurde, als der Konstruktor ausgeführt wurde) für das, was rechts vom Zuweisungsoperator stand. Das heißt, jetzt, da der Compiler ein temporäres Objekt für Sie erzeugt hat, initialisiert er theCtr damit.

Um dies zu verstehen, müssen Sie wissen, daß das Überladen ALLER Operatoren auf die gleiche Art und Weise erfolgt - Sie deklarieren einen überladenen Operator mit dem Schlüsselwort operator. Bei binären Operatoren (wie = oder +) wird die Variable auf der rechten Seite zum Parameter. Dies wird vom Konstruktor erledigt. Demzufolge wird

a = b;

zu

a.operator= (b);

Was passiert jedoch, wenn Sie versuchen, die Zuweisung mit folgenden Schritten rückgängig zu machen?

1:  Counter theCtr(5);
2: USHORT theShort = theCtr;
3: cout << "theShort : " << theShort << endl;

Wieder erhält man einen Compiler-Fehler. Obwohl der Compiler jetzt weiß, wie man ein Counter-Objekt aus einem int erzeugt, bleibt ihm der umgekehrte Vorgang weiterhin ein Rätsel.

Umwandlungsoperatoren

Für diese und ähnliche Probleme erlaubt Ihnen C++, Umwandlungsoperatoren für Ihre Klassen zu definieren. Damit läßt sich in einer Klasse festlegen, wie implizite Konvertierungen in vordefinierte Typen auszuführen sind. Listing 10.18 verdeutlicht dies. Ich möchte Sie aber schon vorab darauf hinweisen, daß Umwandlungsoperatoren keinen Rückgabewert spezifizieren, obwohl sie einen konvertierten Wert zurückliefern.

Listing 10.18: Konvertieren eines Counter-Objekts in einen unsigned short

1:  #include <iostream.h>
2:
3: class Counter
4: {
5: public:
6: Counter();
7: Counter(int val);
8: ~Counter(){}
9: int GetItsVal()const { return itsVal; }
10: void SetItsVal(int x) {itsVal = x; }
11: operator unsigned short();
12: private:
13: int itsVal;
14:
15: };
16:
17: Counter::Counter():
18: itsVal(0)
19: {}
20:
21: Counter::Counter(int val):
22: itsVal(val)
23: {}
24:
25: Counter::operator unsigned short ()
26: {
27: return ( int (itsVal) );
28: }
29:
30: int main()
31: {
32: Counter ctr(5);
33: int theShort = ctr;
34: cout << "theShort: " << theShort << endl;
35: return 0;
36: }

theShort: 5

Zeile 11 deklariert den Umwandlungsoperator. Beachten Sie, daß er keinen Rückgabewert hat. Die Implementierung der Funktion steht in den Zeilen 25 bis 28. Zeile 27 gibt den Wert von itsVal konvertiert in einen int zurück.

Der Compiler weiß jetzt, wie int-Variablen in Counter-Objekte und umgekehrt umzuwandeln sind, und man kann sie ohne weiteres einander zuweisen.

Zusammenfassung

In diesem Kapitel haben Sie erfahren, wie man Elementfunktionen von Klassen überlädt. Weiterhin haben Sie gelernt, wie man Standardwerte für Elementfunktionen bereitstellt und wie man entscheidet, ob es günstiger ist, Standardwerte vorzugeben oder Funktionen zu überladen.

Mit dem Überladen von Klassenkonstruktoren lassen sich flexible Klassen erzeugen, die man auch aus anderen Objekten erstellen kann. Die Initialisierung von Objekten findet in der Initialisierungsstufe der Konstruktion statt, was effizienter ist als das Zuweisen von Werten im Rumpf des Konstruktors.

Der Compiler stellt einen Kopierkonstruktor und den Zuweisungsoperator operator= zur Verfügung, wenn man diese nicht selbst in einer Klasse definiert. Allerdings erstellen die vom Compiler bereitgestellten Versionen lediglich eine elementweise Kopie der Klasse. Für Klassen, die Zeiger auf den Heap als Datenelemente enthalten, muß man diese Methoden überschreiben, damit man selbst Speicher für das Zielobjekt reservieren kann.

Fast alle C++-Operatoren lassen sich überladen. Es empfiehlt sich jedoch, nur solche Operatoren zu überladen, deren Verwendung auf der Hand liegt. Man kann weder die Art des Operators - unär oder binär - ändern, noch neue Operatoren erfinden.

Der Zeiger this verweist auf das aktuelle Objekt und ist ein unsichtbarer Parameter alle Elementfunktionen. Überladene Operatoren geben häufig den dereferenzierten Zeiger this zurück.

Mit Umwandlungsoperatoren kann man Klassen erzeugen, die in Ausdrücken verwendet werden können, die einen anderen Objekttyp erwarten. Sie bilden die Ausnahme zur Regel, daß alle Funktionen einen expliziten Wert zurückgeben. Genau wie Konstruktoren und Destruktoren haben Umwandlungsoperatoren keinen Rückgabetyp.

Fragen und Antworten

Frage:
Warum verwendet man überhaupt Standardwerte, wenn man eine Funktion überladen kann?

Antwort:
Es ist einfacher, nur eine statt zwei Funktionen zu verwalten. Oftmals ist eine Funktion mit Standardparametern auch verständlicher, und man muß sich nicht mit zwei verschiedenen Funktionsrümpfen auseinandersetzen. Außerdem passiert es schnell, daß man die eine Funktion aktualisiert und die andere vergißt.

Frage:
Warum verwendet man angesichts dieser Probleme nicht immer Standardwerte?

Antwort:
Überladene Funktionen eröffnen Möglichkeiten, die sich mit Standardwerten nicht realisieren lassen, beispielsweise die Variation der Parameterliste nach dem Typ statt nur nach der Anzahl.

Frage:
Wie entscheidet man beim Schreiben eines Klassenkonstruktors, was in der Initialisierungsliste und was im Rumpf des Konstruktors stehen soll?

Antwort:
Als Faustregel sollte man soviel wie möglich in der Initialisierungsphase erledigen - das heißt, alle Elementvariablen in der Initialisierungsliste initialisieren. Bestimmte Dinge, wie Berechnungen und Ausgabeanweisungen, muß man im Rumpf des Konstruktors unterbringen.

Frage:
Kann eine überladene Funktion einen Standardparameter haben?

Antwort:
Ja. Es gibt keinen Grund, auf die Kombination dieser leistungsfähigen Merkmale zu verzichten. Die überladenen Funktionen (eine oder auch mehrere) können jeweils eigene Standardwerte haben - unter Berücksichtigung der üblichen Regeln für Standardwerte in Funktionen.

Frage:
Warum werden einige Elementfunktionen in der Klassendeklaration definiert und andere nicht?

Antwort:
Die Implementierung einer Elementfunktion innerhalb einer Deklaration macht die Funktion inline. In der Regel macht man dies nur bei extrem einfachen Funktionen. Denken Sie daran, daß Sie eine Elementfunktion auch mit dem Schlüsselwort inline als Inline-Funktion deklarieren können, sogar wenn die Funktion außerhalb der Klassendeklaration deklariert wurde.

Workshop

Der Workshop enthält Quizfragen, die Ihnen helfen sollen, Ihr Wissen zu festigen, und Übungen, die Sie anregen sollen, das eben Gelernte umzusetzen und eigene Erfahrungen zu sammeln. Versuchen Sie, das Quiz und die Übungen zu beantworten und zu verstehen, bevor Sie die Lösungen in Anhang D lesen und zur Lektion des nächsten Tages übergehen.

Quiz

  1. In welcher Hinsicht müssen sich überladene Elementfunktionen unterscheiden?
  2. Was ist der Unterschied zwischen einer Deklaration und einer Definition?
  3. Wann wird der Kopierkonstruktor aufgerufen?
  4. Wann wird der Destruktor aufgerufen?
  5. Wie unterscheidet sich der Kopierkonstruktor vom Zuweisungsoperator (=)?
  6. Was ist der this-Zeiger?
  7. Wie unterscheiden Sie zwischen dem Präfix- und Postfix-Inkrementoperator?
  8. Können Sie den +-Operator für Operanden vom Typ short überladen?
  9. Ist es in C++ erlaubt, den ++-Operator zu überladen, so daß er einen Wert Ihrer Klasse dekrementiert?
  10. Mit welchem Rückgabewert müssen Umwandlungsoperatoren deklariert werden?

Übungen

  1. Schreiben Sie eine Klassendeklaration SimpleCircle mit (nur) einer Elementvariablen: itsRadius. Sehen Sie einen Standardkonstruktor, einen Destruktor und Zugriffsmethoden für radius vor.
  2. Aufbauend auf der Klasse aus Übung 1, setzen Sie die Implementierung des Standardkonstruktors auf und initialisieren Sie itsRadius mit dem Wert 5.
  3. Fügen Sie der Klasse einen zweiten Konstruktor hinzu, der einen Wert als Parameter übernimmt, und weisen Sie diesen Wert itsRadius zu.
  4. Erzeugen Sie für Ihre SimpleCircle-Klasse einen Präfix- und einen Postfix-Inkrementoperator, die itsRadius inkrementieren.
  5. Ändern Sie SimpleCircle so, daß itsRadius auf dem Heap gespeichert wird, und passen Sie die bestehenden Methoden an.
  6. Fügen Sie einen Kopierkonstruktor für SimpleCircle hinzu.
  7. Fügen Sie einen Zuweisungsoperator für SimpleCircle hinzu.
  8. Schreiben Sie ein Programm, das zwei SimpleCircle-Objekte erzeugt. Verwenden Sie den Standardkonstruktor zur Erzeugung des einen Objekts und instantiieren Sie das andere mit dem Wert 9. Wenden Sie den Inkrement-Operator auf beide Objekte an und geben Sie dann die Werte beider Objekte aus. Abschließend weisen Sie dem ersten Objekt das zweite zu und geben Sie nochmals die Werte beider Objekte aus.
  9. FEHLERSUCHE: Was ist falsch an der folgenden Implementierung des Zuweisungsoperators?
    SQUARE SQUARE ::operator=(const SQUARE & rhs)
    {
    itsSide = new int;
    *itsSide = rhs.GetSide();
    return *this;
    }
  10. FEHLERSUCHE: Was ist falsch an der folgenden Implementierung des Additionsoperators?
    VeryShort   VeryShort::operator+ (const VeryShort& rhs)
    {
    itsVal += rhs.GetItsVal();
    return *this;
    }



vorheriges KapitelInhaltsverzeichnisStichwortverzeichnisFeedbacknächstes Kapitel


© Markt&Technik Verlag, ein Imprint der Pearson Education Deutschland GmbH