Axel Rogat
Objektorientiertes Programmieren mit C++ und JAVA
 
17.10.1: Grafische Objekte Kapitel 17 18: Mehrfachvererbung 
 
  17.10.2 Pointer-Klassen  
 

Das in C++ eingebaute (und von C übernommene) Pointer-Konzept hat einige Schwächen, die bei unvorsichtiger Programmierung leicht zu schweren Abstürzen und unverständlichen Fehlern führen könnten. Es bietet einerseits zu viel, andererseits zu wenig Möglichkeiten:

Wenn man beispielsweise mit new Speicherplatz alloziert, erhält man einen Pointer darauf zurück.

Es gibt viele verschiedene Möglichkeiten, Klassen(-Templates) zu schreiben, die Pointer simulieren, aber zusätzliche Kontrollmöglichkeiten bieten, sogenannte " smart pointer":

Solche Pointer-Klassen arbeiten mit überladenen Operatoren für Dereferenzieren (*) und eventuell auch für den Member-Zugriff (->). (Da letzterer nur für zusammengesetzte Typen erlaubt ist, werden Pointer auf eingebaute und zusammengesetzte Typen meist unterschieden.)

Es gibt zwei wichtige Typen von Smart Pointern, für die wir im folgenden auch Implementationen angeben werden:

Copied Pointer
(copied-object pointer): Hier besteht eine 1:1-Korrespondenz von Pointern zu Objekten auf dem Heap. Das Anlegen mit new wird mit der Erzeugung des Smart Pointers gekoppelt. Über ihn finden alle Zugriffe statt (Lesen, Schreiben, Member-Zugriff, Kopieren), und über ihn wird das Objekt auch wieder freigegeben. Beim Kopieren und Zuweisen des Pointers wird das Objekt mitkopiert.

Counted Pointer
(reference-counted-object pointer): Hier dürfen mehrere Pointer auf dasselbe Objekt zeigen -- wie oft, wird mitgezählt. Bei Kopieren des Pointers wird nicht das Objekt kopiert, sondern nur der Zähler wird erhöht. Beim Anwenden von delete wird dieser Zähler erniedrigt. Wenn er 0 erreicht, wird das Objekt selbst gelöscht.

Wir stellen im folgenden kurz die unterschiedliche Wirkungsweise der beiden Pointer-Typen grafisch gegenüber. Sie sollen sinnvollerweise als Schablonen implementiert sein, die auf einen beliebigen Typ T zeigen können.

Copy-Konstruktor:

Der Copy-Konstruktor von CopiedPtr legt eine Kopie des Objekts an, auf das der Pointer zeigt.

CopiedPtr<T> p1=new T; CopiedPtr<T> p1(p1);
 

Dagegen zeigen bei CountedPtr hinterher beide Pointer auf dasselbe Objekt; der Zähler, den sich beide teilen, wurde aber erhöht.

CountedPtr<T> p1=new T; CountedPtr<T> p1(p1);
 

Destruktor:

Beim Copied Pointer wird mit dem Pointer-Objekt selbst auch das Objekt zerstört, auf das er zeigte.

CopiedPtr<T> p1=new T; (~p1)
 

Für den Counted Pointer ist der Fall dargestellt, in dem noch zwei Pointer auf dasselbe Objekt zeigen, so daß nur der Zähler erniedrigt wird, das Objekt aber erhalten bleibt.

CountedPtr<T> p1=new T; (~p2)
CountedPtr<T> p2(p1);
 

Primärer Zuweisungsoperator C_Ptr<T> = C_Ptr<T>:

Er muß bei CopiedPtr das Objekt, auf das der erste Pointer zeigt, freigeben und eine Kopie des Objekts anlegen, auf das der zweite Pointer zeigt:

CopiedPtr<T> p1=new T, p2=new T; p1=p2;
 

Wenn bei CountedPtr der erste Pointer als einziger auf sein Objekt zeigt, wird es auch gelöscht. Auf jeden Fall zeigen beide Pointer hinterher auf das gleiche Objekt, teilen sich den gleichen Zähler, der erhöht wird.

CountedPtr<T> p1=new T, p2=new T; CountedPtr<T> p1=p2;
 

Zuweisungsoperator C_Ptr<T> = T*:
Es sollen natürlich auch Zuweisungen von normalen Pointern an Smart Pointer möglich sein.

Bei einer solchen Zuweisung an einen Copied Pointer wird dessen altes Objekt gelöscht:

CopiedPtr<T> p1=new T; p1=pt;
T* pt=new T;
 

Wenn ein Counted Pointer der einzige ist, der auf sein Objekt zeigt, wird es bei einer solchen Zuweisung genauso gelöscht. Im Bild ist aber die Situation dargestellt, wo noch mehrere Pointer auf das Objekt zeigen. Durch die Zuweisung werden sie dann entkoppelt.

CountedPtr<T> p1=new T, p2(p1); p1=pt;
T* pt=new T;
 

Copied Pointer

Wir implementieren eine einfache Klasse für Copied Pointer. Der Operator -> darf natürlich nur überladen werden, wenn der Typ, auf den gezeigt wird, eine class oder struct ist, nicht für einfache Typen wie double. Daher schreiben wir zunächst eine Klasse CopiedPtrBI ("BI"=built-in), bei der dieser Operator noch fehlt, und die also allgemein verwendet werden kann:

template <class T> class CopiedPtrBI { protected: T *ptr; public: CopiedPtrBI() : ptr(0) { } CopiedPtrBI(T* fresh) : ptr(fresh) { } CopiedPtrBI(const CopiedPtrBI<T> &cp2) : ptr(cp2.nil()?0:new T(*cp2.ptr)) { } ~CopiedPtrBI() { delete ptr; } CopiedPtrBI<T>& operator = (T*); CopiedPtrBI<T>& operator = (const CopiedPtrBI<T>&); T& operator * () const { return *ptr; } bool nil() const { return ptr==0; } friend bool operator == (const CopiedPtrBI<T> &p1, const CopiedPtrBI<T> &p2) { return p1.ptr==p2.ptr; } friend bool operator != (const CopiedPtrBI<T> &p1, const CopiedPtrBI<T> &p2) { return p1!=p2; } }; template <class T> CopiedPtrBI<T>& CopiedPtrBI<T>::operator = (T *fresh) { delete ptr; ptr=fresh; return *this; } template <class T> CopiedPtrBI<T>& CopiedPtrBI<T>::operator = (const CopiedPtrBI<T> &p2) { if (ptr!=p2.ptr) { delete ptr; ptr=p2.nil()?0:new T(*p2.ptr); } return *this; }
Adreßarithmetik ist in unserem Fall nicht sinnvoll und wird durch das Fehlen der entsprechenden Operatoren wie ++ -- + - unmöglich gemacht. == und != stellen wir zur Verfügung. Zwei unterschiedliche CopiedPtrBI können aber (bei sachgemäßer Verwendung) nie auf dasselbe Objekt zeigen.

Die Klasse CopiedPtr mit dem Operator -> leiten wir nun von CopiedPtrBI ab:

template <class T> class CopiedPtr : public CopiedPtrBI<T> { public: CopiedPtr() : CopiedPtrBI<T>() { } CopiedPtr(const CopiedPtr<T>& p) : CopiedPtrBI<T>(p) { } CopiedPtr(T* fresh) : CopiedPtrBI<T>(fresh) { } CopiedPtr<T>& operator = (T* fresh) { CopiedPtrBI<T>::operator=(fresh); return *this; } CopiedPtr<T>& operator = (const CopiedPtr<T> &p2) { CopiedPtrBI<T>::operator=(p2); return *this; } T* operator -> () const { return ptr; } };
Die Definition besteht nur in der Umleitung der Konstruktoren und des Operators = auf die Oberklasse und stellt -> zur Verfügung.

Beispiel: Folgendes Hauptprogramm liefert die Ausgaben 1,2  1,2  3,4  1,2:

struct teststruct { int a,b; teststruct(int aa, int bb) : a(aa), b(bb) { } }; void main(void) { CopiedPtr<teststruct> p=new teststruct(1,2); cout << "p: " << p->a << ',' << p->b << endl; { CopiedPtr<teststruct> q=p; cout << "q: " << q->a << ',' << q->b << endl; q->a=3; q->b=4; cout << "q: " << q->a << ',' << q->b << endl; } cout << "p: " << p->a << ',' << p->b << endl; }
Die Änderung des Objekts über den zweiten Pointer hat also keine Änderung des Objekts zur Folge, auf das der erste zeigt. Bei Kopieren des Pointers ist also das Objekt mitkopiert worden.

Dadurch, daß wir den new-Aufruf direkt in die Definition des Pointers eingebaut haben, gibt es gar keine andere Zugriffsmöglichkeit mehr auf das Objekt als über unseren Pointer. Das ist immer der sicherste Weg, versehentliche Eingriffe in den Mechanismus auszuschließen.

Counted Pointer

Bei unsererer Implementation von Counted Pointern gehen wir wiederum den Umweg über eine "BI"-Klasse für die eingebauten Typen:

template <class T> class CountedPtrBI { private: ReferenceCount counter; // Zähler für die Zugriffe auf das Objekt protected: T *ptr; public: CountedPtrBI() : ptr(0) { } CountedPtrBI(T* fresh) : ptr(fresh) { } CountedPtrBI<T>& operator = (const CountedPtrBI<T>&); CountedPtrBI<T>& operator = (T*); ~CountedPtrBI(); bool unique() const; { return counter.unique(); } T& operator * () const { return *ptr; } bool nil() const { return ptr==0; } friend bool operator == (const CountedPtrBI<T>& l, const CountedPtrBI<T>& r) { return l.ptr == r.ptr; } friend bool operator != (const CountedPtrBI<T>& l, const CountedPtrBI<T>& r) { return l!=r; } };
Da nun mehrere Pointer auf das gleiche Objekt zeigen können, machen die Operatoren == und != (im Gegensatz zu CopiedPtr) Sinn.

Die eigentliche Zählarbeit lagern wir in einen speziellen Mechanismus namens ReferenceCount aus:

class ReferenceCount { private: unsigned int* p_count; void decrement() { if (unique()) delete p_count; else --*p_count; } public: ReferenceCount() : p_count(new unsigned int(1)) { } ReferenceCount(const ReferenceCount& r2) : p_count(r2.p_count) { ++*p_count; } ReferenceCount& operator = (const ReferenceCount& r2) { ++*r2.p_count; decrement(); p_count=r2.p_count; return *this; } ~ReferenceCount() { decrement(); } bool unique() const { return *p_count==1; } };
Pointer, die auf dasselbe Objekt zeigen, müssen sich natürlich auch den Zähler teilen. Das geschieht auf folgende Weise:

Wenn die Verbindung zu einem Objekt getrennt wird, wird decrement aufgerufen. Wenn der Zähler auf 1 steht, gibt es nur noch einen Zugriff (unique), und der Zähler wird freigegeben, ansonsten wird er erniedrigt.

decrement wird natürlich im Destruktor aufgerufen, aber auch im Zuweisungsoperator =. Unser Pointer wird danach auf ein anderes Objekt zeigen. Daher wird der alte Zähler erniedrigt, der fremde Zähler erhöht und dann zugewiesen.

Unter Verwendung dieses Mechanismus ergeben sich die restlichen Memberfunktionen von CounterPtrBI wie folgt:

template <class T> CountedPtrBI<T>& CountedPtrBI<T>::operator = (const CountedPtrBI<T>& p2) { if (ptr!=p2.ptr) { if (unique()) delete ptr; ptr=p2.ptr; counter=p2.counter; } return *this; } template <class T> CountedPtrBI<T>& CountedPtrBI<T>::operator = (T* fresh) { if (unique()) delete ptr; ptr=fresh; counter=ReferenceCount(); return *this; } template <class T> CountedPtrBI<T>::~CountedPtrBI() { if (counter.unique()) delete ptr; }
Wir brauchen keinen Copy-Konstruktor für CopiedPtrBI anzugeben, da der automatisch zur Verfügung gestellte bereits ausreicht -- er ruft schließlich automatisch den Copy-Konstruktor von ReferenceCount auf, und dort wird die eigentliche Arbeit getan.

Ähnliches ergibt sich beim Destruktor -- das Zählen geschieht dadurch, daß automatisch der Destruktor von ReferenceCount aufgerufen wird.

Die Klasse für allgemeinere Objekte (und mit ->) entsteht wieder durch Ableiten:

template <class T> class CountedPtr : public CountedPtrBI<T> { public: CountedPtr() { } CountedPtr(T* fresh) : CountedPtrBI<T>(fresh) { } CountedPtr<T>& operator = (T* fresh) { CountedPtrBI<T>::operator=(fresh); return *this; } CountedPtr<T>& operator = (const CountedPtr<T>& p2) { CountedPtrBI<T>::operator=(p2); return *this; } T* operator -> () const { return ptr; } };
Das folgende Testprogramm demonstriert nur kurz den Gegensatz zu den CopiedPtr-Klassen. Wir sehen, daß sich beide Pointer auf dasselbe Objekt beziehen. Die Änderung geschieht mit dem ersten, die Ausgabe mit dem zweiten Pointer (Ausgabe 3,4).
void main(void) { CountedPtr<teststruct> p=new teststruct(1,2); CountedPtr<teststruct> q=p; p->a=3; p->b=4; cout << q->a << ',' << q->b << endl; }

 
17.10.1: Grafische Objekte Startseite 18: Mehrfachvererbung 
 

© 1998 Axel Rogat