" Scriptum C++ 2/2

Objektorientierte Konzepte in C++

OO Grundlagen

Die Softwarekrise, die 1968 bei einer Nato Konferenz `festgestellt' wurde, diente als Ansporn für neue Entwicklungen. Das Paradigma der objektorientierte Programmierung löst jenes der funktionsorientierten Programmierung ab: funktionsorientiert bedeutet (wie der Name schon sagt), daß die Daten eine untergeordnete Rolle spielen. Ein Anwendungssystem wird im Laufe des Entwurfs von allgemeiner Funktionalität bis zur Funktionalität auf unterer Ebene `herabgebrochen'. Objektorientiert bedeutet, daß Daten und Funktionen nicht getrennt werden.
Top Down Design, funktionale Zerlegung und schrittweise Verfeinerung sind die Eckmerkmale der funktionalen Programmierung. Folgende Probleme können dabei auftreten

OO-Konzepte und Begriffe

Tabelle 3: OO Konzept und Implementierung in C++/Smalltalk
Konzept Implementierung in C++ in Smalltalk
Klasse class class
Instanz Variable oder dynamisch erzeugtes Objekt dynamisch erzeugtes Objekt
Instanzvariable data-member einer Klasse instance variable einer Klasse
Klassenvariable static data-member einer Klasse class variable
Methode und Message member-function und deren Aufruf Methode und deren Aufruf
Klassenmethode static member-function Klassenmethode und deren Aufruf
Protokoll (Schnittstelle) Deklaration einer Klasse Deklaration einer Klasse
Vererbung abgeleitete Klassen (Einfach-, Mehrfachvererbung; beliebig viele Hierarchien) abgeleitete Klassen (Einfachvererbung; eine Hierarchie)
Abstrakte Klasse Klasse mit rein virtuellen Methoden, ohne Instanzen Klasse ohne Instanzen
Parametrisierte Typen Schablonen (Templates) ---
Polymorphismus Überladen von Funktionen, Operatoren, virtuelle Methoden Überladen von Funktionen, Operatoren, virtuelle Methoden
Dynamisches Binden Virtuelle Funktionen alles ist dynamisch
Zugriffsrechte auf Instanzvariablen und Methoden private, protected, public Mechanismen Instanzvariablen sind grundsätzlich private, Methoden public

Klasse Person (1/2)

#ifndef Person_h
#define Person_h

class Person {

private:
  int systemId;
protected:
  char *name;
public:
  int socSecNo;

  Person(int socSecNo); // contructor
  ~Person(); // destructor

char*   Name();
void    Print();
int     SocSecNo();
int     GetUniqueId();
};

#endif
Standardmäßig (d.h ohne Angabe von private, protected oder public) gelten alle Daten und Methoden einer Klasse als privat. Wie arbeitet man nun mit dieser Klasse bzw. ihren Objekten?

Klasse Person (2/2)

Person *p2; // p2 calls Person::Person; dynamically allocated
Person p(4711); // statically allocated

p2 = new Person(4712); // dynamically allocated
cout << "\nsystem id of p " << p.GetUniqueId() << 
"\n";
cout << "\nsystem id ofp2 " << p2->GetUniqueId() 
<< "\n";

Man beachte: -> entspricht `*.', d.h p->GetUniqueId() entspricht (*p).GetUniqueId().

Konstruktoren und Destruktoren

Konstruktoren sind vergleichbar mit Klassenmethoden, die zur Initialisierung automatisch aufgerufen werden. Konstruktoren können Argumente erwarten, man kann aber keinen Rückgabedatentyp vereinbaren (Rückgabedatentyp ist implizit ein Objekt der jeweiligen Klasse). Wurde kein Konstruktor definiert, legt der Compiler automatisch einen Default-Konstruktor an.

Initialisierung bei Konstruktoren

Person::Person (): id((int)this) // andere Konstruktoren
{
//id = (int) this; zweite Möglichkeit der Zuweisung
}
Bei mehreren Initialisierungen entspricht die Reihenfolge der Initialisierung der Definitionsreihenfolge der Instanzvariablen.
Initialisierungsreihenfolge

int f(int i) { cout << i << " "; return i;}
class Foo {
  int a,b,c;
public:
Foo() : a(f(1)), c(f(2)), b(f(3)){}
};
Foo foo; // gibt 1 3 2 aus
Der Konstruktor wurde hierbei implizit als inline vereinbart, weil er innerhalb des Definitionsteils der Klasse Foo steht.

Destruktoren

In C++ muß Speicherplatz explizit wieder frei gegeben werden. Ein Destruktor wird daher automatisch aufgerufen, wenn ein Objekt aufhört zu existieren. Das ist dann der Fall wenn

Destruktor

class IntStack {
	  int *contents;
  int size;
  init() { contents = new int[100]; size = 0;)
public:
  IntStack() { init();}
  ~IntStack() { delete [] contents; size = 0;}
};
Grundsätzlich gilt, daß für alles, wofür selbst Speicher angelegt wurde, dieser Speicher auch wieder frei gegeben werden muß. Destruktoren werden (fast) immer als virtual vereinbart. Wenn man beispielsweise eine Liste von beliebigen Objekten löschen will, so müssen auch alle Objekte in dieser Liste gelöscht werden, d.h. für jedes Element wird die Message `Destruktor' aktiviert. Da man aber zur Definitionszeit noch nicht weiß von welchem Typ das Objekt ist, muß man den Destruktor als virtuell definieren (siehe auch "Virtuelle Methoden" auf Seite 62).
Aufpassen muß man bei Destruktoren insofern als es natürlich möglich sein kann, daß andere Objekte jenes Objekt, das gerade gelöscht wird, noch referenzieren. Außerdem könnte es ja sein, daß der Speicherplatz, der freigegeben wird, noch gar nicht allokiert worden ist. Beide Fälle führen zu Problemen; im besten Fall zum sofortigen Absturz, im schlechtesten Fall erfolgt der Absturz an einer späteren Stelle im Programm und der Fehler ist dann schwer auffindbar.
Manche Programmierumgebungen informieren den Benutzer über den allokierten und wieder freigegebenen Speicher. Folgendes Beispiel stammt aus dem Application Framework ET++:
Speicher-Statistik beim Verlassen von ET++

instances still in ObjectTable
class                     cnt    size
=====================================
GapText                     3      60
LineMark                    6      28
OrdCollection               2      48
TypingCommand               1      52
-------------------------------------
Total                      12     
=====================================

Mem statistics
   size  alloc   free   diff  alloc recycl  freedchunks
=======================================================
      2     15     12      3
      8     81     76      5
     24     37     35      2
     28     28     22      6
     30      8      6      2
     40    200    189     11
     44     89     86      3
     48    228    226      2
     50      1      0      1
     52     17     16      1
     60    155    152      3
     72     18     16      2
-------------------------------------------------------
total:  218670 217254   1416
=======================================================

Default-Konstruktoren

Als Default-Konstruktor wird ein Konstruktor bezeichnet, der ohne Angabe von Parametern aufgerufen werden kann. Dies kann sein, weil es

Copy-Konstruktoren

Copy-Konstruktoren sind notwendig, wenn eine Instanz mit einer anderen Instanz initialisiert werden soll, also etwa
IntStack s;
s.push(1); s.push(2);
IntStack t(s); //entspricht IntStack t = s;
s.push(3);
t.push(0);
wobei angenommen wird, daß
class IntStack {
  int *contents;
  int next; // gegnwärtige Anzahl der Elemente
...
};
Da nur die Komponenten kopiert werden, d.h. in diesem Fall die Zeiger auf contents und NICHT der Inhalt selbst verweisen jeweils die Zeiger von s und t auf denselben Inhalt! Man nennt das shallow copy (im Unterschied zu deep-copy, siehe unten).

Shallow-Copy, Deep-Copy, Identität

Annahme: man möchte eine Variable vom Typ IntStack auf eine andere Variable zuweisen:
...
IntStack t = s;
...
Hierbei tritt dasselbe Problem auf wie beim Copy-Konstruktor: standardmäßig wird nur eine shallow copy angefertigt und daher verweisen beide Objekte auf denselben Inhalt (wenn auch unterschiedliche Pointerobjekte darauf hinzeigen).
Abhilfe: operator overloading
Überladen des Zuweisungsoperators

IntStack& operator = (const IntStack &s)
{
  if (&s == this) return *this;
  /* es müßte auch noch geprüft werden, ob *this den 
Inhalt von s 
     überhaupt aufnehmen kann; *this und s könnten ja 
unterschiedlich
     groß sein! */
  next = s.next;

  for (int i=0; i < next; i++)
    contents[i] = s.contents[i];
  return *this;
}
somit werden bei
IntStack t = s;
nicht nur die Komponenten kopiert, sondern auch der Inhalt. Dies führt zur allgemeinen Unterscheidung der drei Typen von Gleichheit

Typen von Gleichheit

Im folgenden Beispiel wird auf die drei unterschiedlichen Typen von Gleichheit geprüft:
Überladen des Operators `=='

int IntStack operator == (const IntStack& t)
{
  if (t.next != next) return 0; // unterschiedliche Größe
  if (t.cont == cont) return 1; // Fall (1) oder (2)
  for (int i=0; i<next; i++)
     if (contents[i] != t.contents[i]) return 0;
  return 1; // Fall (3)
}

Konstante Komponentenfunktionen

Durch Anhängen des Schlüsselwortes const kann dem Compiler gesagt werden, daß man in einer Methode keine Komponenten ändern will. Tut man dies doch versehentlich, so bringt der Compiler eine Fehlermeldung.

Konstante Komponentenfunktion

class Foo {
protected:
  int a;
public:
  Foo() : a(1) { }
  int TestConst() const; // const führt zu ...
};

int Foo::TestConst() const
{
  return a++; //... error: increment of read-only member `int Foo::a'
}
Die Definition wird allerdings nicht vererbt, also

class SubFoo : public Foo {
public:
    int TestConst();
};

int SubFoo::TestConst()
{
    return a++;
}
ist erlaubt. Anwendung finden konstante Komponentenfunktionen vor allem bei der Deklaration von Schnittstellen.




Klassenvariablen

Klassenvariablen sind Variablen, die für eine ganze Klasse gelten, d.h. für alle Instanzen dieser Klasse gibt es nur die eine (selbe) Instanzvariable. Durch das Schlüsselwort static werden Instanzvariablen zu Klassenvariablen. Die Initialisierung muß dabei außerhalb der Klassendefinition erfolgen. Das Auftreten innerhalb der Klassendefinition wird nämlich als Deklaration angesehen; auch würde durch die Verwendung des Definitionsteils, also des h-Files, in mehreren Dateien eine Initialisierung mehrmals erfolgen. Anwendung finden Klassenvariablen z.B. bei der Verwendung von Default-Werten.

Klassenvariable für Personenklasse

Definition (h-File)

class Person {

private:
	static int numberOfPersons;
...
};
und Implementierung (cc-File)

#include "person.h"

int Person::numberOfPersons = 0;

Person::Person()
{
  numberOfPersons++;
	  id = numberOfPersons;
...
Konstante Klassenvariable

class Symbol;

class SGMLApplication : public Application {
	  static const Symbol cDocTypeSGML;
...
und im cc-File

...
const Symbol SGMLApplication::cDocTypeSGML = "SGMLApplication";
...

Klassenmethoden in C++

Klassenmethoden sind Methoden, die auf Klassen angewendet werden können; d.h. ich brauche keine Instanzen, um diese Methoden aufzurufen. Konstruktoren in C++ können mit Klassenmethoden verglichen werden. Realisiert werden Klassenmethoden durch die Definition von static Methoden.

Klassenmethoden

class Stack {
  static const int stacksize = 100; // Klassenvariable
public:
  ...
  int SizeOfStack() {return stacksize;}
};
Die Methode SizeOfStack() nimmt keinen Bezug auf eine Instanz der Klasse Stack, d.h. man könnte die Methode SizeOfStack() auch ohne die Existenz einer Instanz aufrufen.

Aber wie soll man sie ohne Instanz aufrufen?

int x = ???.SizeOfStack();
Lösung: Definition der Methode SizeOfStack() als static:

...
public:
  static int SizeOfStack() {return stacksize;}
...
};

und Aufruf durch direktes Referenzieren der Methode

int x = Stack::SizeOfStack();

Gewährung von Zugriffsrechten für Freunde

Das friend Attribut erlaubt die `Umgehung' der definierten Zugriffsrechte für Freunde.

friend-Attribut

class IntStack
...
friend int SizeOfStack();
Obige Deklaration bedeutet, daß alle Methoden/Funktionen, die dem nach dem friend-Attribut angegebenen Protokoll übereinstimmen, auch auf private bzw. protected Member der Klasse IntStack zugreifen können. Das friend-Attribut kann auch für ganze Klassen vergeben werden

class A {
  int i;
  friend class B;
};
class B {
  A a;
public:
  B (int b) { a.i = b;}
};
oder auch nur für eine bestimmte Member-Funktion

class Y {
  friend char * X::Foo(int);
...
}
Dieser Mechanismus, der das Geheimnisprinzip umgeht, sollte möglichst sparsam verwendet werden. Meist werden friend-functions für die Ein- und Ausgabe verwendet, also etwa

class IntStack
...
friend ostream& operator << (ostream &s, const IntStack 
&s);
...
};
In diesem Fall wird dem operator << das friend-Attribut `verliehen', d.h. dieser Operator darf auf alle Member der Klasse IntStack zugreifen. Man beachte, daß in obigem Beispiel ein const IntStack als Parameter übergeben wird: so wird zwar das friend-Attribut an den ostream Operator << vergeben, dieser darf aber auf die Werte der Klasse IntStack nur lesenderweise zugreifen.

Ein- und Ausgabe sind die häufigste Anwendung des friend-Attributs. Das Problem entsteht dadurch, daß bei den Operatoren `<<` und `>>' der Stream immer links steht und rechts das Objekt das ausgegeben oder eingelesen werden soll. Um für neue Klassen dieselbe Ein-/Ausgabeform zu ermöglichen, müßte man daher auf Ebene der Klasse ostream bzw. istream eine Operatorfunktion für das neue Objekt definieren.

class ostream {
...
ostream& operator << (IntStack&);
...
};
Im allgemeinen will man aber bestehende (teilweise vielleicht auch standardisierte) Klassen nicht ändern. Man umgeht das nun dadurch, daß man dem ostream operator << das friend-Attribut verleiht.

Das friend Attribut ist nicht vererbbar. Angenommen ...

class IntStack {
  friend ostream& operator << (ostream& os, IntStack 
&stack);
  int size;
...
};

class SubStack : public IntStack {
  int testFriendship;
...
};
gilt, dann darf man beispielsweise in einer main() Funktion folgendes implementieren:

ostream& operator << (ostream &o, IntStack &s)
{
  ...
  o << s.size;
  return o;
}
nicht erlaubt ist aber

ostream& operator << (ostream &o, SubStack &s)
{
  o << endl << s.testFriendship << endl; // private 
member
  return o;
}
Wichtig dabei ist, daß ja die Klasse SubStack von IntStack erbt, d.h man kann schon ein Objekt der Klasse SubStack mit Hilfe des Operators `<<` ausgeben. Man darf allerdings nicht auf die privaten Elemente von SubStack zugreifen.

Das friend-Attribut ist auch nicht transitiv, es kann also nicht weitergegeben werden. Wenn A friend von B ist und B friend von C ist, dann heißt das also nicht, daß auch A friend von C ist.

Das Freundschaftsrecht erhält man von einer Klasse, d.h. man kann es sicht nicht nehmen.

Iteratoren

Iteratoren dienen dazu, Objekte vom Typ Container, also Klassen wie List, Collection, Set, usw., elementweise zu durchlaufen. Wenn man also beispielsweise eine Liste von Objekten hat, und diese der Reihe nach ausgeben möchte, könnte man sich folgenden Code vorstellen:

List *objList;
objList->Add(firstObj,anotherObj,yetAnotherObj,lastObj);
for (Iterator i(objList); i; i++) {
  cout << *i;
}
Anstelle des überladenen Operators `++' wird häufig auch die Methode `next()' verwendet.

Iteratoren kann man dabei innerhalb einer Klasse definieren, da sie nur im Zusammenhang mit einer Klasse Sinn machen. Diese Vorgangsweise hat auch den Vorteil, daß die Iterator Klasse für alle Container-Klassen immer `Iterator' heißt, weil sie ja nur lokal bekannt ist und daher keine Namenskonflikte auftreten.

class List {
...
  class Iterator {
  ...
  };
...
};
Meist werden Iteratoren von einer (abstrakten) Basisklasse abgeleitet. Diese Basisklasse bietet die grundsätzliche Funktionalität für Iteratoren und hat in etwa folgendes Aussehen (beide Beispiele stammen von den ET++ Container-Klassen [5]):

class Iterator {
  friend Container;
protected:
  Iterator();
public:
  ~Iterator();
  void Reset();
protected:
  bool IsActive() { return (started && ! terminated); };
  bool Start();                    //calls virtual GetContainer()
  void Terminate();
  virtual Container *GetContainer();
...
protected:
  bool Started() { return started; };
  bool Terminated() { return terminated; };
private:
  void Init() { next= this; prev= this; };
private:
  bool started, terminated;          
  Iterator *next, *prev;
};
Eine davon abgeleitete Klasse für SetIter Sets könnte so aussehen:

class SetIter : public Iterator {
  friend class Set;
public:
  SetIter(Set *aSet);
  ~SetIter();

  Object *operator()();
  void Reset();
protected:
  Container *GetContainer();
...
protected:
  Set *set;
...
};

Abgeleitete Klassen

Vererbung ist ein Mittel der Implementierung von Spezialisierung. Im folgenden Beispiel wird eine Unterklasse Student als Spezialisierung der Klasse Person implementiert.

Abgeleitete Klasse

Das h-File

#ifndef Student_h
#define Student_h

#include "person.h"

class Student: public Person {

protected:
  char *name;
public:
  Student(int);
  ~Student();

void Print(ostream &o = cout);
};

#endif

und das cc-File

#include <iostream.h>
#include "student.h"

Student::Student(int no) : name("unnamed"), Person(no)
{
  name = "unnamed"; //andere Initialisierungsmöglichkeit
}


Student::~Student()
{
}

void Student::Print(ostream &o)
{
  o << endl << this->name;
}
Die Instanzvariable name wird mit der konstanten Zeichenkette "unnamed" initialisiert. Der übergebene int-Parameter no wird an den Konstruktor der Oberklasse Person weitergereicht. Man könnte die Instanzvariable name auch im Block des Konstruktors initialisieren.

Das Schlüsselwort this in der Methode Print() bezeichnet das aktuelle Objekt selbst, d.h. this ist ein Zeiger auf das Objekt selbst.

Für das Schlüsselwort public bei der Ableitung können auch protected oder private verwendet werden. Wenn nichts angegeben wird, wird private angenommen! Die folgende Tabelle gibt eine Übersicht über die Art der Ableitung und der damit verbundenen Änderung der Zugriffseigenschaften von Klassenmembern:

Tabelle 4: Ableitungen in C++
Art der Ableitung /
Attribut einer Komponente der Basisklasse
public protected private
public public protected private
protected protected protected private
private private private private

Man beachte, daß statische Komponenten ohne Änderung ihrer Eigenschaften von der Basisklasse übernommen werden.

Folgendes Beispiel soll die Verwendung von privater Vererbung verdeutlichen [1]. Eine Basisklasse BasisStack wird mit privaten Instanzvariablen und protected Methoden definiert ...

private Vererbung

class BasisStack {
  const int stacksize;
  void** contents;
  int next;
protected:
  BasisStack(int s) : stacksize(s), contents(new void*[s]), next(0) 
{}
  ~BasisStack() { delete contents;}

BasisStack& Push(void *p) { contents[next++] = p; return *this;}
BasisStack& Pop() { next--; return *this;}
void* Top() const { return contents[next-1];}
int Size() const { return next;}
int IsEmpty () const { return next == 0;}
};
Von dieser Basisklasse wird ein abgeleitetes Template für Stacks definiert, das private erbt ...

#include "basisstack.h"

template <class ElType, int size>

class Stack: private BasisStack {

public:
  Stack(): BasisStack(size) {};
  Stack<ElType, size>& Push(const ElType &element) 
    {BasisStack::Push((void*)&element); return *this;}
  Stack<ElType, size>& Pop() { BasisStack::Pop(); return 
*this;}
  ElType& Top() const { return *(ElType*)BasisStack::Top();}
  int Size() const { return BasisStack::Size();}
  int IsEmpty() const { return BasisStack::IsEmpty();}
};

Unterklassen von Stack können somit nicht mehr direkt auf die protected Methoden von BasisStack zugreifen. Der Zugriff muß über die Klasse Stack erfolgen. Hier sind alle Methoden public, sodaß auch fremde Klassen Stackfunktionalität nutzen können.

In der Klassendefinition können Komponenten hinzugefügt oder auch überladen werden (doppelte sind nicht erlaubt).

Überschreiben

SecureStack& SecureStack::Push(int element)
{
if (size < stackSize)
  IntStack::Push(element);
return *this;
}
Durch IntStack::Push() wird die Methode Push() der Oberklasse IntStack aufgerufen (sonst Rekursion!).

Konstruktoren und Destruktoren werden nicht vererbt.

Student::Student(int no) : name("initName"), Person(no)
{
        name = "unnamedbutwithid";
}
wobei als Konstruktor ein beliebiger Konstruktor der Basisklasse aufgerufen werden kann.

Konversionen zwischen Unterklassen und Basisklassen

class B { int i;};
class D: public B { int j;};

B b;
D d;

d = b; // geht nicht
b = d;
Die Klasse D hat eine Instanzvariable mehr, d.h. D hat mehr als B und daher kann b nicht auf d zugewiesen werden. Umgekehrt ist die Zuweisung erlaubt, führt aber zu Datenverlust! Bei Pointern ist die Sache einfacher, man kann durch Typecasts Pointer auf den gewünschten Datentyp `hinbiegen', muß dabei allerdings selbst auf die Semantik achten. Man könnte beispielsweise ein Objekt der Klasse Window auf ein Objekt der Klasse OperatingSystem `hinbiegen', obwohl diese Zuweisung wahrscheinlich nicht sinnvoll ist.
Typecast

OSystem *os = new OSystem("Macintosh");
Window *w = new Window(eMotifStyle);
os= (OSystem*)w;

Virtuelle Methoden

Eingabefunktion für IntStack

istream& operator >> (istream& in, IntStack& s)
{
char c;
int element;
...
in >> c;
while (c != `>')
  in.Putback(c); // zurücklesen von `c'
  in >> element;
  s.Push(element);
  ...
}
return in;
}
Wenn die Methode Push() als virtuell deklariert wurde, dann wird die Methode Push() des jeweiligen Elements aufgerufen (obwohl s als vom Typ IntStack deklariert wurde, kann es ja zur Laufzeit vom Typ SecureStack sein!).
Da SecureIntStack von IntStack erbt, kann der Operator >> auch für diese Klasse verwendet werden. Es wird dabei aber die Methode Push() von IntStack aufgerufen und nicht jene von SecureStack (die auf Überlauf prüft)! Durch Definition der Methode Push() als virtuell wird erst zur Laufzeit entschieden, welche Methode tatsächlich aufgerufen wird (abhängig vom Typ der jeweiligen Instanz). Man nennt das Polymorphismus. Virtuelle Funktionen sind ein für allemal virtuell, d.h. das Schlüsselwort virtual muß nicht wiederholt werden!
Noch ein Beispiel für virtuelle Methoden:
Virtuelle Print()-Methode

class Object {
public:
  virtual char *Print() {return "\nObject::Print()\n";};
};

class DerivedA: public Object {
public:
  char *Print() {return "\nDerivedA::Print()\n";};
};

class DerivedB: public Object {
public:
  char *Print() {return "\nDerivedB::Print()\n";};
};

void main()
{
Object *o;

o = new Object();
cout << "\no->Print() results in " << o->Print();
delete o;
o = new DerivedA();
cout << "\no->Print() results in " <<  o->Print();
delete o;
o = new DerivedB();
cout << "\no->Print() results in " <<  o->Print();
delete o;
}
Führt zur Ausgabe von
o->Print() results in 
Object::Print()

o->Print() results in 
DerivedA::Print()

o->Print() results in 
DerivedB::Print()
Werden virtuelle Methoden in Konstruktoren oder Destruktoren aufgerufen, so werden nicht die entsprechenden Methoden aus den abgeleiteten Klassen aktiviert. Man kann sich das so vorstellen, daß das jeweilige Objekt noch nicht seinen entgültigen Datentyp erreicht hat:
Virtuelle Methoden und Konstruktor

class Base {
  virtual void f() { cout << "\nBase::f() ";}
public:
  Base () {cout << "\nBase::Base() "; f();}
};

class Derived: public Base {
  void f() { cout << "\nDerived::f() ";}
};

main()
{
  Derived d;
}
Führt zur Ausgabe von
Base::Base() Base::f()
In diesem Fall wird nicht wie erwartet die Methode f() von Derived aufgerufen. Derived d ist zum Zeitpunkt des Aufrufes des Konstruktors von Base noch kein gültiges Derived Objekt. Der Konstruktor für Derived wird übrigens vom Compiler automatisch erzeugt.

Rein virtuelle Methoden und abstrakte Klassen

Eine rein virtuelle Methode ist eine Methode, die nur deklariert, jedoch nicht definiert ist. Eine C++ Klasse, die mindestens eine solche virtuelle Methode hat, wird als abstrakte Klasse bezeichnet. Abstrakte Klassen stellen nur das Protokoll (die Schnittstelle) zur Verfügung.
Es gibt von abstrakten Klassen keine Instanzen. Haben abstrakte Klassen keine Instanzvariablen, so werden sie reine abstrakte Klassen genannt.
Abstrakte Klassen sind nur dann sinnvoll, wenn sie zumindest eine abgeleitete Klasse haben.
Rein virtuelle Methoden werden wie folgt deklariert:
Deklaration von rein virtuellen Methoden

class ABC {
protected:
  virtual int f() = 0; // auch: = NULL;
};

class Derived: public ABC {};

main()
{
	  Derived *d = new Derived();
  d->ABC::f();
}
führt zu folgendem Fehler:
> cannot allocate an object of type \QDerived' since the following 
virtual functions are abstract:
> main.cc:15:     int ABC::f()
Zusammenfassung

Virtuelle Methoden und Mehrfachvererbung

C++ unterstützt auch Mehrfachvererbung (siehe "Mehrfachvererbung" auf Seite 67 ). Dabei ist die Implementierung von virtuellen Methoden etwas schwieriger:
Virtuelle Methoden und Mehrfachvererbung

class A {
public:
	  virutal void f();
};

class B {
public:
  virtual void f();
};

class C : public A, public B {
public:
  void f();
};

C *pc = new C(); 
A *pa = pc; B *pb = pc;
Die folgenden Aufrufe verwenden alle C::f(), weil die Klasse C von A und B ab-ge-leitet ist.
pa->f();
pb->f();
pc->f();

Mehrfachvererbung

Die Idee, von mehreren Basisklassen zu erben, ist naheliegend: warum sollte eine Klasse nicht auch über mehrere Basisklassen verfügen?


Ein Beispiel aus der Universitätsorganisation

Die Implementierung der Klasse Studienassistent in obiger Hierarchie könnte in C++ folgendermaßen ausschauen:

class Studienassistent: public Assistent, public Student {};
Mehrfachvererbung und Fenstersysteme

class Window {...};
class XWindow : public Window {
public:
  void Scroll();
  void Clear();
...
};
class EditWindow : public Window {
  int topLineVal, bottomLineVal;
  Editor *e;
...
};
class XEditWindow : public EditWindow, public XWindow { ...};
In obigem Beispiel erbt die Klasse XEditWindow von der Klasse XWindow hauptsächlich systemspezifische Funktionalität (Scroll(), Clear()) und von der Klasse EditWindow hauptsächlich Daten (topLineVal, ...).

Folgende Probleme können bei Mehrfachvererbung auftreten: ein Studienassistent erbt zwei mal name, nämlich von Student und von Assistent. name kann dabei

Ad 1) Eindeutige Identifikation mit Bereichsoperator.

Mit Hilfe des Bereichsoperators kann man auf die gewünschte Komponente name zugreifen, also entweder auf name von Assistent oder von Student.

cout << Assistent::name << " " << Student::name;
Ad 2) Virtuelle Basisklassen

Wird im Zuge einer Ableitung eine Basisklasse durch das Schlüsselwort virtual als virtuell erklärt, wird in den folgenden Ableitungen nur ein einziges Exemplar dieser Basisklasse übernommen, auch wenn sie über mehrere Pfade erreicht werden kann.

Virtuelle Ableitung bei Mehrfachvererbung

class Bediensteter: virtual public Person{...};
class Student: virtual public Person {...};
class Studienassistent: virtual public Assistent, virtual public 
Student {
public:
  void Print(ostream &o) { o << name << endl;}
};
Voraussetzung ist allerdings, daß die Basisklasse in allen Unterklassen als virtuell erklärt wird:


Mehrfachvererbung und doppelte Komponenten

class B { protected: int x; };
class D1: public virtual B {...};
class D2: public virtual B {...};
class DD: public D1, public D2, public B{...};
DD erbt hier zweimal! Virtualität ist also keine Eigenschaft der Klasse, sondern eine Eigenschaft der Ableitung!

Achtung: die virtuelle Ableitung hilft nicht, wenn man von zwei Basisklassen erbt, die keine gemeinsame Wurzel haben:


Virtuelle Mehrfachvererbung

In obigem Beispiel erbt DD zweimal die Komponente x, einmal von B und einmal von C.

class B { public: int x;};
class D1: public virtual B {};
class D2: public virtual B {};
class C { public: int x;};
class DD: virtual public D1, virtual public D2, public C {};
int main(int argc, char *argv[], char *envp[])
{ 
...
DD d;
d.x = 4711; // request for member 'x' is ambiguous
...
}
Die Komponente X wird über D1 und D2 zwar nur einmal geerbt, allerdings hat ja auch C eine Komponente x. Virtuelle Vererbung hilft hier nichts.

Die Konstruktoren werden in der Reihenfolge ihrer Ableitung aufgerufen. Diese Reihenfolge kann nicht geändert werden.

Einige Besonderheiten treten bei virtueller Mehrfachvererbung auf. Gemäß Ellis/Stroustroup [1] werden virtuelle Basisklassen als erste initialisiert; und weiters wird der sogenannte memory-initializer, also die Initialisierungsangaben beim Konstruktor, der am tiefsten abgeleiteten Klasse verwendet (falls er definiert ist, sonst muß ein Defaultkonstruktor in der virtuellen Basisklasse bestehen. Folgendes Beispiel soll diesen Zusammenhang verdeutlichen.

Virtuelle Mehrfachvererbung und Konstruktorprobleme

class B { 
public:
	    int x; 
	    int y; 
public:
	    B(int xx) : x(xx), y(xx) {cout << "\nB::B() " << xx 
<< endl;}
	    virtual void f() { cout << "\nB::f()\n";} 
    	int GetX() { return x;}
	    void SetX(int i) { x = i;}
};

class D1: virtual B {
public:
	    D1(): B((int)this) { cout << "\nD1::D1() " << 
(int)this << "\n";}
	    B::x; 
    	B::y;
};

class D2: virtual B {
public:
    	D2(): B((int)this) { cout << "\nD2::D2() " << 
(int)this << "\n";}
	    void f() { cout << "\nD2::f()\n"; };
};

class C {
public:
	    int y;
};
Eine Basisklasse B wird definiert, von der D1 und D2 virtuell und private erben. Damit daher auf die Instanzvariablen x und y in B zugegriffen werden kann, müssen diese beiden erneut als public definiert werden.

Die Anweisung

...
private:
    B::y;
...
funktioniert allerdings nicht, weil man das Zugriffsrecht, das ja in der Basisklasse schon als public definiert wurde, nicht auf private rückdefinieren kann.

Eine unabhängige Klasse C wurde ebenfalls definiert. Sie hat eine Instanzvariable y.

Eine Unterklasse DD die von D1, D2 und C virtuell erbt schaut foglendermaßen aus:

class DD: virtual public D1, virtual public D2, virtual public C {
public:
//	DD() : B(5666)  {} //eigener constructor, der den konstruktor 
			   //der basisklasse explizit spezifiziert
	DD() : D2(), D1(), B(5666) { }
};
Besonders interessant ist hierbei der Konstruktor bzw. das memory-initialize Statement (D2(), D1(), B(5666)). Wird dieses Statement nicht definiert, so nimmt er Compiler defaultmäßig D2(), D1(), B() an, wobei aber der Konstruktor B() nicht definiert ist. Andererseits muß aber die virtuelle Basisklasse vor den abgeleiteten initialisiert werden. Man hat somit zwei Möglichkeiten: entweder man definiert einen Defaultkonstruktor (das kann auch ein Konstruktor mit lauter defaultmäßig vorbelegten Parametern sein) oder man spezifiziert das memory-initializer Statement in der tiefsten abgeleiteten Klasse, also die, von der die Initalisierung ausgeht (in obigem Beispiel DD). Das Statement lautet D2(), D1(), B(5666). Die Reihenfolge der Initialisierung ist übrigens unabhängig von der Spezifikation im memory-initializer Statement, sonder wird rein durch die Reihenfolge der Ableitung definiert. Vertauschen der Reihenfolge von D1, D2 und B hilft nichts!

Eine beispielhaft main-Routine, die obige Tatscahen widerspiegelt könnte folgendermaßen ausschauen:

...
	  DD d;
  	B b(100);
	  C c;
	  c.y = 47;
	  b.SetX(33);
	  	d.D1::x = 4712;
direkter Zugriff auf Variable x in D1

	d.D2::x = 4712;
geht aber nicht, weil x in D2 private ist bzw. nur in D1 als public redefiniert wurde.

Die Anfrage

d.y = 4711;
ist nicht eindeutig ("ambigous"), weil ja auch von C eine Variable x geerbt wird; daher

	d.D1::y = 4711;

Das Statement

	d.f();
resultiert in

D2::f()
weil bei virtuellen Methoden und Mehrfachvererbung das am nächsten liegende f() aufgerufen wird (siehe auch "Virtuelle Methoden und Mehrfachvererbung" auf Seite 65).

Templates/Schablonen

Schablonen können als Meta-Funktionen aufgefaßt werden, die zur Übersetzungszeit neue Klassen bzw. neue Funktionen erzeugen.

Anwendungsbeispiel: man braucht einen generischen Stack, der allgemein Objekte verwaltet, also etwa Windows genauso wie Integer. Dabei wünscht man sich, daß ein Stack zwar beliebige, aber eindeutige Datentypen verwalten kann.

Lösung ohne Templates: man verwendet void*-pointer oder leitet sämtliche Objekte von einer Wurzel (Object) ab. Mit diesem Ansatz kann man zwar beliebige aber nicht eindeutige Objekte verwalten.

Lösung in C++ mit Templates:

Stack<Window> windowStack;
Stack<int> intStack;
windowStack.Push(editorW);
windowStack.Push(printDialogW);
intStack.Push(4711);
intStack.Push(33);
Vorteile von Klassenschablonen

Nachteil von Klassenschablonen

Beispiel Stack: wir wollen erreichen, daß wir Stackvariablen erzeugen können, die einen beliebigen, aber eindeutigen Datentyp haben (im Gegensatz zu generischen Stacks).

Stack-Template

template <class ElType, int stacksize>

class Stack {

ElType contents[stacksize];
  int size;
public:
  Stack() : size(0) {}
  virtual ~Stack() {}
  virtual Stack<ElType, stacksize>& Push (const ElType& 
el) { 
          contents[size++] = el; return *this; }
  Stack<ElType,stacksize>& Pop() { size--; return *this;}
  ElType Top() const {return contents[size-1];}
  int Size() const { return next;}
  int IsEmpty () const { return size == 0;}
};
Die Definition der Schablone Stack erfolgt durch das Schlüsselwort template sowie eine Liste von formalen Parametern in spitzen Klammern.

Die Methode Pop() als

  ElType& Pop() { next--; return *this;}
zu definieren geht deshalb nicht, weil Pop() so definiert werden soll, daß eine Instanz der Klasse Stack<ElType,stacksize> zurückgegeben werden soll und nicht der Parameter ElType; dieser Parameter kann ja zur Laufzeit beispielsweise ein Integer sein und dann müßte *this in einen Integer umgewandelt werden.

Auch folgende Variante scheidet aus:

Stack& Pop() { next--; return *this;}

Obiges Beispiel funktioniert insofern nicht, als in diesem Fall ein Stack Objekt zurückgegeben würde und nicht eines von dem Typ, der durch die Parameter instantiiert wurde (Stack<ElType,stacksize>).

Die Verwendung des Stack-templates in main.cc könnte etwa folgendermaßen aussehen:

  Stack<int, 100> si;
  Stack<float, 100> sf;
  typedef Stack<double, 100> DoubleStack;
  DoubleStack sd;
  
  si.Push(10);
  sf.Push(3.14);
  sf.Push(5.14);
  sd.Push(1.7E308);

  cout << "\nsi Top " << si.Top() << endl;
  cout << "\nsf Top " << sf.Top() << endl;
  sf.Pop();
  cout << "\nsf Top " << sf.Top() << endl;
  cout << "\nsd Top " << sd.Top() << endl;
ElType und stacksize sind formale, typbehaftete Parameter des Templates. Klassen Parameter symbolisieren einen Typnamen (aber nicht notwendigerweise eine Klasse!). Der Compiler erzeugt zur Übersetzungszeit automatisch die entsprechenden Klassendefinitionen.

Templates können auch bei der Vererbung verwendet werden. In untenstehendem Beispiel ist einmal eine Klasse Derived als Template von einem template-Stack abgeleitet und ein anderes mal eine Klasse DerivedWithoutTemplate, die als einfache Klasse vom template-Stack abgeleitet wurde:

#include "stack.h"

template <class dStack>
class Derived: public Stack<dStack, 100> {
public:
       Derived() : Stack<dStack,100>() { ...;}	
       ...
};

class DerivedWithoutTemplate: public Stack<int,100> {
public:
       	DerivedWithoutTemplate() { ...;}
       	~DerivedWithoutTemplate() { ...;}
       ...
};
In der Unterklasse von Stack mit Template, also Derived, wird ein formaler Klassenparameter erwartet, der an das Template der Oberklasse (Stack) weitergegeben wird. Der zweite formale Parameter der Klasse Stack (int) wird einfach mit dem Standardwert 100 belegt.

Zwei parametrisierte Klassennamen sind dann gleich, wenn die Schablonen ident sind und die Argumente dieselben Werte aufweisen, also etwa Stack<int, 100> und Stack<int, 10*10>.

Was ist der Preis von Templates? Jede Instanziierung einer Klassenschablone erzeugt natürlich neuen Code für alle Methoden der Klasse. Einen möglichen Ausweg stellt die Implementierung von Schablonen durch Ableitung von generischen Klassen dar. Aufwendiger Code wird dadurch nicht dupliziert; trotzdem bleibt aber die Typsicherheit gewährleistet (siehe auch Beispiel "private Vererbung" auf Seite 60).

Funktionsschablonen

Eine Funktionsschablone definiert eine Familie überladener Funktionen, deren Mitglieder im Bedarfsfall automatisch erzeugt werden. Funktionsschablonen sind syntaktisch mit Klassenschablonen ident. Verwendung finden template Methoden z.B. für sortieren, Maximum ermitteln, usw.
Funktionsschablone (1/2)

template <class T>
T max (int n_arg, T a, T b, ...);

double m, x, y, z;
double m = max(3, x, y, z);
Auch die Attribute extern, inline, und static sind möglich. Sie müssen nach dem Schlüsselwort template angegeben werden:
template <class T>
inline T swap(T &first, T &second);
Im Gegensatz zu Klassenschablonen sind keine aktuellen Schablonenargumente erforderlich (wie etwa max<double> (3, x, y, z)), da die Auswahl und eventuelle Generierung der geeigneten Variante auf Grund der Datentypen der Funktionsargumente erfolgen.
Der Compiler sucht zunächst eine entsprechende (normale) Funktion; wird keine solche gefunden, werden alle verfügbaren Funktionsschablonen auf eine potentiell exakt passende Variante untersucht. Falls eine gefunden wird, wird eine Funktion generiert, andernfalls wird durch Homonyauflösung eine Entsprechung unter den Funktionen gleichen Namens gesucht. Ist auch durch implizite Konvertierung keine passende Funktion zu finden, gibt der Compiler eine Fehlermeldung aus.
Funktionsschablone (2/2)

template <class ElemType>

ElemType minimum(ElemType elemField[], int fieldSize)
{
int i;
int min = 0;

for (i=1; i< fieldSize; i++) {
    if (elemField[i] < elemField[min]) min = i;
}
return elemField[min];
}

int main()
{

int iF[] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 1000};
double dF[] = {1.3, 0.3, 55.5};

cout << "\nMinimum: ";
cout << endl << minimum(iF, 
(int)(sizeof(iF)/sizeof(int)));
cout << endl << minimum(dF, 
(int)(sizeof(dF)/sizeof(double)));
}

Methodenschablonen

Jede Komponentenfunktion einer Klassenfunktion stellt implizit eine Methodenschablone dar.
Achtung bei Konstruktoren und Destruktoren: der Funktionsname selbst kennzeichnet nämlich nicht, daß er eine Schablonenfunktion darstellt.
template <class ElType, int s>
Stack<ElType, s>::Stack() : next(0) {}
und nicht
template <class ElType, int s>
Stack<ElType, s>::Stack<ElType, s>() : next(0) {}

Exception Handling/Ausnahmebehandlung in C++

Das Berücksichtigen von (möglichen) Fehlern in Programmen ist oft aufwendig. Speicherüberlauf, Division durch Null, Probleme mit Peripheriegeräten, usw. sind ja im vorhinein nicht bekannt und daher schwer zu berücksichtigen. Die Grundidee der Ausnahmebehandlung in C++ ist nun, die für die Fehlerbehandlung zuständige Routine bereits im vorhinein zu definieren und dann vom Laufzeitsystem automatisch aufrufen zu lassen.

Bedenken sollte man dabei, daß nicht jeder Fehler automatisch eine Ausnahmebehandlung bedeutet: Ausnahmen sind nur jene Fehler, die an jeder Stelle im Programm zu berücksichtigen zu aufwendig wäre, also z.B. ein Hardware-Fehler beim Schreiben auf eine Festplatte. So ein Fehler kann potentiell überall auftreten, der Programmcode würde aber unleserlich und auch fehleranfällig (sic!), wenn man bei jedem Statement einen möglichen Plattencrash berücksichtigen würde.

Realisiert werden Ausnahmebehandlungen in C++ durch die Schlüsselworte throw, catch und try.

Folgendes Beispiel eines einfachen Kommandointerpreters soll die Verwendung von Ausnahmebehandlungen in C++ verdeutlichen. Die grundsätzliche Funktionsweise eines Kommandointerpreters ist dabei, von einem Eingabestrom Kommandos zu lesen und dann die entsprechende Methode aufzurufen.

Exception Handling und Kommandointerpreter

class Command {
public:
  static Command* ReadAndCreate(istream&);
virtual void Perform () = 0;
virtual ~Command();
};

class Copy : public Command {
public:
void Perform();
~Copy();
};
//... other commands

int main()
{
for (;;) {
  cout << "\nnächster Befehl: ";
  Command *c = ReadAndCreate(cin);
  if (c != 0) {
      c->Perform();
      delete c;
  }
}
};
Implementierung von Klassen für die Ausnahmebehandlung ...

class DiskFull{};
class DeviceNotReady {
public:
  const char *address;
  DeviceNotReady(const char *addr) : address(addr) {}
};
... die neue Implementierung von main.cc ...

...
Command *c = ReadAndCreate(cin);
if (c != 0) {
  try {
    c->Perform();
  }
  catch(DiskFull& d) {
    cout << "\nNo Space left on device!";
  }
  catch(DeviceNotReady& dvnr) {
    cout << "\nDevice with address " << dvnr.address;
    cout << " does not answer";
  }
  delete c;
}
...
... und die Implementierung von Copy::Perform() ...

void Copy::Perform()
{
File *file = new File(); // im Falle einer Ausnahme wird 
                         // wird der Destructor von file auf-
                         // gerufen
if (write(file, buf, bufsize) < bufsize)
  throw DiskFull();
...
Im Falle der Ausnahmebehandlung wird die catch-Routine des nächsten darüberliegenden (= aufrufenden) Blockes aktiviert. Der Vorteil von Ausnahmebehandlungen etwa gegenüber dem setjmp() von C liegt darin, daß alle angelegten Variablen ordnungsgemäß (d.h. mit Destruktor) wieder gelöscht werden (z.B. file im obigen Beispiel). Nach der catch-Routine wird an den auf den try-Block folgenden Block weiterverzweigt und nicht an die throw-Routine weitergeleitet.

Strukturierte Ausnahmebehandlung

Das Springen zu Ausnahmebehandlungsroutinen widerspricht den Prinzipien strukturierter Programmierung. C++ bietet daher eine Möglichkeit, gleich bei der Definition von Methoden mögliche Ausnahmebedingungen anzugeben:
Perform() throw(Diskfull, DeviceNotReady(char* addr));
Die Definition
Perform() throw();

bedeutet, daß die Funktion keine Ausnahmen zu aktivieren gedenkt. Wenn man daher in der Implementierung von Perform eine Ausnahme aktivieren will, erkennt das der Compiler:
g++ -g -fhandle-exceptions -g -fhandle-exceptions -c ncommand.cc
ncommand.cc: In method \Qvoid Dummy::Perform()':
ncommand.cc:20: declaration of \QDummy::Perform()' throws different 
exceptions...
ncommand.h:18: ...from previous declaration here
make: *** [ncommand.o] Error 1
Durch Vererbung, Polymorphismus, etc. kann allerdings doch eine Ausnahme aufgerufen werden.
throw()-Deklarationen werden erst zur Laufzeit kontrolliert, d.h. wenn eine Ausnahme aufgerufen wird, die nicht definiert wurde, wird die Funktion unexpected() aufgerufen, die ihrerseits (standardmäßig) die Funktion terminate() aufruft.
Durch Setzen der Funktion, die für unexpected() aufgerufen wird, kann das Verhalten beeinflußt werden:
Ausnahmebehandlung

typedef void (*func)();
void aetsch() { cout << "\naetsch!\n"; throw;}
...
func orig = (set_unexpected(aetsch);
...
try {f();}
catch(...) { cout <<"\nHoppala!\n";}
set_unexpected(orig);
Die Funktion aetsch(), die aufgerufen wird, wenn eine Ausnahme aktiviert wird, die eigentlich nicht vorgesehen ist, muß selbst wieder eine Ausnahme aktivieren, damit an das Ende des try-Blocks verzweigt wird. Die Standardisierung von Ausnahmebehandlungen ist vorgesehen.

Zur Problematik von Ausnahmebehandlungen

Im folgenden Abschnitt soll die Verwendung von Ausnahmebehandlung in C++ nochmals durch Beispiele verdeutlicht werden. Auch auf die mit Ausnahmebehandlung verbundene Problematik wird hierbei eingegangen.
Grundbeispiel Ausnahmebehandlung

class Animal {
public:
  Animal();
virtual ~Animal();
virtual void DoSomething() = 0; // abstract virtual method
};

class Bird : public Animal {
public:
  Bird();
  ~Bird();

void DoSomething();
};

class Dog : public Animal {
public:
  Dog();
  ~Dog();

void DoSomething();
};
sowie folgende Funktion in main.cc:
void process()
{
  char command = `n';

    while(toupper(command) != `Y') {
      Animal *a = CreateAnimal();
      a->DoSomething();
      delete a;
      cout << "\nQuit?[y/n]\t";
      cin >> command;
    }
};
Die Funktion CreateAnimal() liefert dabei abhängig von der Benutzereingabe ein Objekt vom Typ Animal oder eines abgeleiteten Typs (also Dog oder Bird).

Ausnahmebehandlung während der Ausführung von Funktionen

Angenommen DoSomething() ruft eine Ausnahmebedingung hervor. Da in process() keine catch-Routine existiert, wird die Ausnahme weitergeleitet (an main()). Die while-Schleife wird vor dem Löschen des Objektes a verlassen, das Objekt a daher nicht gelöscht.
Man schreibt daher eine neue Version von process() mit catch-Routine und löschen von a
void process()
{
  char command = `n';

  while(toupper(command) != `Y') {
    Animal *a = CreateAnimal();
    try {
      a->DoSomething();
    }
    catch (...) { //catch any exception
      delete a;
      throw;
    }
    delete a;
    cout << "\nQuit?[y/n]\t";
    cin >> command;
  }
};
Resultat ist eine Code-Verdoppelung, a muß ja auch gelöscht werden, wenn keine Ausnahmebedingung auftritt. Eine Lösung für dieses Problem besteht darin, das Löschen des Zeigers a in einen Destruktor eines lokalen Objektes in process() zu verpacken. Lokale Objekte werden unabhängig von der Art des Verlassens der Funktion (also auch bei Ausnahmebedingungen) gelöscht. D.h. man könnte a in einen sogenannten smart pointer `verwandeln'. Smart pointer verhalten sich wie Zeiger, sind aber Objekte.
Die Klasse auto_ptr in C++ stellt solche smart pointer zur Verfügung (das untenstehende Klassenprotokoll ist nur ein Auszug)
template<class T>

class auto_ptr {

public:
  auto_ptr(T *p = 0): ptr(p) {}
  ~auto_ptr() {delete ptr;}
private:
    T *ptr;
};
Durch die Verwendung von auto_ptr ist somit sichergestellt, daß der Zeiger a auch bei Ausnahmebedingungen gelöscht wird:
void process()
{
        char command = `n';

        while(toupper(command) != `Y') {
          auto_ptr<Animal> a = CreateAnimal();
          a->DoSomething();
          cout << "\nQuit?[y/n]\t";
          cin >> command;
        }
};
Bis auf den Destruktor verhält sich auto_ptr wie ein `normaler' Zeiger. Man nennt diese Technik "Resource acquisition is initialization".

Ausnahmebehandlung während der Initialisierung

Die Verwendung von smart pointern hilft in vielen Fällen der Ausnahmebehandlung. Probleme gibt es allerdings dann, wenn bei der Initialisierung eines smart pointers eine Ausnahme auftritt. Folgende Beispiele sollen das verdeutlichen.
In einer Adreßkartei werden Personeneinträge mit Namen, Bild sowie einem Tonstück der jeweiligen Person verwaltet. Die Klasse BookEntry könnte etwa folgendermaßen ausschauen:
class Image {
  String name;
public:
  Image(String &n) : name(n) {};
};

class Audio {
  String name;
public:
  Audio(String &n) : name(n) {};
};

class BookEntry {
  String name;
  Image *image;
  Audio *audio;
public:
  BookEntry(String &n, String &i = "", String &a= "");
  ~BookEntry() {};
};
Und der Konstruktor von BookEntry:
BookEntry::BookEntry(String &n, String &i, String &a) : 
name(n), 
image(0), audio(0)
{
  name = n;
  if (i != "") image = new Image(i);
  if (a != "") audio = new Audio(a); // throws exception
}
Die Instanzvariablen image und audio werden mit NULL initialisiert -- falls ein Wert übergeben wurde, wird ein neues Objekt vom Typ Image bzw. Audio angelegt. Der Destruktor garantiert, daß beide Objekte auch wieder gelöscht werden:
BookEntry::~BookEntry()
{
  delete image;
  delete audio;
}
So weit, so gut. Ein Problem tritt allerdings dann auf, wenn beispielsweise im Konstruktor in der Zeile
if (a!= "") audio = new Audio(a);
eine Ausnahmebedingung auftritt (es könnte z.B. kein Speicher mehr verfügbar sein). image hat an dieser Stelle schon einen Wert, da kein catch-Statement im Konstruktor von BookEntry vorkommt, wird die Ausnahmebedingung nach `oben' weitergeleitet. Nachdem der Konstruktor von BookEntry nicht erfolgreich beendet wurde, gilt das Objekt vom Typ BookEntry als nicht wirklich instanziiert, d.h., daß auch der Destruktor von BookEntry nicht aufgerufen wird. Der bereits allokierte Speicherplatz von image wird daher nie frei gegeben!
Die naheliegend Lösung, nämlich ein BookEntry Objekt dynamisch auf dem Heap zu erzeugen und die Instanziierung in einen try und catch-Block zu verpacken hilft allerdings auch nicht:
BookEntry *bePtr;
try {
  bePtr = new BookEntry(...);
}
catch (...) {
  delete bePtr; throw;
}
...
bePtr zeigt nämlich solange der Konstruktor nicht erfolgreich durchlaufen wurde auf Null, d.h., daß der Destruktor (in Form der delete-Anweisung) nicht aufgerufen wird. Auch die oben erwähnte Möglichkeit mit auto_ptr hilft nicht, weil auch hier die Zuweisung auf ein unvollständiges Objekt nicht stattfindet.
Eine Möglichkeit eventuelle Ausnahmebedingungen bei der Konstruktion von Objekten zu beachten, liegt in der Behandlung von Ausnahmen direkt im Konstruktor, also etwa
BookEntry::BookEntry(String &n, String &i, String &a) : 
name(n), 
image(0), audio(0)
{
  name = n;
  try {
      if (i != "") image = new Image(i);
      if (a != "") audio = new Audio(a);
  }
  catch (...) {
       delete image; // clean-up
       delete audio;
       throw;        // propagate exception
  }
}
Die jeweils gleichen `clean-up-Statements' im Konstruktor und Destruktor könnte man noch in eine eigene Methode verpacken, um Codeverdoppelung zu vermeiden.
Für den Fall, daß man Zeiger als Instanzvariablen verwendet, bietet sich wiederum die Möglichkeit an, auto_ptr zu verwenden. Die Deklaration von BookEntry
class BookEntry {
  Image *image;
  Audio *audio;
...
};
schreibt man besser als
class BookEntry {
  auto_ptr<Image> image;
  auto_ptr<Audio> audio;
...
};
Zusammenfassend kann man daher festhalten, daß Zeigervariablen durch auto_ptr ersetzt werden sollen, um Ausnahmebedingungen, die bei der Konstruktion von Objekten hervorgerufen werden, möglichst ohne Nebeneffekte zu behandeln.

Ausnahmebehandlung während des Löschens von Objekten

Es gibt zwei grundsätzliche Bedingungen unter denen Destruktoren aufgerufen werden (siehe auch "Destruktoren" auf Seite 47): entweder ein Objekt hört `normal' auf zu existieren, also wenn beispielsweise der Block beendet wird in dem eine Variable definiert wurde; oder aber, ein Objekt wird durch die `Stack-Unwinding' Prozedur, die durch eine Ausnahmebedingung hervorgerufen wurde, gelöscht.
Nun muß man wissen, daß, wenn eine Ausnahmebehandlung aktiv ist und eine weitere Ausnahmebedingung auftritt, C++ `einfach' die terminate() Funktion aufruft und das Programm beendet ohne allokierten Speicherplatz wieder freizugeben. D.h., daß Ausnahmebedingungen nicht rekursiv aufgerufen werden können (wohl aber kann eine bestehende Ausnahmebedingung weitergeleitet werden).
Im Falle von Destruktoren muß man daher immer von der pessimistischen Annahme ausgehen, daß bereits eine Ausnahme aktiv ist (zum Zeitpunkt der Implementierung des Destruktors weiß man ja noch nicht, warum er aufgerufen wird).
Als Beispiel soll eine Klasse dienen, die online-Sessions mitprotokolliert:
class Session {
private:
  static void logCreation(Session *objAddr);
  static void logDestruction(Session *objAddr);
public:
  Session();
  ~Session();
};
Der Destruktor könnte etwa folgendes Aussehen haben
Session::~Session()
{
  logDestruction(this);
}
Wenn jetzt beispielsweise die Methode logDestruction() eine Ausnahmebedingung hervorruft und bereits eine andere Ausnahme aktiv ist, wird automatisch die terminate() Funktion aufgerufen und das Programm beendet. Mögliche Lösung:
Session::~Session()
{
  try {
      logDestruction(this);
  }
  catch (...) {
       cerr << "\nUnable to log destruction of " << 
this << "!\n";
  }
}
was theoretisch durch eine Ausnahmebedingung in der Methode `<<` zu denselben Problemen wie oben führen könnte. Die Lösung einen try-Block in den catch-Block zu implementieren erscheint als zu extrem, d.h. die einzige saubere Möglichkeit ist ein leerer catch-Block, der verhindert, daß die Ausnahme weitergeleitet wird:
Session::~Session()
{
  try {
      logDestruction(this);
  }
  catch (...) {// no propagation
  }
}
Destruktoren sollten somit immer so implementiert werden, daß sie keine Ausnahmebedingungen hervorrufen. Auch wird ja der Destruktor bei Aktivieren einer Ausnahmebedingung nicht mehr vollständig ausgeführt, was folgendes Beispiel verdeutlichen soll
Session::Session()
{
  logCreation(this);
  beginTransaction();
}
...
Session::~Session()
{
  logDestruction(this); // throws exception
  endTransaction();
}
In diesem Fall würde bei Aktivierung einer Ausnahme in logDestruction(this) die Datenbanktransaktion nicht mehr beendet.

Ausnahmebehandlung und Parameterübergabe

Dieser Abschnitt befaßt sich mit Parameterübergabe und Ausnahmebehandlung. Hierbei gibt es neben einigen Gemeinsamkeiten zur `herkömmlichen' Parameterübergabe auch wichtige Unterschiede.
Die Parameterübergabe kann durch call-by-value, call-by-reference oder durch Zeiger realisiert werden. Im Unterschied zu normalen Funktionsaufrufen, wo das Programm nach Beendigung der Funktion wieder zur aufrufenden Stelle zurückkehrt, wird bei Ausnahmebehandlung nicht an das aufrufende Programmstück rückverzweigt. Dadurch werden Parameter immer kopiert, auch bei call-by-reference oder Zeiger. Ein call-by-value führt somit zu einer Kopie einer Kopie. Folgendes Beispiel zeigt dies:
istream operator >> (istream &is, Widget &w);
...
void PassAndThrowWidget()
{
  Widget localWidget;
  cin >> localWidget;
  throw localWidget;
}
Der Operator >> gibt alle eingelesenen Daten direkt an die Variable localWidget weiter (call-by-reference). Wenn nun localWidget als Ausnahme aktiviert wird (throw localWidget), dann wird eine Kopie von localWidget als Parameter für diese Ausnahme angelegt und übergeben. localWidget selbst kann ja gar nicht übergeben werden (oder eine Referenz darauf), da durch die Ausnahmebedingung ja die Gültigkeit des Bereichs von PassAndThrowWidget() aufhört und somit der Destruktor für localWidget aufgerufen wird. Daher würde bei call-by-reference ja eine Referenz auf ein bereits gelöschtes Objekt weitergegeben!
Die Variable localWidget wird übrigens auch dann kopiert, wenn Sie als statisch definiert wurde und daher bis zum Ende des Programmlaufes gültig bleibt:
void PassAndThrowWidget()
{
  static Widget localWidget;
  ...
  throw localWidget;
}
Durch dieses Kopieren sind Parameterübergaben bei Ausnahmebehandlungen langsamer als normale Parameterübergaben.
Ein weiterer großer Unterschied zwischen Parameterübergaben bei Ausnahmebehandlungen und normalen Parameterübergaben besteht in der nicht automatischen Konvertierung bei Ausnahmebehandlungen. Folgender Programmcode läßt sich ohne Probleme übersetzen, weil der Integer i automatisch in eine double-Variable konvertiert wird:
double sqrt(double);
int i = 4;
double sqrtOfInt = sqrt(i);
Wenn man sich nun eine Ausnahmebehandlung vorstellt, die Ausnahmen mit double-Objekten behandeln soll, also etwa
void f(int i)
{
  try {
      doSomething(i); // doSomething throws int i
  }

  catch (double d)
  {
  ...
  }
  ...
}
dann wird hier der Integer i nicht durch den catch-Block `aufgefangen', weil er nicht implizit konvertiert wird. Für Instanzen von Klassen gilt aber schon der Polymorphismus, d.h., daß Vererbungshierarchien bei Ausnahmebehandlung schon ihre Gültigkeit behalten. Allerdings ergibt sich hierbei die Schwierigkeit, daß die Strategie des `first-fit' angewendet wird. Wenn also am Beginn einer Reihe von catch-Statements die Oberklasse einer Instanz einer Klasse steht, so wird dieser catch-Block durchlaufen:
class Object {};
class SubObject : public Object {};

void f(SubObject &so)
{
  try {
      doSomething(so); // doSomething throws SubObject so
  }

  catch (Object &o)    // so fits here!
  {
  ...
  }
  catch (SubObject &sub)
  {
  ...
  }
  ...
}
In obigem Beispiel ist also der Block `catch (Object &o)' der erste, der paßt. Für die Implementierung bedeutet das, daß man bei catch-Blöcken mit der untersten Ebene einer Hierarchie beginnen sollte.

Die Kosten von Ausnahmebehandlung

Die Kosten von Ausnahmebehandlung liegen vor allem in der Verwaltung der für Ausnahmebehandlung notwendigen Daten durch den Compiler und andererseits in der Implementierung in try-blocks und catch-Statements. Letztere vergrößern den Objektcode um etwa 5 - 10 % und verlangsamen die Laufzeit um etwa denselben Faktor [3]. Die Kosten für das Aktivieren von Ausnahmen sind eher gering, da ja Ausnahmen eben Ausnahmen und nicht die Regel sein sollten und daher selten auftreten. Falls man daher gezwungen ist, viel Gebrauch von Ausnahmen zu machen, sollte man sich überlegen, ob nicht ein Re-Design notwendig ist.

Portabilität von C++ Programmen

Es gibt (noch) keinen ANSI-C++ Standard, aber auch ein Standard kann nicht alle Portabilitätsprobleme lösen. Dieser Abschnitt beschreibt Unterschiede zwischen verschiedenen C++ Plattformen und nicht Unterschiede in den Betriebs- oder Fenstersystemen.

Probleme bei der Verwendung von `Advanced Features'

Templates: Probleme teilweise bei HP Unix C++ Compiler; manche Compiler erlauben auch nur inline-Templates. Inzwischen sollten Templates aber von allen Compilern unterstützt werden.
Exception Handling: Wird von (fast) allen derzeitigen Compilern unterstützt; kleine Unterschiede in der Syntax.
RTTI (Run Time Type Information): dient dazu den Typ eines Objekts zur Laufzeit abzufragen. Meist noch nicht klar, welcher Compiler in welchem Ausmaß RTTI unterstützt. Die entsprechenden Funktionen sind in <typeinfo.h> definiert.
Run-Time-Type-Information (RTTI)

X *x = new X();
X xx, yy;

cout << "\nx = " << typeid(x).name() << endl;
cout << "\nx = " << typeid(*x).name() << endl;
cout << "\nxx = " << typeid(xx).name() << endl;
cout << "\nxx==yy = " << (typeid(xx) == typeid(yy)) 
<< endl;
führt zur Ausgabe von
x = (null)     // x is pointer
x = TD$1X      // x*
xx = TD$Fv_1X  // xx is of type X
xx == yy 1     // type of xx is type of yy
Wide Character Support: Beispiel
whcar_type MyWideString[] = "some international text goes here";

Konstruktoren/Destruktoren

PC C++ Compiler stellen automatisch einen Copy-Konstruktor zur Verfügung, Unix C++ Compiler normalerweise nicht. Ausweg: IMMER selbst einen Copy-Konstruktor definieren. Das hat auch den Vorteil, daß man den Algorithmus des Copy-Konstruktors selbst `in der Hand hat' (siehe auch "Shallow-Copy, Deep-Copy, Identität" auf Seite 50).

Syntax Unterschiede

Manche Compiler `vertragen' keine extra Strichpunkte nach Makros
MyMacro(1, 2); // error
MyMacro(1, 4)  // works

Typecasts

PC C++ Compiler erlauben type-name (case-expression) Unix Compiler (teilweise) nicht. Ausweg: Unix Version verwenden.
int i = 4711;
double d = 55555555;
d = double(i);     // geht unter Unix teilweise nicht
d = (double)i;

Deklaration von Variablen

Deklaration von Variablen, wenn der Block für den Sie gültig sind, nicht eindeutig definiert ist. Folgende Anweisung ist (meist) bei Unix Compilern nicht erlaubt:
case
...
default:
  int Number = 5;
  ...
  break;
...
Ausweg
default: {
  int Number = 7;
  ...
  }
  break;

Strukturen

Unix Compiler verlangen, daß Komponenten von structs über ihren `Pfad' angesprochen werden, auch wenn sie eindeutig sind:
typedef struct struct1 {
  int one;
  struct struct2 {
    int two;
    int three;
  } a;
  int four;
  } struct1_type;

struct1_type foo;
foo.a.two = 100;
//bei PC auch: foo.two = 100;

Methoden ohne Klammerung

PC Compiler erlauben ...
foo.a;
Unix erlauben nur ...
foo.a();
Daher (auch im Sinne besserer Lesbarkeit) die Unix Version verwenden.

Inlining

Teilweise gibt es Compilerprobleme mit `komplexen' inline-Methoden, wobei komplex bedeutet, daß if/else, case, usw. verwendet werden.

const static Objekte

Dürfen auf PCs meist ohne Initialisierung instanziiert werden. Unter Unix nicht. Ausweg: immer initialisieren.

Mischen von printf() und cout

Die aus C bekannten Ausgabefunktionen printf() sollen nicht gemeinsam mit dem C++ Ausgabeoperator << gemischt werden. Die beiden Methoden verwenden unterschiedliche Konzepte des Zugriffs auf Streams, die sich teilweise überschneiden.
Zusammenfassend läßt sich sagen, daß Unix Compiler im allgemeinen eher `strenger' sind. Testweises Portieren von `problematischem' Sourcecode schon von Anfang an hilft viel Zeit sparen.

Referenzen

ELLIS Margaret A., STROUSTROUP Bjarne: The Annotated C++ Reference Manual 2nd Edition. Addison-Wesley 1992.

HITZ Martin, "C++, Grundlagen und Programmierung". Springer Verlag 1992.

MEYERS Scott, "How to Navigate the Treacherous Waters of C++ Exception Handling", Microsoft Systems Journal, November 1995.

MÜLLER Harald M., "Ten Rules for Handling Exception Handling Successfully", C++ Report, January 1996, pp. 23--36.

WEINAND A., GAMMA E., MARTY R. "Design and Implementation of ET++, a Seamless Object-Oreinted Application Framework", Structured Programming, Vol. 10, No. 2, Springer-Verlag 1989.

WISE G. Bowden, "An Overview of The Standard Template Library", ACM Crossroads 2.3, Februar 1996.


Last Modified: 11:28pm PST, November 24, 1996