namespace cpp

C++ lernen, kennen, anwenden

Benutzer-Werkzeuge

Webseiten-Werkzeuge


lernen:baustein

Bausteine, Aggregate, Assoziationen

We eat the night, we drink the time
Make our dreams come true
And hungry eyes are passing by
On streets we call the zoo
— Scorpions : The Zoo

Zielstellung

Objektbeziehungen des Systementwurfes werden Implementierungstechniken in C++ zugeordnet und katalogisiert.

Auf der Baustelle zwischen UML-Entwurf und Umsetzung in C++

Ein objektorientierter Entwurf ist irgendwann auch zu implementieren. Die Umsetzung in die Programmiersprache ist aber keinesfalls ein mechanischer Prozess. Man kann dies mit einem Case-Tool wie Janus, Objektif oder Rational Rose tun. Case Tools sind dazu nur in der Lage, weil sie dem Bediener mehr abverlangen als nur einen UML-Entwurf. Der schöpferische Teil der Implementationsphase wird teils unmerklich in den Entwurf verlagert. Oder man kann Programmierer direkt mit der Implementierung beauftragen. Diese stehen dann vor der Frage: Wie setze ich diesen Entwurf um? Viele Aspekte sind zu bedenken. Dieser Text konzentriert sich auf Klassendiagramme, und dabei auch nur auf die darin festgehaltenen Objektbeziehungen. Vererbungsbeziehungen werden nur nebenbei dort erwähnt, wo sie auf die Implementierung von Objektbeziehungen Einfluss haben können. Richtig kodieren heißt, genau das zu schreiben, was man meint, und exakt das zu wollen, was man schreibt [Meyers, Tip 50]. Ein Katalog der Möglichkeiten, die zur Auswahl stehen, kann den Entscheidungsprozess verkürzen und helfen, treffende Ausdrücke zu finden.

Beziehungen als Entwurf

Die grafische Sprache UML (Unified Modeling Language) stellt Ausdrucksmöglichkeiten für den objektorientierten Entwurf bereit. Das Ergebnis des Entwurfsprozesses ist eine Sammlung von "technischen Zeichnungen", die jede für sich verschiedene Sichten auf das herzustellende Softwareprodukt festhalten. Eine wichtige Darstellungsform sind Klassendiagramme, die neben Vererbungsbeziehungen vor allem Beziehungen zwischen den Objekten des Systems knüpfen. Beziehungen werden als durchgezogene Linien zwischen Rechtecken dargestellt, in denen der Klassenname steht (Abb. 1).

Abb. 1. Assoziation, Aggregation, Komposition (von oben nach unten) und Kardinalitäten.

Dabei werden drei Gruppen von Beziehungen unterschieden:

  • Assoziationen: lose Beziehungen zwischen Objekten
  • Aggregationen: Teil-Ganzes-Beziehungen mit nicht existenzabhängigen Teilen,
  • Kompositionen: Teil-Ganzes-Beziehungen mit existenzabhängigen Teilen.

Existenzabhängig ist ein Teil dann, wenn es mit dem Ungültigwerden des Ganzen seine Existenz(berechtigung) ebenfalls verliert. Grady Booch [Booch] unterscheidet Beziehungen durch textuelle Beschreibungen wie "A nutzt ein B", "A hat ein B", um Assoziationen (Nutzen) von Teil-Ganzes-Beziehungen (Haben, Enthaltensein) zu unterscheiden. Auf der Seite des Ganzen endet die Linie in einem Rhombus, der bei Existenzabhängigkeit gefüllt ist.

Zusätzlich können Angaben über die Anzahl miteinander in Beziehung stehender Objekte verzeichnet werden (Kardinalitäten):

  • 1:1-Beziehungen: ein Paar aus je einem Objekt der Klassen A und B,
  • 1:0/1-Beziehungen: ein A, zu dem ein B gehören kann, aber nicht muss,
  • 1:n-Beziehungen: ein A mit n Objekten von Typ B,
  • 1:*-Beziehungen: ein A mit beliebig vielen B-Objekten.

Ein Nachteil der grafischen Notation UML ist, dass sie in elektronischer Form ein Datenformat benutzen muss, für das es bisher keinen Standard gibt, entweder als proprietäre Binärdatei eines Case-Tools, oder schwer modifizierbar als Vektor- oder Bitmap-Grafik. Mit Quelltexten lässt sich diese Darstellung schlecht verbinden. Für einfache Sachverhalte lässt sich vielleicht eine abgerüstete Variante finden, die auch in Quelltexten als Textgrafik darstellbar ist:

   1    1
[A]------[B]     Paar-Assoziation
 
   1    n
[A]<>----[B]     ein A mit vielen, existenzunabhängigen B 
 
   1  0/1
[A]<*>---[B]     ein A mit höchstens einem, existenzabhängigen B

Selbstverständlich kann das keine ganze technische Zeichnung ersetzen. Feinheiten sind damit nicht darstellbar. Im Quelltext werden aber die Sachverhalte nacheinander modelliert, so dass kleine Ausschnitte aus dem UML-Diagramm zur Kommentierung ausreichen. "Bildchen" sind aussagekräftiger als der zugehörige Text, sie lassen sich schnell optisch erfassen (Abb. 2).

        1                           1      4         1      
   +--<*>[MenschÄrgerDichNichtSpiel]<>---[Spieler]<>-----+
 1 |                                                     | 4
[Brett]--------[Position]--------------------------<*>[Figur]<--+
         <auf            1        <belegt            1   | bedroht/schlägt
                                                         +------+

Abb. 2. Klassendiagramm mit Klassenbeziehungen eines Mensch-Ärger-Dich-nicht-Spiels. Eine solche Darstellung als Textgrafik ist im Quelltext zur Dokumentation integrierbar.

Klassendiagramme sind nicht alles

Die aus einer solchen Konstellation von Objekten entstehenden Verwicklungen sollten im Entwurf festgehalten sein. Es braucht Klassenschnittstellen, Zugriffsspezifikationen, Anwendungsfall-, Aktivitäts-, Kollaborations-, Sequenz-, Zustandsdiagramme, in Struktogrammen oder Pseudocode festgelegte Abläufe. Neben der Fachkonzeptschicht sollte ein Software-System eine Oberflächenschicht (Darstellung und Bediener-Interaktion) und eine Datenschicht zur Speicherung enthalten (Microsoft: Mehr-Schichten-Architektur). Werden die Schichten sauber getrennt, können sie bis auf Schnittstellen unabhängig voneinander umgesetzt werden. Sind alle diese Aspekte festgelegt, kann die Implementation beginnen.

Das Klassendiagramm ist nur eine notwendige Sicht auf das zu entwerfende System. Diese Sicht ist statisch. Sie stellt die Beziehungen dar, die irgendwann im Programmverlauf von Bedeutung sind. Für den richtigen Aufbau der Beziehungen müssen eine Reihe von Entscheidungen getroffen werden (Abb. 3).

          Objekte begegnen sich nur in Methoden / Funktionen?
               /                                    \ 
ja: Assoziation: "A uses B"            nein: Teil-Ganzes-Beziehung "A has B"
              |                                      | 
Muss jede der Klassen mit Objekten     Sind Teile existenzabhängig?
der anderen Klasse umgehen?                   /                 \
         /              \                    /                   \    
     nein:             ja:                nein:                  ja: 
  gerichtete       ungerichtete             |                     |
  Assoziation      Assoziation           Aggregation        Komposition   
       |                 |                  |                     |
 Client-Server-    Agenten               Gehen die Objekte    Sind Teile 
 Architektur       Double-Dispatch-      wechselnde              /        \ 
                   Techniken             Gruppierungen ein?   aus-        nur  
                                         /        \           wechselbar? zeitweise
                                        ja       nein                     vorhanden? 

Abb. 3. Design-Entscheidungen.

Für einige Problemstellungen haben sich Standard-Lösungen herausgeschält, die bestimmten Mustern folgen. Die "Viererbande" (Gang of Four) Gamma, Helm, Johnson und Vlissides hat Design-Pattern katalogisiert [GoF], darunter

  • Erzeugungsmuster (Fabrik, Singleton)
  • Strukturmuster (Adapter, Brücke, Proxy, Kompositum - Vorsicht: nicht mit dem Begriff Komposition verwechseln!),
  • Verhaltensmuster (Beobachter, Besucher, Iterator, Strategie, Memento, Zustand).

Dieser Musterkatalog ist nicht vollständig, zielt aber auf Wiederverwendbarkeit. Stets wiederkehrende Problemstellungen haben meist ähnliche Lösungen.

Nicht alle Entscheidungen des Entwurfs sind im Klassendiagramm dargestellt. So sind Beziehungen im UML-Diagramm gewöhnlich bidirektional, sofern die Navigierbarkeit nicht ausdrücklich eingeschränkt ist. Zweiseitig navigierbare Beziehungen bedeuten, dass das Ganze seine Teile kennt (das ist leicht einzusehen), das Teil jedoch auch weiß, zu welchem Ganzen es gehört (das ist nicht selbstverständlich). Das gegenseitige Zur-Kenntnis-Nehmen erfordert Protokolle des Bekanntmachens und eventuell des Verabschiedens. Gegenseitige Kenntnis führt zu Abhängigkeits-Zyklen in den Schnittstellen (Henne-Ei-Problem) oder ist über Ableitung von einer gemeinsamen Protokoll-Basis zu implementieren. Dies ist nicht ohne zusätzliche Daten-Elemente und Methoden zu bewerkstelligen, es kann auch ein Leistungsproblem hervorrufen. Der Implementierer muss entscheiden, ob solche Protokolle notwendig sind.

Ausdrucksmittel der Implementierungs-Sprache

Klassen sind die Grundbausteine des objektorientierten Programmierens:

class Klassenname : public Basisklasse
{
public:
  Ergebnistyp methode(Parametertyp parameter);
protected: 
  // bzw.
private:
  Datentyp attribut;
};

Die in den Klassendiagrammen dargestellten Beziehungen zwischen den Objekten entscheiden darüber, wie Daten-Attribute und Methoden-Parameter zu deklarieren sind. Andere Gesichtspunkte werden im folgenden ausgeblendet.

Objekte werden über Bezeichner angesprochen. In C++ kann das eine Variable, ein Feldelement, eine Referenz oder ein Zeiger sein. Zeiger bzw. Referenzen sind in der Lage, auch auf polymorphe Objekte zu verweisen, wenn Vererbung und Schnittstellenabstraktion eine Rolle spielt (z.B. Mensch und Computer als Spieler):

int nummer = 1;             // Objekt mit Speicherplatz, Name und Wert
int* zeiger = &nummer;      // verweisen auf Objekte, die anderswo liegen
int& referenz = nummer;     //  -"-
std::shared_ptr<int> b = new int(2); //  -"-
std::shared_ptr<Spieler> spieler = new Mensch(); // besondere Sorte Spieler

Für das Umsetzen des Entwurfs zählt, welche Möglichkeiten die Implementierungssprache anbietet. Dazu lohnt ein Blick über den Gartenzaun.

C++ != Java

Java baut sein Objektmodell auf der automatischen Speicherbereinigung (garbage collection) auf. Bis auf Grunddatentypen werden alle Objekte über Referenzen verwaltet. Um Missverständnissen vorbeugen, muss der Unterschied zwischen einer Referenz in Java und einer Referenz in C++ deutlich gemacht werden.

Eine Referenz in Java ist eine lösbare Verbindung. Eine Java-Referenz kann nacheinander auf verschiedene Objekte zeigen. Die Java-Referenz ist implementierbar als versteckter Zeiger mit einer Referenzzähler-Semantik: Wird der letzte Verweis auf das referenzierte Objekt gelöscht, kann der Speicher vom garbage collector aufgesammelt werden. Das Verhalten einer Java-Referenz lässt sich in C++ durch einen referenzzählenden Zeiger nachbilden.

Eine Referenz in C++ wird für die Existenzdauer der Referenz an ein Objekt gebunden. Sie kann nicht zu einem anderen Objekt wechseln. Das referenzierte Objekt muss die ganze Zeit über vorhanden sein. So ist die Rückgabe einer Referenz auf ein lokales Objekt ein gemeiner Fehler (dangling reference problem).

Wechselnde Objekt-Beziehungen können in C++ mit Zeigern modelliert werden. Als eine Art "Schweizer Taschenmesser" sind Zeiger zu vielen Zwecken einsetzbar. Damit hat man in C++ ein "inverses Problem":

class A
{
  //...
private: 
  B* zeiger_auf_objekt; // was verbirgt sich dahinter?
}; 

Aus dem Ausschnitt der Klassenschnittstelle kann nicht geschlossen werden,

  • ob es sich um ein Komposit, ein Aggregat oder eine "Bekannschaft" handelt
  • ob das verwiesene Objekt zeitweilig oder immer vorhanden ist,
  • ja noch nicht einmal, ob es sich um ein Objekt oder um mehrere (ein Feld) handelt.

Weitere Informationen liegen in der Klasse verstreut, in den Konstruktoren, dem Destruktor, dem Zuweisungsoperator und in anderen Methoden der Klasse, die den Zeiger manipulieren. Kommentare können den Zweck erhellen, aber auch verschleiern.

Wie Schweizer Taschenmesser haben Zeiger den Nachteil, das man sich verletzen kann, wenn man das Werkzeug falsch einsetzt. Zeiger können auf falsche Adressen verweisen, bei Zeigern in Felder bleiben die Feldgrenzen ungeprüft, die anderswo zu verwaltende Feldgröße muss stets mitgeführt werden. Und für manchen Zweck gibt es Spezial-Werkzeuge, die die Aufgabe besser erfüllen als ein Schweizer Taschenmesser:

  • Containerklassen (vector<T>, list<T>, set<T>,…) für mehrere Objekte und
  • "Zeiger"-Klassen (sogenannte smart pointer), z.B. ein shared_ptr<T> verwalten die Objekte, kümmern sich um Kopieren, Zuweisen und Speicherbereinigung selbst im Fall von Ausnahmen und ersparen das Schreiben von Destruktoren, Kopierkonstruktoren und Zuweisungsoperatoren.

Den guten Handwerker erkennt man an seinem Werkzeug. Auch der professionelle Programmierer sollte gute Werkzeuge kennen und nutzen. Notfalls muss er sie sogar selbst schaffen.

Referenzzählende Zeiger

Die referenzierten Objekte schwanken zwischen existenzunabhängig und existenzabhängig: Das Objekt existiert weiter, wenn noch mindestens eine weitere Referenz auf Objekt verweist. Aber es verliert aber seine Existenzberechtigung in dem Moment, wenn die letzte Referenz verschwindet. Dennoch kann das Objekt noch eine Weile im Speicher verweilen, bis, aus welchem Anlass auch immer, der Müllsammler aktiv wird.

Solche Verweise bergen in sich die Gefahr der zirkulären Referenz: B verweist auf A und A verweist auf B. Das Programm hat jeglichen Verweis auf A und B verloren. Obwohl beide Objekte nicht mehr genutzt werden, kann der Speicher nicht freigegeben werden. Die Verweiszähler der Objekte werden nicht null. Diese höhere Form der Speicherlecks hat ihre Entsprechung in der Systemverklemmung parallel arbeitender Programme.

Der Zoo der Möglichkeiten

Je nach Art, Kardinalität und Lösbarkeit lassen sich unterschiedliche Umsetzungen finden, die nachfolgend angedeutet werden. Der Baustellencharakter wird durch die Unvollständigkeit des Katalogs deutlich. Ein vector<T> steht stellvertretend für beliebige Container, ein shared_ptr<T> für eine referenzzählende "smarte" Zeigerklasse.

Existenzabhängige Teile eines Ganzen (Komposition)

----------------------------------   ----------------------------------

[A]<*>----[B]                        [A]<*>----[B]
   1     1                               1   0/1

class A                              class A
{                                    {
  B bauteil;                           A(const A&);            // Kopie
};                                     A& operator=(const A&); // Zuw.
                                       ~A() { delete bauteil; }
                                       B* bauteil;
                                     };

Komposition 1:1                      Komposition 1:0/1
unlösbare Verbindung                 lösbare Verbindung
----------------------------------   ----------------------------------

[A]<*>----[B]                        [A]<*>----[B]
   1     n                              1     *

class A                              class A
{                                    {
  B bauteile[anzahl];                  A(const A&);            // Kopie
};                                     A& operator=(const A&); // Zuw.
                                       ~A() { delete [] bauteile; }
                                       int anzahl;
                                       B* bauteile;
                                     };

Komposition 1:n                      Komposition 1:*
unlösbare Verbindung                 lösbare Verbindung
----------------------------------   ----------------------------------

[A]<*>----[B]
   1     *

class A
{
  vector<B> bauteile;
};

Komposition 1:*
lösbare Verbindung
----------------------------------

Existenzunabhängige Teile eines Ganzen (Aggregation)

----------------------------------   ----------------------------------

[A]<>-----[B]                        [A]<>-----[B]
   1     1                              1   0/1

class A                              class A
{                                    {
  A(B& ref) : bestandteil(ref) {}      B* bestandteil;
  B& bestandteil;                    };
};

Aggregation 1:1                      Aggregation 1:0/1
unlösbare Verbindung                 lösbare Verbindung
----------------------------------   ----------------------------------

[A]<>-----[B]                        [A]<>-----[B]-----<>[Dritter]
   1     n                              1   0/1

class A                              class A
{                                    {
  B* bestandteile[anzahl];             RefPtr<B> bestandteil;
};                                   };


Aggregation 1:n                      Aggregation 1:0/1
lösbare Verbindung                   lösbare Verbindung
----------------------------------   ----------------------------------

[A]<>-----[B]
   1   *

class A
{
  vector<B*> bestandteile;
};
  // oder vector<shared_ptr<B>> bestandteile

Aggregation 1:*
lösbare Verbindung
----------------------------------

Gleichberechtigte Klassen (Assoziation)

----------------------------------   ----------------------------------
                                           : Interface
    nutzt>                                 :
[A]------>[B]                        [A]-------[B]
                                           : 
class A                              class A
{                                    {
  void nutzt(B server);                void gruesst(B& bekannter)
};                                     {
                                         bekannter.antwortet(*this);
void f()                               }
{                                    };
  A client;
  B server;
  client.nutzt(server);
}

Assoziation (gerichtet)              Assoziation (ungerichtet)
----------------------------------   ----------------------------------

Was nicht im Katalog steht

Ein Katalog bildet nur ab, was es so Schönes gibt. Er sagt nichts darüber, wann und wie die abgebildeten Dinge eingesetzt werden. Nicht aufgeführt ist die Vergabe von Zugriffsrechten (private / protected / public), Schreibschutz (const), ebenso nicht, woher die Angaben über die einzelnen Objekte kommen. Es muss sichergestellt sein, dass die Daten initialisiert werden:

class A
{
public:  
  A(B& b) : bauteil(b) {}
private:
  B& bauteil;
};

Zeiger sind zu "erden" (NULL-Potential) oder auf vorhandene Objekte zu setzen, Konstruktoren müssen Referenzen setzen, die mindestens solange existieren, wie auf sie zugegriffen wird (referentielle Integrität):

Kaffeekanne k;
Kaffeemaschine m(k); // Beziehung während Zubereitung nicht zerbrechen!

Für gegenseitige Bekanntmachungen ist ein Protokoll (Handshaking) zu vereinbaren, das über Zeiger- oder Referenzaustausch eine zweiseitige Beziehung zustande bringt oder auflöst:

void A::aufnehmen(B* b)
{
  A::registriere(b);
  b.eigentuemer(this);  
}

Solche Beziehungen (double dispatch) sind nicht nur moralisch bedenklich:

void A::unterwirft_sich(B& domina)
{
  domina.kommandiert(*this);  // Geheimnis als Prinzip! 
}

Die Technik tritt bevorzugt dort auf, wo es wirklich komplex und abstrakt wird. Sie ist mit Bedacht einzusetzen. Ein hoher Kohäsionsgrad der Komponenten behindert die Zerlegung des Entwurfs, erschwert die Implementierung, schränkt Testmöglichkeiten und Wandlungsfähigkeit ein. In großen Klassenbibliotheken, Anwendungsrahmen-Architekturen und zur Trennung von Datenmodell, Steuerung, Darstellung und Bedienoberfläche(n) kann das Vorschalten abstrakter Schnittstellen (in Entwurfsmustern wie Fabrik, Brücke, Fassade, Beobachter, Strategie, Memento [GoF]) nützlich sein.

Oftmals reicht jedoch eine einseitige Beziehung aus, ist aber im Klassendiagramm nicht markiert. Der Befehlsempfänger b einer Objekt-Hierarchie muss nichts über die Auftraggeber-Klasse A wissen, um seine Aufgabe zu erfüllen (Client-Server-Architektur):

void A::nutzt(B b)
{
  b.do_it();
}

Dabei kann b eine Wegwerf-Kopie sein, ein Verweis auf ein gemeinsam genutztes Objekt oder eine ganze Sammlung von Objekten (eine Server-Farm?).

Der Einfluss der Kardinäle

Irgendwo müssen die Vielheiten (0/1, 1, n, *) gekapselt werden. Welches Sprachmittel dazu eingesetzt wird, hängt ab von den Besitzverhältnissen, der Dauer der Objekt-Beziehung und davon, ob es sich um gleichartige oder um polymorphe Objekte handelt.

  • (1) Einzelne Bausteine sind als Objekt, Referenz und über (gültige) Zeiger handhabbar.
  • (0/1) Ist ein Objekt nur manchmal vorhanden, kann nur ein Zeiger eingesetzt werden. Vor jedem Zugriff ist zu prüfen, ob das Objekt da ist. Mehrere Objekte erfordern Felder oder Container von Objekten, Referenzen oder Zeigern.
  • (n) Die maximale Anzahl der Objekte ist bei statischen Feldern begrenzt.
  • (*) Dynamische Felder und Container können ihre Größe anpassen.

Dauerhaftigkeit von Beziehungen

Objektvariablen und Referenzen begründen lebenslange, dauerhafte Beziehungen zu genau einem Objekt. Zeiger und Container können sich von ihren Objekten trennen. Die Anzahl referenzierter Objekte ist damit veränderbar, sie kann sogar Null werden. Ist die Existenz von Objekten überhaupt fraglich, muss das vor jedem Zugriff geprüft werden, was wiederum Zeit kostet.

Besitzverhältnisse

Eigentum verpflichtet. Die Verantwortung für die referentielle Integrität liegt beim Besitzer der Objekte.

Über Zeiger angeforderter Speicher ist spätestens im Destruktor zurückzugeben. Dies ist offensichtlich. Gefährlich wird es, wenn die Klasse mit den Zeigern nicht regelt, was beim Kopieren und Zuweisen von Objekten geschieht. Dann hat man die Kontrolle über die Speicherbereinigung aufgegeben. Speichererschöpfung und Schutzverletzungen sind die Folge. Was zu geschehen hat, ist nicht einheitlich für alle Fälle festlegbar. Es gibt mehrere Ansätze: Verbieten, tiefes Kopieren (Klonen), flaches Kopieren (mit Referenzzählung), verzögertes Kopieren (lazy copy, copy on write).

Referenzzählende Zeiger und Container kapseln diese Verantwortung und entlasten ihre Nutzer. Das Programm wird so auch im Fehlerfall sicherer, wenn durch das Auslösen von Ausnahmen der normale Programmlauf unterbrochen werden muss.

Für die Gültigkeit von Referenzen ist nicht die Klasse, sondern deren Umgebung (der Nutzer) verantwortlich.

Gemeinsame genutzte Ressourcen

Nicht jeder muss alles alleine besitzen, nur um irgendwann mal damit umzugehen: Meine Kinder stritten sich, als sie kleiner waren, regelmäßig um ein Auto mit Beinantrieb, 10 Minuten später stand es achtlos herum. Es kann besser sein, Ressourcen nacheinander oder gemeinsam zu nutzen. Sogenannte "ausgebuffte" (engl. smart) Zeigerklassen können das Kopier- und Nutzungsverhalten kapseln. Die gemeinsame Nutzung kann sich auf Einzelobjekte oder auf Gruppen von Objekten beziehen:

std::vector<std:shared_ptr<B> > meine; // Menge von gemeinsam genutzten Bausteinen
std::shared_ptr<vector<B> > alle;      // gemeinsam genutzte Menge von Bausteinen

Es kommt auf die Formulierung an. Erkennen Sie den Unterschied?

Der Umgang mit Erbschaften

Objekte abgeleiteter Klassen sind nur über Referenzen und Zeiger vom Basistyp ansprechbar. Ein existenzabhängiges, nicht mit anderen gemeinsam genutztes polymorphes Objekt ist schwierig zu erhalten. Das Komposit kann einen polymorphen Baustein von außen erhalten, stösst aber auf Probleme, wenn es kopiert oder zugewiesen werden soll. Existenzabhängige polymorphe Objekte lassen sich nur dann typgenau kopieren, wenn die Basisklasse des Bausteins eine Art "virtuellen Konstruktor" besitzt. Sowas gibt es (in C++ und Java) zwar nicht, es kann aber über eine virtuelle clone()-Methode nachgebildet werden, die von jedem Baustein-Erben überschrieben werden muss. Der Rückgabetyp ändert sich dabei mit dem Klassennamen:

class B              // B = Baustein = Basis
{
public:
  virtual ~B() {}    // virtueller Destruktor - siehe nächste Seite
  virtual B* clone();
  // ...
};
 
class C : public B   // C = child = Kind
{
public:
  virtual C* clone(); // Rückgabetyp ist kovariant
  // ...
};
 
class A              // A = Aggregat, hier Komposit
{
public:
  A(B* bptr) : b(bptr) {}
  A(const A& original) : b(original.b->clone()) {} 
  A& operator=(const A& rhs)
  {
    if (this != &rhs)
    {
      A tmp(rhs);          // erzeuge Kopie  
      std::swap(b, tmp.b); // tausche alle (!) Attribute
    }
    return *this;
  }
  ~A() { delete b; }
  // ...
private:
  B* b;              // existenzabhängig
};

Am besten kapselt man das Kopierverhalten der Klasse A in einer ClonePtr-Klasse. Verzichtet man auf die "fette" Schnittstelle für die Bausteine B und deren Erben, bleibt die Möglichkeit der eigenen Kopie verschlossen. Dann sind Referenzen zu zählen. Auch das kann typunabhängig (allgemeingültig) von std::shared_ptr<T> erledigt werden.

Es kann gefährlich sein, von solchen polymorphen Objekten Objekt-Kopien vom Basistyp zu erzeugen. Solchen Objekten geht es dann wie Aschenputtels Stiefschwestern. Da das Basisobjekt meist weniger Speicherplatz zur Verfügung hat, werden Informationen abgeschnitten (object slicing) und die Objekte überlappen sich dann im Speicher: "Ruckedigu, Blut ist im Schuh!"

Echte Zerstörer

Werden polymorphe Objekte über Zeiger verwaltet, muss ihre Basisklasse über einen virtuellen Destruktor verfügen, damit beim Abbauen immer die tatsächlichen (virtual = engl. eigentümlichen) Massnahmen, vielleicht weitere Freigaben, ergriffen werden. Hat eine Klasse keinen solchen virtuellen Destruktor, ist sie für Vererbung nicht ernsthaft einsetzbar. Nichts und niemand hindert den Programmierer in C++, von einer Basisklasse ohne virtuellen Destruktor abzuleiten. Die Compiler geben nicht einmal einen Hinweis, dass ein solcher Destruktor erforderlich ist. Lediglich in größeren, länger laufenden Programmen verliert man nach und nach immer wieder Speicher, der nicht ans System zurückgegeben wird.

Zu allem Unglück steht der Hinweis auf die Notwendigkeit des virtuellen Basis-Destruktors in den (Lehr-)Büchern und Texten zur Objektorientierung in C++ sehr weit hinten, wenn überhaupt. Vorher müssen die Begriffe dynamische Speicherverwaltung, Klasse, Objekt, Methode, Konstruktor, Destruktor, Vererbung und virtuelle Methode erklärt sein. Bis das Verständnis soweit gediehen ist, hat der Autor oder der Leser die Geduld am Thema verloren und die ersten Hierarchien mit fehlerhaften Basisklassen in die Welt gesetzt. Das Stichwort "virtueller Destruktor" ist ein gutes Kriterium für die Qualität eines Lehrbuchs über C++:

class Basis
{
public:
  virtual ~Basis() {}  // <== virtual muss da stehen!
  // ...
};
 
class Abgeleitet : public Basis
{
  // ...
};

Prüfen Sie Ihre Bücher, wenn möglich vor dem Kauf! Ein schlechtes Beipiel liefert Jesse Liberty ("C++ in 21 Tagen" und "Jetzt lerne ich C++"). Er erwähnt virtuelle Destruktoren, verwendet aber in den daneben stehenden Quelltexten keinen — wozu auch, er gibt die Objekte überhaupt nicht frei… (Eigentlich gehört dieser Exkurs in einen Aufsatz über Vererbung, aber man kann es nicht oft genug sagen.)

Quellen

  • [Meyers] Scott D. Meyers: Effective C++. 50 ways to improve your programs. Addison-Wesley (1992).
  • [Booch] Grady Booch: Object-Oriented Design with Applications. Benjamin-Cummings (1991).

Weiterführende Literatur:

  • [Balzert] Helmut Balzert: Lehrbuch der Softwaretechnik. Spektrum (2000).
  • [GoF] Erich Gamma et al.: Entwurfsmuster. Addison-Wesley (1996).
  • [Herold] Herold, Klar, Klar: GoTo Objektorientierung. Addison-Wesley (2001).
  • [Horstmann] Cay S. Horstman: Object-Oriented Design in C++ and Java. Wiley (1997).
lernen/baustein.txt · Zuletzt geändert: 2016-12-28 12:52 (Externe Bearbeitung)