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:
Größere Programme bestehen immer aus mehreren Quelltext-Dateien, die von verschiedenen
Programmierern bearbeitet werden. Wenn dann nach einiger Zeit alle Teile zu
einem Gesamtsystem zusammengefügt werden sollen, stellt man nicht selten fest,
dass einzelne Mitarbeiter für ihre Konstanten, Funktionen oder Klassen dieselben
Bezeichner verwendet haben. Im begrenzten Rahmen des Subsystems, das der jeweilige
Entwickler zu verantworten hat, ist daran ja auch nichts auszusetzen. Mit der
theoretischen Unabhängigkeit der einzelnen Programmteile ist es aber zu Ende,
sobald alle zu einem Gesamtprogramm zusammengelinkt werden sollen. Der einzige
Ausweg war in solchen Fällen, den entsprechenden Bezeichner in einem Modul umzubenennen
-- in der Hoffnung, dass dieser Schritt keine Seiteneffekte nach sich zieht.
Viele Entwicklungsleiter haben zudem versucht, das Problem von vornherein dadurch
zu vermeiden, dass sie für alle Bezeichner Präfixe vorgeschrieben haben, die
das jeweilige Subsystem kennzeichnen sollten. Aber leider sind es ja nicht die
»großen« Datentypen mit der Geschäftslogik, die kollidieren, sondern meistens
die Hilfsgrößen (also etwa Konstanten wie OK
oder ERROR
); und
für diese wurden die Namenskonventionen nur selten eingehalten.
Um dieses Problem zu überwinden, gibt es in C++ den Begriff des Namensraums (Englisch namespace). So simpel die Syntax im Grunde ist, die Unterstützung durch die Compiler war doch lange Zeit rar. Erst in jüngster Zeit beherrschen alle namhaften Compiler dieses Sprachmerkmal -- natürlich auch der GCC.
Stellen Sie sich einen Namensraum als benannten Block vor, ähnlich wie eine Struktur oder eine Klasse, nur dass man davon keine Instanzen bilden kann. Alle Bezeichner, die in diesem Block definiert werden, müssen von außen zusätzlich über den Namen des Namensraumes angesprochen werden. Ihr Name allein genügt nicht mehr.
Als Beispiel betrachten wir folgende Situation: Zwei Programmierer, Max und Moritz,
haben jeweils eine Komponente zu unserem Softwaresystem entwickelt. Dabei verwendet
jeder von ihnen intern einen Algorithmus, der eine double
-Zahl erhält
und eine andere desselben Typs zurückliefert. Sie schreiben also jeweils eine
Funktion algorithm()
, die zu allem Überfluss auch noch von einer Konstante
EPS
abhängt. Wenn wir nun die Komponenten der beiden in unser Hauptprogramm
integrieren, meldet der Linker:
moritz.o: In function `algorithm(double)':
moritz.o(.text+0x0): multiple definition of
`algorithm(double)'
max.o(.text+0x0): first defined here
// Datei max.h
namespace Max
{
double algorithm(double _x);
extern const double EPS;
}
extern
ist für Konstanten
übrigens so etwas wie der Prototyp für Funktionen: Es gibt an, dass die so deklarierte
Variable zwar existiert, aber nicht hier, sondern an anderer Stelle definiert
ist. Wenn Sie Ihre Konstanten (oder globalen Variablen) gleich in der Header-Datei
definieren und diese in verschiedene Implementierungsdateien einbinden, beschwert
sich wieder der Linker über doppelte Definitionen.
Bei der Verwendung von Namensräume sollten Sie Folgendes beachten:
namespace
-Deklaration ist kumulativ. Das bedeutet, wenn Sie nach
einer Deklaration im weiteren Verlauf oder einer anderen Header-Datei eine weitere
angeben, wird dadurch nicht die erste ungültig, sondern die Elemente der zweiten
Deklaration werden in den bestehenden Namensraum aufgenommen.
std
Der C++-Standard schreibt einen vordefinierten Namensraum
std
für alle Bestandteile der C++-Standardbibliothek vor (siehe Seite
). In diesem sollen sich alle Funktionen der C-Bibliothek,
alle Klassen der STL und so weiter befinden. Derzeit ist diese Vorgabe aber
beim GCC abgeschaltet, so dass Sie eigentlich ohne Angabe des Raumes auf alle
Elemente zugreifen können. Da sich die Situation indessen schon bald ändern
könnte, sollten Sie sich bereits jetzt der Problematik bewusst sein und bei
einem Update die Fehlermeldungen richtig interpretieren können (siehe auch Seite
).
Wenn Sie eine Funktion, eine Klasse oder einen anderen Bezeichner verwenden wollen, die in einem Namensraum deklariert sind, haben Sie zwei Möglichkeiten:
using
-Direktive; dann sind die Namen im gesamten aktuellen
Gültigkeitsbereich bekannt.
Hierbei setzen Sie den Namen des Namensraums vor den Bezeichner und trennen die beiden
durch den Bereichsoperator ::
. Für unser Beispiel heißt das:
// Datei maxmoritzmain.cc
#include ``max.h''
#include ``moritz.h''
int main()
{
double x = Max::algorithm(1.0);
// ...
algorithm()
eindeutig identifizieren.
Konsequenterweise gehört alles, was Sie nicht explizit in einen selbst definierten Namensraum gepackt haben, in den globalen Namensraum. Wollen Sie ausdrücklich betonen, dass Sie auf ein Element dieses globalen Namensraums Bezug nehmen möchten, so setzen Sie nur den Bereichsoperator davor, etwa
int* p = ::new int[10];
using
Sie können auch einen ganzen Namensraum bekannt machen, so dass Sie ohne weitere
Angaben darauf zugreifen können. Dazu geben Sie das Schlüsselwort using
gefolgt von namespace
und dem Namen des Raumes
an. Haben Sie sich beispielsweise für die Lösung unseres Entwicklers Moritz
entschieden, schreiben Sie zu Beginn Ihres Programms:
using namespace Moritz;
// Datei maxmoritzmain.cc
#include ``max.h''
#include ``moritz.h''
int main()
{
using namespace Moritz;
double x = algorithm(1.0); // aus ``Moritz''
// ...
using
-Anweisung gilt dabei für den aktuellen Block. Wenn Sie sie
also innerhalb einer Funktion verwenden, ist der Inhalt des Namensraums nur
in dieser Funktion global bekannt; in einer anderen nicht mehr. Um einen Namensraum
für eine ganze Implementierungsdatei verfügbar zu machen, gibt man using
auch oft außerhalb aller Funktionen an, zum Beispiel gleich nach den #include
-Anweisungen.
Wenn Ihnen der komplette Raum zu viel ist, erlaubt Ihnen using
genauso die
Bekanntmachung einzelner Funktionen oder Konstanten. In diesem Fall schreiben
Sie dahinter den voll qualifizierten Namen, also mit Raum und Bereichsoperator.
Anschließend ist dieser dann global, das heißt ohne Angabe des Namensraumes,
verfügbar.
int func(double x)
{
using Moritz::EPS;
if (x < EPS) { // entspricht Moritz::EPS
// ...
public
-Vererbung
sind alle als protected
deklarierten Klassenelemente auch in der abgeleiteten
Klasse protected
; eine Freigabe ist nur über eine zusätzliche kapselnde
Methode möglich.
Die letzten Aussagen stimmen leider nicht unumschränkt. Das Schlüsselwort using
kann man nämlich auch einsetzen, um eigentlich geschützte Methoden in einer
Unterklasse öffentlich zu machen. Im Grunde deklariert man damit zwar nur ein
öffentliches Alias für ein an sich weiterhin geschütztes Element -- die Wirkung
ist aber dieselbe.
Sehen wir uns das an einem Beispiel an:
class MediaClip
{
protected:
void play();
// ...
};
AudioClip
ab:
class AudioClip : public MediaClip
{
public:
using MediaClip::play;
int getDuration();
// ...
};
play()
darüber
(natürlich) nicht zugreifen:
MediaClip clip;
clip.play(); // nicht erlaubt
AudioClip sound;
sound.play(); // erlaubt
private
-Elementen ist eine solche Art von Veröffentlichung übrigens
nicht möglich. (Wissen Sie, warum nicht?)
Selbst wenn diese Vorgehensweise gültige Syntax darstellt, ist sie noch lange nicht gute
Syntax. Denn das Design einer Klasse sollte den öffentlichen und privaten Teil
so weit trennen, dass es auch in einer Unterklasse weder notwendig noch sinnvoll
ist, geschützte Elemente zu veröffentlichen. Meines Erachtens deutet das Bestreben
einer solchen Veröffentlichung eher auf einen Designfehler in der Basisklasse
hin. Sollte es dennoch einmal einen guten Grund für einen solchen Schritt geben,
empfehle ich Ihnen, dafür lieber eine neue public
-Methode einzuführen,
die den Aufruf an die protected
-Methode weiterleitet. Auf diese Weise
ist für alle Beteiligten die Situation klar.
Wenn sich Namensräume nicht überschneiden, kann man sie zu einem einzigen zusammenfassen,
um sie besser handhaben zu können. Dazu macht man sie einfach global in einem
neuen bekannt und übergibt diesem damit den Bezug. Sind etwa Max
und
Moritz
ohne identische Bezeichner, können wir aus ihnen einen neuen Raum Algorithmen
machen.
// Datei Algorithmen.h
namespace Algorithmen
{
using namespace Max;
using namespace Moritz;
}
Das Konzept der Namensräume geht noch etwas weiter. Wenn unsere Programmierer
ihrerseits wieder ihre einzelnen Komponenten in Namensräume eingeteilt hätten,
erhielten wir innerhalb des Raumes Max
einen weiteren, zum Beispiel Auxiliary
.
Der Zugriff erfolgt wieder auf die oben beschriebene Weise:
//...
if (x< Max::Auxiliary::EPS) {
// ...
// Der neue Raum MAD enthaelt drei
// verschachtelte Namensraeume
namespace MAD = Max::Auxiliary::Debug;
In diesem Abschnitt gab es wieder einiges Neues:
x = Max::algorithm(1.0);
using
-Anweisung; dann sind die Namen
im gesamten aktuellen Gültigkeitsbereich bekannt. Es lassen sich entweder vollständige
Namensräume bekannt machen (etwa using namespace Moritz;
) oder auch nur
einige Elemente daraus (zum Beispiel using Moritz::EPS;
).
namespace
-Deklaration ist kumulativ, das heißt, eine weitere Deklaration
desselben Namensraums fügt lediglich neue Elemente hinzu.
using
-Anweisung in eine Header-Datei zu schreiben?
Oftmals 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:
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
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:
template <typename T> T max(T a, T b)
{
return (a>b)? a : b;
}
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 main()
{
int x = max(1, 5); // int-Version
double y = max(1.33, 3.14); // double-Version
// ...
}
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
), bei der diese ebenfalls nur anhand der Signatur,
also des Namens und der Typen der Argumente, unterschieden werden.
Was macht man aber, wenn man gleichzeitig die automatische Typumwandlung nutzen
will? Wir wissen ja beispielsweise (Seite !), dass
int
nach double
konvertiert werden kann. Wenn wir aber nun schreiben:
cout << "max(3, 3.5): " << max(3, 3.5) << endl;
templ.cc:11: no matching function for
call to `max (int, double)'
cout << "max(3, 3.5): " << max<double>(3, 3.5)
<< endl;
Nicht immer kann jedoch ein Funktionstemplate wirklich alle Fälle abdecken. Wenn wir
beispielsweise unsere Funktion max()
mit einem char
-String aufrufen:
cout << "max(abc, bcde): "
<< max("abc", "bcde") << endl;
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 ):
template<>
const char* max(const char* a, const char* b)
{
return strcmp(a,b)>0? a: b;
}
Natürlich muss dabei die überladene Funktion in der Form ihrer Signatur mit
dem Funktionstemplate überstimmen. 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 ),
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:
// Datei max.h
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);
// ...
}
max()
, sondern den gesamten Code. In diesem Sinne lassen sich Funktionstemplates
ähnlich handhaben wie inline
-Funktionen (siehe Seite
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 ).
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 so der Code schnell stark 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 ,
Seite
, 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.
// Datei Vektor.h
// 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 <iostream>
#include "Vektor.h"
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;
// ...
}
3 4 5 6 7
Natürlich können Sie auch selbst definierte Datentypen für den Platzhalter einsetzen,
zum Beispiel eigene Klassen. In Abschnitt haben wir
ab Seite
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 von Datum
Vektor<Datum> daten(31);
for(i=0; i<31; i++)
daten.at(i).setze(i+1, 12, 2000);
for(i=23; i<31; i++)
daten.at(i).ausgeben();
24.12.2000
25.12.2000
26.12.2000
27.12.2000
28.12.2000
29.12.2000
30.12.2000
31.12.2000
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 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
Vektor<Datum> daten(31);
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 Templateparameters 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 auch tun):
// Vektor von Vektor
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;
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
Vektor<Vektor<double> > m(3);
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 ). Dieser
Weg steht Ihnen für Templateparameter 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 Templateparameter konfigurierbar machen:
template <typename T=int,
unsigned int _size = 10>
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);
};
enum Status { SUCCESS, INFO, WARNING,
ERROR, FATAL};
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;
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):
int read(const string& _s)
{
// 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
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.
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.
// Deklaration der Klasse
template<> class Vektor<void*>
{
private:
unsigned int size;
void** v;
public:
void*& at(unsigned int _i);
// ...
};
Stroustrupverrä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 ff.).
template<typename T> class Vektor<T*> :
private Vektor<void*>
{
public:
T*& at(unsigned int _i)
{ return (T*&)(Vektor<void*>::at(_i)); }
// ...
};
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 Code-Bestandteilen, 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;
.
int i;
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) { /* ... */ }
f(cFeld,20), f(iFeld,20), f(iFeld[0], i),
f(i, ui), f(iFeld, ui), f(&i, i)
template
davorschreiben, wenn Sie
eine Methode einer Templateklasse innerhalb der Klassendeklaration implementieren?
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
an den Stützstellen in einem Vektorobjekt. Testen Sie das Programm
an verschiedenen Funktionen, deren Integral leicht zu berechnen ist, zum Beispiel
oder einem Polynom.
Als ich Ihnen ab Seite 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 auf der STL basiert. Nachdem in diesem Buch schon viel von der Standardbibliothek die Rede war, sollten wir einmal klären, was diese genau enthält:
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. Allerdings sieht dies bei Linux, das heißt beim GCC, etwas anders
aus. Wie auch die Funktionen der C-Standardbibliothek (Seite )
sollten die Klassen der STL gemäß des ANSI/ISO-Standards im Namensraum
std::
definiert sein. (Was ein Namensraum ist, haben Sie auf Seite
erfahren.) Die aktuelle Implementierung berücksichtigt dies zwar, hat die Namensraumdefinitionen
jedoch für den GCC ausdrücklich abgeschaltet. Daher könnte ein Linux-Programmierer
diese Feinheiten eigentlich ignorieren. Da sich dieser Zustand aber schon bald
ändern kann, sollten Sie sich dieser Problematik bewusst sein, damit sich Ihre
Programme auch morgen noch übersetzen lassen. Die beiden Möglichkeiten, die Sie
haben, sind Vor- oder Nachsorge. Zur Vorsorge (und natürlich auch zur Verbesserung
der Portabilität Ihres Codes) lassen Sie Programme und Implementierungsdateien,
die Elemente aus der C++-Standardbibliothek verwenden, immer mit der Zeile
using namespace std;
__STL_NO_NAMESPACES
, das Sie am besten auf
der Kommandozeile des Compileraufrufes definieren, zum Beispiel:
% g++ -D__STL_NO_NAMESPACES ...
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 Text-Zeichenketten zu erlauben,
zum Beispiel:
string s1 = ``Die C++-Standardbibliothek'';
string s2(``Die C++-Standardbibliothek'', 7);
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 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 Text-Zeichenketten, etwa:
s2 += ``-Standardbibliothek'';
if (s1==s2)
cout << ``Die beiden Strings ''' << s1
<< ``' und ''' << s2 << ``' stimmen ''
<< ``überein.'' << endl;
<
, >
, <=
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:
string s1 = ``Die C++-Standardbibliothek'';
s2 = s1.substr(16, 10) + ``.dat'';
s2
den Text bibliothek.dat. In ähnlicher
Weise lassen sich auch Teile eines Strings ersetzten. 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
s1.replace(4, 3, ''C'');
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).
int pos = s1.find(``Standard'');
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:
ofstream out_file(s2.c_str());
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 ),
von denen ich nur ein paar hier vorstellen kann. Eine sehr detaillierte Beschreibung
finden Sie beispielsweise in [STROUSTRUP 1998].
Ein besonders wichtiger Container, den wir oben sogar selbst implementiert haben
(siehe Seite ), 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, das heißt über einen beliebigen Indexwert
zu. 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:
struct Konto {
unsigned long nummer;
string inhaber;
Konto() : nummer(0L) {}
};
int main()
{
vector<Konto> Kundenkonten;
resize()
, die auch für die Verlängerung
bestehender Vektoren genutzt werden kann.
vector<Konto> Lieferantenkonten(10);
Kundenkonten.resize(10);
Ein Ausweg kann da der Konstruktor sein, der einen Vektor aus lauter identischen Objekten enthält.
vector<Konto> konto_init()
{
Konto k1;
k1.nummer = 3485932;
k1.inhaber = ``Hoffmann'';
vector<Konto> meineKonten(25, k1);
return meineKonten;
}
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
soll über die Methode at()
abgewickelt werden, die ansonsten auch nicht
mehr tut, als das Element an der angegebenen Stelle zu liefern --
nur gibt es at()
in der aktuellen GCC-Implementierung
der STL nicht!
Ihnen bleibt 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.
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
(oder ein eigenes at()
) 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:
void fill(vector<float>& v)
{
for(unsigned i=0; i<v.size(); i++)
v[i] = i*0.1;
}
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.
Eine andere wichtige Datenstruktur ist die Liste, die in der Standardbibliothek gleich in mehreren Varianten verfügbar ist. Üblicherweise verwendet man im Gegensatz zum Vektor eine Liste dann, wenn man nicht im Voraus weiß, wie groß die Anzahl der zu speichernden Elemente sein wird. Diese Unterscheidung spielt aber bei der Standardbibliothek wie gesagt keine Rolle mehr, da hier viele Methoden zwischen beiden Klassen gleich sind. So kann man auch bei einem Vektor am Ende ein Element anhängen und bei einer Liste von vornherein die Größe festlegen. Es geht vielmehr um die unterschiedliche Art des Zugriffs, die bei der Wahl zwischen der einen oder anderen Klasse entscheidet. Bei Listen kann man nicht über einen Index zugreifen, sondern geht sie entweder sequenziell durch oder sucht nach einem bestimmten Wert. Doch dazu später mehr.
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()
.
void liesKonten(list<Konto>& kontoListe,
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);
}
}
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.
void testStack(const string& dateiName)
{
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();
}
}
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 . 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:
void druckeKonten(list<Konto>& kontoListe)
{
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;
}
}
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 reicht der Platz an dieser Stelle nicht aus, um umfassend auf alle Algorithmen eingehen zu können. Ich muss Sie daher abermals auf [STROUSTRUP 1998] 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.
void findeInListe()
{
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);
}
}
replace()
; mit dieser können wir zum Beispiel unsere Änderungen
wieder rückgängig machen:
replace(l.begin(), l.end(), 99, 3);
copy()
sowie einen
Iterator für das Einfügen, einen inserter.
list<unsigned> l2;
copy(l.begin(), l.end(), back_inserter(l2));
replace(l2.begin(), l2.end(), 3, 99);
copy()
kann eben mit einer beliebigen Sequenz von Elementen
umgehen.)
Auch 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.
void sortBeispiel()
{
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;
}
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ädikationsfunktion 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 ostrstream
(siehe Seite
).
bool hat28(const Konto& einKonto)
{
ostrstream ostr;
ostr << einKonto.nummer;
string nrstr(ostr.str());
if (nrstr.find(``28'') != string::npos)
return true;
return false;
}
find_if()
.
void sucheNach28(list<Konto>& kontoListe)
{
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);
}
}
for_each()
, die für jedes Element die Prädikationsfunktion 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
sein. Bei der gegenwärtigen GCC-Implementierung ist dies jedoch
deaktiviert.
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 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?
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 ).
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
<name>_cast<T>(Ausdruck)
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.
float f = 1.5;
int i = static_cast<int>(f);
static_cast
-Operator natürlich auch Zeiger und Referenzen
umwandeln. Besonders wichtig wird dies bei Objekten aus einer Klassenhierarchie.
Auf Seite
void starten(Raumobjekt* _pRaum_obj)
{
Raumfahrzeug* pRaum_fahrz = 0;
pRaum_fahrz =
static_cast<Raumfahrzeug*>(_pRaum_obj);
pRaum_fahrz->starten();
}
starten()
ein Objekt übergeben, bei dem es sich nicht
um ein Objekt vom Typ Raumfahrzeug
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
class Basis
{
public:
virtual int id() {
return 0; }
};
class Unterklasse : public Basis
{
public:
virtual int id() {
return 1; }
};
id()
zurückzuliefern. Immerhin können wir damit sicher sein, von welcher Klasse ein
Objekt gerade ist.
Die Testfunktion versucht die Umwandlung eines Zeigers und weist uns darauf hin, wenn diese nicht geklappt hat.
void test(Basis* _b)
{
Unterklasse* u;
u = dynamic_cast<Unterklasse*>(_b);
if (u)
cout << "test: " << u->id() << endl;
else
cout << "_b nicht vom Typ"
<< " Unterklasse!" << endl;
}
main()
-Funktion legt Objekte beider Klassen an und ruft die Testfunktion
mit Zeigern auf Basis
sowie auf Unterklasse
auf.
int main()
{
Basis b;
Unterklasse u;
test(&b);
test(&u);
}
_b nicht vom Typ Unterklasse!
test: 1
_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:
int send(char* message);
class Message
{
public:
const char* getString() const;
// ...
};
int sendMessage(const Message& _mes)
{
return send(const_cast<char*>(_mes.getString));
}
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:
const int k=17;
int* p = const_cast<int*>(&k); // !!!
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 Schnittstellen-Design 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 hineingesteckt 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:
istream& istream::read(char *ptr, streamsize n);
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:
class ValueBuffer
{
private:
int* buffer;
unsigned int size;
public:
ValueBuffer() :
buffer(0), size(0) {}
~ValueBuffer() {
if (buffer) delete[] buffer; }
int read(const string& _name);
};
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.
int ValueBuffer::read(const string& _name)
{
// 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;
}
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 so 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 vorrü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 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 Templatefunktion,
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 Sinn 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 Zahlwerte
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 und
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 ).
Wir wollen also in einem Programm etwa folgende Syntax erlauben:
matrix a, b, c;
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:
Rückgabewert operator( Argumente )
{
// Funktionskörper
}
matrix operator+(const matrix& a,
const matrix& b)
{
// ...
return c;
}
matrix c = a + b;
matrix c = operator+(a,b);
Die Definition eines Operators als Methode einer Klasse hat die Form:
Rückgabewert Klasse::operator( Argumente )
{
// Funktionskörper
}
matrix matrix::operator+(const matrix& b)
{
// ...
}
matrix c = a + b;
matrix c = a.operator+(b);
Sie können alle C++-Operatoren überladen (wie =
, !=
, ==
, +
,
*
, +=
und so weiter, siehe auch Tabelle Tab:Operatoren,
jedoch nicht den Punkt-
.
, den Bereichs- ::
und den Auswahloperator
?:
), aber keine eigenen hinzufü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 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 operator!(const MyClass& _o);
MyClass o;
!o;
+
zwischen Matrizen von oben.
Bei Methoden können Sie alle drei Arten verwenden, beispielsweise:
class MyClass
{
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);
// ...
};
void f()
{
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);
}
An dieser Stelle möchte ich das Vektor-Beispiel von Seite
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.
template <typename T> class Vektor
{
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.
template <typename T> class Vektor
{
public:
const T& operator[](unsigned int _i) const
{ return at(_i); }
// ...
};
const
-Methoden
aufrufen.
Wenn Sie den Indexoperator für Schreibzugriffe verwenden wollen, er also beispielsweise auf der linken Seite einer Zuweisung stehen soll:
o[i] = t;
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.
template <typename T> class Vektor
{
public:
T& operator[](unsigned int _i)
{ return at(_i); }
// ...
};
Unklar ist für den Compiler meist nur der Fall des Ausgabeoperators. Wenn Sie also schreiben:
Vektor<int> v;
// ... (Fülle v)
cout << v[0] << endl;
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:
Vektor<int> v;
// ... (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
). 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:
class MyClass
{
public:
// Präfix
MyClass operator++();
// Postfix
MyClass operator++(int);
// ...
};
// Präfix
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;
}
Und noch ein Tipp: Wenn Sie einen Inkrement-Operator überladen, bieten Sie aus Symmetriegründen stets sowohl eine Präfix- als auch eine Postfix-Variante 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:
MyClass a,b,c;
a= (b=c);
if ((b=a).size() >0) { //... }
In Abschnitt Sec:DynKonstruktor 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
Vektor
-Objekte 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 Objekte
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 unser Vektor
-Klasse, 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 A
Wenn 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 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 Variablen
template <typename T>
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
if (this != &_vector)
{
// ...
}
Noch ein paar weitere Anmerkungen zu Zuweisungsoperatoren:
MyClass a,b;
MyClass c = b; // Kopierkonstruktor
c = a; // Zuweisungsoperator
MyClass::MyClass(const MyClass& _c) :
// ... (Initialisierungsliste)
{
operator=(_c);
}
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 mit
und erweitert damit den Zahlenbereich um alle reellen
Vielfachen von
. Das Ergebnis sind die komplexen Zahlen, die man üblicherweise
als Zahlenpaar
mit
schreibt. Man nennt dabei
den
Realteil und
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.
class komplex
{
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; }
};
double
-Zahl möglich ist, zum Beispiel
komplex c = 3.14;
+
hinzufügen, müssen wir also
nicht nur den Fall
komplex a,b,c;
c = a + b;
komplex a,c;
c = 3.14 + a;
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.
komplex& komplex::operator+=(komplex _k)
{
re += _k.re;
im += _k.im;
return *this;
}
+
-Operator schreiben:
komplex operator+(komplex _x, komplex _y)
{
komplex tmp = _x;
return tmp += _y;
}
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:
bool operator==(const komplex& _k1,
const komplex& _k2)
{
return (_k1.real() == _k2.real() &&
_k1.img() == _k2.img());
}
!=
) stets so implementieren, dass Sie dabei die Operatorfunktion eines
bereits vorhandenen Falls aufrufen:
bool operator!=(const komplex& _k1,
const komplex& _k2)
{
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 ), etwa folgendermaßen:
class komplex
{
friend bool operator==(
const komplex& _k1, const komplex& _k2);
private:
double re, im;
public:
//...
};
bool operator==(const komplex& _k1,
const komplex& _k2)
{
return (_k1.re == _k2.re &&
_k1.im == _k2.im);
}
+
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:
ostream& operator<<(ostream& _o,
const MyClass& _r);
istream& operator>>(istream& _i, MyClass& _r);
Für unsere Klasse komplex
können wir diese Operatoren etwa wie folgt programmieren:
ostream& operator<<(ostream& _o,
const komplex& _k)
{
_o << ``(`` << _k.real() << ``, ``
<< _k.img() << ``)'';
return _o;
}
istream& operator>>(istream& _i, komplex& _k)
{
_i >> _k.re >> _k.im;
return _i;
}
In diesem besonderen Fall können wir mit ein paar Klimmzügen sogar die
friend
-Deklaration vermeiden:
istream& operator>>(istream& _i, komplex& _k)
{
double re, im;
_i >> re >> im;
_k = komplex(re,im);
return _i;
}
Im Abschnitt Typumwandlungskonstruktor
(ab Seite ) haben Sie gesehen, wie man
eine Regel formuliert, 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 das Objekt ohne weitere Aufrufe
verwendet, zählt diese Art von Methode zu den Operatoren. Man spricht dabei
vom Typumwandlungsoperator.
Die Syntax ist sehr einfach:
class MyClass
{
public:
operator Datentyp();
// ...
}
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 -- etwas
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.
template <typename T>
class Vektor
{
public:
operator const T*() const;
// ...
};
template <typename T>
Vektor<T>::operator const T*() const
{
return v;
}
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.
Vektor<int> x;
// ...
const int* p;
p = (const int*)x;
strstr()
(siehe Seite
Vektor<char> code;
// ...
char* occ;
occ = strstr((const char*)code, "abcd");
"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 Templateklasse, in denen Sie eine Veränderung an einem Parameter vom Templatetyp 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 Templateklassen 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 int
s (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
operator+()
, gehört immer die Kombination
aus Verknüpfung und Zuweisung, also operator+=()
(siehe Seite operator+(double, komplex)
auch die Variante operator+(komplex, double)
.
Die wichtigsten Gesichtspunkte aus diesem Abschnitt waren:
operator
sowie dem Operatorsymbol.
=
, !=
, ==
, +
, *
,
+=
und so weiter) können überladen werden, nicht aber . ::
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 kenntlicht 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 add()
,
mult()
und so weiter durch Operatorsymbole erreichbar sind. Fügen Sie
auch Vergleichoperatoren, einen Ausgabeoperator und einen Typumwandlungsoperator
nach double
hinzu und testen Sie Ihre Klasse an geeigneten Beispielen.
class Set {
// 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);
};
maxCard
sei dabei wahlweise eine Konstante
oder ein Templateparameter. 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?
In Abschnitt (ab Seite
) 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 aber zwischen zwei Arten von Fehlern unterscheiden:
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:
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ächst hö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 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
.
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 Exception auswirft, 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:
void f1()
{
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:
if (bad())
müssen Sie vorher einen Typ -- zum Beispiel eine Klasse -- namesMyException
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.
if (bad())
throw MyException("Operation failed",
status);
Das Argument von catch
kann sowohl als Objekt als auch als Referenz abgefangen
werden. Dadurch lassen sich auch die enthaltenen Information auswerten, zum
Beispiel:
catch(MyException& _exc)
{
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:
catch(...)
{
cerr << "Unbekannter Fehler!" << endl;
}
main()
.
Ist auch dort keine Behandlung implementiert, bricht das Programm mit einer
Fehlermeldung ab. Dann haben Sie in punkto Fehlerbehandlung und Stabilität Ihres
Programms herzlich wenig erreicht.
Es ist aber auch möglich, eine Ausnahme bewusst an die nächst hö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 Exception
mit einem schlichten throw
einfach weiter, zum Beispiel:
catch(...)
{
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 Exception eine Funktion auswirft, kann man sie in einer Liste hinter dem Funktionsprototyp deklarieren.
void func(int _arg) throw(RangeException,
DomainException);
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 vor 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) 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 StackError {};
class StackEmpty : public StackError {};
class StackFull : public StackError {};
template <typename T>
class SimpleStack
{
public:
// ...
T pop() throw(StackEmpty);
void push(const T&) throw(StackFull);
};
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:
int main()
{
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;
}
//...
}
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:
bool do_repeat = true;
do
{
try
{
get_user_input();
do_repeat = false;
}
catch(InvalidInput)
{
cerr << "Input invalid!";
}
}
while(do_repeat);
Die Technik der Ausnahmebehandlung wollen wir nun bei unserer bekannten Vektorklasse
einsetzen, die wir zuletzt in Abschnitt
ü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
strstream
erzeugt haben:
// Ausnahmebasisklasse
class VektorException
{
public:
VektorException() throw() {}
virtual ~VektorException();
virtual const char* what() const = 0;
};
// Ausnahme bei Bereichüberschreitung
class VektorOutOfRange
{
private:
unsigned int size, index;
public:
VektorOutOfRange(unsigned int _size,
unsigned int _index) :
size(_size), index(_index) {}
virtual ~VektorOutOfRange() {};
virtual const char* what() const
{
strstream s;
s << "Bereichsüberschreitung (Größe: "
<< size << ", Index: " << index
<< ")" << ends;
return s.str();
}
};
VektorNoMem
ist ganz analog zu
VektorOutOfRange
aufgebaut, so dass ich sie hier wohl nicht
abgedrucken muss.
Wenn wir nun alle möglichen Ausnahmen in die Schnittstelle der Klasse
Vektor
aufnehmen,
erlangt diese folgende Form:
// Deklaration der Klasse
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);
// ...
};
template <typename T>
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 Index-Verletzung das Programm gleich stehen bleibt, sondern
man die Situation unter Umständen noch retten kann.
template <typename T>
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];
}
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:
int main()
{
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;
}
3 4 5 6 7 Fehler: Bereichsüberschreitung
(Größe: 5, Index: 5)
Als moderner Bestandteil von C++ sollte natürlich auch die Standardbibliothek
-- insbesondere die STL -- Gebrauch von Exceptions machen. Allerdings ist
die gegenwärtige Implementierung der GNU-STL noch nicht so weit fortgeschritten,
dass sie überall mit Ausnahmen arbeitet. Nur ein kleiner Teil, das Bitset
,
unterstützt sie; doch dazu später mehr.
Die Basisklasse für die Exceptions wird jedoch immer mitgeliefert. Sie können daher auch Ihre eigenen Ausnahmen davon ableiten:
class exception {
public:
exception () { }
virtual ~exception () { }
virtual const char* what () const;
};
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 range_error
und overflow_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 leider hier 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 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:
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ächst hö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 Ausnahme 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
(C) T. Wieland, 2001. Alle Rechte vorbehalten.