next up previous contents index
Next: Editoren für die Programmierung Up: C++-Entwicklung mit Linux Previous: Programmieren mit C++   Contents   Index

Subsections

Fortgeschrittenes C++

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:


Namensräume

Größere Programme bestehen immer aus mehreren Quelltextdateien, 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.

Definition

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

Um die beiden Funktionen für den Linker unterscheidbar zu machen, betten wir sie in einen Namensraum ein.

// Datei max.h

 

namespace Max

{

  double algorithm(double _x);

  extern const double EPS;

}

Das Schlüsselwort extern ist für Variablen und 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:

Figure: Namensräume machen gleichlautende Bezeichner verschiedener Programmierer eindeutig.



\resizebox*{1\columnwidth}{!}{\includegraphics{images/namespace.eps}}


Der Namensraum std

Der C++-Standard schreibt einen vordefinierten Namensraum std für alle Bestandteile der C++-Standardbibliothek vor (siehe Seite [*]). In diesem befinden sich alle Funktionen der C-Bibliothek, alle Klassen der STL und so weiter. Ab Version 3.0 des GCC ist diese Vorgabe nicht mehr abgeschaltet, so dass für den Zugriff auf Bibliotheksfunktionen eine der im nächsten Abschnitt angegebenen Vorgehensweisen erforderlich ist.

Zugriff auf Bezeichner in Namensräumen

Wenn Sie eine Funktion, eine Klasse oder einen anderen Bezeichner verwenden wollen, die in einem Namensraum deklariert sind, haben Sie zwei Möglichkeiten:

Diese zwei Varianten wollen wir uns jetzt genauer ansehen.

Explizite Angabe des Namensraums

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);

  // ...

Auf diese Weise können wir die Funktion 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];

Das Schlüsselwort 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;

Anschließend werden alle Elemente dieses Namensraums so behandelt, als ob sie global verfügbar wären. Auf Konflikte weist Sie der Compiler gegebenenfalls hin.

// Datei maxmoritzmain.cc

 

#include ``max.h''

#include ``moritz.h''

 

int main()

{

  using namespace Moritz;

  double x = algorithm(1.0); // aus ``Moritz''

  // ...

Die 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 Namensraums, verfügbar.

int func(double x)

{

  using Moritz::EPS;

  if (x < EPS) {  // entspricht Moritz::EPS

  // ...

Hintergrund

In den Abschnitten 2.4.3 (ab Seite [*]) und [*] (ab Seite [*]) haben Sie gelernt, dass private und geschützte Methoden einer Klasse streng von den öffentlichen unterschieden werden müssen. Denn auf sie kann man nur aus dem Inneren der Klasse, also etwa von anderen Methoden aus, zugreifen und nicht von außen über ein Objekt. Auch durch die Bildung einer Unterklasse konnte man die Zugriffsbeschränkungen keinesfalls lockern, sondern höchstens erhöhen. Bei der üblichen 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();

  // ...

};

Davon leiten wir als Spezialisierung eine Klasse AudioClip ab:

class AudioClip : public MediaClip

{

public:

  using MediaClip::play;

  int getDuration();

  // ...

};

Wenn Sie ein Objekt der Basisklasse bilden, können Sie auf play() darüber (natürlich) nicht zugreifen:

  MediaClip clip;

  clip.play(); // nicht erlaubt

Ohne dass wir diese Methode in der Unterklasse überladen hätten, ist dort jedoch der Zugriff legal:

  AudioClip sound;

  sound.play(); // erlaubt

Mit 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.

Zusammenfassung mehrerer Namensräume

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;

}

Für diese Vorgehensweise ist ebenso eine selektive Aufnahme einzelner Bezeichner möglich.

Verschachtelte Namensräume

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) {

  // ...

Bei mehreren verschachtelten Namensräumen wird der Zugriff natürlich schnell unhandlich, da man vor lauter Namensräumen den eigentlichen Bezeichner kaum noch sieht. Es gibt daher die Möglichkeit, für solche Namensketten einen Alias zu vergeben, der die vollständige Schachtelungstiefe abdeckt. Selbst für einstufige Namensräume eignen sich Aliase, um sehr lange Namen abzukürzen.

  // Der neue Raum MAD enthaelt drei 

  // verschachtelte Namensraeume

  namespace MAD = Max::Auxiliary::Debug;

Mit diesem Alias haben Sie nun unmittelbaren Zugriff auf den innersten Namensraum und können nicht nur viel Tipparbeit einsparen, sondern auch erheblich zur besseren Übersichtlichkeit Ihres Quelltextes beitragen.

Zusammenfassung

In diesem Abschnitt gab es wieder einiges Neues:

Übungsaufgaben

  1. Warum sind gleiche Namen für Funktionen auch dann nicht erlaubt, wenn sie sich in verschiedenen Implementierungsdateien (und damit verschiedenen Objektdateien) befinden?
  2. Was ist der Unterschied zwischen einem Namensraum und einer Klasse?
  3. Halten Sie es für sinnvoll, eine using-Anweisung in eine Header-Datei zu schreiben?


Templates

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:

In C könnte man dazu Makros definieren, die der Präprozessor dann expandiert. Allerdings umgeht man dabei die Typprüfung des Compilers und ist insbesondere nicht vor Fehlern durch unbeabsichtigte Typkonstruktionen geschützt, die man bei der Makrokonzeption noch gar nicht bedacht hatte.

Die Sprache C++ bietet für dieses Problem einen anderen Ausweg: das Template-Konzept. Damit ist gemeint, dass Sie in Ihrer Implementierung nicht einen konkreten Datentyp verwenden, sondern lediglich einen Platzhalter. Auf diese Weise kommt ein Verfahren oder eine Klasse auch nur einmal in Ihrem Quelltext vor, was die Wartung erheblich vereinfacht. Zudem haben Sie damit eine Aufgabenstellung so allgemein gelöst, dass Sie Ihren Code besser wiederverwenden können.

Figure: Templates sind Schablonen für Funktionen oder Klassen, ebenso wie dies eine Schablone für eine Familie ist.



\resizebox*{0.5\columnwidth}{!}{\includegraphics{/usr/homes/thomas/cpp/cpp_linux/Text/images/schablone.eps}}




Funktionstemplates

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.

Definition

Zur Kennzeichnung, dass es sich um eine solche generische Funktion handelt, müssen Sie das Schlüsselwort template davor setzen, gefolgt von einer Liste von Typ- oder Klassenparametern, in der jeder Eintrag aus class oder typename und einem Parameternamen besteht. Damit der parametrisierte Typ auf den ersten Blick erkennbar ist, verwendet man oft einzelne Großbuchstaben, zum Beispiel template <typename T>. Anschließend schreiben Sie die Funktion wie gewohnt. An allen Stellen, wo ein Typ steht, dürfen Sie nun auch einen der Parameternamen verwenden.

Gerade elementare Algorithmen wie Sortierung, Minima und Maxima oder Suchfunktionen bieten sich für die Implementierung als Funktionstemplates an. Unser Beispiel gibt das Maximum zweier Werte zurück:

template <typename T> T max(T a, T b)

{

  return (a>b)? a : b;

}

Der Compiler prüft bei der Übersetzung dieses Codestücks zwar die Syntax, insbesondere die korrekte Verwendung der Typen, erzeugt allerdings keinen Objektcode. Das ist an dieser Stelle ja auch noch gar nicht möglich, da dazu eine konkrete Realisierung nötig wäre.

Ausprägung

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

  // ...

}

Sobald der Compiler erkennt, dass sich der Aufruf von max() auf ein Funktionstemplate bezieht, erzeugt er den zugehörigen Code. Dazu ersetzt er den Parameter durch den gerade verwendeten Typ und kompiliert die so entstandene Funktion in üblicher Manier.

In unserem Beispiel war die Ausprägung implizit definiert. Anhand der verwendeten Datentypen konnte der Compiler ermitteln, welche Realisierung erstellt werden soll. Beim ersten Aufruf zeigten die beiden int-Parameter an, dass int für T einzusetzen ist, beim zweiten Aufruf war es double.

Beachten \resizebox{10mm}{!}{\includegraphics{images/vorsicht.eps}} 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.

Übrigens zeigt sich an dieser Stelle der Vorteil von Namensräumen, zumindest beim GCC ab Version 3.0. Über den Header für die I/O-Streams werden hier nämlich auch ein paar Basisalgorithmenfunktionen der Standardbibliothek in unseren Code eingebunden, genauer: als Templates deklariert. Darunter gibt es auch eine Funktion

template <class _Tp> inline const _Tp& 

  max(const _Tp& __a, const _Tp& __b);

die damit in unmittelbarer Konkurrenz zu unserer oben definierten eigenen Funktion max() steht. Allerdings befindet sich dieses max() im Namensraum std, so dass der Compiler auf die richtige Version verzweigt - solange nicht durch ein unbedarftes using namespace std dieser Namensraum global gemacht wird. Eine andere Möglichkeit wäre, unsere Funktion in einen eigenen Namensraum einzuschließen - aber das ist bei so einem kleinen Beispiel nicht nötig. Hier können wir für cout und endl explizit als Herkunft die Standardbibliothek angeben. Unsere main()-Funktion könnte also folgendermaßen aussehen:

int main()

{

  using std::cout;

  using std::endl;

 

  cout << "max(1, 5): " << max(1, 5) << endl;

 

  return 0;

}

Explizite Ausprägung

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;

erhalten wir die Fehlermeldung:

templ.cc:11: no matching function for 

call to `max (int, double)'

Hier behilft man sich, indem man beim Aufruf der Funktion genau den Typ angibt, der für den Parameter eingesetzt werden soll, also etwa

  cout << "max(3, 3.5): " << max<double>(3, 3.5) 

       << endl;

Auf diese Weise wird genau die gewünschte Ausprägung erstellt und verwendet.


Spezialisierung von Funktionstemplates

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;

wird die Ausprägung max<const char*>() erzeugt. Aufgrund unserer Implementierung werden dabei nicht die Zeichenketten, sondern nur die Zeiger verglichen.

Es ist daher möglich, auch Funktionstemplates zu überladen, um damit Ausprägungen für Spezialfälle getrennt zu implementieren. In unserem Fall etwa (zu strcmp() siehe Seite [*]):

template<>

const char* max(const char* a, const char* b)

{

  return strcmp(a,b)>0? a: b;

}

Hiermit weisen wir den Compiler darauf hin, dass dies eine Spezialisierung unseres Funktionstemplates ist, bei dem der Parameter bereits fest ist und aus der Argumentliste bestimmt werden kann. Beim Übersetzen wird zunächst nach einer Spezialisierung gesucht, mit der der Aufruf verbunden werden kann, und erst dann eine Ausprägung des Funktionstemplates erzeugt, wenn sich keine passende Funktion finden lässt.

Natürlich muss dabei die überladene Funktion in der Form ihrer Signatur mit dem Funktionstemplate ü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.

Organisation des Quelltextes

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); 

  // ...

}

Wie Sie sehen, enthält max.h nicht nur eine prototypische Deklaration von max(), sondern den gesamten Code. In diesem Sinne lassen sich Funktionstemplates ähnlich handhaben wie inline-Funktionen (siehe Seite [*]), wenn auch aus etwas anderen Gründen.


Klassentemplates

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.

Design mit Templates

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 der Code auf diese Weise schnell aufgebläht werden. Versuchen Sie also, die entsprechenden Klassen klein, das heißt vor allem die Methoden relativ kurz sowie Zahl und Größe der Attribute überschaubar zu halten. Versuchen Sie auch, alle Klassenelemente, die nicht von einem Template abhängen, in eine Basisklasse zu packen und erst eine Unterklasse davon zu parametrisieren. Auf diese Weise werden wirkliche Gemeinsamkeiten zwischen den verschiedenen Ausprägungen besser sichtbar - für Leser und Compiler.

Aus theoretischer Sicht ist ein Design mit Templates eigentlich eine Unterwanderung des objektorientierten Ansatzes. Aus diesem Grund verzichten einige Programmiersprachen, die streng objektorientiert aufgebaut sind (zum Beispiel Java), ganz auf Templates. Sie sollten bei Ihrem Programmentwurf auch zunächst nach konventionellen Möglichkeiten suchen und Klassentemplates erst bei Klassen von allgemeiner Natur mit hohem Wiederverwendungsgrad einsetzen.

Implementierung

Bei der Definition einer parametrisierten Klasse müssen Sie Folgendes beachten:


Beispiel: Vektor

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_

Einsatz mit Standardtypen

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"

 

using namespace std;

 

int main()

{

  unsigned int i=0;

 

  // Ganzzahlvektor

  Vektor<int> v(5);

 

  for(i=0; i<5; i++)

    v.at(i) = i+3;

 

  for(i=0; i<5; i++)

    cout << v.at(i) << " ";

 

  cout << endl;

  // ...

}

Diese Programm gibt Ihnen lediglich die Zahlen von drei bis sieben aus:

3 4 5 6 7

Einsatz mit eigenen Typen

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();

In diesem Beispiel setzen wir die Einträge zunächst auf die Tage des Monats Dezember 2000 und geben dann alle Tage zwischen Weihnachten und Silvester aus.

24.12.2000

25.12.2000

26.12.2000

27.12.2000

28.12.2000

29.12.2000

30.12.2000

31.12.2000    

Sie sehen hier einmal mehr die Wirkung einer Referenz. Die Methode at() gibt eine Referenz auf ein Objekt des Schablonentyps zurück. Also können wir direkt anschließend Methoden dieses Objektes aufrufen.

Im Innern der Klasse Vektor werden die Objekte in Form eines Feldes gespeichert. Wie wir bereits auf Seite [*] 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);

sorgt dafür, dass anschließend 31 Objekte vom Typ Datum existieren, die alle auf den heutigen Tag gesetzt sind. (Das ist übrigens wieder ein Argument dafür, den Konstruktor möglichst schmal zu halten und dort keine aufwändigen Operationen durchzuführen. Wenn Sie nämlich mal einen größeren Vektor anlegen, führt das zu Hunderten oder Tausenden von Konstruktoraufrufen; wenn diese alle Rechenzeit brauchen, ist schon beim Anlegen eines Objekts viel Zeit verstrichen, obwohl man dies dem Quelltext überhaupt nicht ansieht.)

Verschachtelte Templates

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;

Damit wird eine Einheitsmatrix mit drei Zeilen und Spalten definiert.


\begin{displaymath}\left(\begin{array}{ccc} 1 & 0 & 0  0 & 1 & 0  0 & 0 & 1  \end{array}\right)\end{displaymath}

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);

besteht m also aus drei Elementen vom Typ Vektor<double>, die aber allesamt leer sind. Diese inneren Objekte müssen erst über eine Schleife mit Hilfe der Methode resize() instantiiert werden.

Zum anderen erkennen Sie hier wieder, wie der Aufruf von Methoden bei derartig konstruierten Objekten geschrieben wird. Da man die Schachtelung auch noch tiefer fortsetzen könnte, werden solche Methodenzugriffe durch die vielen Punkte und Zwischenaufrufe unter Umständen recht lang.

Vorgabewerte für Templateparameter

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);

};

Der Nachteil daran ist, dass dieser Parameter zur Zeit des Kompilierens feststehen muss. Eine dynamische Größenausrichtung zur Laufzeit ist so natürlich nicht möglich. Was beim Vektor noch als unpraktisch angesehen werden kann, mag in vielen anderen Situationen sehr sinnvoll sein. Oft unterscheiden sich Klassen auch nur in kleinen Parametern. Nehmen wir zum Beispiel eine Klasse, die Fehlersituationen darstellen soll. Die meisten sind einfache Fehler; nur einige stehen für Fälle, in denen gar nichts mehr geht. In unserer Fehlerklasse soll der erste Templateparameter eine Zusatzangabe zum Fehlertext sein, der zweite die Fehlerkategorie (wir verwenden wieder diejenige von Seite [*]).

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;

Auf diese Weise lassen sich die Klassendefinitionen übersichtlich halten. Falls aber doch einmal eine andere Kategorie benötigt wird, kann diese ebenso verwendet werden. In Abhängigkeit vom Niveau des Fehlers können Sie im Konstruktor oder in weiteren Methoden auch zusätzliche Aktionen auslösen (beispielsweise für FATAL das sofortige Ende des Programms).

Eine typische Anwendung solcher Fehlerklassen ist etwa folgende (die Methode string::c_str() gibt übrigens den Inhalt der Zeichenkette als char-String zurück; mehr Details finden Sie im nächsten Abschnitt):

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);

  }

  // ...

}

Hintergrund

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:

Einen Anwendungsfall finden wir bei der oben bereits angesprochenen Vektorklasse. Ähnlich wie bei den Funktionen, stellen Zeiger als Templateparameter auch bei Klassen oft einen Sonderfall dar. Besonders flexibel wird die Spezialisierung, wenn wir dafür einen Zeiger auf void einsetzen [STROUSTRUP 1998]. Wie wir schon mehrfach erkennen konnten, stellt dieser einen so universellen Zeigertyp dar, dass sich alle anderen Zeiger darauf umwandeln lassen.

// Deklaration der Klasse

template<> class Vektor<void*>

{

private:

  unsigned int size;

  void** v;

        

public:

  void*& at(unsigned int _i);

  // ...

};

Stroustrup verrät uns in [STROUSTRUP 1998] auch noch einen Trick, wie jeder Vektor von Zeigern damit typsicher repräsentiert werden kann: Man leitet ein Klassentemplate für Zeiger privat von Vektor<void*> ab (zur privaten Vererbung siehe Seite [*] ff.).

template<typename T> class Vektor<T*> :

  private Vektor<void*>

{

public:

  T*& at(unsigned int _i)

  { return (T*&)(Vektor<void*>::at(_i)); }

  // ...

};

Hier wird der Templateparameter nicht wie bei void* vollständig vorgegeben, sondern nur die Form des Parameters weiter spezifiziert. Es sind nicht beliebige Datentypen zugelassen, sondern nur Zeigertypen. Bei Vektor<Datum*> ist also T nicht Datum*, sondern Datum.

Spezialisierung ist ein Mittel, um das bei Klassentemplates vorkommende Aufblähen des Codes, vor dem ich oben bereits gewarnt habe, in erträglichen Grenzen zu halten. Auf diese Weise können Sie den Anteil an Codebestandteilen, die auch als Ausprägung gemeinsam genutzt werden, so groß wie möglich werden lassen. Auf der anderen Seite erfordert ein durchdachter Klassenentwurf mit Templates und Spezialisierungen erst einige Erfahrung in der C++-Programmierung, so dass ich Ihnen die Verwendungen dieser Techniken für den Anfang noch nicht empfehlen würde.

Zusammenfassung

Aus diesem Abschnitt sollten Sie sich merken:

Übungsaufgaben

  1. Warum stellt ein Programmdesign mit Templates eigentlich eine Unterwanderung des objektorientierten Ansatzes dar?

  2. Die folgenden Definitionen seien gegeben:

    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) { /* ... */ }

    Welche Funktionen werden bei den folgenden Aufrufen ausgeführt, sofern der Aufruf kein Fehler ist?

    f(cFeld,20), f(iFeld,20), f(iFeld[0], i), 

    f(i, ui), f(iFeld, ui), f(&i, i)

  3. Müssen Sie das Schlüsselwort template davorschreiben, wenn Sie eine Methode einer Template-Klasse innerhalb der Klassendeklaration implementieren?

  4. Warum kann bei geschachtelten Templates kein allgemeiner Konstruktor für das innere Objekt aufgerufen werden?

  5. Ein stetige Funktion kann numerisch beispielsweise mit Hilfe der Trapez-Regel integriert werden. Dabei betrachtet man ein Intervall $[a; b]$ und unterteilt dieses in $N$ Teilintervalle. Daraus ergibt sich die so genannte Schrittweite $h:= \frac{b-a}{N}$. Dann kann man das bestimmte Integral einer Funktion $f(x)$ nähern durch (siehe [STOER 1989]):

    \begin{displaymath}\int^a_b f(x)  dx \approx \sum^{N-1}_{i=0}\frac{h}{2}[f(a+ih)+f(a+(i+1)h)]\end{displaymath}

    Passen Sie die Vektorklasse aus diesem Abschnitt geeignet an und schreiben Sie ein Programm, das das bestimmte Integral einer im Quelltext festgelegten Funktion in einem Bereich berechnet, den der Benutzer eingibt. Speichern Sie die Funktionswerte $f(a+ih)$ an den Stützstellen in einem Vektorobjekt. Testen Sie das Programm an verschiedenen Funktionen, deren Integral leicht zu berechnen ist, zum Beispiel $e^x$ oder einem Polynom.


Die STL: die Containerklassen der
C++-Standardbibliothek

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 zwar auf der STL basiert, aber noch einige weitere Bestandteile enthält. Nachdem in diesem Buch schon viel von der Standardbibliothek die Rede war, sollten wir einmal klären, was diese genau enthält:

Die beiden letzten Aspekte sind nur für sehr fortgeschrittene C++-Programmierer interessant; in diesem Rahmen kann ich darauf leider nicht eingehen. Wenn Sie mehr erfahren möchten, finden Sie Details unter anderem in [MUSSER . SAINI 1996] oder [STROUSTRUP 1998]. In diesem Abschnitt werden wir uns auf den ersten Punkt konzentrieren, nämlich Containerklassen und Algorithmen.


Namenskonventionen

Für die Standardbibliothek gibt es eine Reihe von Namenskonventionen, die Sie berücksichtigen müssen. Sonst kann bereits der erste Versuch sie einzusetzen fehlschlagen. Seit der Version 3.0 sieht dies auch bei Linux, das heißt beim GCC, nicht mehr anders aus. Wie die Funktionen der C-Standardbibliothek (Seite [*]) sind nämlich nun auch die Klassen der STL gemäß des ANSI/ISO-Standards im Namensraum std:: definiert. (Was ein Namensraum ist, haben Sie auf Seite [*] erfahren.) Die Implementierungen bis GCC 2.9x berücksichtigten dies zwar, hatten die Namensraumdefinitionen jedoch für den GCC ausdrücklich abgeschaltet. Daher konnte ein Linux-Programmierer diese Feinheiten eigentlich ignorieren. Heute sollten Sie (bei Verwendung eines älteren Compilers zumindest zur Verbesserung der Portabilität Ihres Codes) Programme, die Elemente aus der C++-Standardbibliothek verwenden, immer mit der Zeile

using namespace std;

beginnen lassen. Wie an anderer Stelle schon mal betont, ist dieser Tipp jedoch nur für Implementierungsdateien sinnvoll, und dort auch nur, sofern keine Kollisionen mit anderen Funktionen Ihres Projekts zu befürchten sind. Wenn Sie Datentypen der Standardbibliothek in Header-Dateien verwenden, ist ein solches Kommando generell unangebracht. In Header-Dateien ist es daher am besten, die Referenzierung der Elemente der Standardbibliothek explizit mittels eines vorangestellten std:: vorzunehmen oder das using nur innerhalb von Blöcken wie Klassendeklarationen oder Inline-Funktionen anzugeben. Auf diese Weise schützen Sie Ihre Implementierung vor Missverständnissen und machen sie für die Wiederverwendung robuster.

Über die Konvention für den Namensraum hinaus zeichnen sich die Header-Dateien der STL-Klassen dadurch aus, dass man sie ohne ein angehängtes .h verwendet. Von Seite [*] wissen Sie sicher noch, dass die Header der C-Bibliothek ebenfalls unter diese Regel fallen.


Strings

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'';

Man kann auch neben dem Text als zweites Argument die Anzahl der Zeichen angeben, die man vom Anfang des Textes an in den String übernehmen möchte. Beispielsweise erzeugt

  string s2(``Die C++-Standardbibliothek'', 7);

einen String mit dem Text Die C++. Leider gibt es aber keinen Konstruktor, der einen Zahlenwert (wie int oder float) in einen String umwandelt. Für solche Anwendungen muss eine abgeleitete Klasse geschrieben werden, die diese Funktionalität hinzufügt.

Außerdem sind in der string-Klasse eine Reihe von Operatoren vorhanden, die für die Anwendung recht praktisch sind. Es ist nämlich in C++ auch möglich, die vorhandenen Operatoren für eigene Datentypen zu überladen. Obwohl wir darauf erst im nächsten Abschnitt ab Seite [*] 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'';

Zudem gibt es Operatoren für Vergleiche. Damit kann man etwa die Übereinstimmung zwischen zwei Objekten überprüfen:

  if (s1==s2)

    cout << ``Die beiden Strings '''  << s1 

         << ``' und ''' << s2 << ``' stimmen '' 

         << ``überein.'' << endl;

Neben dem analogen Test auf Ungleichheit lässt sich auch ein lexikografischer Vergleich mittels <, >, <= und >= vornehmen. Und wie Sie sehen, ist auch der Ausgabeoperator << für Strings definiert, ebenso übrigens wie der Eingabeoperator >>.

Eine weitere wichtige Funktionalität, die man von String-Klassen erwartet, ist der Zugriff auf bestimmte Teile eines Strings. Dazu gibt es eine Reihe von Methoden. Mit substr() erhält man beispielsweise eine Kopie des Teils, der beim Zeichen mit dem im ersten Argument angegebenen Index beginnt und so lang ist, wie das zweite Argument vorgibt, zum Beispiel:

  string s1 = ``Die C++-Standardbibliothek'';

  s2 = s1.substr(16, 10) + ``.dat'';

Dies ergibt in s2 den Text bibliothek.dat. In ähnlicher Weise lassen sich auch Teile eines Strings ersetzen. Dazu gibt man in der Methode replace() den Index und die Länge sowie den neuen Text an; dieser kann auch eine ganz andere Länge haben als der alte, beispielsweise macht uns

  s1.replace(4, 3, ''C'');

aus der C++- eine C-Standardbibliothek.

Das Auffinden eines Teilstrings ist ebenso einfach, doch gibt es dafür eine ganze Reihe von Methoden. Die elementarste ist find() mit dem gesuchten Teilstring als Argument; diese Methode liefert die Position im String zurück (sofern er überhaupt enthalten ist).

  int pos = s1.find(``Standard'');

So schön und sicher der Umgang mit Strings auch ist - manchmal braucht man doch einen char-Zeiger, oftmals um andere Funktionen der Standardbibliothek aufzurufen, zum Beispiel den Konstruktor einer Ausgabedatei. Doch auch diese Zugriffsmöglichkeit bietet die string-Klasse. Die Methode c_str() liefert den gewünschten Zeiger zurück:

  ofstream out_file(s2.c_str());


Container

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].


Table: Die wichtigsten Container der STL erlauben große Flexibilität.
       
Klasse Beschreibung    
 

deque<t>

double ended queue, eine Warteschlange für Operationen an beiden Enden. Erlaubt sowohl indizierte als auch pop()- und push()-Zugriffe  
list<T> Doppelt verkettete Liste, mit Zugriffsmöglichkeit auf beliebige Positionen und dynamisch angepasster Speicherverwaltung    
map<KeyType, ValueType> Datenstruktur für Paare aus Schlüssel und Wert. Dabei muss jeder Schlüssel eindeutig sein, kann dafür als Zugriffsindex im []-Operator angegeben werden.    
multimap<KeyType, ValueType> Datenstruktur für Paare aus Schlüssel und Wert. Dabei muss nicht jeder Schlüssel eindeutig sein (Hashing), dafür gibt es keinen []-Operator.    
multiset<t> Menge, in der jedes Element auch mehrfach vorkommen darf    
queue<T> Warteschlange, bei der man Elemente nur am Ende einfügen und am Anfang entfernen kann    
set<T> Menge, die jedes Element genau einmal enthält    
stack<T, Container> Stapel mit Möglichkeit zum Einfügen und Löschen nur am oberen Ende; kapselt nur den Zugriff auf darunter liegenden Container    
vector<T> Vektor, der einen Zugriff über den []-Operator erlaubt    


Vektoren

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) {}

};

Schon können wir ein Vektorobjekt davon bilden:

int main()

{

  vector<Konto> Kundenkonten;

Eine Möglichkeit, um die Anzahl der Elemente festzulegen, ist die Angabe beim Konstruktor. Eine andere bietet die Methode resize(), die auch für die Verlängerung bestehender Vektoren genutzt werden kann.

  vector<Konto> Lieferantenkonten(10);

  Kundenkonten.resize(10);

Auf \resizebox{10mm}{!}{\includegraphics{images/vorsicht.eps}} eines muss man allerdings bei Vektoren aus selbst definierten Objekten achten: Der Vektor-Konstruktor ruft für alle Elemente deren Standardkonstruktor auf. Eine Erzeugung mit einem spezifischen Konstruktor ist nicht möglich. Daher sollten alle Klassen, von deren Objekten Vektoren gebildet werden sollen, über einen vernünftigen Standardkonstruktor verfügen. Diese Vorsicht ist auch bei den meisten anderen Containern der Standardbibliothek geboten.

Ein Ausweg kann da der Konstruktor sein, der einen Vektor aus lauter identischen Objekten enthält.

vector<Konto> konto_init()

{

  Konto k1;

  k1.nummer = 3485932;

  k1.inhaber = ``Hoffmann'';

  vector<Konto> meineKonten(25, k1);

  return meineKonten;

}

Nach diesem Aufruf haben Sie einen Vektor mit 25 Elementen des Typs Konto, die alle dieselbe Nummer (3485932) und denselben Inhabernamen, nämlich Hoffmann, haben.

Bei Funktionen, die - wie die gerade gezeigte Funktion konto_init() - Objekte zurückgeben, ist übrigens etwas Vorsicht geboten. Denn ein solches Vektorobjekt kann unter Umständen sehr groß werden, so dass das Kopieren mit erheblichem Aufwand verbunden sein kann.

Der typische Zugriff auf die Elemente eines Vektors erfolgt über deren Index. Bei Arrays besteht in C dabei bekanntermaßen die Gefahr, dass bei einem Index, der größer als erlaubt ist, kein Fehler ausgegeben wird, sondern ein undefinierter Speicherbereich als Array-Element interpretiert und zurückgeliefert wird. Der Vorteil beim Einsatz einer Vektorklasse sollte sein, dass genau dies verhindert wird. Mit der Klasse vector<T> der Standardbibliothek ist die Sache aber etwas komplizierter. Denn aus Gründen der Performance prüft der []-Operator eben nicht, ob der Index gültig ist oder nicht. Der Zugriff mit Bereichsprüfung wird über die Methode at() abgewickelt, die außer diesem Test auch nicht mehr tut, als das Element an der angegebenen Stelle zu liefern. Wenn Sie die Standardbibliothek eines älteren GCC (2.9x oder darunter) verwenden, müssen Sie jedoch bedenken, dass es die Methode at() dort überhaupt nicht gibt!. In diesem Fall bleibt Ihnen eigentlich nur, eine neue Klasse von vector<T> abzuleiten und dort den []-Operator mit einer sicheren Version zu überladen beziehungsweise eine sichere Methode at() hinzuzufügen. Eventuell können Sie die dortige Bereichsprüfung auch an eine Präprozessor-Variable koppeln, so dass sie sich in der Produktivversion wieder abschalten lässt. Wenn Sie eine jüngere STL-Implementierung verwenden (wie sie ab GCC 3.0 mitgeliefert wird), führt ein nicht zulässiger Index zu einer Ausnahme vom Typ out_of_range; Näheres dazu erfahren Sie ab Seite [*].

Im Allgemeinen sollten Sie sich also bei jedem Zugriff überlegen, ob der Index ungültig sein kann oder nicht, und dementsprechend im ersten Fall eine Bereichsprüfung (mit at(), sofern vorhanden, oder mit einer selbst definierten Methode) durchführen und im zweiten Fall den []-Operator verwenden. Wenn Sie beispielsweise eine Schleife programmieren, sorgt die Schleifenbedingung bereits dafür, dass nur zulässige Indizes verwendet werden, zum Beispiel:

void fill(vector<float>& v)

{

  for(unsigned i=0; i<v.size(); i++)

    v[i] = i*0.1;

}

Wie Sie vielleicht an diesem Beispiel erraten haben, liefert die Methode size() eines Vektors die Gesamtzahl seiner Elemente. Auch dies ist eine generische Methode, die bei vielen anderen Containern der STL in gleicher Weise vorhanden ist.

Eine besondere Variante des Vektors ist vector<bool>. Obwohl dieser über dieselbe Schnittstelle wie die normalen Vektoren verfügt, ist er so effizient implementiert, dass er pro Element auch wirklich nur ein Bit Speicher verbraucht.


Listen und Stacks

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);    

    }

}

Ein Stack ist ein Container, der sich wie ein Akten- oder Bücherstapel verhält: Was zuletzt dort abgelegt wurde, erhält man auch als Erstes wieder zurück (so genanntes LIFO-Prinzip). Die Stack-Implementation der Standardbibliothek ist kein eigener Container, sondern stülpt nur eine Stack-typische Schnittstelle über vorhandene Container wie deque<T>, list<T> oder vector<T>. Wenn Sie wollen, können Sie den zugrunde liegenden Container bei der Definition auch als zweites Template-Argument angeben; Sie können aber auch einfach die Vorgabe (deque<T>) verwenden.

Die zentralen Aktionen bei Stacks sind zum einen das Ablegen eines Objekts auf dem Stack, für das hier die Methode push() dient, und zum anderen das Abnehmen des obersten Elements vom Stack, die bei dieser Klasse wie üblich pop() heißt, die jedoch nicht das Element, sondern nur void zurückliefert. Auskunft über das jeweils oberste Element gibt die Methode top(), welche dieses zurückliefert, ohne es vom Stack zu entfernen.

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();

    }

}

Außerdem bietet die Bibliothek noch Operatoren, um zwei Stacks auf Identität oder lexikografisch zu vergleichen.

Iteratoren

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;   

  }

}

Beachten Sie, dass Sie immer die Containerklasse und deren Templatetyp bei der Angabe des Iteratortyps dazusetzen müssen. Denn wie erwähnt hat jeder Container seine eigenen Iteratoren.

Wenn Sie über eine Elementfunktion einmal einen Iterator von einem Objekt erhalten haben, scheint dieser fast unabhängig vom Objekt weiterzuexistieren. Um jedoch die Konsistenz zu bewahren, bleiben Iteratoren im Gegensatz zu Zeigern unmittelbar an die Containerobjekte gebunden, auf die sie sich beziehen. Das zeigt sich nicht zuletzt daran, dass bei den meisten Containern alle Iteratoren ungültig werden, sobald Sie Elemente in den Container eingefügt oder aus ihm gelöscht haben. Achten Sie also darauf, sich stets einen neuen Iterator zu besorgen, sobald Sie Veränderungen am Container durchführen.

Algorithmen

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.

Suchen und Ersetzen

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);  

  }

 }

Das Suchen und Ersetzen ist allerdings eine Aufgabe von so allgemeiner Bedeutung, dass Sie sie nicht selbst implementieren müssen. Dafür enthält die Standardbibliothek die Funktion replace(); mit dieser können wir zum Beispiel unsere Änderungen wieder rückgängig machen:

  replace(l.begin(), l.end(), 99, 3);

Und wenn Sie die Ersetzungen nicht im Original, sondern in einer Kopie vornehmen wollen, geht auch das. Dazu benötigen Sie die Funktion copy() sowie einen Iterator für das Einfügen, einen inserter.

  list<unsigned>  l2;

 

  copy(l.begin(), l.end(), back_inserter(l2));

  replace(l2.begin(), l2.end(), 3, 99);

(Natürlich übertreibt dieses Beispiel ein wenig: Eine Kopie der ganzen Liste lässt sich mit dem Kopierkonstruktor oder Zuweisungsoperator viel leichter erzeugen. Aber die Funktion copy() kann eben mit einer beliebigen Sequenz von Elementen umgehen.)

Sortieren

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;

}

Für andere Sortierreihenfolgen kann man auch Regeln angeben, nach denen sortiert werden soll.


Prädikate

Solche benutzerdefinierte Regeln benötigt man auch bei anderen Algorithmen immer wieder. Es gibt daher Varianten der Funktionen, die neben dem Bereich den Namen einer zusätzlichen Funktion erwarten, die ein Containerelement als Argument hat und einen booleschen Wert zurückgibt. Solche Funktionen nennt man auch Prädikate.

Kommen wir nochmals zu unserem Beispiel mit den Konten zurück. Wir wollen nun alle Konten ermitteln, die die Zahlenkombination 28 in ihrer Nummer haben. Für die Prädikatsfunktion müssen wir die als numerischen Werte gespeicherten Kontonummern wieder in einen String umwandeln, in dem wir dann suchen können. Für eine sichere Umwandlung verwenden wir einen 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;

}

Das Suchen selbst erfolgt dann analog zu oben, allerdings mit der Funktion 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);

  }

}

Ähnliche Funktionen, die mit Prädikaten arbeiten, sind

Zusammenfassung

Die wichtigsten Aspekte aus diesem Abschnitt waren:

Übungsaufgaben

  1. Beantworten Sie folgende Fragen:

  2. Schreiben Sie ein Programm, das dem Benutzer erlaubt, eine Reihe von Begriffen einzugeben. Überprüfen Sie nach jeder Eingabe, ob dieser Ausdruck nicht bereits vorhanden ist. Speichern Sie die Begriffe in Objekten vom Typ string. Welcher Container ist für diese Aufgabe am geeignetsten?
  3. Erweitern Sie das Programm aus Aufgabe 2, indem Sie die Begriffe alphabetisch sortieren und anschließend untereinander ausgeben.
  4. Schreiben Sie eine eigene Implementierung des Erinnerungsprogramms für Geburtstage aus Abschnitt [*], Seite [*], in dem Sie STL-Container und -Algorithmen verwenden.


Operatoren zur Typumwandlung

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.

Figure: Typumwandlung bedeutet, dem Objekt eine Maske aufzusetzen.



\resizebox*{3cm}{!}{\includegraphics{/usr/homes/thomas/cpp/cpp_linux/Text/images/masken.eps}}

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)

wobei Ausdruck der ursprüngliche Ausdruck und T der Typ ist, in den der Ausdruck konvertiert werden soll. Beispiele werden wir im Folgenden noch kennen lernen.

Der static_cast-Operator

Dieser Operator ist für alle einfachen Umwandlungen gedacht, das heißt für solche, die bereits zur Übersetzungszeit als gültig erkannt werden können. Damit können Sie auch implizit erlaubte Umwandlungen wieder rückgängig machen. Ein einfaches Beispiel ist:

  float f = 1.5;

  int i = static_cast<int>(f);

Sie können mit dem static_cast-Operator natürlich auch Zeiger und Referenzen umwandeln. Besonders wichtig wird dies bei Objekten aus einer Klassenhierarchie. Auf Seite [*] haben wir festgestellt, dass immer eine implizite Konvertierung von der Referenz einer Unterklasse auf die Referenz einer Basisklasse möglich ist. Wenn wir genau wissen, dass die Referenz oder der Zeiger eigentlich zur Unterklasse gehört, können wir ihn auch wieder zurückwandeln:

void starten(Raumobjekt* _pRaum_obj)

{

  Raumfahrzeug* pRaum_fahrz = 0;

  pRaum_fahrz = 

    static_cast<Raumfahrzeug*>(_pRaum_obj);

  pRaum_fahrz->starten();


Beachten \resizebox{10mm}{!}{\includegraphics{images/vorsicht.eps}} Sie aber, dass man hier davon ausgeht, dass der Programmierer diese Anweisung auch immer mit den korrekten Argumenten versorgt. Wenn Sie der Funktion 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.


Der dynamic_cast-Operator

Um Situation wie die gerade beschriebene abfangen zu können, wurde noch ein weiterer Operator definiert: der 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:

Sehen wir uns das Verhalten an einem Beispiel an.

class Basis

{

public:

  virtual int id() {

    return 0; }

};

 

class Unterklasse : public Basis

{

public:

  virtual int id() {

    return 1; }

};

Die Klassen tun nichts anderes, als eine Identifikationszahl in ihren Methoden id() zurückzuliefern. Immerhin können wir damit sicher sein, von 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;

}

Unsere 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);

}

Das Ergebnis ist wie erwartet:

_b nicht vom Typ Unterklasse!

test: 1

Beim ersten Aufruf lässt sich das Argument _b nicht nach Unterklasse* konvertieren, da es sich um einen Zeiger auf Basis handelt. Beim zweiten Aufruf können wir die implizite Umwandlung korrekt rückgängig machen und auf das Objekt zugreifen.

Es ist zwar auch möglich, die Funktion test() mit einem static_cast-Operator beziehungsweise einer expliziten Umwandlung im C-Stil zu implementieren. Dann haben Sie aber keine Chance, auf falsche Typen zu reagieren. Ein anschließender Methodenaufruf führt dann nicht mehr zum gewünschten Ergebnis.

Der const_cast-Operator

Um Funktionen vor unerwünschten Seiteneffekten in Form von Modifikationen an übergebenen Objekten zu bewahren, habe ich Ihnen bisher immer empfohlen, die Argumente nach Möglichkeit als konstante Referenzen oder Zeiger zu übergeben. Das geht so lange gut, bis die Funktion die Objekte an eine andere Funktion weitergeben muss, die eben nicht konstante, sondern richtige Referenzen oder Zeiger erwartet. Bei der C++-Standardbibliothek tritt diese Problematik zwar nicht mehr auf; wenn Sie aber mit irgendeiner sonstigen Bibliothek zusammenarbeiten müssen, werden Sie früher oder später auf diesen Fall stoßen. Was ist hier zu tun?

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);

Ihre Klasse hat ein besseres Design und liefert nur einen konstanten Zeiger:

class Message

{

public:

  const char* getString() const;

  // ...

};

In der folgenden Funktion brauchen Sie also diesen Umwandlungsoperator:

int sendMessage(const Message& _mes)

{

  return send(const_cast<char*>(_mes.getString));

}

Auf \resizebox{10mm}{!}{\includegraphics{images/vorsicht.eps}} zwei Aspekte sollten Sie bei diesem Operator besonders achten:

Der reinterpret_cast-Operator

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);

Sie müssen ihr also einen char-Zeiger auf den Datenbereich übergeben, in den Sie die Dateiinhalte schreiben wollen. Nehmen wir an, Sie möchten eine Reihe von int-Zahlen einlesen. Diese sind in der Datei binär hintereinander abgelegt. Die erste Zahl gibt die Größe des Feldes an, also wie viele Zahlen noch folgen.

Zur internen Darstellung entwerfen Sie eine Klasse:

class ValueBuffer

{

private:

  int* buffer;

  unsigned int size;

public:

  ValueBuffer() :

    buffer(0), size(0) {}

  ~ValueBuffer() {

    if (buffer) delete[] buffer; }

  int read(const string& _name);

};

In der Lesemethode öffnen wir die Datei, lesen den ersten Eintrag, reservieren entsprechend viel Speicher und rufen dann die read()-Methode der Stream-Klasse auf. Da diese aber keinen Zeiger auf int erwartet, sondern auf char, müssen wir Ihren Zeiger uminterpretieren; dazu brauchen wir jetzt den reinterpret_cast-Operator.

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)

          size*sizeof(int));

 

  if (in.fail())

  {

    cerr << "Zu wenig Daten in " << _name 

         << "!" << endl;

    return -2;

  }

 

  in.close();

  return 0;

}

Bei read() müssen wir außer dem Zeiger die Anzahl der Bytes angeben, die wir einlesen wollen. Diese lassen sich als Produkt der Anzahl der Elemente und ihrer Größe ermitteln.

Zusammenfassung

Dieser kurze Abschnitt diente dazu, Ihnen die neuen Operatoren für die Typumwandlung vorzustellen. Sie sind eine vollwertige Alternative zur bekannten C-Schreibweise; Sie sollten ausschließlich diese Operatoren verwenden, da dadurch nicht nur die Typumwandlung als solche im Quelltext leichter erkennbar ist, sondern auch das Ziel dieser Umwandlung. Im Einzelnen haben Sie folgende Operatoren kennen gelernt:

Übungsaufgaben

  1. Beantworten Sie folgende Fragen:

  2. Schreiben Sie einen Operator 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.

  3. Schreiben Sie ein Programm, das eine Datei mit einem gegebenen Namen binär öffnet und ihren Inhalt als Hexadezimalzahlen ausgibt, wobei jeweils acht zu einer Zeile gruppiert werden sollen.


Überladen von Operatoren

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 $A$ und $B$ ganz selbstverständlich:

\begin{displaymath}C = A+B\end{displaymath}

obwohl mit diesem $+$ eine ganz andere Operation gemeint ist als etwa bei $x = 3 + 5$. Trotzdem liegt beiden Ausdrücken ein gemeinsames Verständnis davon zugrunde, was eine Addition ist; allerdings muss für die Matrizen genau definiert werden, was unter einer Summe zu verstehen ist. Letztendlich erreicht man damit eine sehr kompakte und somit übersichtliche und eingängige Schreibweise.

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 [*]).

Figure: Auch beim Überladen von Operatoren sollten Sie sich nicht übernehmen.



\resizebox*{4cm}{!}{\includegraphics{/usr/homes/thomas/cpp/cpp_linux/Text/images/overload.eps}}



Wir wollen also in einem Programm etwa folgende Syntax erlauben:

  matrix  a, b, c;

  c = a + b;

Operatorfunktionen und -methoden

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.


Operatorfunktionen

Eine Operatorfunktion ist damit nach folgendem Schema aufgebaut:

Rückgabewert operator$\otimes$Argumente )

{

  // Funktionskörper

}

Für unsere Matrix heißt das beispielsweise:

matrix operator+(const matrix& a,

                 const matrix& b)

{

  // ...

  return c;

}

Der Compiler setzt die von Ihnen zur Verfügung gestellte Operatordefinition immer dann ein, wenn er eine Verknüpfung zwischen zwei Objekten findet, die im Typ mit den Argumenten der Operatorfunktion übereinstimmen. Daher ist

  matrix c = a + b;

äquivalent mit

  matrix c = operator+(a,b);

Auch diese zweite Form ist gültiger Code!

Operatormethoden

Die Definition eines Operators als Methode einer Klasse hat die Form:

Rückgabewert Klasse::operator$\otimes$Argumente )

{

  // Funktionskörper

}

Auch auf diese Weise könnten wir die Matrix-Addition programmieren:

matrix matrix::operator+(const matrix& b)

{

  // ...

}

Dann wäre die Anweisung

  matrix c = a + b;

allerdings gleichbedeutend mit

  matrix c = a.operator+(b);

Arten von Operatoren

Sie können alle C++-Operatoren überladen (wie =, !=, ==, +, *, += und so weiter, siehe auch Tabelle 2.1Tab: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 2.1 sehen, unterscheidet man Operatoren in primäre, unäre und binäre. Als Funktion können Sie sowohl unäre Operatoren definieren, zum Beispiel

MyClass operator!(const MyClass& _o);

 

MyClass o;

!o;

als auch binäre wie das + 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);

 

  // ...

};

Die Anwendung erfolgt dann wie bei den eingebauten Operatoren:

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);

}

Zu fast allen dieser Operatoren erhalten Sie in den folgenden Abschnitten weitere Informationen.


Der Indexoperator

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.

Lesezugriff

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); }

  // ...

};

Der Aufrufer erhält somit zwar Zugriff auf das Originalobjekt, darf an diesem aber keine Veränderungen vornehmen, sondern nur dessen const-Methoden aufrufen.

Schreibzugriff

Wenn Sie den Indexoperator für Schreibzugriffe verwenden wollen, er also beispielsweise auf der linken Seite einer Zuweisung stehen soll:

  o[i] = t;

dann ist die Rückgabe eines Wertes oder einer konstanten Referenz natürlich ungeeignet. Hier brauchen wir eine echte Referenz, über die das Originalobjekt manipuliert werden kann. Analog zu at() gibt es damit auch vom Indexoperator eine zweite Version mit gleicher Signatur, aber anderem Rückgabetyp. Diese ist natürlich auch nicht mehr als const deklariert.

template <typename T> class Vektor

{

public:

  T& operator[](unsigned int _i)

  { return at(_i); }

  // ...

};

Der Compiler stellt auch hier anhand des Kontextes fest, welche der beiden Versionen in einer konkreten Situation zu verwenden ist.

Hintergrund

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;

wird nicht die konstante, sondern die modifizierbare Version des Indexoperators aufgerufen. Im Allgemeinen ist dies zwar ärgerlich, stört aber nicht besonders. In manchen Fällen wird in der Methode für den nichtkonstanten Operator jedoch erheblich mehr Arbeit geleistet als in der des konstanten. Da kann es einen spürbaren Unterschied in der Laufzeit ausmachen, welche Methode genau zum Einsatz kommt.

Wenn Sie erzwingen wollen, dass der Ausgabeoperator die konstante Version verwendet, können Sie dies zum Beispiel dadurch, dass Sie lokal (etwa in einem separaten Block) eine konstante Referenz auf den betreffenden Container erzeugen und dann deren Indexoperator aufrufen. Dieser muss zwangsläufig der konstante sein. Obiges Beispiel hätte damit die Form:

  Vektor<int>  v;

  // ... (Fülle v)

 

  {

    // Lokale konstante Referenz

    const Vektor<int>& rv = v;

    cout << rv[0] << endl;

  }


Der Inkrementoperator

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);

 

  // ...

};

Bei der Implementierung müssen Sie jedoch selbst aufpassen, dass Ihre Operatormethoden auch das Verhalten zeigen, das der Benutzer von ihnen erwartet. Sie müssen also beispielsweise beim Postfixoperator das Objekt zwar verändern, es aber in dem Zustand vor der Veränderung zurückliefern. Dies können Sie etwa mit folgendem Schema erreichen:

// 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;

}

Wie Sie sehen, müssen Sie bei der Implementierung etwas aufpassen, um nicht die Erwartungen an den Inkrementoperator zu enttäuschen. Sie sollten einen solchen auch nur dann überladen, wenn er sich auf natürliche Weise für Ihre Klasse anbietet. Ist er nicht intuitiv verständlich, braucht man also erst eine längere Erklärung, was eigentlich sein Sinn ist, lassen Sie ihn lieber weg.

Und noch ein Tipp: Wenn Sie einen Inkrementoperator überladen, bieten Sie aus Symmetriegründen stets sowohl eine Präfix- als auch eine Postfixvariante an. Sie können nie wissen, wie der Benutzer Ihrer Klasse seine Aufrufe strukturieren will.


Der Zuweisungsoperator

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) { //... }

Zuweisung bei Objekten mit dynamisch reserviertem Speicher

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 Objekt vernichtet wird.

Wenn wir also zwei Objekte A und B haben, die über dynamisch reservierten Speicher verfügen, müssen wir diesen wie beim Kopierkonstruktor wirklich kopieren. Bevor Sie weiterlesen, überlegen Sie sich zunächst, wie Sie einen derartigen Zuweisungsoperator, etwa für 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:

  1. Freigeben des von A belegten Speichers
  2. Reservieren von Speicher für A in der von B benötigten Größe
  3. Kopieren des Inhalts von 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:

  1. Kopieren des Zeigers auf den von A belegten Speicher in eine lokale Variable
  2. Reservieren von Speicher für A in der von B benötigten Größe
  3. Kopieren des Inhalts von B nach A
  4. Freigeben des bisher von A belegten Speichers mittels der lokalen Variablen
Für unsere Vektorklasse sieht der Zuweisungsoperator dann etwa folgendermaßen aus:

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);

}

Selbstzuweisung

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)

  {

     // ...

  }

geschützt sind.

Weitere Tipps

Noch ein paar weitere Anmerkungen zu Zuweisungsoperatoren:

Vergleiche und mathematische Operatoren

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.

Komplexe Zahlen

Oftmals gibt es jedoch Typumwandlungskonstruktoren, mit deren Hilfe Standardtypen in ein Objekt der jeweiligen Klasse konvertiert werden können. Ein einfaches Beispiel sind komplexe Zahlen. (Hintergrund: Um auch beliebige polynomiale Gleichungen lösen zu können, definiert man eine neue Zahl $i$ mit $i^2=-1$ und erweitert damit den Zahlenbereich um alle reellen Vielfachen von $i$. Das Ergebnis sind die komplexen Zahlen, die man üblicherweise als Zahlenpaar $a+bi$ mit $a,b \in {\bf R}$ schreibt. Man nennt dabei $a$ den Realteil und $b$ den Imaginärteil.) Obwohl es in der C++-Standardbibliothek bereits einen Datentyp complex gibt (verwendbar über #include <complex>), wollen wir eine kleine Klasse erstellen, die eine komplexe Zahl repräsentiert.

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; }

};

Die beiden Vorgabewerte im Konstruktor sorgen dafür, dass auch ein Anlegen eines Objekts mit nur einer double-Zahl möglich ist, zum Beispiel

  komplex c = 3.14;

denn jede reelle Zahl ist auch eine komplexe, jedoch mit Imaginärteil $0$, also $3,14 + 0i$. Wollen wir nun einen Operator + hinzufügen, müssen wir also nicht nur den Fall

  komplex a,b,c;

  c = a + b;

betrachten, sondern auch

  komplex a,c;

  c = 3.14 + a;

Offensichtlich macht die Definition eines binären Operators als Methode hier wenig Sinn.

Selbst bei zwei gleichartigen Objekten sind binäre Verknüpfungen aus Symmetriegründen besser als eigenständige Funktion zu betrachten; denn was zeichnet das erste gegenüber dem zweiten dermaßen aus, dass dessen Methode aufgerufen wird und das zweite darin nur ein Parameter ist?


Kombinierte Verknüpfung und Zuweisung

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;

}

Damit können wir nun sehr einfach den +-Operator schreiben:

komplex operator+(komplex _x, komplex _y)

{

  komplex tmp = _x;

  return tmp += _y;

}

Auch die weiteren Operatoren lassen sich durch dieses Zusammenspiel ganz leicht erstellen.

Was lernen wir daraus? Erfüllen Sie immer die Erwartungen der Benutzer Ihrer Klassen und bieten Sie auch die Varianten der Operatoren an, die sie von den Standardtypen gewohnt sind.

Vergleiche

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());

}

Um konsistent zu bleiben, sollten Sie die Negation des Vergleichs (also hier das !=) stets so implementieren, dass Sie dabei die Operatorfunktion eines bereits vorhandenen Falls aufrufen:

bool operator!=(const komplex& _k1, 

                const komplex& _k2)

{

  return !(_k1==_k2);

}

Operatoren als Freunde

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:

  //...

};

Dann können Sie innerhalb der Operatorfunktion auch die privaten Datenelemente benutzen:

bool operator==(const komplex& _k1, 

                const komplex& _k2)

{

  return (_k1.re == _k2.re &&

          _k1.im == _k2.im);

}

Achten Sie aber darauf, nicht allzu leichtfertig mit der Befreundung umzugehen. Es sind zwar Operatoren, die unmittelbar zur Schnittstelle der Klasse gehören (und daher auch in derselben Datei untergebracht werden sollten; siehe [SUTTER 2000]), jedoch können sie diese Schnittstelle leicht unübersichtlich machen. Die Rückführung eines binären Operators auf eine öffentliche Methode wie im Fall von + auf += ist ein probates Mittel, um ein Zuviel an Freunden zu vermeiden. Bei Klassen geht es wie im richtigen Leben: Zu viele Freunde bedeuten meist nichts Gutes.


Ein- und Ausgabeoperator

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);

Es ist außerdem offensichtlich, dass der Eingabeoperator eine echte Referenz auf das Objekt erhalten muss, da damit ja das Objekt beschrieben werden soll.

Für unsere Klasse komplex können wir diese Operatoren etwa wie folgt programmieren:

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;

}

Wie Sie sehen, greifen wir beim Eingabeoperator auf private Attribute zu. Dazu muss diese Funktion mit der Klasse befreundet sein. Bei Eingabeoperatoren ergibt sich diese Notwendigkeit sehr viel häufiger als bei Ausgabeoperatoren, da dort ein reiner Lesezugriff nötig ist, der meist bereits existiert.

In diesem besonderen Fall können wir mit ein paar Klimmzügen sogar die friend-Deklaration vermeiden:

istream& operator>>(istream& _i, komplex& _k)

{

  double re, im;

  _i >> re >> im;

  _k = komplex(re,im);

  return _i;

}


Typumwandlungsoperator

Im Abschnitt Typumwandlungskonstruktor (ab Seite [*]) haben Sie gesehen, wie man eine Regel formuliert, die angibt, wie ein Objekt eines anderen Datentyps in ein Objekt der Klasse umgewandelt werden kann. Nun wollen wir den umgekehrten Fall betrachten, nämlich eine Methode, die beschreibt, wie ein Objekt der Klasse in einen anderen Datentyp konvertiert werden kann. Da man dabei die Objektvariable direkt, das heißt ohne einen durch . oder -> angehängten Methodenaufruf verwendet, zählt diese Art von Methode zu den Operatoren. Man spricht dabei vom Typumwandlungsoperator.

Die Syntax ist sehr einfach:

class MyClass

{

public:

  operator Datentyp();

  // ...

}

Typumwandlungsoperatoren haben eine besondere Form: Sie sind Methoden einer Klasse und haben weder eine Parameterliste noch einen Rückgabetyp im Sinne der formalen Struktur einer Funktion (siehe Seite [*]). Tatsächlich haben Sie aber doch einen Rückgabewert, nämlich den Inhalt des Objekts im angegebenen Typ.

Typumwandlungsoperatoren \resizebox{10mm}{!}{\includegraphics{images/vorsicht.eps}} sind eine potenzielle Fehlerquelle. Der Programmierer erlaubt damit nämlich die implizite Umwandlung seines Objekts in einen völlig anderen Typ. Diese Konvertierung ist oft mit einem Verlust an Information verbunden. Da die Umwandlung auch implizit geschehen kann, ist sie nicht immer unmittelbar als solche im Code erkennbar. Noch weniger ist offensichtlich, dass dabei eine Methode des Objekts aufgerufen wird. Sie sollten von dieser Technik also nur äußerst sparsam Gebrauch machen und auch nur dann, wenn es sich wirklich anbietet. Meist sind explizite Methoden mit derselben Funktionalität sinnvoller - etwa solche, die die Umwandlung bereits in ihrem Namen ausdrücken (beispielsweise getAsChar()).

Typumwandlungsoperatoren bieten sich immer dann an, wenn der Zieltyp keine Klasse ist, die von Ihnen selbst definiert wurde, das heißt, wenn es sich bei diesem entweder um einen Standardtyp (wie float oder char*) oder eine Klasse von Dritten, etwa aus einer Bibliothek, handelt.

Als Beispiel betrachten wir die Umwandlung eines Objekts vom Typ Vektor<T> in ein Array, also in einen Zeiger auf T. Dies ist durchaus eine sinnvolle Ergänzung, da viele Bibliotheksfunktionen nicht mit Vektorklassen umgehen können (auch nicht mit solchen der STL), sondern Zeiger erwarten. Um die Inhalte unseres Objekts trotzdem nicht völlig preiszugeben, womit die Rückgabe des Zeigers ja verbunden wäre, liefern wir einen konstanten Zeiger. Damit sind auch die Bibliotheksfunktionen im Allgemeinen zufrieden.

template <typename T>

class Vektor

{

public:

  operator const T*() const;

  // ...

};

 

template <typename T>

Vektor<T>::operator const T*() const

{

  return v;

}

In der Deklaration der Methode finden Sie gleich zwei const. Haben Sie bereits erkannt, welchen Zweck diese erfüllen? Das erste bezieht sich auf den Typ, den der Operator zurückliefern soll, nämlich einen konstanten Zeiger auf T. Das const nach den runden Klammern weist darauf hin, dass diese Methode auch für konstante Objekte vom Typ Vektor<T> verwendet werden darf; sie verändert das Objekt ja auch nicht.

Verwendet wird diese Funktionalität wie jede andere Typumwandlung. Um die Konvertierung deutlich zu machen, empfehle ich Ihnen, sie explizit anzugeben.

Vektor<int> x;

// ...

const int* p;

p = (const int*)x;

Elegant, aber eben nur schwer durchschaubar, ist der Gebrauch mit Bibliotheksfunktionen, beispielsweise mit strstr() (siehe Seite [*]) zur Teilstringsuche:

Vektor<char> code;

// ...

char* occ;

occ = strstr((const char*)code, "abcd");

Hier suchen wir nach dem ersten Vorkommen von "abcd" im Vektor code.

Allgemeine Prinzipien

Zum Abschluss möchte ich Ihnen noch ein paar allgemeine Prinzipien vorstellen, die Sie beim Design Ihrer Klassen mit überladenen Operatoren beherzigen sollten.

Operatoren in Templateklassen

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.

Orientierung an Standardoperatoren

Bevor ein Benutzer mit Ihrer Klasse in Kontakt kommt, hat er sicher schon viele C++-Programmzeilen geschrieben. Er hat daher auch durch die vordefinierten Operatoren eine klare Vorstellung davon, welche Bedeutung ein bestimmter Operator hat. Diese Vorstellung sollten Sie durch das Verhalten der von Ihnen überladenen Operatoren keinesfalls auf den Kopf stellen. Denn nur wenn der Sinn eines Operators fast intuitiv erfassbar wird, kann ein Operator seinen eigentlichen Vorteil geltend machen, nämlich den Code lesbarer zu halten.

Wenn Sie sich also nicht sicher sind, wie sich ein von Ihnen definierter Operator verhalten sollte, orientieren Sie sich an den ints (wie unter anderen auch Scott Meyers vorschlägt [MEYERS 1996]). Nur wenn Sie mit Ihrem Operator überhaupt nicht in die Nähe der Standardoperatoren kommen - und es dafür einen guten Grund gibt -, können Sie ein kleines Stück davon abrücken und Ihre Definition pragmatisch gestalten.

Auch ist das Verhalten eines Operators manchmal bekanntermaßen ein wenig anders als bei Ganzzahlen, etwa weil es in der Natur des Begriffes liegt, den die Klasse modelliert. So ist beispielsweise bei Matrizen aus der Mathematik bekannt, dass die Multiplikation im Allgemeinen nicht kommutativ ist; folglich kann auch der Operator * dies nicht sein.

Ausgewogene Operatoren

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.

Figure: Nicht nur bei den Operatoren ist Ausgewogenheit ein wichtiges Prinzip.



\includegraphics{/usr/homes/thomas/cpp/cpp_linux/Text/images/waage.eps}

Denn die Benutzer Ihrer Klasse erwarten von Ihnen, dass Sie die volle Funktionalität anbieten. Folgende Aspekte sind dabei zu beachten:

Wenn Sie bei inhaltlich zusammenhängenden Operatoren nur einen Operator anbieten, den anderen aber weglassen, ist der Benutzer trotzdem versucht, ihn aus Gewohnheit zu verwenden, läuft aber auf einen Fehler, der sich oft nur sehr schwer lokalisieren lässt. Es ist meist wenig Aufwand, die zusätzlichen Operatoren hinzuzufügen, zumal Sie deren Implementierung sehr oft auf die des ersten zurückführen können.

Zusammenfassung

Die wichtigsten Gesichtspunkte aus diesem Abschnitt waren:

Übungsaufgaben

  1. Beantworten Sie folgende Fragen:

  2. Erweitern Sie die Klasse Rational aus Abschnitt [*] (Seite [*]) und [*] (Seite [*]) um Operatoren, so dass die Methoden add(), mult() und so weiter durch Operatorsymbole erreichbar sind. Fügen Sie auch Vergleichsoperatoren, einen Ausgabeoperator und einen Typumwandlungsoperator nach double hinzu und testen Sie Ihre Klasse an geeigneten Beispielen.

  3. Es soll eine Klasse Set für eine Menge ganzer Zahlen implementiert werden. Die Elemente sind in einem Feld konstanter Größe abzulegen, und als privates Datenelement soll zudem noch die Kardinalität (Anzahl der Elemente) gespeichert werden. Als Operatoren sind zu implementieren:

    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);                 

    };

    Die Maximalzahl der Elemente 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?


Ausnahmebehandlung (Exceptions)

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 zwischen zwei Arten von Fehlern unterscheiden:

  1. Fehler, die nach einem gründlichen Test nicht mehr auftreten sollten
  2. Fehler, die im laufenden Betrieb jederzeit auftreten können
Es ist damit eigentlich irreführend, in beiden Fällen von Fehlern zu sprechen. Im ersten Fall haben wir es mit tatsächlichen Programmierfehlern zu tun, im zweiten dagegen nur mit Störungen des Programmablaufs. Um einen Mechanismus der Sprache C++ zur Behandlung dieser Störungen soll es in diesem Abschnitt gehen.

Behandlung von Fehlersituationen

Zunächst wollen wir uns überlegen, wie wir auf solche Störungen reagieren könnten. Ich hatte Ihnen schon mehrfach geraten, möglichst defensiv zu programmieren, das heißt hinter allen Funktionsaufrufen Fehlschläge zu vermuten und die entsprechenden Rückgabewerte abzufragen. Solche Fehlschläge können unter anderem sein:

Es geht also um Ausnahmesituationen, die Sie beim Programmieren bereits voraussehen und für die Sie entsprechende Gegenmaßnahmen treffen können. Von welcher Art könnten diese sein?

Diese letzte Möglichkeit wird auch in der Standardbibliothek oft verwendet. Sie ist aber leider nicht für alle Situationen auch die beste. Denn zuweilen kann das Problem als solches zwar bei seinem Auftreten entdeckt werden; es ist jedoch nicht möglich, in diesem Kontext die eigentliche Ursache sowie eine geeignete Reaktion zu erkennen. Erst der Aufrufer kann darauf angemessen reagieren und den Fehler gegebenenfalls beheben. Nun ist leider der Aufrufer nicht immer gleich in der nächsthöheren Ebene, sondern kann bei einer komplexen Programmstruktur mehrere Ebenen darüber liegen. Wenn es sich um ein Problem handelt, von dem der Benutzer unterrichtet werden sollte, will dieser ebenfalls nicht mit einer Meldung in der Form Ungültiger Wert 0 in Zugriff auf m_Order[i] belästigt, sondern über die wahren Hintergründe aufgeklärt werden.

Eine weitere Schwierigkeit mit dieser Vorgehensweise ist, dass es C++ erlaubt, den Rückgabewert von Funktionen und Methoden zu ignorieren. Die Funktion, die das Problem feststellt, kann also nach dem Zurückmelden weder sicherstellen noch nachprüfen, ob ihrer Meldung auch Beachtung geschenkt wurde.

Exception Handling

Eine zusätzliche Alternative zu den gerade aufgezeigten Möglichkeiten besteht darin, dass die Funktion, die das Problem erkennt, selbst eine andere Funktion zu dessen Behandlung aufruft und dieser eventuell auch noch entsprechende Kontextinformationen mitgibt. Prinzipiell bedeutet dies nichts anderes, als einen zweiten Weg zum Verlassen einer Funktion neben der Rückkehr mit return zu schaffen.

Zur Behandlung von vorhersehbaren Fehlern, die in einer Funktion auftreten und die der Aufrufer behandeln kann, stellt C++ den Mechanismus der Ausnahmebehandlung (besser bekannt unter dem englischen Ausdruck exception handling) zur Verfügung. Dabei ist der Ablauf typischerweise der folgende:

  1. Eine Funktion versucht, eine andere aufzurufen. Dieser Versuch wird durch das Schlüsselwort try ausgedrückt.
  2. Gibt es während der Abarbeitung des Aufrufs einen Fehler, löst sie eine Ausnahme aus. Dieser Vorgang wird auch Werfen einer Ausnahme genannt und erfolgt mit dem Schlüsselwort throw.
  3. Die aufrufende Funktion kennt die Möglichkeit, dass eine solche Ausnahme auftreten könnte, und ist bereit, diese abzufangen. Das geschieht mittels catch.
Sowohl try als auch catch leiten dabei einen Block ein und stehen in der aufrufenden Funktion. Der Befehl throw befindet sich in der aufgerufenen Funktion.

Wenn \resizebox{10mm}{!}{\includegraphics{images/vorsicht.eps}} eine Funktion eine Ausnahme (Exception) wirft, ist dies ein alternativer Rücksprung. Alle Objekte, die dort vollständig erzeugt wurden (also deren Konstruktoren schon beendet sind), werden gelöscht. Es findet aber kein normaler Rücksprung mehr statt!

Allgemeine Syntax

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

}

Figure: In der Fehlersituation wird eine Ausnahme geworfen.



\resizebox*{2cm}{!}{\includegraphics{/usr/homes/thomas/cpp/cpp_linux/Text/images/werfer.eps}}

Ausnahmeobjekte

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())

    throw MyException();

müssen Sie vorher einen Typ - zum Beispiel eine Klasse - names MyException definiert haben. Diese darf sogar völlig leer sein; denn eventuell drücken Sie ja schon durch den Typ selbst die Art der Ausnahme aus. Wenn Sie hier nur den Namen der Klasse sehen, so bedeutet dies, dass beim throw-Kommando ein temporäres Objekt von diesem Typ angelegt wird; nach Ende des zugehörigen catch-Blocks wird es automatisch wieder freigegeben. Sie verstehen sicher, dass es bei diesem Ablauf höchst ungeschickt wäre (wenn auch leider nicht syntaktisch falsch), das Ausnahmeobjekt dynamisch zu erzeugen.

Über den Konstruktor kann dieses Objekt bei Bedarf noch mit Informationen über die Umstände der Ausnahme gefüllt werden.

  if (bad())

    throw MyException("Operation failed", 

                      status);

Natürlich sollte die Klasse dann auch über entsprechende öffentliche Methoden verfügen, so dass der Fänger der Ausnahme auf diese Informationen zugreifen kann.

Auffangen der Ausnahmen

Das Argument von catch kann sowohl als Objekt als auch als Referenz abgefangen werden. Dadurch lassen sich auch die enthaltenen Informationen auswerten, zum Beispiel:

  catch(MyException& _exc)

  {

    cerr << _exc.getMessage() << endl;

    // weitere Fehlerbehandlung

  }

Figure: Geworfene Ausnahmen sollten auch aufgefangen werden.



\resizebox*{3cm}{!}{\includegraphics{/usr/homes/thomas/cpp/cpp_linux/Text/images/catcher2.eps}}

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;

  }

Sie sollten in Ihren Programmen darauf achten, dass Sie alle möglichen Ausnahmen auch auffangen. Denn es gilt: Wird eine Ausnahme in der aufrufenden Funktion nicht abgefangen, wird sie an deren aufrufende Funktion weitergegeben. Das setzt sich fort bis hin zu main(). Ist auch dort keine Behandlung implementiert, bricht das Programm mit einer Fehlermeldung ab. Dann haben Sie in puncto Fehlerbehandlung und Stabilität Ihres Programms herzlich wenig erreicht.

Weiterwerfen

Es ist aber auch möglich, eine Ausnahme bewusst an die nächsthöhere Ebene weiterzugeben. Vielleicht wollen Sie die Abfolge innerhalb des Aufruf-Stacks nur dokumentieren und den Fehler nicht wirklich behandeln. Dann werfen Sie die aktuelle Exception mit einem schlichten throw einfach weiter, zum Beispiel:

  catch(...)

  {

    log("Unbekannter Fehler!");

    throw; // Weitergabe

  }

Angabe von Ausnahmen in Schnittstellen

Wenn Sie eine Klasse verwenden, die ein anderer implementiert hat, möchten Sie gerne wissen, welche Ausnahmen deren Methoden werfen könnten. Da die Header-Datei mit der Schnittstelle Ihre primäre Informationsquelle ist, sollten auch die Ausnahmen dort vermerkt sein.

Genau dies unterstützt die Sprache C++. Um anzugeben, welche Exceptions eine Funktion auswirft, kann man sie in einer Liste hinter dem Funktionsprototyp deklarieren.

void func(int _arg) throw(RangeException, 

                          DomainException);

Ist eine solche Angabe vorhanden, ist sie auch verbindlich. In diesem Fall darf die Funktion func ausschließlich die Ausnahmen RangeException und DomainException werfen; bei anderen meldet der Compiler einen Fehler. Als Entwickler müssen Sie beachten, dass die Liste bei der Funktionsdefinition wiederholt werden muss.

Steht hinter dem Prototyp nur throw(), so verspricht die Funktion damit, überhaupt keine Exceptions auszuwerfen.

Aus Gründen der Abwärtskompatibilität gilt aber leider auch: Fehlt eine Angabe von throw hinter einer Funktion, so darf sie beliebige Ausnahmen auswerfen. Eine Schnittstelle mit gutem Design sollte daher immer die vor ihr geworfenen Ausnahmen auflisten.


Exception-Objekte als Hierarchie

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 {};

In der Klasse sieht das dann folgendermaßen aus:

template <typename T>

class SimpleStack

{

public:

// ...

  T pop() throw(StackEmpty);

  void push(const T&) throw(StackFull);

};

Wie üblich bezeichnet dabei push() das Ablegen und pop() das Herunternehmen eines Objekts. In der Anwendung können Sie nun durch das Fangen von StackError beide Fälle behandeln:

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; 

  }

  

  //...

}

Da StackError über keinerlei Elemente verfügt, genügt in der catch-Anweisung die Angabe des Typs. Einen Argumentnamen benötigen wir nicht, da wir mit dem Objekt sowieso nichts anfangen können.

Hintergrund

Man kann Exceptions auch (miss-)brauchen, um den Programmfluss zu steuern. In folgendem Beispiel wirft die Funktion 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);

Eine solche Vorgehensweise ist aber nur in den seltensten Fällen eine gute Idee. Normalerweise sollten Sie andere Wege zur Kontrollflusssteuerung finden können und dafür keine Exceptions benötigen. Bewusst ausgelöste Exceptions sind ohnehin immer überflüssig.

Beispiel: Vektor

Die Technik der Ausnahmebehandlung wollen wir nun bei unserer bekannten Vektorklasse einsetzen, die wir zuletzt in Abschnitt [*] überarbeitet haben. Wir definieren zwei Ausnahmen:

Beide leiten wir wie eben von einer Basisklasse ab, die auch gleich die Schnittstelle für den Zugriff auf die Informationen in abstrakter Weise definiert. Wir geben die Fehlermeldung einfach als Text zurück, den wir mittels eines strstream erzeugt haben:

// Ausnahmebasisklasse

class VektorException

{

public:

  VektorException() throw() {}

  virtual ~VektorException();

  virtual const char* what() const = 0;

};

 

// Ausnahme bei Bereichsüberschreitung

class VektorOutOfRange

{

private:

  unsigned int size, index;

 

public:

  VektorOutOfRange(unsigned int _size,

                   unsigned int _index) :

    size(_size), index(_index) {}

  virtual ~VektorOutOfRange() {};

  virtual const char* what() const

  {

    strstream s;

    s << "Bereichsüberschreitung (Größe: " 

      << size << ", Index: " << index

      << ")" << ends;

    return s.str();

  }

};

Die Klasse VektorNoMem ist ganz analog zu VektorOutOfRange aufgebaut, so dass ich sie hier wohl nicht abdrucken muss.

Wenn wir nun alle möglichen Ausnahmen in die Schnittstelle der Klasse Vektor aufnehmen, erlangt diese folgende Form:

// 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);

  // ...

};

Der Konstruktor mit Größenangabe sieht eigentlich aus wie bisher; nur der Fall, dass kein Speicher mehr zur Verfügung steht, wird mit einer Exception quittiert.

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];

}

Durch die Übergabe der entscheidenden Größen size und _i ist der Aufrufer in der Lage, seine Anfrage gegebenenfalls zu überdenken und zu wiederholen.

Wenn wir in unserem Testprogramm beispielsweise den Index um eins daneben platzieren, erhalten wir die Exception:

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;

  }

Das Programm führt zur Ausgabe:

3 4 5 6 7 Fehler: Bereichsüberschreitung 

(Größe: 5, Index: 5)


Exceptions und die Standardbibliothek

Als moderner Bestandteil von C++ sollte natürlich auch die Standardbibliothek - insbesondere die STL - Gebrauch von Exceptions machen. Seit Version 3.0 des GCC ist die Implementierung der GNU-STL nun so weit fortgeschritten, dass sie überall mit Ausnahmen arbeitet. Bei früheren Versionen hat nur ein kleiner Teil, das Bitset, sie unterstützt.

Die Basisklasse für die Exceptions wurde jedoch schon immer mitgeliefert. Sie können daher auch Ihre eigenen Ausnahmen davon ableiten:

class exception {

public:

  exception () { }

  virtual ~exception () { }

  virtual const char* what () const;

};  

Wie Sie sehen, habe ich mich bei den Ausnahmen der Vektorklasse bereits daran orientiert. Diese Klasse ist in der Header-Datei <exception> zu finden. Es gibt davon noch eine Reihe von abgeleiteten Klassen, die verschiedene Arten von Fehlern ausdrücken sollen. Diese sind in <stdexcept> definiert:

Ähnlich wie bei meiner Klasse VektorException erhalten Sie auch hier über die Methode what() eine textuelle Beschreibung des Fehlers.

Tipps und Hinweise

Exceptions haben zweifellos einige Vorteile. Neben der Eleganz sind dies vor allem:

Allerdings ist dieses Konzept auch kein Allheilmittel. Verschiedene Autoren (zum Beispiel [COPLIEN 1992] oder der C-Erfinder B. Kernighan in [KERNIGHAN . PIKE 2000]) warnen zurecht davor, zu häufigen Gebrauch von Exceptions zu machen. Wenn sich etwa eine Datei nicht öffnen lässt, weil der Name falsch war, ist das keine so dramatische Situation, dass sie eine Exception rechtfertigen würde. Gehen Sie also sparsam damit um und versuchen Sie, weitgehend mit Rückgabewerten auszukommen. Nur bei komplex verschachtelten Strukturen und bei Fehlern in Konstruktoren, die ja keine Rückgabewerte erlauben, sind Exceptions sinnvoll (aber nicht in Kopierkonstruktoren oder Destruktoren!).

Noch ein Hinweis am Schluss: Werfen Sie niemals Exceptions in einem Destruktor! Sie bringen damit Ihr Programm vermutlich vollends durcheinander.

Zusammenfassung

Aus diesem Abschnitt sollten Sie sich folgende Gedanken einprägen:

Übungsaufgaben

  1. Beantworten Sie folgende Fragen:

  2. Schreiben Sie eine Klasse mit Namen Char, die bei einem bevorstehenden Unter- beziehungsweise Überlauf eine Ausnahme auswirft, sich aber ansonsten wie der Datentyp char verhält.

  3. Vervollständigen Sie die Klasse SimpleStack von Seite [*] und testen Sie sie an geeigneten Beispielen.


next up previous contents index
Next: Editoren für die Programmierung Up: C++-Entwicklung mit Linux Previous: Programmieren mit C++   Contents   Index
thomas@cpp-entwicklung.de