Die Programmiersprache C++ hält weitaus mehr Elemente bereit, als wir bislang besprochen haben. Gerade der neue ANSI/ISO-Standard hat eine ganze Reihe von Erweiterungen eingeführt, die die Programmierung noch flexibler, aber auch noch komplizierter gemacht haben. Da kein C++-Programmierer heute mehr ohne Grundkenntnisse dieser Sprachmerkmale auskommt, möchte ich mit Ihnen in diesem Kapitel die wichtigsten durchsprechen. Dazu gehören:
Gelegentlich werden Sie es als notwendig empfinden, Funktionen oder Klassen mehrmals zu implementieren, die sich allerdings nur in ihren Datentypen unterscheiden, sonst aber völlig gleich sind. Durch Kopieren und Einfügen lässt sich die Abwandlung zwar schnell erstellen. Diese Vorgehensweise hat aber eine Reihe von Nachteilen:
In C könnte man dazu Makros definieren, die der Präprozessor dann expandiert. Allerdings umgeht man dabei die Typprüfung des Compilers und ist insbesondere nicht vor Fehlern durch unbeabsichtigte Typkonstruktionen geschützt, die man bei der Makrokonzeption noch gar nicht bedacht hatte.
Die Sprache C++ bietet für dieses Problem einen anderen Ausweg: das Template-Konzept. Damit ist gemeint, dass Sie in Ihrer Implementierung nicht einen konkreten Datentyp verwenden, sondern lediglich einen Platzhalter. Auf diese Weise kommt ein Verfahren oder eine Klasse auch nur einmal in Ihrem Quelltext vor, was die Wartung erheblich vereinfacht. Zudem haben Sie damit eine Aufgabenstellung so allgemein gelöst, dass Sie Ihren Code besser wiederverwenden können.
|
|
Eine Art der Verwendung von Templates (der deutsche Ausdruck "Schablonen" ist unüblich) sind Funktionen mit parametrisierten Datentypen. Das bedeutet, dass die Funktion für einen beliebigen Datentyp geschrieben wird, der erst später festgelegt wird. Überall, wo eigentlich der Typ stehen müsste, setzen Sie einen Platzhalter ein.
Zur Kennzeichnung, dass es sich um eine solche generische Funktion
handelt, müssen Sie das Schlüsselwort template davor setzen, gefolgt von
einer Liste von Typ- oder Klassenparametern, in der jeder Eintrag aus class
oder typename und einem Parameternamen besteht. Damit der
parametrisierte Typ auf den ersten Blick erkennbar ist, verwendet man oft
einzelne Großbuchstaben, zum Beispiel template ¡typename T¿.
Anschließend schreiben Sie die Funktion wie gewohnt. An allen Stellen,
wo ein Typ steht, dürfen Sie nun auch einen der Parameternamen
verwenden.
Gerade elementare Algorithmen wie Sortierung, Minima und Maxima oder Suchfunktionen bieten sich für die Implementierung als Funktionstemplates an. Unser Beispiel gibt das Maximum zweier Werte zurück:
{
return (a>b)? a : b;
}
Der Compiler prüft bei der Übersetzung dieses Codestücks zwar die Syntax, insbesondere die korrekte Verwendung der Typen, erzeugt allerdings keinen Objektcode. Das ist an dieser Stelle ja auch noch gar nicht möglich, da dazu eine konkrete Realisierung nötig wäre.
Die Ausprägung eines Funktionstemplates entsteht erst durch die
Verwendung der Funktion. Wir nehmen an, dass in derselben Datei weiter
unten die main()-Funktion folgt:
{
int x = max(1, 5); // int-Version
double y = max(1.33, 3.14); // double-Version
// ...
}
Sobald der Compiler erkennt, dass sich der Aufruf von max() auf ein
Funktionstemplate bezieht, erzeugt er den zugehörigen Code. Dazu ersetzt
er den Parameter durch den gerade verwendeten Typ und kompiliert die so
entstandene Funktion in üblicher Manier.
In unserem Beispiel war die Ausprägung implizit definiert. Anhand der
verwendeten Datentypen konnte der Compiler ermitteln, welche
Realisierung erstellt werden soll. Beim ersten Aufruf zeigten die beiden
int-Parameter an, dass int für T einzusetzen ist, beim zweiten Aufruf war
es double.
Beachten Sie, dass in die Bestimmung des Substitutionstyps nur die Typen der Funktionsargumente eingehen, aber nicht der des Rückgabewerts. Insofern steht dieser Mechanismus in Einklang mit der Überladung von Funktionen (siehe Seite 176), bei der diese ebenfalls nur anhand der Signatur, also des Namens und der Typen der Argumente, unterschieden werden.
Übrigens zeigt sich an dieser Stelle der Vorteil von Namensräumen (siehe Seite 158). Über den Header für die I/O-Streams werden hier nämlich auch ein paar Basisalgorithmenfunktionen der Standardbibliothek in unseren Code eingebunden, genauer: als Templates deklariert. Darunter gibt es auch eine Funktion
max(const _Tp& __a, const _Tp& __b);
die damit in unmittelbarer Konkurrenz zu unserer oben definierten
eigenen Funktion max() steht. Allerdings befindet sich dieses max() im
Namensraum std, so dass der Compiler auf die richtige Version verzweigt -
solange nicht durch ein unbedarftes using namespace std dieser
Namensraum global gemacht wird. Eine andere Möglichkeit wäre, unsere
Funktion in einen eigenen Namensraum einzuschließen - aber das ist bei so
einem kleinen Beispiel nicht nötig. Hier können wir für cout und
endl explizit als Herkunft die Standardbibliothek angeben. Unsere
main()-Funktion könnte also folgendermaßen aussehen:
{
using std::cout;
using std::endl;
cout << "max(1, 5): " << max(1, 5) << endl;
return 0;
}
Was macht man aber, wenn man gleichzeitig die automatische
Typumwandlung nutzen will? Wir wissen ja beispielsweise (Seite 103!),
dass int nach double konvertiert werden kann. Wenn wir aber nun
schreiben:
erhalten wir die Fehlermeldung:
Hier behilft man sich, indem man beim Aufruf der Funktion genau den Typ angibt, der für den Parameter eingesetzt werden soll, also etwa
Auf diese Weise wird genau die gewünschte Ausprägung erstellt und verwendet.
Nicht immer kann jedoch ein Funktionstemplate wirklich alle Fälle
abdecken. Wenn wir beispielsweise unsere Funktion max() mit einem
char-String aufrufen:
<< max("abc", "bcde") << endl;
wird die Ausprägung max¡const char*¿() erzeugt. Aufgrund unserer
Implementierung werden dabei nicht die Zeichenketten, sondern nur die
Zeiger verglichen.
Es ist daher möglich, auch Funktionstemplates zu überladen, um damit
Ausprägungen für Spezialfälle getrennt zu implementieren. In unserem Fall
etwa (zu strcmp() siehe Seite 438):
const char* max(const char* a, const char* b)
{
return strcmp(a,b)>0? a: b;
}
Hiermit weisen wir den Compiler darauf hin, dass dies eine Spezialisierung unseres Funktionstemplates ist, bei dem der Parameter bereits fest ist und aus der Argumentliste bestimmt werden kann. Beim Übersetzen wird zunächst nach einer Spezialisierung gesucht, mit der der Aufruf verbunden werden kann, und erst dann eine Ausprägung des Funktionstemplates erzeugt, wenn sich keine passende Funktion finden lässt.
Natürlich muss dabei die überladene Funktion in der Form ihrer
Signatur mit dem Funktionstemplate übereinstimmen. Wenn Sie bei max()
etwa eine Überladung max(int a, double b) implementieren würden, wo
das Template zwei gleiche Typen erwartet, hätten Sie damit keine
Spezialisierung erreicht.
Bei Templates stellt sich immer die Frage, wo denn die Implementierung abgelegt werden soll. Denn wir haben ja gelernt, dass der Compiler erst dann Objektcode erzeugt, wenn er auf eine konkrete Ausprägung trifft. Das bedeutet aber auch, dass die Trennung in Prototyp (in der Header-Datei) und Implementierung, wie wir sie bisher immer praktiziert haben (seit Seite 174), bei Templates nicht möglich ist.
Denn die Objektdateien solcher Implementierungen wären - da die
konkreten Ausprägungen fehlten - so gut wie leer. Andererseits könnte der
Compiler beim Auftauchen einer Ausprägung die Implementierung
nicht übersetzen, da diese sich in einer anderen Datei befindet. (Der
C++-Standard würde diesen Ansatz mittels des Schlüsselwortes export
zwar unterstützen. Der GCC kann damit allerdings nichts anfangen und
ignoriert es, so dass wir diesen Gedankengang gar nicht weiter verfolgen
müssen.)
Daraus folgt: Sämtliche Definitionen von Funktions- und Klassentemplates (zu denen wir gleich kommen werden) müssen in die Dateien eingebunden werden, die Ausprägungen enthalten. Am einfachsten geht dies, wenn Sie die komplette Implementation in die Header-Datei schreiben. (Natürlich können Sie ihr aber auch zur besseren Kennzeichnung eine andere Endung geben, etwa .c, .t oder .tpl.)
Für unser Beispiel ergibt sich damit die Aufteilung:
template <typename T> T max(T a, T b)
{
return (a>b)? a : b;
}
// Datei maxmain.cc
#include ``max.h''
int main()
{
int x = max(1, 5);
double y = max(1.33, 3.14);
// ...
}
Wie Sie sehen, enthält max.h nicht nur eine prototypische Deklaration von
max(), sondern den gesamten Code. In diesem Sinne lassen sich
Funktionstemplates ähnlich handhaben wie inline-Funktionen (siehe Seite
190), wenn auch aus etwas anderen Gründen.
Ebenso wie bei Funktionen können Sie auch die Definition von Klassen mit Platzhaltern für Datentypen versehen. In solchen parametrisierten Klassen darf der variable Typ an allen Stellen vorkommen, wo sonst ein realer Typ stehen würde, also als Typ eines Attributes, Arguments oder eines Rückgabewerts einer Methode und sogar als Basisklasse.
Besonders geeignet für diese Technik sind Containerklassen, also Listen, Vektoren, Stapel und so weiter. Durch die Definition mit Templates sind sie für eine Vielzahl von Typen verwendbar. Ein wichtiger Teil der C++-Standardbibliothek, die Standard Template Library STL, umfasst genau derartige Klassen, verbunden noch mit passenden Algorithmen (ab Seite 511).
Wenn Sie eigene Klassentemplates schreiben, sollten Sie daran denken, dass der Compiler für jeden Typ, mit dem Sie eine Instanz des Klassentemplates bilden, die gesamte Klasse in Objektcode umsetzt. Bei großen Klassen kann der Code auf diese Weise schnell aufgebläht werden. Versuchen Sie also, die entsprechenden Klassen klein, das heißt vor allem die Methoden relativ kurz sowie Zahl und Größe der Attribute überschaubar zu halten. Versuchen Sie auch, alle Klassenelemente, die nicht von einem Template abhängen, in eine Basisklasse zu packen und erst eine Unterklasse davon zu parametrisieren. Auf diese Weise werden wirkliche Gemeinsamkeiten zwischen den verschiedenen Ausprägungen besser sichtbar - für Leser und Compiler.
Aus theoretischer Sicht ist ein Design mit Templates eigentlich eine Unterwanderung des objektorientierten Ansatzes. Aus diesem Grund verzichten einige Programmiersprachen, die streng objektorientiert aufgebaut sind (zum Beispiel Java), ganz auf Templates. Sie sollten bei Ihrem Programmentwurf auch zunächst nach konventionellen Möglichkeiten suchen und Klassentemplates erst bei Klassen von allgemeiner Natur mit hohem Wiederverwendungsgrad einsetzen.
Bei der Definition einer parametrisierten Klasse müssen Sie Folgendes beachten:
class,
setzen Sie die Parameterliste, also etwa template ¡typename T¿.
Sehen wir uns das an einem Beispiel an. In Abschnitt 3.3.5, Seite 380,
haben wir eine Klasse Vektor für double-Zahlen definiert. Diese wollen wir
nun dahingehend verallgemeinern, dass die Elemente von einem
beliebigen Typ sein dürfen. Dazu ersetzen wir double durch einen
Platzhalter.
// Schutz vor Mehrfacheinbindungen
#ifndef _VEKTOR_H_
#define _VEKTOR_H_
// Verwendung von Assertions
#include <cassert>
// Deklaration der Klasse
template <typename T> class Vektor
{
private:
unsigned int size;
T* v;
public:
Vektor() : size(0), v(0) {}
Vektor(unsigned int _size);
Vektor(const Vektor& _vek);
~Vektor() { if (v) delete[] v; }
void resize(unsigned int _size);
unsigned int getSize() {
return size; }
const T& at(unsigned int _i) const;
T& at(unsigned int _i);
};
// Konstruktor mit Größenvorgabe
template <typename T>
Vektor<T>::Vektor(unsigned int _size) :
size(_size)
{
v = new T[size];
}
// Kopierkonstruktor
template <typename T>
Vektor<T>::Vektor(const Vektor<t>& _vek) :
size(_vek.size)
{
v = new T[size];
for(unsigned int i=0; i<size; i++)
v[i] = _vek.v[i];
}
// Initialisierung mit Größenvorgabe
template <typename T>
void Vektor<T>::resize(unsigned int _size)
{
if (v)
delete[] v;
size = _size;
v = new T[size];
}
// Lesezugriff
template <typename T>
const T& Vektor<T>::at(unsigned int _i) const
{
// Vorbedingung: _i gültig und v vorhanden
assert(_i<size && v!=0);
return v[_i];
}
// Schreibzugriff
template <typename T>
T& Vektor<T>::at(unsigned int _i)
{
// Vorbedingung: _i gültig und v vorhanden
assert(_i<size && v!=0);
return v[_i];
}
#endif //_VEKTOR_H_
Jedes Mal, wenn Sie ein Objekt von einer Klasse wie Vektor bilden wollen,
müssen Sie außer dem Namen auch noch den Typ angeben, der anstelle des
Templates eingesetzt werden soll. Dieser Typ erscheint in spitzen
Klammern hinter dem Klassennamen. Erst durch beide ist die Klasse
als abstrakter Datentyp eindeutig gekennzeichnet. Anders als bei
Funktionstemplates kann nämlich der Compiler bei Klassen nicht
automatisch erkennen, mit welchem Typ Sie eine Instanz bilden wollen.
Der einfachste Fall ist ein Standardtyp.
#include "Vektor.h"
using namespace std;
int main()
{
unsigned int i=0;
// Ganzzahlvektor
Vektor<int> v(5);
for(i=0; i<5; i++)
v.at(i) = i+3;
for(i=0; i<5; i++)
cout << v.at(i) << " ";
cout << endl;
// ...
}
Diese Programm gibt Ihnen lediglich die Zahlen von drei bis sieben aus:
Natürlich können Sie auch selbst definierte Datentypen für den Platzhalter
einsetzen, zum Beispiel eigene Klassen. In Abschnitt 2.7 haben wir ab Seite
200 eine Klasse Datum erstellt. Für jede einfache Kalenderanwendung
braucht man aber nicht nur ein Datum, sondern eine Liste davon
- oder einen Vektor. Auch diese Klasse können wir als Template
einsetzen.
Vektor<Datum> daten(31);
for(i=0; i<31; i++)
daten.at(i).setze(i+1, 12, 2004);
for(i=23; i<31; i++)
daten.at(i).ausgeben();
In diesem Beispiel setzen wir die Einträge zunächst auf die Tage des Monats Dezember 2004 und geben dann alle Tage zwischen Weihnachten und Silvester aus.
25.12.2004
26.12.2004
27.12.2004
28.12.2004
29.12.2004
30.12.2004
31.12.2004
Sie sehen hier einmal mehr die Wirkung einer Referenz. Die Methode
at() gibt eine Referenz auf ein Objekt des Schablonentyps zurück.
Also können wir direkt anschließend Methoden dieses Objektes
aufrufen.
Im Innern der Klasse Vektor werden die Objekte in Form eines Feldes
gespeichert. Wie wir bereits auf Seite 377 festgestellt haben, ist damit
verbunden, dass bei der Erzeugung des Feldes für jedes einzelne Objekt der
Standardkonstruktor aufgerufen wird. So verhält es sich also auch bei den
Elementen von Vektor. Die Definition
sorgt dafür, dass anschließend 31 Objekte vom Typ Datum existieren, die
alle auf den heutigen Tag gesetzt sind. (Das ist übrigens wieder ein
Argument dafür, den Konstruktor möglichst schmal zu halten und dort
keine aufwändigen Operationen durchzuführen. Wenn Sie nämlich mal
einen größeren Vektor anlegen, führt das zu Hunderten oder Tausenden von
Konstruktoraufrufen; wenn diese alle Rechenzeit brauchen, ist schon beim
Anlegen eines Objekts viel Zeit verstrichen, obwohl man dies dem
Quelltext überhaupt nicht ansieht.)
Wenn man schon jede beliebige Klasse anstelle des Template-Parameters
einsetzen kann, warum dann nicht auch das Klassentemplate selbst? In der
Tat kann man auch Klassentemplates ineinander verschachteln. Sie müssen
dabei nur etwas aufpassen: Zwischen den abschließenden spitzen Klammern
der Templateparameter muss immer mindestens ein Leerzeichen stehen.
Wissen Sie auf Anhieb den Grund? Ohne Leerzeichen erhielte man
ein ’¿¿’ - diese Zeichen sind aber schon für den Eingabeoperator
vergeben.
Durch verschachtelte Vektoren kann man zum Beispiel auf einfache Weise Matrizen definieren (wie wir es auf Seite 683 auch tun):
Vektor<Vektor<double> > m(3);
for(i=0; i<3; i++)
m[i].resize(3);
for(i=0; i<3; i++)
m.at(i).at(i) = 1;
Damit wird eine Einheitsmatrix mit drei Zeilen und Spalten definiert.

An diesem kurzen Beispiel werden abermals zwei Aspekte deutlich, die ich oben schon angesprochen habe. Zum einen die Tatsache, dass sich der allgemeine Konstruktor mit Größenangabe nur auf die äußere Ausprägung bezieht. Nach der Definition
besteht m also aus drei Elementen vom Typ Vektor¡double¿, die aber
allesamt leer sind. Diese inneren Objekte müssen erst über eine Schleife mit
Hilfe der Methode resize() instantiiert werden.
Zum anderen erkennen Sie hier wieder, wie der Aufruf von Methoden bei derartig konstruierten Objekten geschrieben wird. Da man die Schachtelung auch noch tiefer fortsetzen könnte, werden solche Methodenzugriffe durch die vielen Punkte und Zwischenaufrufe unter Umständen recht lang.
In C++ gibt es bei Funktionen und Methoden bekanntlich die Möglichkeit, in der Deklaration Vorgabewerte für Parameter festzulegen, so dass diese dann beim Aufruf auch weggelassen werden können (siehe Seite 180). Dieser Weg steht Ihnen für Template-Parameter ebenfalls offen. Sie können nämlich nicht nur Klassen als derartige Parameter verwenden, sondern auch ganz konkrete Datentypen vorgeben.
Für unsere Vektorklasse ließe sich somit die Größe nicht nur als Konstruktorargument, sondern auch als Template-Parameter konfigurierbar machen:
class Vektor
{
private:
unsigned int size;
T v[_size];
public:
Vektor() : size(_size) {};
Vektor(const Vektor& _vek);
~Vektor() {}
unsigned int getSize() {
return size; }
const T& at(unsigned int _i) const;
T& at(unsigned int _i);
};
Der Nachteil daran ist, dass dieser Parameter zur Zeit des Kompilierens feststehen muss. Eine dynamische Größenausrichtung zur Laufzeit ist so natürlich nicht möglich. Was beim Vektor noch als unpraktisch angesehen werden kann, mag in vielen anderen Situationen sehr sinnvoll sein. Oft unterscheiden sich Klassen auch nur in kleinen Parametern. Nehmen wir zum Beispiel eine Klasse, die Fehlersituationen darstellen soll. Die meisten sind einfache Fehler; nur einige stehen für Fälle, in denen gar nichts mehr geht. In unserer Fehlerklasse soll der erste Template-Parameter eine Zusatzangabe zum Fehlertext sein, der zweite die Fehlerkategorie (wir verwenden wieder diejenige von Seite 106).
template <typename T, Status level = ERROR>
class GeneralError
{
private:
T addInfo;
string errorText;
public:
GeneralError(const string& _errorText,
const T& _addInfo);
print(ostream& _o);
// ...
};
typedef GeneralError<string> ErrFileNotFound;
typedef GeneralError<int> ErrInputInvalid;
typedef GeneralError<long, FATAL> ErrOutOfMem;
Auf diese Weise lassen sich die Klassendefinitionen übersichtlich halten.
Falls aber doch einmal eine andere Kategorie benötigt wird, kann diese
ebenso verwendet werden. In Abhängigkeit vom Niveau des Fehlers können
Sie im Konstruktor oder in weiteren Methoden auch zusätzliche
Aktionen auslösen (beispielsweise für FATAL das sofortige Ende des
Programms).
Eine typische Anwendung solcher Fehlerklassen ist etwa folgende (die
Methode string::c_str() gibt übrigens den Inhalt der Zeichenkette als
char-String zurück; mehr Details finden Sie im nächsten Abschnitt):
{
// Versuche, Datei zu öffnen
ifstream i(_s.c_str());
if (i.fail())
{
// Mit temporärem Objekt
ErrFileNotFound(``Datei nicht gefunden'',
_s).print(cout);
return -1;
}
// Reserviere Speicher
char* inp = new char[100000];
if (char == 0)
{
// Mit benanntem Objekt
ErrOutOfMem oof(``Kein Speicher'', 100000);
oof.print(cerr);
exit(-1);
}
// ...
}
Im Zuge der Festlegung des jüngsten ANSI/ISO-Standards der Sprache C++ kamen einige Neuerungen hinzu, die das Template-Konzept noch flexibler und leistungsfähiger machen, die aber schon für erfahrene C++-Programmierer nicht einfach zu durchschauen sind.
Als Beispiel für eine der Neuerungen will ich die Spezialisierung herausgreifen. Bei Funktionen hatten wir auf Seite 490 bemerkt, dass es zuweilen sinnvoll sein kann, einzelne Ausprägungen nicht über die schablonenhafte Definition festzulegen, sondern selbst zu überladen. Das funktioniert bei Klassen in fast gleicher Weise. Auch hier gilt:
template muss auf alle Fälle dabeistehen.
Einen Anwendungsfall finden wir bei der oben bereits angesprochenen Vektorklasse.
Ähnlich wie bei den Funktionen, stellen Zeiger als Template-Parameter
auch bei Klassen oft einen Sonderfall dar. Besonders flexibel wird die
Spezialisierung, wenn wir dafür einen Zeiger auf void einsetzen [STROUSTRUP
1998]. Wie wir schon mehrfach erkennen konnten, stellt dieser einen so
universellen Zeigertyp dar, dass sich alle anderen Zeiger darauf umwandeln
lassen.
template<> class Vektor<void*>
{
private:
unsigned int size;
void** v;
public:
void*& at(unsigned int _i);
// ...
};
Stroustrup verrät uns in [STROUSTRUP 1998] auch noch einen Trick, wie jeder
Vektor von Zeigern damit typsicher repräsentiert werden kann: Man leitet ein
Klassentemplate für Zeiger privat von Vektor¡void*¿ ab (zur privaten Vererbung
siehe Seite 255 ff.).
private Vektor<void*>
{
public:
T*& at(unsigned int _i)
{ return (T*&)(Vektor<void*>::at(_i)); }
// ...
};
Hier wird der Template-Parameter nicht wie bei void* vollständig vorgegeben,
sondern nur die Form des Parameters weiter spezifiziert. Es sind nicht beliebige
Datentypen zugelassen, sondern nur Zeigertypen. Bei Vektor¡Datum*¿ ist also T
nicht Datum*, sondern Datum.
Spezialisierung ist ein Mittel, um das bei Klassentemplates vorkommende Aufblähen des Codes, vor dem ich oben bereits gewarnt habe, in erträglichen Grenzen zu halten. Auf diese Weise können Sie den Anteil an Codebestandteilen, die auch als Ausprägung gemeinsam genutzt werden, so groß wie möglich werden lassen. Auf der anderen Seite erfordert ein durchdachter Klassenentwurf mit Templates und Spezialisierungen erst einige Erfahrung in der C++-Programmierung, so dass ich Ihnen die Verwendungen dieser Techniken für den Anfang noch nicht empfehlen würde.
Aus diesem Abschnitt sollten Sie sich merken:
inline sind.
class, setzen
Sie die Parameterliste, also etwa template ¡typename T¿.
Innerhalb der Klassendeklaration können Sie den Namen der
Klasse dann ohne Parameterangabe verwenden. Wenn Sie
Methoden außerhalb der Klasse definieren, müssen Sie wieder die
Parameterliste dazusetzen.
Vektor¡unsigned int¿ v;.
unsigned int ui;
char cFeld[20];
int iFeld[20];
template<typename T> T f(T* t, int i)
{ /* ... */ }
template<typename T> T f(T s, T t)
{ /* ... */ }
char f(char* s, int i) { /* ... */ }
double f(double x, double y) { /* ... */ }
Welche Funktionen werden bei den folgenden Aufrufen ausgeführt, sofern der Aufruf kein Fehler ist?
f(i, ui), f(iFeld, ui), f(&i, i)
template davorschreiben, wenn Sie eine
Methode einer Template-Klasse innerhalb der Klassendeklaration
implementieren?
. Dann kann man das
bestimmte Integral einer Funktion f(x) nähern durch (siehe [STOER
2004]):
![integral
a N sum - 1h-
b f (x)dx ~~ 2[f(a+ ih)+ f(a+ (i+ 1)h)]
i=0](cpp_main56x.gif)
Passen Sie die Vektorklasse aus diesem Abschnitt geeignet an und schreiben Sie ein Programm, das das bestimmte Integral einer im Quelltext festgelegten Funktion in einem Bereich berechnet, den der Benutzer eingibt. Speichern Sie die Funktionswerte f(a + ih) an den Stützstellen in einem Vektorobjekt. Testen Sie das Programm an verschiedenen Funktionen, deren Integral leicht zu berechnen ist, zum Beispiel ex oder einem Polynom.
Stack aus Aufgabe 5 in Abschnitt 3.3.8
(Seite 399) so, dass der Typ der Daten, die auf dem Stack
gespeichert werden, als Template angegeben ist - statt als
double.
Testen Sie Ihre Klasse mit Beispielen wie Stack ¡int¿, Stack ¡char*¿,
Stack ¡Datum¿, Stack ¡Vektor¡float¿ ¿.
Als ich Ihnen ab Seite 400 die C-Anteile der C++-Standardbibliothek vorgestellt habe, wurde bereits klar, dass ein entscheidender Teil noch fehlt: die Containerklassen. Denn Container wie Listen, Vektoren und Kellerspeicher werden in fast jedem größeren Programm benötigt. Und so erwartet der Entwickler von einer modernen Programmiersprache, dass sie nicht nur Schlüsselwörter und Regeln für Kontrollstrukturen festlegt, sondern auch eine Reihe von elementaren Datenstrukturen definiert. Für C++ galt dies lange Zeit nicht. Natürlich haben die meisten kommerziellen Hersteller von Compilern diese Lücke erkannt und mehr oder weniger umfangreiche Bibliotheken mit ihren Produkten ausgeliefert. Zum anderen sind auch viele freie Bibliotheken (gerade für Linux) entstanden. In beiden Fällen steht der Programmierer, der diese einsetzt, vor dem Problem, dass sein Code dann eben nicht mehr "Standard-C++" und somit kaum noch portabel ist, sondern eben von der gewählten Bibliothek abhängig ist.
Bereits 1994 hat man daher bei Hewlett Packard (HP) versucht, die gebräuchlichsten Containerklassen zusammen mit einigen unterstützenden Algorithmen in einer effizienten Implementierung, aber mit einer einfachen Schnittstelle allgemein und frei zur Verfügung zu stellen. Diese Bibliothek wurde unter dem Begriff Standard Template Library (STL) bekannt, da sie sehr exzessiv Klassentemplates einsetzt.
Als 1998 der ANSI/ISO-Sprachstandard für C++ verabschiedet wurde, schrieb man für jede vollständige Compiler-Implementation eine Standardbibliothek vor, die zwar auf der STL basiert, aber noch einige weitere Bestandteile enthält. Nachdem in diesem Buch schon viel von der Standardbibliothek die Rede war, sollten wir einmal klären, was diese genau enthält:
Die beiden letzten Aspekte sind nur für sehr fortgeschrittene C++-Programmierer interessant; in diesem Rahmen kann ich darauf leider nicht eingehen. Wenn Sie mehr erfahren möchten, finden Sie Details unter anderem in [MUSSER und SAINI 1996], [STROUSTRUP 1998] oder [JOSUTTIS 1999]. In diesem Abschnitt werden wir uns auf den ersten Punkt konzentrieren, nämlich Containerklassen und Algorithmen.
Für die Standardbibliothek gibt es eine Reihe von Namenskonventionen,
die Sie berücksichtigen müssen. Sonst kann bereits der erste Versuch sie
einzusetzen fehlschlagen. Seit der Version 3.0 sieht dies auch bei Linux, das
heißt beim GCC, nicht mehr anders aus. Wie die Funktionen der
C-Standardbibliothek (Seite 401) sind nämlich nun auch die Klassen der
STL gemäß dem ANSI/ISO-Standard im Namensraum std:: definiert. (Was
ein Namensraum ist, haben Sie auf Seite 158 erfahren.) Heute sollten Sie
Programme, die Elemente aus der C++-Standardbibliothek verwenden,
immer mit der Zeile
beginnen lassen. Wie an anderer Stelle schon mal betont, ist dieser Tipp
jedoch nur für Implementierungsdateien sinnvoll, und dort auch nur, sofern
keine Kollisionen mit anderen Funktionen Ihres Projekts zu befürchten
sind. Wenn Sie Datentypen der Standardbibliothek in Header-Dateien
verwenden, ist ein solches Kommando generell unangebracht. In
Header-Dateien ist es daher am besten, die Referenzierung der Elemente der
Standardbibliothek explizit mittels eines vorangestellten std:: vorzunehmen
oder das using nur innerhalb von Blöcken wie Klassendeklarationen oder
Inline-Funktionen anzugeben. Auf diese Weise schützen Sie Ihre
Implementierung vor Missverständnissen und machen sie für die
Wiederverwendung robuster.
Über die Konvention für den Namensraum hinaus zeichnen sich die Header-Dateien der STL-Klassen dadurch aus, dass man sie ohne ein angehängtes ".h" verwendet. Von Seite 401 wissen Sie sicher noch, dass die Header der C-Bibliothek ebenfalls unter diese Regel fallen.
Wie mehrfach betont, ist eine der größten Fehlerquellen in C-Programmen der unbedachte Umgang mit Speicher. In diesen Problemkreis fallen auch Zeichenketten. Denn anders als in vielen anderen Programmiersprachen gibt es in C - und damit in C++ - keinen elementaren Datentyp für Zeichenketten. Die Größe einer Zeichenkette muss im Allgemeinen bereits bei ihrer Deklaration festgelegt werden; schreibt man später einmal einen längeren Text hinein, als die Größe zulässt, ist der Ärger vorprogrammiert, denn die meisten String-Routinen der C-Bibliothek achten auf solche Speicherverletzungen nicht.
Umso erfreulicher ist daher, dass mit dem ANSI/ISO-Standard von
1998 auch eine Klasse string definiert wurde, die somit allgemein in C++
verfügbar ist. Ihre Schnittstelle ist ungefähr so, wie man es auch erwarten
würde, und entspricht vielen Implementierungen in den diversen
Bibliotheken.
Die wichtigste Eigenschaft ist, dass sich die Größe eines Objekts
automatisch dem Inhalt anpasst. Man muss sich also normalerweise keine
Sorgen mehr um Speicherfehler oder Ähnliches machen. Neben den
üblichen Standard- und Kopierkonstruktoren gibt es auch einen für die
Erzeugung eines Strings aus einem char-Zeiger, hauptsächlich um
eine einfache Umwandlung von Textzeichenketten zu erlauben, zum
Beispiel:
Man kann auch neben dem Text als zweites Argument die Anzahl der Zeichen angeben, die man vom Anfang des Textes an in den String übernehmen möchte. Beispielsweise erzeugt
einen String mit dem Text "Die C++". Leider gibt es aber keinen
Konstruktor, der einen Zahlenwert (wie int oder float) in einen String
umwandelt. Für solche Anwendungen muss eine abgeleitete Klasse
geschrieben werden, die diese Funktionalität hinzufügt.
Außerdem sind in der string-Klasse eine Reihe von Operatoren
vorhanden, die für die Anwendung recht praktisch sind. Es ist nämlich in
C++ auch möglich, die vorhandenen Operatoren für eigene Datentypen zu
überladen. Obwohl wir darauf erst im nächsten Abschnitt ab Seite 541 zu
sprechen kommen, will ich Ihnen hier die Operatoren der Klasse string
natürlich nicht vorenthalten.
Beispielsweise funktioniert die Konkatenation, also das bei Zeichenketten
besonders wichtige Aneinanderhängen, sehr gut. Neben dem einfachen
+-Operator kann man auch den zusammengesetzten +=-Operator
verwenden, sowohl für String-Objekte als auch für Textzeichenketten,
etwa:
Zudem gibt es Operatoren für Vergleiche. Damit kann man etwa die Übereinstimmung zwischen zwei Objekten überprüfen:
cout << ``Die beiden Strings ''' << s1
<< ``' und ''' << s2 << ``' stimmen ''
<< ``überein.'' << endl;
Neben dem analogen Test auf Ungleichheit lässt sich auch ein lexikografischer
Vergleich mittels ¡, ¿, ¡= und ¿= vornehmen. Und wie Sie sehen, ist auch
der Ausgabeoperator ¡¡ für Strings definiert, ebenso übrigens wie der
Eingabeoperator ¿¿.
Eine weitere wichtige Funktionalität, die man von String-Klassen
erwartet, ist der Zugriff auf bestimmte Teile eines Strings. Dazu gibt es
eine Reihe von Methoden. Mit substr() erhält man beispielsweise eine
Kopie des Teils, der beim Zeichen mit dem im ersten Argument
angegebenen Index beginnt und so lang ist, wie das zweite Argument
vorgibt, zum Beispiel:
s2 = s1.substr(16, 10) + ``.dat'';
Dies ergibt in s2 den Text "bibliothek.dat". In ähnlicher Weise lassen sich
auch Teile eines Strings ersetzen. Dazu gibt man in der Methode replace()
den Index und die Länge sowie den neuen Text an; dieser kann auch
eine ganz andere Länge haben als der alte, beispielsweise macht
uns
aus der "C++"- eine "C"-Standardbibliothek.
Das Auffinden eines Teilstrings ist ebenso einfach, doch gibt es dafür
eine ganze Reihe von Methoden. Die elementarste ist find() mit dem
gesuchten Teilstring als Argument; diese Methode liefert die Position im
String zurück (sofern er überhaupt enthalten ist).
So schön und sicher der Umgang mit Strings auch ist - manchmal
braucht man doch einen char-Zeiger, oftmals um andere Funktionen
der Standardbibliothek aufzurufen, zum Beispiel den Konstruktor
einer Ausgabedatei. Doch auch diese Zugriffsmöglichkeit bietet die
string-Klasse. Die Methode c_str() liefert den gewünschten Zeiger
zurück:
In fast jedem Programm tritt der Fall auf, dass man von einem Objekt mehrere gleichartige Exemplare zu behandeln hat. Das können einfache Ganz- oder Dezimalzahlen sein, aber auch komplexe Objekte. Zu deren Verwaltung kennt die Informatik eine Reihe von Strukturen: Vektoren, Listen, Kellerspeicher, Bäume und so weiter. Diese kann man unter dem Begriff Container zusammenfassen. Die C++-Standardbibliothek bietet ein sehr umfangreiches Reservoir an Containerklassen (siehe Tabelle 4.1), von denen ich nur ein paar hier vorstellen kann. Eine sehr detaillierte Beschreibung finden Sie beispielsweise in [STROUSTRUP 1998] oder [JOSUTTIS 1999].
Ein besonders wichtiger Container, den wir oben sogar selbst implementiert haben (siehe Seite 493), ist der Vektor. Man verwendet ihn normalerweise immer dann, wenn die Anzahl der Elemente zum Zeitpunkt seiner Initialisierung bekannt ist und diese sich auch im Programmverlauf nicht weiter ändert. In C benutzte man dafür statische oder dynamische Arrays, beide mit den bekannten Problemen.
Die Klasse vector¡T¿ der Standardbibliothek kennt diese Schwierigkeiten
nicht mehr. Überhaupt ändert sich damit das Kriterium, aufgrund
dessen man sich für einen Vektor entscheidet. Ausschlaggebend
ist nicht mehr die relative Gleichmäßigkeit seiner Größe, sondern
die Art des Zugriffs. Im Gegensatz etwa zu einer Liste greift man
auf die Elemente eines Vektors im Allgemeinen willkürlich zu, das
heißt über einen beliebigen Indexwert. Doch dazu komme ich gleich
noch.
Die Vektorklasse ist wie viele andere Klassen der STL als Template implementiert (worauf ja bereits der Begriff STL hindeutet). Man kann daher aus jedem Standarddatentyp und jeder eigenen Struktur oder Klasse einen Vektor bilden. Selbst Verschachtelungen (als mehrdimensionale Vektoren) sind ohne weiteres möglich. Wollen wir beispielsweise eine Liste von Konten mit Nummern und deren Inhabern bilden, definieren wir als Struktur:
unsigned long nummer;
string inhaber;
Konto() : nummer(0L) {}
};
Schon können wir ein Vektorobjekt davon bilden:
{
vector<Konto> Kundenkonten;
Eine Möglichkeit, um die Anzahl der Elemente festzulegen, ist die Angabe
beim Konstruktor. Eine andere bietet die Methode resize(), die
auch für die Verlängerung bestehender Vektoren genutzt werden
kann.
Kundenkonten.resize(10);
Auf eines muss man allerdings bei Vektoren aus selbst definierten Objekten achten: Der Vektor-Konstruktor ruft für alle Elemente deren Standardkonstruktor auf. Eine Erzeugung mit einem spezifischen Konstruktor ist nicht möglich. Daher sollten alle Klassen, von deren Objekten Vektoren gebildet werden sollen, über einen vernünftigen Standardkonstruktor verfügen. Diese Vorsicht ist auch bei den meisten anderen Containern der Standardbibliothek geboten.
Ein Ausweg kann da der Konstruktor sein, der einen Vektor aus lauter identischen Objekten enthält.
{
Konto k1;
k1.nummer = 3485932;
k1.inhaber = ``Hoffmann'';
vector<Konto> meineKonten(25, k1);
return meineKonten;
}
Nach diesem Aufruf haben Sie einen Vektor mit 25 Elementen des Typs
Konto, die alle dieselbe Nummer (3485932) und denselben Inhabernamen,
nämlich Hoffmann, haben.
Bei Funktionen, die - wie die gerade gezeigte Funktion konto_init() -
Objekte zurückgeben, ist übrigens etwas Vorsicht geboten. Denn
ein solches Vektorobjekt kann unter Umständen sehr groß werden,
so dass das Kopieren mit erheblichem Aufwand verbunden sein
kann.
Der typische Zugriff auf die Elemente eines Vektors erfolgt über deren
Index. Bei Arrays besteht in C dabei bekanntermaßen die Gefahr, dass bei
einem Index, der größer als erlaubt ist, kein Fehler ausgegeben wird,
sondern ein undefinierter Speicherbereich als Array-Element interpretiert
und zurückgeliefert wird. Der Vorteil beim Einsatz einer Vektorklasse sollte
sein, dass genau dies verhindert wird. Mit der Klasse vector¡T¿ der
Standardbibliothek ist die Sache aber etwas komplizierter. Denn aus
Gründen der Performance prüft der []-Operator eben nicht, ob der Index
gültig ist oder nicht. Der Zugriff mit Bereichsprüfung wird über die
Methode at() abgewickelt, die außer diesem Test auch nicht mehr tut, als
das Element an der angegebenen Stelle zu liefern. Wenn Sie die
Standardbibliothek eines älteren GCC (2.9x oder darunter) verwenden,
müssen Sie jedoch bedenken, dass es die Methode at() dort überhaupt nicht
gibt!. In diesem Fall bleibt Ihnen eigentlich nur, eine neue Klasse von
vector¡T¿ abzuleiten und dort den []-Operator mit einer sicheren
Version zu überladen beziehungsweise eine sichere Methode at()
hinzuzufügen. Eventuell können Sie die dortige Bereichsprüfung auch an
eine Präprozessor-Variable koppeln, so dass sie sich in der Produktivversion
wieder abschalten lässt. Wenn Sie eine jüngere STL-Implementierung
verwenden (wie sie ab GCC 3.0 mitgeliefert wird), führt ein nicht zulässiger
Index zu einer Ausnahme vom Typ out_of_range; Näheres dazu erfahren
Sie ab Seite 592.
Im Allgemeinen sollten Sie sich also bei jedem Zugriff überlegen, ob der
Index ungültig sein kann oder nicht, und dementsprechend im ersten Fall
eine Bereichsprüfung (mit at(), sofern vorhanden, oder mit einer selbst
definierten Methode) durchführen und im zweiten Fall den []-Operator
verwenden. Wenn Sie beispielsweise eine Schleife programmieren, sorgt die
Schleifenbedingung bereits dafür, dass nur zulässige Indizes verwendet
werden, zum Beispiel:
{
for(unsigned i=0; i<v.size(); i++)
v[i] = i*0.1;
}
Wie Sie vielleicht an diesem Beispiel erraten haben, liefert die Methode
size() eines Vektors die Gesamtzahl seiner Elemente. Auch dies ist eine
generische Methode, die bei vielen anderen Containern der STL in gleicher
Weise vorhanden ist.
Eine besondere Variante des Vektors ist vector¡bool¿. Obwohl dieser
über dieselbe Schnittstelle wie die normalen Vektoren verfügt, ist er so
effizient implementiert, dass er pro Element auch wirklich nur ein Bit
Speicher verbraucht.
Auch die Klasse list¡T¿ arbeitet auf einem als Template angegebenen
Datentyp. Das Einfügen am Anfang oder am Ende erfolgt über die
Methoden push_front() beziehungsweise push_back().
list<Konto>& inverseListe,
const string& dateiName)
{
ifstream in_file(dateiName.c_str());
while (in_file)
{
Konto einKonto;
in_file >> einKonto.nummer
>> einKonto.inhaber;
// am Anfang einfügen
inverseListe.push_front(einKonto);
// am Ende einfügen
kontoListe.push_back(einKonto);
}
}
Ein Stack (auch "Kellerspeicher" genannt, siehe auch Übung 5 in Abschnitt
3.3.8, Seite 399) ist ein Container, der sich wie ein Akten- oder
Bücherstapel verhält: Was zuletzt dort abgelegt wurde, erhält man
auch als Erstes wieder zurück (so genanntes LIFO-Prinzip). Die
Stack-Implementation der Standardbibliothek ist kein eigener Container,
sondern stülpt nur eine Stack-typische Schnittstelle über vorhandene
Container wie deque¡T¿, list¡T¿ oder vector¡T¿. Wenn Sie wollen, können
Sie den zugrunde liegenden Container bei der Definition auch als zweites
Template-Argument angeben; Sie können aber auch einfach die Vorgabe
(deque¡T¿) verwenden.
Die zentralen Aktionen bei Stacks sind zum einen das Ablegen eines
Objekts auf dem Stack, für das hier die Methode push() dient, und zum
anderen das Abnehmen des obersten Elements vom Stack, die bei dieser
Klasse wie üblich pop() heißt, die jedoch nicht das Element, sondern nur
void zurückliefert. Auskunft über das jeweils oberste Element gibt die
Methode top(), welche dieses zurückliefert, ohne es vom Stack zu
entfernen.
{
stack<string> myStack;
ifstream in_file(dateiName.c_str());
while (in_file)
{
unsigned long nummer;
string inhaber;
in_file >> nummer >> inhaber;
myStack.push(inhaber);
cout << myStack.top() << endl;
}
while (!myStack.empty())
{
cout << myStack.top() << endl;
myStack.pop();
}
}
Außerdem bietet die Bibliothek noch Operatoren, um zwei Stacks auf Identität oder lexikografisch zu vergleichen.
Viele der soeben besprochenen Container sind sequenziell (zum Beispiel
list¡T¿, queue¡T¿ und in gewissem Sinne auch vector¡T¿). In diese möchte
man dann nicht nur Werte einfügen oder entfernen, sondern auch sie
durchlaufen, in ihnen suchen, Teilmengen bilden und so weiter. Für diese
Aufgaben stellt die C++-Standardbibliothek Iteratoren und Algorithmen
bereit. Die Algorithmen machen dabei intensiv von Iteratoren als Ein- und
Ausgabeparameter Gebrauch. Daher wollen wir zunächst diesen Begriff
klären.
Was ist nun ein Iterator? Stellen Sie sich einfach so etwas wie einen Zeiger vor, mit dem Sie einen Container durchlaufen und auf seine Elemente zugreifen können. Die meisten Containerklassen definieren eine (oder gar mehrere) zugehörige Iteratorklasse(n), die aber alle auf eine gemeinsame Basis zurückgehen und daher überall ähnlich zu verwenden sind. (Mehr zur Idee des Iterators erfahren Sie beispielsweise in [GAMMA et al. 1996].)
Eine Möglichkeit, einen Iterator zu erhalten, sind die Methoden der
Containerklassen. Die meisten verfügen beispielsweise über Funktionen
begin() und end(), die - wie ihr Name schon sagt - auf Beginn
beziehungsweise Ende der gespeicherten Datenmenge zeigen. Die
andere Quelle für Iteratoren sind Algorithmen, die diese sowohl als
Eingabeparameter erwarten als auch als Rückgabewerte verwenden.
Der einfachste Typ eines Iterators ist ein Vorwärts-Iterator. Er kann
nur den Container von vorn nach hinten durchlaufen und auch nur um
jeweils ein Element erhöht werden. Im Gegensatz dazu kann man
bidirektionale Iteratoren nicht nur inkrementieren, sondern auch
dekrementieren. Einige Container wie list¡T¿ oder deque¡T¿ bieten auch
den inversen bidirektionalen Iterator (Klasse reverse_iterator) an, der sich
vom Ende zum Anfang bewegt, wenn er inkrementiert wird, und
umgekehrt. Schließlich gibt es für vector¡T¿ und deque¡T¿ den Iterator für
wahlfreien Zugriff, der auch beliebige Sprünge erlaubt - wie ein normaler
Zeiger.
Der Zeiger hat auch Pate für die Definition der Operatoren gestanden,
die mit Iteratoren verwendet werden können. Für Inkrementierung und
Dekrementierung nehmen Sie einfach die Operatoren ++ und --. Wenn Sie
auf den Inhalt des Iterators, also auf das Element, auf dem er gerade steht,
zugreifen wollen, denken Sie ebenfalls an einen Zeiger und verwenden den
Dereferenzierungsoperator * beziehungsweise den Pfeil -¿. Dann geht das
Durchlaufen einer Liste ganz einfach:
{
list<Konto>::iterator iter;
for(iter=kontoListe.begin(); iter != kontoListe.end(); iter++)
{
// erste Zugriffsmöglichkeit
cout << iter->nummer << ``\t ``;
// zweite Zugriffsmöglichkeit
cout << (*iter).inhaber << endl;
}
}
Beachten Sie, dass Sie immer die Containerklasse und deren Templatetyp bei der Angabe des Iteratortyps dazusetzen müssen. Denn wie erwähnt hat jeder Container seine eigenen Iteratoren.
Wenn Sie über eine Elementfunktion einmal einen Iterator von einem Objekt erhalten haben, scheint dieser fast unabhängig vom Objekt weiterzuexistieren. Um jedoch die Konsistenz zu bewahren, bleiben Iteratoren im Gegensatz zu Zeigern unmittelbar an die Containerobjekte gebunden, auf die sie sich beziehen. Das zeigt sich nicht zuletzt daran, dass bei den meisten Containern alle Iteratoren ungültig werden, sobald Sie Elemente in den Container eingefügt oder aus ihm gelöscht haben. Achten Sie also darauf, sich stets einen neuen Iterator zu besorgen, sobald Sie Veränderungen am Container durchführen.
Als zusätzliches Bonbon bietet die C++-Standardbibliothek neben den Containern auch noch eine Reihe von Algorithmen, die auf diesen arbeiten. Die Aufgaben, die sich damit erledigen lassen, reichen vom Auffinden und Zählen von Elementen über Kopieren und Ersetzen bis zu Sortierverfahren. Wieder einmal genügt der Platz an dieser Stelle nicht, um umfassend auf alle Algorithmen eingehen zu können. Ich muss Sie daher abermals auf [STROUSTRUP 1998] oder [JOSUTTIS 1999] verweisen.
Kennzeichnend für die Algorithmen ist unter anderem, dass die meisten nicht auf einen Container beschränkt sind, sondern auf allen Containern in gleicher Weise arbeiten. Sie sind meist nämlich so formuliert, dass sie nicht den Container selbst, sondern Iteratoren auf ihm als Argumente erwarten. Und wenn schon die Iteratoren aller Container in gleicher Weise benutzbar sind, sind es die Algorithmen ebenso.
Ein nützlicher Algorithmus ist beispielsweise find(). Man übergibt ihm
einen Bereich zum Durchsuchen in Form von zwei Iteratoren (für Anfang
und Ende) sowie einen Wert, den man finden möchte. Als Rückgabe erhält
man einen Iterator, der entweder auf das erste Vorkommen des gesuchten
Elements oder auf das Ende des Containers zeigt. In folgendem Beispiel
füllen wir zunächst eine Liste und suchen dann alle Elemente mit dem Wert
3, die wir sogleich auf 99 ändern.
{
list<unsigned> l;
unsigned n;
for(n=1; n<=5; n++)
for(unsigned m=1; m<=n; m++)
l.push_back(m);
list<unsigned>::iterator iter;
// Von Anfang bis Ende suchen
iter = find(l.begin(), l.end(), 3);
// Schleife über alle Elemente
while (iter != l.end())
{
*iter = 99;
iter++;
// nächstes Element suchen
iter = find(iter, l.end(), 3);
}
}
Das Suchen und Ersetzen ist allerdings eine Aufgabe von so allgemeiner
Bedeutung, dass Sie sie nicht selbst implementieren müssen. Dafür
enthält die Standardbibliothek die Funktion replace(); mit dieser
können wir zum Beispiel unsere Änderungen wieder rückgängig
machen:
Und wenn Sie die Ersetzungen nicht im Original, sondern in einer Kopie
vornehmen wollen, geht auch das. Dazu benötigen Sie die Funktion copy()
sowie einen Iterator für das Einfügen, einen inserter.
copy(l.begin(), l.end(), back_inserter(l2));
replace(l2.begin(), l2.end(), 3, 99);
(Natürlich übertreibt dieses Beispiel ein wenig: Eine Kopie der ganzen Liste
lässt sich mit dem Kopierkonstruktor oder Zuweisungsoperator viel leichter
erzeugen. Aber die Funktion copy() kann eben mit einer beliebigen Sequenz
von Elementen umgehen.)
Das Sortieren lässt sich ebenso einfach durchführen. Dazu können wir etwa
die Funktion sort() benutzen. Diese braucht im einfachsten Fall lediglich
die Iteratoren, die den zu sortierenden Bereich angeben.
{
vector<unsigned> v(12);
vector<unsigned>::iterator iter;
for(unsigned i=0; i<4; i++)
for(unsigned j=0; j<3; j++)
v[i*4+j] = i+j;
// Sortiere (nach der Größe aufsteigend)
sort(v.begin(), v.end());
cout << Sortierter Vektor: ;
for(iter = v.begin(); iter != v.end(); iter++)
cout << *iter << ;
cout << endl;
}
Für andere Sortierreihenfolgen kann man auch Regeln angeben, nach denen sortiert werden soll.
Solche benutzerdefinierte Regeln benötigt man auch bei anderen Algorithmen immer wieder. Es gibt daher Varianten der Funktionen, die neben dem Bereich den Namen einer zusätzlichen Funktion erwarten, die ein Containerelement als Argument hat und einen booleschen Wert zurückgibt. Solche Funktionen nennt man auch Prädikate.
Kommen wir nochmals zu unserem Beispiel mit den Konten zurück.
Wir wollen nun alle Konten ermitteln, die die Zahlenkombination
"28" in ihrer Nummer haben. Für die Prädikatsfunktion müssen wir
die als numerischen Werte gespeicherten Kontonummern wieder in
einen String umwandeln, in dem wir dann suchen können. Für eine
sichere Umwandlung verwenden wir einen ostringstream (siehe Seite
345).
{
ostringstream ostr;
ostr << einKonto.nummer;
string nrstr(ostr.str());
if (nrstr.find(``28'') != string::npos)
return true;
return false;
}
Das Suchen selbst erfolgt dann analog zu oben, allerdings mit der Funktion
find_if().
{
list<Konto>::iterator iter;
iter = find_if(kontoListe.begin(),
kontoListe.end(), hat28);
while (iter != kontoListe.end())
{
cout << iter->nummer << ``\t ``
<< iter->inhaber << endl;
iter++;
// nächstes Element suchen
iter = find_if(iter,
kontoListe.end(), hat28);
}
}
Ähnliche Funktionen, die mit Prädikaten arbeiten, sind
for_each(), die für jedes Element die Prädikatsfunktion ausführt,
replace_if(), die nur Elemente ersetzt, welche die Bedingung
erfüllen,
count_if(), die alle Elemente zählt, welche die Bedingung erfüllen.
Die wichtigsten Aspekte aus diesem Abschnitt waren:
std.
string verwenden. Sie unterstützt die Konstruktion aus
einem char-String, Konkatenation, Vergleiche, Teilstringsuche
und vieles mehr.
vector verwenden. Die Anzahl der Elemente können Sie
mit einem speziellem Konstruktor oder resize() festlegen. Der
Zugriff erfolgt mit dem Index in eckigen Klammern (analog
dem einfachen Array). Allerdings wird auch dabei keine Prüfung
durchgeführt, ob der Index innerhalb des gültigen Bereichs ist
oder nicht!
list, deque oder
stack. Bei diesen können Sie jeweils ein Element hinzufügen, eines
entfernen, die Liste durchlaufen etc.
string für Zeichenketten
verwenden als Felder vom Typ char?
vector¡T¿
und list¡T¿?
string. Welcher Container ist für diese
Aufgabe am geeignetsten?
map Kürzel
von Flughäfen verwaltet. Fügen Sie die nachfolgenden Werte ein und
geben Sie sie wieder aus:
set-Container
verwaltet werden. Wenn sechs unterschiedliche Zahlen gezogen sind,
geben Sie diese aus.
Bisher kennen Sie nur die implizite Typumwandlung, bei der der Compiler automatisch dafür sorgt, dass ein Objekt in den passenden Typ konvertiert wird, und die explizite Typumwandlung im C-Stil (den "cast"), bei der Sie den Zieltyp selbst in runden Klammern angeben (siehe Seite 103). Wie schon mehrfach betont, ist Typumwandlung immer eine potenzielle Quelle von Fehlverhalten, sei sie nun explizit oder implizit. Sie unterwandern damit nämlich die Typprüfung durch den Compiler, die C++ als streng typisierte Sprache auszeichnet. Andererseits ist es manchmal umumgänglich, ein Objekt eines Datentyps in einen anderen zu konvertieren, etwa um eine Funktion aufrufen zu können, die einen bestimmten Typ für ihre Argumente vorschreibt.
Die Neufassung des C++-Standards brachte auch neue, eigene Operatoren für die Typumwandlung mit sich. Diese sind leicht verständlich, einfach zu erkennen und nach den verschiedenen Einsatzbereichen unterschieden. Wenn Sie sich erst einmal daran gewöhnt haben, möchte ich Ihnen empfehlen, nur noch diese für explizite Umwandlungen zu verwenden.
Es handelt sich dabei um die Operatoren static_cast, dynamic_cast,
const_cast und reinterpret_cast. Sie haben alle die Form
wobei Ausdruck der ursprüngliche Ausdruck und T der Typ ist, in den der
Ausdruck konvertiert werden soll. Beispiele werden wir im Folgenden noch
kennen lernen.
int i = static_cast<int>(f);
Sie können mit dem static_cast-Operator natürlich auch Zeiger und
Referenzen umwandeln. Besonders wichtig wird dies bei Objekten aus einer
Klassenhierarchie. Auf Seite 246 haben wir festgestellt, dass immer eine
implizite Konvertierung von der Referenz einer Unterklasse auf die
Referenz einer Basisklasse möglich ist. Wenn wir genau wissen, dass die
Referenz oder der Zeiger eigentlich zur Unterklasse gehört, können wir ihn
auch wieder zurückwandeln:
{
PushButton* pPushb = 0;
pPushb = static_cast<PushButton*>(_pButton);
pPushb->setFlat(true);
}
Beachten Sie aber, dass man hier davon ausgeht, dass der Programmierer
diese Anweisung auch immer mit den korrekten Argumenten versorgt.
Wenn Sie der Funktion setFlat() ein Objekt übergeben, bei dem es sich
nicht um ein Objekt vom Typ PushButton oder eines Nachkommen davon
handelt, ist das Ergebnis der Umwandlung undefiniert und Ihr Programm
wird beim darauf folgenden Methodenaufruf abstürzen.
dynamic_cast-Operator. Er ist in der Lage, zur Laufzeit die
Gültigkeit der Umwandlung zu überprüfen. (Dieser Operator ist
damit Teil der so genannten Run-Time Type Information (RTTI),
eines recht neuen Mechanimus, um den Typ eines polymorphen
Objekts zur Laufzeit bestimmen zu können.) Gleichzeitig bedeutet
diese Fähigkeit aber auch, dass der dynamic_cast-Operator nur
für polymorphe Objekte eingesetzt werden kann, der Parameter
T also immer ein Zeiger oder eine Referenz auf eine Klasse sein
muss.
Gelingt die Umwandlung nicht, zum Beispiel weil der angegebene Ausdruck
eben nicht vom Typ der spezifizierten Klasse C ist, gibt es zwei
Möglichkeiten:
C oder einen
ihrer Nachkommen, so ist der Rückgabewert von dynamic_cast
schlicht 0. Dies lässt sich sehr leicht überprüfen.
C
bezieht, so löst dies eine Ausnahme vom Typ bad_cast aus. (Die
Behandlung von Ausnahmen erfahren Sie im Abschnitt 4.5 ab
Seite 575.)
Sehen wir uns das Verhalten an einem Beispiel an.
{
public:
virtual int id() {
return 0; }
};
class Unterklasse : public Basis
{
public:
virtual int id() {
return 1; }
};
Die Klassen tun nichts anderes, als eine Identifikationszahl in ihren
Methoden id() zurückzuliefern. Immerhin können wir damit sicher sein, von
welchem Typ ein Objekt gerade ist.
Die Testfunktion versucht die Umwandlung eines Zeigers und weist uns darauf hin, wenn diese nicht geklappt hat.
{
Unterklasse* u = dynamic_cast<Unterklasse*>(_b);
if (u)
cout << "test: " << u->id() << endl;
else
cout << "_b nicht vom Typ Unterklasse!" << endl;
}
Unsere main()-Funktion legt Objekte beider Klassen an und ruft die
Testfunktion mit Zeigern auf Basis sowie auf Unterklasse auf.
{
Basis b;
Unterklasse u;
test(&b);
test(&u);
}
Das Ergebnis ist wie erwartet:
test: 1
Beim ersten Aufruf lässt sich das Argument _b nicht nach Unterklasse*
konvertieren, da es sich um einen Zeiger auf Basis handelt. Beim zweiten
Aufruf können wir die implizite Umwandlung korrekt rückgängig machen
und auf das Objekt zugreifen.
Es ist zwar auch möglich, die Funktion test() mit einem
static_cast-Operator beziehungsweise einer expliziten Umwandlung im
C-Stil zu implementieren. Dann haben Sie aber keine Chance, auf falsche
Typen zu reagieren. Ein anschließender Methodenaufruf führt dann nicht
mehr zum gewünschten Ergebnis.
Der optimale Ausweg, die Schnittstelle der anderen Funktion zu ändern, ist
Ihnen leider meist versperrt, da Sie keinen Kontakt zu den Autoren
herstellen oder diese nicht zu einer Änderung bewegen können. Also
müssen wir uns nach anderen Ansätzen umsehen. Die erste Möglichkeit
ist, auch bei den Argumenten Ihrer eigenen Funktion das const
zu entfernen, was sehr unschön und eigentlich überflüssig ist. Der
zweite Weg liegt darin, lokale Kopien der Objekte anzulegen und
diese weiterzureichen; das kann unter Umständen einen erheblichen
Aufwand bedeuten und die Laufzeit des Programms in die Länge
ziehen.
Der const_cast-Operator bietet Ihnen einen dritten Weg, der unter den
gegebenen Umständen am elegantesten ist. Er dient dazu, einen konstanten
Zeiger oder eine konstante Referenz in einen Ausdruck desselben Typs,
allerdings nicht konstant, umzuwandeln. Er lässt somit das const
"verschwinden".
Nehmen wir zum Beispiel an, Sie wollten eine Funktion send()
aufrufen, die einen C-String als Parameter erwartet. Dieser String wird
zwar nicht verändert; trotzdem hat ihn der Autor der Schnittstelle leider
nicht als const deklariert:
Ihre Klasse hat ein besseres Design und liefert nur einen konstanten Zeiger:
{
public:
const char* getString() const;
// ...
};
In der folgenden Funktion brauchen Sie also diesen Umwandlungsoperator:
{
return send(const_cast<char*>(_mes.getString));
}
Auf zwei Aspekte sollten Sie bei diesem Operator besonders achten:
const deklariert haben, so ist
es zwar syntaktisch möglich, auch dieses const zu entfernen;
gleichwohl kann das zu einem völlig undefinierten Zustand
führen:
int* p = const_cast<int*>(&k); // !!!
Wenden Sie den Operator also auf solche Objekte nicht an.
const_cast-Operator eigentlich nie
benötigen. Der beschriebene Fall ist so ziemlich der einzige, in
dem sein Einsatz Sinn macht. Aber die Notwendigkeit des
"Wegcastens" weist fast immer auf einen Fehler im Schnittstellendesign
hin. Versuchen Sie auf keinen Fall, Ihnen als konstante Referenz
übergebene Objekte auf diesem Umweg zu modifizieren. Sie
torpedieren damit Ihre gesamte Softwarestruktur und machen sich
darüber hinaus bei Ihren Kollegen unbeliebt, wenn Sie den Sinn einer
const-Deklaration einfach ignorieren.
Dieser Operator ist für alle Fälle, in denen keiner der anderen Umwandlungsoperatoren geeignet ist. Er spiegelt die aus C stammende Sichtweise wider, dass man eine Variable nur als eine Ansammlung von Bytes ansieht und diese beliebig anders interpretieren kann. Natürlich kann sich daraus ein völlig anderes Resultat ergeben, als man ursprünglich beabsichtigt hat. Durch diesen Operator machen Sie im Quelltext aber zumindest deutlich, dass Sie sich dieser Risiken bewusst sind.
Ein Beispiel ist der Umgang mit Dateien, die im Binärformat
gespeichert sind. Zum Einlesen des Inhalts einer solchen Datei können Sie
beispielsweise die Methode istream::read() der Standardbibliothek
verwenden. Diese hat die Schnittstelle:
Sie müssen ihr also einen char-Zeiger auf den Datenbereich übergeben, in
den Sie die Dateiinhalte schreiben wollen. Nehmen wir an, Sie möchten eine
Reihe von int-Zahlen einlesen. Diese sind in der Datei binär hintereinander
abgelegt. Die erste Zahl gibt die Größe des Feldes an, also wie viele Zahlen
noch folgen.
Zur internen Darstellung entwerfen Sie eine Klasse:
{
private:
int* buffer;
unsigned int size;
public:
ValueBuffer() :
buffer(0), size(0) {}
~ValueBuffer() {
if (buffer) delete[] buffer; }
int read(const string& _name);
};
In der Lesemethode öffnen wir die Datei, lesen den ersten Eintrag,
reservieren entsprechend viel Speicher und rufen dann die read()-Methode
der Stream-Klasse auf. Da diese aber keinen Zeiger auf int erwartet,
sondern auf char, müssen wir Ihren Zeiger uminterpretieren; dazu brauchen
wir jetzt den reinterpret_cast-Operator.
{
// Datei öffnen
ifstream in(_name.c_str(),
ios::in | ios::binary);
if(in.bad())
{
cerr << _name
<< " kann nicht geöffnet werden!"
<< endl;
return -1;
}
// Anzahl der Daten einlesen
in >> size;
// Puffer in der Größe reservieren
buffer = new int[size];
// Daten selbst lesen
in.read(reinterpret_cast<char*>(buffer),
if (in.fail())
{
cerr << "Zu wenig Daten in " << _name
<< "!" << endl;
return -2;
}
in.close();
return 0;
}
Bei read() müssen wir außer dem Zeiger die Anzahl der Bytes angeben, die
wir einlesen wollen. Diese lassen sich als Produkt der Anzahl der Elemente
und ihrer Größe ermitteln.
Dieser kurze Abschnitt diente dazu, Ihnen die neuen Operatoren für die Typumwandlung vorzustellen. Sie sind eine vollwertige Alternative zur bekannten C-Schreibweise; Sie sollten ausschließlich diese Operatoren verwenden, da dadurch nicht nur die Typumwandlung als solche im Quelltext leichter erkennbar ist, sondern auch das Ziel dieser Umwandlung. Im Einzelnen haben Sie folgende Operatoren kennen gelernt:
static_cast-Operator verwenden Sie für alle einfachen
Umwandlungen, die bereits zur Übersetzungszeit als gültig
erkannt werden können. Damit können Sie auch implizit erlaubte
Umwandlungen wieder rückgängig machen.
dynamic_cast-Operator wandelt einen Zeiger oder eine
Referenz auf ein polymorphes Objekt in einen Zeiger
beziehungsweise eine Referenz auf eine Unterklasse um. Der
Vorteil ist, dass die Gültigkeit dieser Umwandlung zur Laufzeit
geprüft wird.
const_cast-Operator können Sie das "const" von Zeigern
oder Referenzen vorübergehend verschwinden lassen. Dies ist
aber nur in Ausnahmefällen sinnvoll!
reinterpret_cast-Operator ist für alle anderen Fälle, also
etwa wenn ein Speicherbereich in einer völlig neuen Weise (und
damit auch mit einem völlig neuen Resultat) interpretiert werden
soll. Diese Funktionalität wird beispielsweise bei Funktionen auf
unterer Ebene wie dem Lesen und Schreiben im Binärformat
benötigt.
dynamic_cast vornehmen wollen?
const_cast-Operator nie
verwenden?
istream::read() den reinterpret_cast-Operator
verwenden?
numeric_cast in Form einer
Template-Funktion, der bei der Umwandlung zwischen numerischen
Datentypen überprüft, ob dabei keine Informationen verloren gehen.
Verwenden Sie zur Überprüfung std::numeric_limits¡T¿ aus
der Datei ¡limits¿. Testen Sie Ihren Operator an geeigneten
Beispielen.
Ein Operator stellt im weiteren Sinne ja nichts anderes dar, als ein
Symbol für eine Verknüpfungsregel. Viele Operatoren in C++ sind aus
der Mathematik übernommen, etwa + für die Addition, * für die
Multiplikation oder / für die Division. Während in C++ diese Operatoren
zunächst nur für Zahlenwerte definiert sind, geht die Mathematik sehr viel
abstrakter vor. Dort kann man auf beliebigen Mengen - ein paar bestimmte
Eigenschaften vorausgesetzt - additive und multiplikative Verknüpfungen
definieren. (Der Mathematiker nennt solche Mengen zusammen mit ihren
Verknüpfungsoperationen dann je nach Art der Menge einen Ring oder
einen Körper.)
Ein einfaches Beispiel sind die Matrizen, von denen auch schon hier öfter die Rede war. Mathematisch schreibt man für zwei Matrizen A und B ganz selbstverständlich:

Diesen Vorteil können Sie auch in C++ nutzen. Denn auch hier ist es möglich, die Operatoren für Ihre eigenen Objekte zu definieren. Wie in der Mathematik müssen Sie nur erklären, was bei einer solchen Verknüpfung passieren soll, also eine Operatorfunktion schreiben. Da Sie damit die eingebauten Operatorfunktionen für die Standardtypen mit neuen Argumenten versehen, spricht man auch vom "Überladen von Operatoren". Denn Sie können ja auch Funktionen und Methoden in dem Sinne überladen, dass Sie eine weitere Funktion beziehungsweise Methode mit demselben Namen wie die erste, aber anderen Argumenten definieren (siehe Seite 176).
Wir wollen also in einem Programm etwa folgende Syntax erlauben:
c = a + b;
Sie können einen Operator zwischen Objekten entweder als Funktion oder
Methode der Klasse implementieren. Die Definition hat genau dieselbe
Form wie bei normalen Funktionen; der Unterschied liegt lediglich darin,
dass der Funktionsname sich aus dem Schlüsselwort operator und dem
eigentlichen Operatorsymbol zusammensetzt.
Eine Operatorfunktion ist damit nach folgendem Schema aufgebaut:
( Argumente )
{
// Funktionskörper
}
Für unsere Matrix heißt das beispielsweise:
const matrix& b)
{
// ...
return c;
}
Der Compiler setzt die von Ihnen zur Verfügung gestellte Operatordefinition immer dann ein, wenn er eine Verknüpfung zwischen zwei Objekten findet, die im Typ mit den Argumenten der Operatorfunktion übereinstimmen. Daher ist
äquivalent mit
Auch diese zweite Form ist gültiger Code!
Die Definition eines Operators als Methode einer Klasse hat die Form:
( Argumente )
{
// Funktionskörper
}
Auch auf diese Weise könnten wir die Matrix-Addition programmieren:
{
// ...
}
Dann wäre die Anweisung
allerdings gleichbedeutend mit
Sie können alle C++-Operatoren überladen wie =, !=, ==, +, *, += und
so weiter, siehe auch Tabelle 2.1 auf Seite 110, jedoch nicht den Punkt- (.),
den Bereichs- (::) und den Auswahloperator (?:). Es ist jedoch nicht
erlaubt, eigenen hinzuzufügen, also weder aus bestehenden neue
zusammensetzen noch bisher nicht benutzte Zeichen wie $ als Operatoren
deklarieren. Bei der Überladung können Sie auch die Prioritäten nicht
verändern. Zudem dürfen Sie die vorgegebene Bedeutung einer Operation
für Standardtypen nicht verändern; daher muss bei jeder selbst
geschriebenen Operatorfunktion mindestens ein Argument ein Objekt einer
Klasse sein.
Wie Sie ebenfalls an der Tabelle 2.1 sehen, unterscheidet man Operatoren in primäre, unäre und binäre. Als Funktion können Sie sowohl unäre Operatoren definieren, zum Beispiel:
MyClass o;
!o;
als auch binäre wie das + zwischen Matrizen von oben. Bei Methoden
können Sie alle drei Arten verwenden, beispielsweise:
{
public:
// primärer Operator []
MyType operator[](int _i);
// unärer Operator ++
MyClass operator++();
// binärer Operator ==
bool operator==(const MyClass& _o);
// Sonderfall: Zuweisung
MyClass& operator=(const MyClass& _o);
// ...
};
Die Anwendung erfolgt dann wie bei den eingebauten Operatoren:
{
MyClass o, p;
MyType t;
t = o[1];
// entspricht t=(o.operator[])(1);
++o;
// entspricht o.operator++();
if (o==p) return;
// entspricht if (o.operator==(p)) return;
p = o;
// entspricht p.operator=(o);
}
Zu fast allen dieser Operatoren erhalten Sie in den folgenden Abschnitten weitere Informationen.
An dieser Stelle möchte ich das Vektorbeispiel von Seite 493 wieder
aufgreifen und die Klasse Vektor um Operatoren erweitern. Denn bei
Containerklassen wie Vektoren ist es zweckmäßig und bequem, über
dieselbe Schreibweise wie bei Feldern, also den Indexoperator [], auf die
Elemente zugreifen zu können (die STL-Klassen bieten diesen Service ja
auch).
Meistens implementiert man den Indexoperator zu einer Klasse doppelt, nämlich einmal für den Lese- und einmal für den Schreibzugriff. Zwischen diesen beiden gibt es einige Unterschiede.
Möchte man ein Element lediglich lesen, es also nicht verändern, genügt es,
das Element als Wert zurückzugeben, also eine Kopie mit demselben
Inhalt. Hier dient der Indexoperator also als Ersatz für die konstante
Variante der at()-Methode.
{
private:
unsigned int size;
T* v;
public:
T operator[](unsigned int _i) const
{ return at(_i); }
// ...
};
Wenn Sie genau aufgepasst haben, werden Sie bemerkt haben,
dass diese Deklaration in einem Punkt nicht mit der Methode at()
übereinstimmt - im Typ der Rückgabe. Bei Klassen von sehr allgemeiner
Natur (wie diesem Vektor) kann die Rückgabe als Wert nämlich zum
Problem werden. Bei jeder solchen Rückgabe wird eine Kopie des
gespeicherten Objekts angelegt (gegebenenfalls unter Aufruf des
Kopierkonstruktors), dann das Objekt innerhalb der Anweisung mit dem
Aufruf verarbeitet und anschließend wieder vernichtet. Bei großen
Objekten kann der ständige Aufruf des Kopierkonstruktors sehr viel Zeit
kosten. Eine Alternative ist daher die Rückgabe einer konstanten Referenz,
wie wir das oben bereits getan haben.
{
public:
const T& operator[](unsigned int _i) const
{ return at(_i); }
// ...
};
Der Aufrufer erhält somit zwar Zugriff auf das Originalobjekt, darf an
diesem aber keine Veränderungen vornehmen, sondern nur dessen
const-Methoden aufrufen.
Wenn Sie den Indexoperator für Schreibzugriffe verwenden wollen, er also beispielsweise auf der linken Seite einer Zuweisung stehen soll:
dann ist die Rückgabe eines Wertes oder einer konstanten Referenz
natürlich ungeeignet. Hier brauchen wir eine echte Referenz, über die das
Originalobjekt manipuliert werden kann. Analog zu at() gibt es damit auch
vom Indexoperator eine zweite Version mit gleicher Signatur, aber
anderem Rückgabetyp. Diese ist natürlich auch nicht mehr als const
deklariert.
{
public:
T& operator[](unsigned int _i)
{ return at(_i); }
// ...
};
Der Compiler stellt auch hier anhand des Kontextes fest, welche der beiden Versionen in einer konkreten Situation zu verwenden ist.
Unklar ist für den Compiler meist nur der Fall des Ausgabeoperators. Wenn Sie also schreiben:
// ... (Fülle v)
cout << v[0] << endl;
wird nicht die konstante, sondern die modifizierbare Version des Indexoperators aufgerufen. Im Allgemeinen ist dies zwar ärgerlich, stört aber nicht besonders. In manchen Fällen wird in der Methode für den nichtkonstanten Operator jedoch erheblich mehr Arbeit geleistet als in der des konstanten. Da kann es einen spürbaren Unterschied in der Laufzeit ausmachen, welche Methode genau zum Einsatz kommt.
Wenn Sie erzwingen wollen, dass der Ausgabeoperator die konstante Version verwendet, können Sie dies zum Beispiel dadurch, dass Sie lokal (etwa in einem separaten Block) eine konstante Referenz auf den betreffenden Container erzeugen und dann deren Indexoperator aufrufen. Dieser muss zwangsläufig der konstante sein. Obiges Beispiel hätte damit die Form:
// ... (Fülle v)
{
// Lokale konstante Referenz
const Vektor<int>& rv = v;
cout << rv[0] << endl;
}
Bei den Operatoren für Inkrement und Dekrement, ++ und --, ist das
Überladen etwas trickreich. Das liegt daran, dass es von diesen zwei
Varianten gibt, nämlich als Präfix, das heißt vor das Objekt geschrieben,
und als Postfix, also dahinter (siehe auch Seite 113). Beim Überladen gilt
daher die Konvention, dass der Inkrementoperator als Postfix einen
zusätzlichen Parameter vom Typ int hat, der jedoch ansonsten ignoriert
wird. Fehlt dieses Argument, gilt die entsprechende Methode als
Präfix.
Sehen wir uns das an einem Beispiel an:
{
public:
// Präfix
MyClass operator++();
// Postfix
MyClass operator++(int);
// ...
};
Bei der Implementierung müssen Sie jedoch selbst aufpassen, dass Ihre Operatormethoden auch das Verhalten zeigen, das der Benutzer von ihnen erwartet. Sie müssen also beispielsweise beim Postfixoperator das Objekt zwar verändern, es aber in dem Zustand vor der Veränderung zurückliefern. Dies können Sie etwa mit folgendem Schema erreichen:
MyClass MyClass::operator++()
{
// Erhöhe Objektinhalt um eins
// ...
// Gib Objekt zurück
return *this;
}
// Postfix
MyClass MyClass::operator++(int)
{
// Speichere temporäre Kopie
MyClass tmp(*this);
// Rufe Präfix-Implementierung auf
operator++();
// Gib unbehandelte Kopie zurück
return tmp;
}
Wie Sie sehen, müssen Sie bei der Implementierung etwas aufpassen, um nicht die Erwartungen an den Inkrementoperator zu enttäuschen. Sie sollten einen solchen auch nur dann überladen, wenn er sich auf natürliche Weise für Ihre Klasse anbietet. Ist er nicht intuitiv verständlich, braucht man also erst eine längere Erklärung, was eigentlich sein Sinn ist, lassen Sie ihn lieber weg.
Und noch ein Tipp: Wenn Sie einen Inkrementoperator überladen, bieten Sie aus Symmetriegründen stets sowohl eine Präfix- als auch eine Postfixvariante an. Sie können nie wissen, wie der Benutzer Ihrer Klasse seine Aufrufe strukturieren will.
Was passiert eigentlich, wenn Sie zwei Objekte einer Klasse haben und eines davon dem anderen zuweisen? Wenn Sie keine Regel dafür definiert haben, werden die Datenelemente des Objekts elementweise kopiert, also Bit für Bit. Wenn Ihre Klasse Konstanten, Referenzen oder dynamisch angelegte Speicherbereiche enthält, bekommen Sie Probleme.
Für diese Zwecke können Sie den Zuweisungsoperator = überladen, um
genau festzulegen, was bei einer Zuweisung geschehen soll. Die Syntax ist
nicht vollkommen festgeschrieben. Es ist offensichtlich, dass das Argument
ein Objekt oder eine konstante Referenz auf ein Objekt derselben Klasse
sein muss. Für den Rückgabewert hat sich eingebürgert, dass man eine
Referenz auf das Objekt zurückgibt, dem etwas zugewiesen wurde, also ein
return (*this). Auf diese Weise kann man die Zuweisung auch verketten
und in weitere Anweisungen einbetten, wie das von C leider immer noch
üblich ist:
a= (b=c);
if ((b=a).size() >0) { //... }
In Abschnitt 3.3.5 auf Seite 380 habe ich Ihnen erklärt, wozu man einen
Kopierkonstruktor braucht. Die gleiche Argumentation spricht auch für den
Zuweisungsoperator. Sie erinnern sich: Ohne Zuweisungsoperator
werden alle Datenelemente bitweise eins zu eins kopiert. Enthält das
Objekt Zeiger, so führt das dazu, dass auch das neue Objekt auf
denselben Speicherbereich zeigt wie das vorhandene. Beide Objekte
des Typs Vektor sind damit nicht mehr unabhängig von einander
verwendbar, Schreibzugriffe auf das eine wirken sich auch auf das andere
aus. Die Situation eskaliert, wenn etwa das erste Objekt vernichtet
wird.
Wenn wir also zwei Objekte A und B haben, die über dynamisch
reservierten Speicher verfügen, müssen wir diesen wie beim Kopierkonstruktor
wirklich kopieren. Bevor Sie weiterlesen, überlegen Sie sich zunächst, wie
Sie einen derartigen Zuweisungsoperator, etwa für unsere Vektorklasse,
schreiben würden.
Wenn Sie sich nicht viel Zeit genommen haben, kamen Sie vermutlich
zu folgendem Ablauf für die Zuweisung A=B:
A belegten Speichers
A in der von B benötigten Größe
B nach AWenn Sie besonders clever waren, kamen Sie vielleicht auch auf diese Idee, haben aber das Problem erkannt: Was passiert eigentlich, wenn das Reservieren des Speichers fehlschlägt oder einer der damit ausgelösten Konstruktoraufrufe? Wir werden ab Seite 575 noch sehen, dass dabei durchaus Fehler auftreten und gemeldet werden können. Passiert so etwas tatsächlich, während wir gerade bei Schritt 2 sind, befindet sich das Objekt in einem undefinierten Zustand. Es hat weder den alten Inhalt, noch kann es den neuen aufnehmen; es ist damit völlig unbrauchbar.
Um dies zu vermeiden, ändern wir die Reihenfolge ein wenig:
A belegten Speicher in eine
lokale Variable
A in der von B benötigten Größe
B nach A
A belegten Speichers mittels der lokalen
VariablenFür unsere Vektorklasse sieht der Zuweisungsoperator dann etwa folgendermaßen aus:
Vektor<T>& Vektor<T>::operator=(const Vektor<T>& _vek)
{
if (this != &_vek)
{
// 1. Kopieren auf lokalen Zeiger
T* tmp = v;
// 2. Reservieren des Speichers
v = new T[_vek.size];
if (v == 0) // kein Speicher!
{
v = tmp;
return *this;
}
size = _vek.size;
// 3. Kopieren des Inhalts
for(unsigned i=0; i<size; i++)
v[i] = _vek.v[i];
// 4. Freigeben des bisherigen Speichers
if (tmp)
delete[] tmp;
}
return (*this);
}
An diesem Beispiel können Sie noch ein weiteres Muster erkennen, das Sie bei Zuweisungsoperatoren stets verwenden sollten. Sie müssen den Fall beachten, dass ein Objekt sich selbst zugewiesen wird. Dank unseres Tricks von eben kann dabei zwar nichts Schlimmes passieren, da während des Kopierens noch beide Exemplare nebeneinander existieren. Es ist jedoch überflüssig und damit verschwendete Rechenzeit, eine Kopie von sich selbst anzulegen, um das Original anschließend wegzuwerfen. Achten Sie also immer darauf, dass die wesentlichen Aktionen Ihrer Zuweisungsoperatoren nur in Blöcken stattfinden, die durch
{
// ...
}
geschützt sind.
Noch ein paar weitere Anmerkungen zu Zuweisungsoperatoren:
MyClass c = b; // Kopierkonstruktor
c = a; // Zuweisungsoperator
Hinsichtlich des inneren Aufbaus besteht der Unterschied eigentlich nur darin, dass beim Zuweisungsoperator noch reservierter Speicher vorhanden sein kann, der freigegeben werden muss. Sie können sogar den Zuweisungsoperator in der Implementierung des Kopierkonstruktors aufrufen:
// ... (Initialisierungsliste)
{
operator=(_c);
}
Achten Sie dabei aber darauf, die Datenelemente vorher mit sinnvollen Werten zu initialisieren, zum Beispiel denselben wie im Standardkonstruktor.
Der enge Zusammenhang hat noch eine andere Folge: Es sollte keinen ohne den anderen geben. Wenn Sie also meinen, Sie brauchen einen Kopierkonstruktor, dann brauchen Sie auch einen Zuweisungsoperator und umgekehrt.
Wie Sie bereits oben gesehen haben, gibt es zwei verschiedene Möglichkeiten, binäre Operatoren zu definieren: entweder als eigenständige Funktion oder als Methode der Klasse. Für was soll man sich nun entscheiden? Betrachtet man das jeweilige Objekt isoliert, gibt es keinen besonderen Grund, der für eine bestimmte Lösung spricht; die Entscheidung ist also dem persönlichen Geschmack überlassen.
Oftmals gibt es jedoch Typumwandlungskonstruktoren, mit deren Hilfe
Standardtypen in ein Objekt der jeweiligen Klasse konvertiert werden
können. Ein einfaches Beispiel sind komplexe Zahlen. (Hintergrund: Um
auch beliebige polynomiale Gleichungen lösen zu können, definiert man
eine neue Zahl i mit i2 = -1 und erweitert damit den Zahlenbereich um
alle reellen Vielfachen von i. Das Ergebnis sind die komplexen Zahlen, die
man üblicherweise als Zahlenpaar a + bi mit a,b
R schreibt. Man
nennt dabei a den Realteil und b den Imaginärteil.) Obwohl es in der
C++-Standardbibliothek bereits einen Datentyp complex gibt (verwendbar
über #include ¡complex¿), wollen wir eine kleine Klasse erstellen, die eine
komplexe Zahl repräsentiert.
{
private:
double re, im;
public:
komplex(double _re = 0.0, double _im = 0.0) :
re(_re), im(_im) {}
double real() const {
return re; }
double img() const {
return im; }
};
Die beiden Vorgabewerte im Konstruktor sorgen dafür, dass auch ein
Anlegen eines Objekts mit nur einer double-Zahl möglich ist, zum
Beispiel
denn jede reelle Zahl ist auch eine komplexe, jedoch mit Imaginärteil 0,
also 3,14 + 0i. Wollen wir nun einen Operator + hinzufügen, müssen wir
also nicht nur den Fall
c = a + b;
betrachten, sondern auch
c = 3.14 + a;
Offensichtlich macht die Definition eines binären Operators als Methode hier wenig Sinn.
Selbst bei zwei gleichartigen Objekten sind binäre Verknüpfungen aus Symmetriegründen besser als eigenständige Funktion zu betrachten; denn was zeichnet das erste gegenüber dem zweiten dermaßen aus, dass dessen Methode aufgerufen wird und das zweite darin nur ein Parameter ist?
Bei arithmetischen Operatoren gibt es in C/C++ noch eine Besonderheit,
die Sie auch bei überladenen Operatoren nicht außer Acht lassen dürfen.
Neben der binären Verknüpfung gibt es noch die Kombination aus
Verknüpfung und Zuweisung in Form der Operatoren +=, -=, *= und so
weiter. Hier ist es ein unärer Operator, der folglich am besten als Methode
implementiert wird.
{
re += _k.re;
im += _k.im;
return *this;
}
Damit können wir nun sehr einfach den +-Operator schreiben:
{
komplex tmp = _x;
return tmp += _y;
}
Auch die weiteren Operatoren lassen sich durch dieses Zusammenspiel ganz leicht erstellen.
Was lernen wir daraus? Erfüllen Sie immer die Erwartungen der Benutzer Ihrer Klassen und bieten Sie auch die Varianten der Operatoren an, die sie von den Standardtypen gewohnt sind.
Bei einem Vergleich ist der Rückgabewert vom Typ bool, was angibt, ob
der Vergleich zu einer wahren oder zu einer falschen Aussage geführt hat,
zum Beispiel:
{
return (_k1.real() == _k2.real() && _k1.img() == _k2.img());
}
Um konsistent zu bleiben, sollten Sie die Negation des Vergleichs (also hier
das !=) stets so implementieren, dass Sie dabei die Operatorfunktion eines
bereits vorhandenen Falls aufrufen:
{
return !(_k1==_k2);
}
Bei diesem Beispiel konnten wir die eigenständigen Operatorfunktionen so
schreiben, dass sie nur öffentliche Methoden der Klasse verwendeten. Das
ist sicher nicht immer möglich. Dann müssen Sie den Prototyp der
Funktion als friend in Ihre Klassendeklaration eintragen (siehe Seite 151),
etwa folgendermaßen:
{
friend bool operator==(const komplex& _k1, const komplex& _k2);
private:
double re, im;
public:
//...
};
Dadurch können Sie innerhalb der Operatorfunktion auch die privaten Datenelemente benutzen:
{
return (_k1.re == _k2.re && _k1.im == _k2.im);
}
Achten Sie aber darauf, nicht allzu leichtfertig mit der Befreundung
umzugehen. Es sind zwar Operatoren, die unmittelbar zur Schnittstelle der
Klasse gehören (und daher auch in derselben Datei untergebracht werden
sollten; siehe [SUTTER 2000]), jedoch können sie diese Schnittstelle leicht
unübersichtlich machen. Die Rückführung eines binären Operators auf
eine öffentliche Methode wie im Fall von + auf += ist ein probates
Mittel, um ein Zuviel an Freunden zu vermeiden. Bei Klassen geht es
wie im richtigen Leben: Zu viele Freunde bedeuten meist nichts
Gutes.
Bei Standardtypen ist die Ein- und Ausgabe über Streams sehr bequem.
Man schreibt einfach cout ¡¡ c; und schon erscheint selbst eine hochgenaue
Gleitkommazahl in annehmbarer Form auf dem Bildschirm. Das möchten
wir auch bei unseren eigenen Klassen erreichen. Auch hier haben wir
grundsätzlich die Möglichkeit, den Operator als Methode oder als Funktion
zu schreiben. Aber Methode welcher Klasse? Das könnte zum Beispiel
ostream sein. Allerdings implementieren wir diese nicht selbst, sondern
erhalten sie als Teil der Standardbibliothek. Und von dieser können wir ja
kaum erwarten, dass sie auch unsere Klasse berücksichtigt hat. Also bleibt
nur noch die Funktion.
Von Funktionen für Stream-Operatoren wird erwartet, dass sie eine Referenz auf den Stream zurückgeben, den sie erhalten haben. Erst dadurch wird die Verkettung mehrerer Stream-Operatoren hintereinander möglich. Daher haben solche Funktionen die Form:
istream& operator>>(istream& _i, MyClass& _r);
Es ist außerdem offensichtlich, dass der Eingabeoperator eine echte Referenz auf das Objekt erhalten muss, da damit ja das Objekt beschrieben werden soll.
Für unsere Klasse komplex können wir diese Operatoren etwa wie folgt
programmieren:
{
_o << ``(`` << _k.real() << ``, `` << _k.img() << ``)'';
return _o;
}
istream& operator>>(istream& _i, komplex& _k)
{
_i >> _k.re >> _k.im;
return _i;
}
Wie Sie sehen, greifen wir beim Eingabeoperator auf private Attribute zu. Dazu muss diese Funktion mit der Klasse befreundet sein. Bei Eingabeoperatoren ergibt sich diese Notwendigkeit sehr viel häufiger als bei Ausgabeoperatoren, da dort ein reiner Lesezugriff nötig ist, der meist bereits existiert.
In diesem besonderen Fall können wir mit ein paar "Klimmzügen" sogar
die friend-Deklaration vermeiden:
{
double re, im;
_i >> re >> im;
_k = komplex(re,im);
return _i;
}
Im Abschnitt "Typumwandlungskonstruktor" (ab Seite 219) haben Sie
gesehen, wie man eine Regel formuliert, die angibt, wie ein Objekt eines
anderen Datentyps in ein Objekt der Klasse umgewandelt werden kann.
Nun wollen wir den umgekehrten Fall betrachten, nämlich eine Methode,
die beschreibt, wie ein Objekt der Klasse in einen anderen Datentyp
konvertiert werden kann. Da man dabei die Objektvariable direkt, das
heißt ohne einen durch . oder -¿ angehängten Methodenaufruf, verwendet,
zählt diese Art von Methode zu den Operatoren. Man spricht dabei vom
Typumwandlungsoperator.
Die Syntax ist sehr einfach:
{
public:
operator Datentyp();
// ...
}
Typumwandlungsoperatoren haben eine besondere Form: Sie sind Methoden einer Klasse und haben weder eine Parameterliste noch einen Rückgabetyp im Sinne der formalen Struktur einer Funktion (siehe Seite 169). Tatsächlich haben sie aber doch einen Rückgabewert, nämlich den Inhalt des Objekts im angegebenen Typ.
Typumwandlungsoperatoren sind eine potenzielle Fehlerquelle. Der
Programmierer erlaubt damit nämlich die implizite Umwandlung seines
Objekts in einen völlig anderen Typ. Diese Konvertierung ist oft mit einem
Verlust an Information verbunden. Da die Umwandlung auch implizit
geschehen kann, ist sie nicht immer unmittelbar als solche im Code
erkennbar. Noch weniger ist offensichtlich, dass dabei eine Methode des
Objekts aufgerufen wird. Sie sollten von dieser Technik also nur äußerst
sparsam Gebrauch machen und auch nur dann, wenn es sich wirklich
anbietet. Meist sind explizite Methoden mit derselben Funktionalität
sinnvoller - etwa solche, die die Umwandlung bereits in ihrem Namen
ausdrücken (beispielsweise getAsChar()).
Typumwandlungsoperatoren bieten sich immer dann an, wenn der
Zieltyp keine Klasse ist, die von Ihnen selbst definiert wurde, das heißt,
wenn es sich bei diesem entweder um einen Standardtyp (wie float oder
char*) oder eine Klasse von Dritten, etwa aus einer Bibliothek,
handelt.
Als Beispiel betrachten wir die Umwandlung eines Objekts vom Typ
Vektor¡T¿ in ein Array, also in einen Zeiger auf T. Dies ist durchaus
eine sinnvolle Ergänzung, da viele Bibliotheksfunktionen nicht mit
Vektorklassen umgehen können (auch nicht mit solchen der STL), sondern
Zeiger erwarten. Um die Inhalte unseres Objekts trotzdem nicht völlig
preiszugeben, womit die Rückgabe des Zeigers ja verbunden wäre, liefern
wir einen konstanten Zeiger. Damit sind auch die Bibliotheksfunktionen im
Allgemeinen zufrieden.
class Vektor
{
public:
operator const T*() const;
// ...
};
template <typename T>
Vektor<T>::operator const T*() const
{
return v;
}
In der Deklaration der Methode finden Sie gleich zwei const. Haben Sie
bereits erkannt, welchen Zweck diese erfüllen? Das erste bezieht sich
auf den Typ, den der Operator zurückliefern soll, nämlich einen
konstanten Zeiger auf T. Das const nach den runden Klammern weist
darauf hin, dass diese Methode auch für konstante Objekte vom Typ
Vektor¡T¿ verwendet werden darf; sie verändert das Objekt ja auch
nicht.
Verwendet wird diese Funktionalität wie jede andere Typumwandlung. Um die Konvertierung deutlich zu machen, empfehle ich Ihnen, sie explizit anzugeben.
// ...
const int* p;
p = (const int*)x;
Elegant, aber eben nur schwer durchschaubar, ist der Gebrauch mit
Bibliotheksfunktionen, beispielsweise mit strstr() (siehe Seite 438) zur
Teilstringsuche:
// ...
char* occ;
occ = strstr((const char*)code, "abcd");
Hier suchen wir nach dem ersten Vorkommen von ”abcd” im Vektor
code.
Zum Abschluss möchte ich Ihnen noch ein paar allgemeine Prinzipien vorstellen, die Sie beim Design Ihrer Klassen mit überladenen Operatoren beherzigen sollten.
In Methoden einer Template-Klasse, in denen Sie eine Veränderung an einem Parameter vom Template-Typ vornehmen, ist immer wieder der Einsatz von Operatoren nötig. Diese müssen für jeden Typ, der als Template eingesetzt werden soll, natürlich zur Verfügung stehen. Wenn Sie beispielsweise Stream-Operatorfunktionen für Template-Klassen schreiben, setzen Sie implizit voraus, dass jeder Typ, der als Template eingesetzt wird, ebenfalls die Eingabe beziehungsweise Ausgabe über Streams unterstützt.
Das kann in manchen Fällen eine sehr starke Forderung sein. Überlegen Sie sich also genau, welche Funktionalität Sie vom einzusetzenden Datentyp erwarten und fordern. Das gilt besonders für Vergleiche, Zuweisungen sowie Ein- und Ausgabe.
Bevor ein Benutzer mit Ihrer Klasse in Kontakt kommt, hat er sicher schon viele C++-Programmzeilen geschrieben. Er hat daher auch durch die vordefinierten Operatoren eine klare Vorstellung davon, welche Bedeutung ein bestimmter Operator hat. Diese Vorstellung sollten Sie durch das Verhalten der von Ihnen überladenen Operatoren keinesfalls auf den Kopf stellen. Denn nur wenn der Sinn eines Operators fast intuitiv erfassbar wird, kann ein Operator seinen eigentlichen Vorteil geltend machen, nämlich den Code lesbarer zu halten.
Wenn Sie sich also nicht sicher sind, wie sich ein von Ihnen
definierter Operator verhalten sollte, orientieren Sie sich an den ints
(wie unter anderen auch Scott Meyers vorschlägt [MEYERS 1996]).
Nur wenn Sie mit Ihrem Operator überhaupt nicht in die Nähe der
Standardoperatoren kommen - und es dafür einen guten Grund gibt -,
können Sie ein kleines Stück davon abrücken und Ihre Definition
pragmatisch gestalten.
Auch ist das Verhalten eines Operators manchmal bekanntermaßen ein
wenig anders als bei Ganzzahlen, etwa weil es in der Natur des Begriffes
liegt, den die Klasse modelliert. So ist beispielsweise bei Matrizen aus
der Mathematik bekannt, dass die Multiplikation im Allgemeinen
nicht kommutativ ist; folglich kann auch der Operator * dies nicht
sein.
Wenn Sie überladene Operatoren verwenden, sollten Sie stets darauf achten, dass die Schnittstelle Ihrer Klasse sowohl sinnvoll als auch vollständig bleibt. Das bedeutet insbesondere, dass Sie das Prinzip der Ausgewogenheit der Operatoren (siehe auch [Taligent 1994]) berücksichtigen.
Denn die Benutzer Ihrer Klasse erwarten von Ihnen, dass Sie die volle Funktionalität anbieten. Folgende Aspekte sind dabei zu beachten:
operator==() gehört auch immer
einer für die Ungleichheit operator!=().
operator¡() anbieten, sollten Sie auch
operator¡=(), operator¿() und operator¿=() hinzufügen. (Bei der
Implementierung ist es hingegen ratsam, die Operatorfunktion
der drei letzten auf die Funktion für den ersten zurückzuführen.)
operator++() als Präfix sollte immer eine
Postfix-Version (zu deklarieren als operator++(int)) neben sich
stehen haben (siehe Seite 551).
operator+(), gehört
immer die Kombination aus Verknüpfung und Zuweisung, also
operator+=() (siehe Seite 559).
operator+(double, komplex) auch die Variante
operator+(komplex, double).Wenn Sie bei inhaltlich zusammenhängenden Operatoren nur einen Operator anbieten, den anderen aber weglassen, ist der Benutzer trotzdem versucht, ihn aus Gewohnheit zu verwenden, läuft aber auf einen Fehler, der sich oft nur sehr schwer lokalisieren lässt. Es ist meist wenig Aufwand, die zusätzlichen Operatoren hinzuzufügen, zumal Sie deren Implementierung sehr oft auf die des ersten zurückführen können.
Die wichtigsten Gesichtspunkte aus diesem Abschnitt waren:
operator sowie
dem Operatorsymbol.
=, !=, ==, +, *, += und so
weiter) können überladen werden, nicht aber . sowie :: und ?: oder
andere Zeichen wie $. Es ist nicht möglich, neue Operatoren zu
definieren. Die vorgegebenen Prioritäten können nicht verändert
werden. Bei jeder selbst geschriebenen Operatorfunktion muss
mindestens ein Argument ein Objekt einer Klasse sein.
friend der Klasse gemacht werden. Unäre Operatoren sind
dagegen fast immer Methoden einer Klasse.
int-Argument kenntlich gemacht, das nicht ausgewertet wird.
¿¿ und ¡¡ überladen. Damit ist eine einfachere
Schreibweise, etwa cout ¡¡ myObject;, möglich.
friend
einer Klasse deklariert werden? Wo steht diese Deklaration?
Rational aus Abschnitt 2.6.10 (Seite 196)
und 2.7.10 (Seite 234) um Operatoren, so dass die Methoden add(),
mult() und so weiter durch Operatorsymbole erreichbar sind. Fügen
Sie auch Vergleichsoperatoren, einen Ausgabeoperator und einen
Typumwandlungsoperator nach double hinzu und testen Sie Ihre
Klasse an geeigneten Beispielen.
Set für eine Menge ganzer Zahlen implementiert
werden. Die Elemente sind in einem Feld konstanter Größe abzulegen.
Als privates Datenelement soll zudem noch die Kardinalität (Anzahl
der Elemente) gespeichert werden. Als Operatoren sind zu
implementieren:
// Elemente der Menge
int elems[maxCard];
// Kardinalität
int card;
public:
Set() { card = 0; }
// Test auf Mitgliedschaft
friend bool operator& (int,Set);
// Gleichheit
friend bool operator== (const Set&, const Set&);
// Ungleichheit
friend bool operator!= (const Set&, const Set&);
// Schnitt
friend Set operator* (const Set&, const Set&);
// Vereinigung
friend Set operator+ (const Set&, const Set&);
// Mengenminus
friend Set operator- (const Set&, const Set&);
// Teilmenge
friend bool operator<= (const Set&, const Set&);
// Ausgabe
friend ostream& operator<< (ostream&, const Set&);
// Eingabe
friend istream& operator>> (istream&, Set&);
// Hinzufügen
bool AddElem(int);
// Herausnehmen
bool RmvElem(int);
};
Die Maximalzahl der Elemente maxCard sei dabei wahlweise eine
Konstante oder ein Template-Parameter. Ergänzen Sie die
Schnittstelle der Klasse so, dass die Operatoren ausgewogen sind, und
testen Sie die Klasse an geeigneten Beispielen. Halten Sie diese
Schnittstelle für gelungen?
unsigned char einen
Bitvektor mit 8 Elementen darstellen. Um größere Bitvektoren
zu speichern, kann man Arrays solcher kleiner Bitvektoren
verwenden.
Implementieren Sie eine Klasse BitVec, die etwa folgende Schnittstelle
haben soll:
{
private:
unsigned char* vec; // Array mit 8*bytes Bits
short bytes;
public:
BitVec (unsigned short dim);
BitVec (const BitVec& v);
~BitVec();
bool operator[](unsigned short index);
void set (unsigned short index); // Setzen auf 1
void reset(unsigned short index); // Setzen auf 0
void print();
};
Zusätzlich sollen noch die binären Operatorfunktionen operator&
und operator| bereitgestellt werden, mit denen logische UND-
bzw. ODER-Verknüpfungen möglich werden. Fügen Sie außerdem
Operatoren für Gleichheit und Ungleichheit sowie einen
Zuweisungsoperator hinzu.
In Abschnitt 6.2 (ab Seite 676) sehen wir uns an, welche Möglichkeiten es gibt, Fehler zu machen, und welche Möglichkeiten der Entwickler hat, diese zu erkennen und zu beseitigen. Grundsätzlich muss man zwischen zwei Arten von Fehlern unterscheiden:
Es ist damit eigentlich irreführend, in beiden Fällen von "Fehlern" zu sprechen. Im ersten Fall haben wir es mit tatsächlichen Programmierfehlern zu tun, im zweiten dagegen nur mit Störungen des Programmablaufs. Um einen Mechanismus der Sprache C++ zur Behandlung dieser Störungen soll es in diesem Abschnitt gehen.
Zunächst wollen wir uns überlegen, wie wir auf solche Störungen reagieren könnten. Ich hatte Ihnen schon mehrfach geraten, möglichst defensiv zu programmieren, das heißt hinter allen Funktionsaufrufen Fehlschläge zu vermuten und die entsprechenden Rückgabewerte abzufragen. Solche Fehlschläge können unter anderem sein:
Es geht also um Ausnahmesituationen, die Sie beim Programmieren bereits voraussehen und für die Sie entsprechende Gegenmaßnahmen treffen können. Von welcher Art könnten diese sein?
true, ging alles
glatt; ansonsten drückt er ein Problem oder sogar schon eine
mögliche Ursache aus.
Diese letzte Möglichkeit wird auch in der Standardbibliothek oft verwendet. Sie ist aber leider nicht für alle Situationen auch die beste. Denn zuweilen kann das Problem als solches zwar bei seinem Auftreten entdeckt werden; es ist jedoch nicht möglich, in diesem Kontext die eigentliche Ursache sowie eine geeignete Reaktion zu erkennen. Erst der Aufrufer kann darauf angemessen reagieren und den Fehler gegebenenfalls beheben. Nun ist leider der "Aufrufer" nicht immer gleich in der nächsthöheren Ebene, sondern kann bei einer komplexen Programmstruktur mehrere Ebenen darüber liegen. Wenn es sich um ein Problem handelt, von dem der Benutzer unterrichtet werden sollte, will dieser ebenfalls nicht mit einer Meldung in der Form "Ungültiger Wert 0 in Zugriff auf m_Order[i]" belästigt, sondern über die wahren Hintergründe aufgeklärt werden.
Eine weitere Schwierigkeit mit dieser Vorgehensweise ist, dass es C++ erlaubt, den Rückgabewert von Funktionen und Methoden zu ignorieren. Die Funktion, die das Problem feststellt, kann also nach dem Zurückmelden weder sicherstellen noch nachprüfen, ob ihrer Meldung auch Beachtung geschenkt wurde.
Eine zusätzliche Alternative zu den gerade aufgezeigten Möglichkeiten
besteht darin, dass die Funktion, die das Problem erkennt, selbst eine
andere Funktion zu dessen Behandlung aufruft und dieser eventuell auch
noch entsprechende Kontextinformationen mitgibt. Prinzipiell bedeutet
dies nichts anderes, als einen zweiten Weg zum Verlassen einer Funktion
neben der Rückkehr mit return zu schaffen.
Zur Behandlung von vorhersehbaren Fehlern, die in einer Funktion auftreten und die der Aufrufer behandeln kann, stellt C++ den Mechanismus der Ausnahmebehandlung (besser bekannt unter dem englischen Ausdruck exception handling) zur Verfügung. Dabei ist der Ablauf typischerweise der folgende:
try ausgedrückt.
throw.
catch. Sowohl try als auch catch leiten dabei einen Block ein und stehen in der
aufrufenden Funktion. Der Befehl throw befindet sich in der aufgerufenen
Funktion.
Wenn eine Funktion eine Ausnahme (Exception) wirft, ist dies ein alternativer Rücksprung. Alle Objekte, die dort vollständig erzeugt wurden (also deren Konstruktoren schon beendet sind), werden gelöscht. Es findet aber kein normaler Rücksprung mehr statt!
Syntaktisch entspricht diese Beschreibung folgendem Schema:
{
try
{
// Versuche, eine andere Funktion
// aufzurufen; diese könnte eine
// Exception auslösen und dabei ein
// Fehlerobjekt übergeben
f2();
}
// Werte die möglichen Fehlerobjekte aus
catch(Fehlerklasse1& _f)
{
// Fehlerbehandlung
}
catch(Fehlerklasse2& _f)
{
// Fehlerbehandlung
}
// ... ggf. weitere catch-Blöcke
// Hier geht der Programmfluss weiter
}
Beim Werfen einer Ausnahme darf ein Objekt eines beliebigen Typs übergeben werden, also sowohl ein Standardtyp als auch ein Objekt einer Klasse. Sie sollten aber immer darauf achten, dass Sie das Objekt selbst übergeben und keinen Zeiger darauf.
Wenn Sie etwa schreiben:
müssen Sie vorher einen Typ - zum Beispiel eine Klasse - namens
MyException definiert haben. Diese darf sogar völlig leer sein; denn
eventuell drücken Sie ja schon durch den Typ selbst die Art der Ausnahme
aus. Wenn Sie hier nur den Namen der Klasse sehen, so bedeutet dies, dass
beim throw-Kommando ein temporäres Objekt von diesem Typ angelegt
wird; nach Ende des zugehörigen catch-Blocks wird es automatisch wieder
freigegeben. Sie verstehen sicher, dass es bei diesem Ablauf höchst
ungeschickt wäre (wenn auch leider nicht syntaktisch falsch), das
Ausnahmeobjekt dynamisch zu erzeugen.
Über den Konstruktor kann dieses Objekt bei Bedarf noch mit Informationen über die Umstände der Ausnahme gefüllt werden.
throw MyException("Operation failed", status);
Natürlich sollte die Klasse dann auch über entsprechende öffentliche Methoden verfügen, so dass der Fänger der Ausnahme auf diese Informationen zugreifen kann.
Das Argument von catch kann sowohl als Objekt als auch als Referenz
abgefangen werden. Dadurch lassen sich auch die enthaltenen Informationen
auswerten, zum Beispiel:
{
cerr << _exc.getMessage() << endl;
// weitere Fehlerbehandlung
}
Manchmal kann man nicht genau wissen, welche Ausnahmen auf einen Programmabschnitt zukommen können. Aber auch dafür gibt es einen Ausweg: Wenn Sie nur drei Punkte als Argument angeben, so fangen Sie damit alle (vorher nicht behandelten) Ausnahmen ab:
{
cerr << "Unbekannter Fehler!" << endl;
}
Sie sollten in Ihren Programmen darauf achten, dass Sie alle möglichen
Ausnahmen auch auffangen. Denn es gilt: Wird eine Ausnahme
in der aufrufenden Funktion nicht abgefangen, wird sie an deren
aufrufende Funktion weitergegeben. Das setzt sich fort bis hin zu
main(). Ist auch dort keine Behandlung implementiert, bricht das
Programm mit einer Fehlermeldung ab. Dann haben Sie in puncto
Fehlerbehandlung und Stabilität Ihres Programms herzlich wenig
erreicht.
Es ist aber auch möglich, eine Ausnahme bewusst an die nächsthöhere
Ebene weiterzugeben. Vielleicht wollen Sie die Abfolge innerhalb des
Aufruf-Stacks nur dokumentieren und den Fehler nicht wirklich behandeln.
Dann werfen Sie die aktuelle Ausnahme mit einem schlichten throw einfach
weiter, zum Beispiel:
{
log("Unbekannter Fehler!");
throw; // Weitergabe
}
Wenn Sie eine Klasse verwenden, die ein anderer implementiert hat, möchten Sie gerne wissen, welche Ausnahmen deren Methoden werfen könnten. Da die Header-Datei mit der Schnittstelle Ihre primäre Informationsquelle ist, sollten auch die Ausnahmen dort vermerkt sein.
Genau dies unterstützt die Sprache C++. Um anzugeben, welche Exceptions eine Funktion auswirft, kann man sie in einer Liste hinter dem Funktionsprototyp deklarieren.
Ist eine solche Angabe vorhanden, ist sie auch verbindlich. In diesem Fall
darf die Funktion func ausschließlich die Ausnahmen RangeException und
DomainException werfen; bei anderen meldet der Compiler einen
Fehler. Als Entwickler müssen Sie beachten, dass die Liste bei der
Funktionsdefinition wiederholt werden muss.
Steht hinter dem Prototyp nur throw(), so verspricht die Funktion
damit, überhaupt keine Exceptions auszuwerfen.
Aus Gründen der Abwärtskompatibilität gilt aber leider auch: Fehlt
eine Angabe von throw hinter einer Funktion, so darf sie beliebige
Ausnahmen auswerfen. Eine Schnittstelle mit gutem Design sollte daher
immer die von ihr geworfenen Ausnahmen auflisten.
Die Klassen der Exception-Objekte können auch eine Hierarchie bilden.
Eine catch-Anweisung, die als Argument ein Objekt der Basisklasse
enthält, fängt dann auch alle davon abgeleiteten Exceptions ab. Damit Sie
auch tatsächlich auf die Informationen der abgeleiteten Klasse zugreifen
können, müssen Sie die Methoden der Basisklasse natürlich als virtual
deklarieren.
Wenn Sie beispielsweise einen Kellerspeicher (stack) (siehe auch Seite 399 und 519) mit fester Größe implementieren, also einen Container für Objekte, bei dem das letzte Objekt, das Sie dort abgelegt haben, auch das erste ist, das Sie wieder herausbekommen, können Sie folgende Hierarchie bilden:
class StackEmpty : public StackError {};
class StackFull : public StackError {};
In der Klasse sieht das dann folgendermaßen aus:
class SimpleStack
{
public:
// ...
T pop() throw(StackEmpty);
void push(const T&) throw(StackFull);
};
Wie üblich bezeichnet dabei push() das Ablegen und pop() das
Herunternehmen eines Objekts. In der Anwendung können Sie nun durch
das Fangen von StackError beide Fälle behandeln:
{
SimpleStack<int> stack;
try
{
cout << stack.pop();
for (int i=0;i<30; i++)
stack.push(i);
}
catch(StackError)
{
cerr << "So geht's nicht!" << endl;
}
//...
}
Da StackError über keinerlei Elemente verfügt, genügt in der
catch-Anweisung die Angabe des Typs. Einen Argumentnamen benötigen
wir nicht, da wir mit dem Objekt sowieso nichts anfangen können.
get_user_input() dann eine Ausnahme, wenn die Eingabe ungültig war:
do
{
try
{
get_user_input();
do_repeat = false;
}
catch(InvalidInput)
{
cerr << "Input invalid!";
}
}
while(do_repeat);
Eine solche Vorgehensweise ist aber nur in den seltensten Fällen eine gute Idee. Normalerweise sollten Sie andere Wege zur Kontrollflusssteuerung finden können und dafür keine Exceptions benötigen. Bewusst ausgelöste Exceptions sind ohnehin immer überflüssig.
Die Technik der Ausnahmebehandlung wollen wir nun bei unserer bekannten Vektorklasse einsetzen, die wir zuletzt in Abschnitt 4.3 überarbeitet haben. Wir definieren zwei Ausnahmen:
VektorNoMem, wenn kein Speicher mehr für das dynamische
Anlegen des Feldes vorhanden ist
VektorOutOfRange, wenn bei at() der Index den zulässigen
Bereich überschreitet Beide leiten wir wie eben von einer Basisklasse ab, die auch gleich die
Schnittstelle für den Zugriff auf die Informationen in abstrakter Weise
definiert. Wir geben die Fehlermeldung einfach als Text zurück, den wir
mittels eines ostringstream erzeugt haben:
class VektorException
{
public:
VektorException() throw() {}
virtual ~VektorException();
virtual std::string what() const = 0;
};
// Ausnahme bei Bereichsüberschreitung
class VektorOutOfRange
{
private:
unsigned int size, index;
public:
VektorOutOfRange(unsigned int _size, unsigned int _index) :
size(_size), index(_index) {}
virtual ~VektorOutOfRange() {};
virtual std::string what() const
{
std::ostringstream s;
s << "Bereichsüberschreitung (Größe: "
<< size << ", Index: " << index
<< ")" << std::ends;
return s.str();
}
};
Die Klasse VektorNoMem ist ganz analog zu VektorOutOfRange
aufgebaut, so dass ich sie hier wohl nicht abdrucken muss.
Wenn wir nun alle möglichen Ausnahmen in die Schnittstelle der Klasse
Vektor aufnehmen, erlangt diese folgende Form:
template <typename T> class Vektor
{
private:
unsigned int size;
T* v;
public:
Vektor() : size(0), v(0) {}
Vektor(unsigned int _size) throw(VektorNoMem);
Vektor(const Vektor& _vek) throw(VektorNoMem);
~Vektor() { if (v) delete[] v; }
void resize(unsigned int _size)
throw(VektorNoMem);
const T& at(unsigned int _i) const
throw(VektorOutOfRange);
T& at(unsigned int _i)
throw(VektorOutOfRange);
// ...
};
Der Konstruktor mit Größenangabe sieht eigentlich aus wie bisher; nur der Fall, dass kein Speicher mehr zur Verfügung steht, wird mit einer Exception quittiert.
Vektor<T>::Vektor(unsigned int _size)
throw(VektorNoMem) : size(_size)
{
v = new T[size];
if (v == 0)
throw VektorNoMem(size);
}
Beim Zugriff auf einzelne Elemente mittels der at()-Methode haben wir das
assert() durch ein throw ausgetauscht. Das hat vor allem den Vorteil,
dass nicht bei jeder Indexverletzung das Programm gleich stehen
bleibt, sondern man die Situation unter Umständen noch retten
kann.
T& Vektor<T>::at(unsigned int _i) throw(VektorOutOfRange)
{
// Vorbedingung: _i gültig und v vorhanden
if (v == 0 || _i>=size)
throw VektorOutOfRange(size,_i);
return v[_i];
}
Durch die Übergabe der entscheidenden Größen size und _i ist der Aufrufer
in der Lage, seine Anfrage gegebenenfalls zu überdenken und zu
wiederholen.
Wenn wir in unserem Testprogramm beispielsweise den Index um eins daneben platzieren, erhalten wir die Exception:
{
unsigned int i=0;
// Ganzzahlvektor
Vektor<int> v(5);
for(i=0; i<5; i++)
v.at(i) = i+3;
try
{
// einen Index zu viel
for(i=0; i<=5; i++)
cout << v.at(i) << " ";
}
catch (VektorOutOfRange& _e)
{
cerr << "Fehler: " << _e.what() << endl;
}
Das Programm führt zur Ausgabe:
Als moderner Bestandteil von C++ sollte natürlich auch die Standardbibliothek - insbesondere die STL - Gebrauch von Exceptions machen. Die Implementierung der GNU-STL, wie sie vom GCC verwendet wird, arbeitet durchgängig mit Ausnahmen.
Von der Basisklasse für die Exceptions können Sie auch Ihre eigenen Ausnahmen ableiten:
public:
exception () { }
virtual ~exception () { }
virtual const char* what () const;
};
Wie Sie sehen, habe ich mich bei den Ausnahmen der Vektorklasse bereits daran orientiert. Diese Klasse ist in der Header-Datei ¡exception¿ zu finden. Es gibt davon noch eine Reihe von abgeleiteten Klassen, die verschiedene Arten von Fehlern ausdrücken sollen. Diese sind in ¡stdexcept¿ definiert:
logic_error bezeichnet Fehler in der Programmlogik, die
prinzipiell vermeidbar sind. Davon wiederum abgeleitet sind
die Klassen invalid_argument, length_error, out_of_range sowie
domain_error.
runtime_error
ist für Probleme, die zur Laufzeit von außerhalb des Programms
herrühren. Hierunter gibt es die Ausnahmeklassen out_of_range,
overflow_error und underflow_error.
exception erben. Diese stellen besondere Probleme im
Programmablauf dar:
bad_alloc wird bei Speichermangel vom Operator new
geworfen. Voraussetzung ist, dass Sie die Header-Datei
¡new¿ einbinden.
bad_typeid bezieht sich auf einen falschen Objekttyp bei der
Bestimmung von Typinformationen zur Laufzeit. Darauf
kann ich hier leider nicht näher eingehen.
bad_cast wird vom Operator dynamic_cast¡¿ geworfen, falls
Sie als Argument eine Referenz angeben, die nicht auf
ein Objekt vom angegebenen Typ (oder eine Unterklasse
davon) verweist (siehe Seite 533). Ähnlich wie bei meiner Klasse VektorException erhalten Sie auch hier
über die Methode what() eine textuelle Beschreibung des Fehlers.
Exceptions haben zweifellos einige Vorteile. Neben der Eleganz sind dies vor allem:
Allerdings ist dieses Konzept auch kein Allheilmittel. Verschiedene Autoren (zum Beispiel [ALEXANDRESCU 2001] oder der "C"-Erfinder B. Kernighan in [KERNIGHAN und PIKE 1999]) warnen zurecht davor, zu häufigen Gebrauch von Exceptions zu machen. Wenn sich etwa eine Datei nicht öffnen lässt, weil der Name falsch war, ist das keine so dramatische Situation, dass sie eine Exception rechtfertigen würde. Gehen Sie also sparsam damit um und versuchen Sie, weitgehend mit Rückgabewerten auszukommen. Nur bei komplex verschachtelten Strukturen und bei Fehlern in Konstruktoren, die ja keine Rückgabewerte erlauben, sind Exceptions sinnvoll (aber nicht in Kopierkonstruktoren oder Destruktoren!).
Noch ein Hinweis am Schluss: Werfen Sie niemals Exceptions in einem Destruktor! Sie bringen damit Ihr Programm vermutlich vollends durcheinander.
Aus diesem Abschnitt sollten Sie sich folgende Gedanken einprägen:
throw geworfen. Als
Argument darf dabei ein beliebiger Typ mitgegeben werden.
Oftmals ist es ein selbst definiertes Objekt, das über seinen
Konstruktor auch noch einige Kontextinformationen aufnehmen
kann.
try
steht. Nach dem Block stehen einige oder mehrere weitere Blöcke,
die mit der catch-Anweisung beginnen. Für jede Klasse, von der
ein Objekt mit der Ausnahme übergeben werden kann, darf eine
catch-Anweisung stehen. Mit catch(...) werden alle Ausnahmen
abgefangen.
throw-Kommando ohne weitere Argumente
gibt man eine bereits gefangene Exception an die nächsthöhere
Ebene weiter.
throw( und ist durch Kommas getrennt und
wird mit einer runden Klammer ) geschlossen. Die Angabe
ist verbindlich, das heißt, dass die Funktion auch nur diese
Ausnahmen werfen darf. Geben Sie ein throw() mit leerem
Klammernpaar an, darf die Funktion überhaupt keine Ausnahme
werfen.
Char, die bei einem
bevorstehenden Unter- beziehungsweise Überlauf eine Ausnahme
auswirft, sich aber ansonsten wie der Datentyp char verhält.
SimpleStack von Seite 586 und testen
Sie sie an geeigneten Beispielen.
Rational, die Sie bereits aus
verschiedenen vorherigen Übungen kennen (Seiten 196, 234 und 572).
Fügen Sie zu dieser Klasse eine Ausnahmebehandlung hinzu. Ein
Problemfall, den Sie dabei abfangen sollten, ist die Division durch
0:
std::exception ableiten.
die die Umwandlung einer Zeichenkette in eine Ganzzahl übernimmt.
Dabei sollen auch negative Zahlen (mit Minusvorzeichen) und
Dezimalzahlen erkannt werden. Falls es sich bei der übergebenen
Zeichenkette nicht um eine Zahl handelt, soll die Funktion eine
NumberFormatException werfen. Diese Klasse können Sie wieder von
std::exception ableiten.