22 Von der UML nach C++ |
Dieses Kapitel behandelt die folgenden Themen:
Vererbung
Interfaces
Assoziationen
Multiplizität
Aggregation
Komposition
Die Unified Modeling Language (UML) ist eine weit verbreitete grafische Beschreibungssprache für Klassen, Objekte, Zustände, Abläufe und noch mehr. Sie wird vornehmlich in der Phase der Analyse und des Softwareentwurfs eingesetzt. Auf die UML-Grundlagen wird hier nicht eingegangen; dafür gibt es gute Bücher wie [Oe]. Hier geht es darum, die wichtigsten UML-Elemente aus Klassendiagrammen in C++-Konstruktionen, die der Bedeutung des Diagramms möglichst gut entsprechen, umzusetzen. Die vorgestellten C++-Konstruktionen sind Muster, die als Vorlage dienen können. Diese Muster sind nicht einzigartig, sondern nur Empfehlungen, die Umsetzung zu gestalten. Im Einzelfall kann eine Variation sinnvoll sein.
22.1 | Vererbung |
Über Vererbung als »ist ein«-Beziehung wurde in diesem Buch schon einiges gesagt, was hier nicht wiederholt werden muss. Sie finden alles dazu in Kapitel 6. Die Abbildung 22.1 zeigt das zugehörige UML-Diagramm.
In vielen Darstellungen wird die Oberklasse oberhalb der abgeleiteten Unterklasse dargestellt; in der UML ist aber nur der Pfeil mit dem Dreieck entscheidend, nicht die relative Lage. In C++ wird Vererbung syntaktisch durch »: public« ausgedrückt:
class Unterklasse : public Oberklasse { // ... Rest weggelassen };
22.2 | Interface anbieten und nutzen |
Abbildung 22.2 zeigt das zugehörige UML-Diagramm. Die Klasse Anbieter implementiert das Interface Schnittstelle-X. Bei der Vererbung stellt die abgeleitete Klasse die Schnittstelle der Oberklasse zur Verfügung. Insofern gibt es eine Ähnlichkeit, auch gekennzeichnet durch die gestrichelte Linie im Vergleich zum vorherigen Diagramm.
Die Ähnlichkeit wird in der Umsetzung nach C++ abgebildet: Anbieter wird von dem Interface SchnittstelleX1 abgeleitet. Um klarzustellen, dass es um ein Interface geht, soll SchnittstelleX abstrakt sein. Das Datenobjekt d wird nicht als const-Referenz übergeben, weil service() damit auch die Ergebnisse an den Aufrufer übermittelt. Ein einfaches Programmbeispiel finden Sie im Verzeichnis cppbuch/k22/interface.
class SchnittstelleX { public: virtual void service(Daten& d) = 0; // abstrakte Klasse virtual ~SchnittstelleX() = default; // virtueller Destruktor SchnittstelleX() = default; SchnittstelleX(const SchnittstelleX&) = delete; SchnittstelleX& operator=(const SchnittstelleX&) = delete; }; class Anbieter : public SchnittstelleX { public: void service(Daten& d) { // ... Implementation der Schnittstelle } };
Bei der Nutzung des Interfaces bedient sich der Nutzer einer entsprechenden Methode des Anbieters. Die Abbildung 22.3 zeigt das zugehörige UML-Diagramm.
Ein Nutzer muss ein Anbieter-Objekt kennen, damit der Service genutzt werden kann. Aus diesem Grund wird in der folgenden Klasse bereits dem Konstruktor von Nutzer ein Anbieter-Objekt übergeben, und zwar per Referenz, nicht per Zeiger. Der Grund: Zeiger können nullptr sein, aber undefinierte Referenzen gibt es nicht.
class Nutzer { public: Nutzer(SchnittstelleX& a) : anbieter(a) { daten = ... } void nutzen() { anbieter.service(daten); } private: Daten daten; SchnittstelleX& anbieter; };
Warum wird die Referenz oben nicht als const übergeben? Das kann je nach Anwendungsfall sinnvoll sein oder auch nicht. Es hängt davon ab, ob sich der Zustand des Anbieter-Objekts durch den Aufruf der Funktion service(daten) ändert. Wenn ja, zum Beispiel durch interne Protokollierung der Aufrufe, entfällt const.
22.3 | Assoziation |
Eine Assoziation sagt zunächt einmal nur aus, dass zwei Klassen in einer Beziehung (mit Ausnahme der Vererbung) stehen. Die Art der Beziehung und zu wie vielen Objekten sie aufgebaut wird, kann variieren. In der Regel gelten Assoziationen während der Lebensdauer der beteiligten Objekte. Nur kurzzeitige Verbindungen werden meistens nicht notiert. Ein Beispiel für eine kurzzeitige Verbindung ist der Aufruf anbieter.service(daten);. anbieter kennt durch die Parameterübergabe das Objekt daten, wird aber vermutlich die Verbindung nach Ablauf der Funktion lösen.
Die Abbildung 22.4 zeigt das UML-Diagramm einer einfachen gerichteten Assoziation.
Mit »gerichtet« ist gemeint, dass die Umkehrung nicht gilt, wie zum Beispiel die Beziehung »ist Vater von«. Falls zwar Klasse1 die Klasse2 kennt, aber nicht umgekehrt, wird dies durch ein kleines Kreuz bei Klasse1 vermerkt. Es kann natürlich sein, dass eine Beziehung zwischen zwei Objekten derselben Klasse besteht. Im UML-Diagramm führt dann der von einer Klasse ausgehende Pfeil auf dieselbe Klasse zurück. In C++ wird eine einfache gerichtete Assoziation durch ein Attribut zeigerAufKlasse2 realisiert:
class Klasse1 { public: Klasse1() : zeigerAufKlasse2(nullptr) { } void setKlasse2(Klasse2* ptr2) { zeigerAufKlasse2 = ptr2; } private: Klasse2* zeigerAufKlasse2; };
Ein Zeiger ist hier besser als eine Referenz geeignet, weil es sein kann, dass das Kennenlernen erst nach dem Konstruktoraufruf geschieht.
Die Multiplizität, auch Kardinalität genannt, gibt an, zu wie vielen Objekten eine Verbindung aufgebaut werden kann. In Abbildung 22.5 bedeutet die 1, dass jedes Objekt der Klasse2 zu genau einem Objekt der Klasse1 gehört. Das Sternchen * bei Klasse2 besagt, dass einem Objekt der Klasse1 beliebig viele Objekte der Klasse2 zugeordnet sind, also möglicherweise auch keins.
Im folgenden C++-Beispiel entspricht Fan der Klasse1 und Popstar der Klasse2. Ein Fan kennt N Popstars. Die Beziehung ist also »kennt«. Der Popstar hingegen kennt seine Fans im Allgemeinen nicht. Um die Multiplizität auszudrücken, bietet sich ein vector an, der Verweise auf Popstar-Objekte speichert. Wenn die Verweise eindeutig sein sollen, ist ein set die bessere Wahl.
class Fan { public: void werdeFanVon(Popstar* star) { meineStars.insert(star); // einfügen } void denKannsteVergessen(Popstar* star) { meineStars.erase(star); // entfernen. Rückgabewert ignoriert } // Rest weggelassen private: std::set<Popstar*> meineStars; };
Die Objekte als Kopie abzulegen, also Popstar als Typ für den Set statt Popstar* zu nehmen, hat Nachteile. Erstens ist es wenig sinnvoll, die Kopie zu erzeugen, wenn es doch das Original gibt, und zweitens kostet es Speicherplatz und Laufzeit. Es gibt nur einen Vorteil: Es könnte ja sein, dass es das originale Popstar-Objekt nicht mehr gibt, zum Beispiel durch ein delete irgendwo. Ein noch existierender Zeiger wäre danach auf eine undefinierte Speicherstelle gerichtet. Eine noch existierende Kopie könnte als Wiedergänger auftreten.
Eine ungerichtete Assoziation wirkt in beiden Richtungen und heißt deswegen auch bidirektionale Assoziation. Die Abbildung 22.6 zeigt das UML-Diagramm.
Wenn zwei sich kennenlernen, kann das mit einer ungerichteten Assoziation modelliert werden. Zur Abwechslung sei die Umsetzung in C++ nicht mit zwei, sondern nur mit einer Klasse (namens Person) gezeigt. Das heißt, die Klasse hat eine Beziehung zu sich selbst, siehe Abbildung 22.7. Solche Assoziationen werden auch rekursiv genannt und dienen zur Darstellung der Beziehung verschiedener Objekte derselben Klasse.
Die Umsetzung in C++ wird am Beispiel von Personen gezeigt, die sich gegenseitig kennenlernen. Ein Aufruf A.lerntkennen(B); impliziert, dass B auch A kennenlernt. Natürlich kann es vorkommen, dass es zwei Personen mit demselben Namen gibt, hier Frau Holle.
#include "Person.h" int main() { Person mabuse("Dr.˽Mabuse"); Person klicko("Witwe˽Klicko"); Person holle1("Frau˽Holle"); Person holle2("Frau˽Holle"); // eine Namensvetterin! mabuse.lerntkennen(klicko); holle1.lerntkennen(klicko); holle1.lerntkennen(holle2); mabuse.bekannteZeigen(); klicko.bekannteZeigen(); holle1.bekannteZeigen(); }
Die entscheidende Methode der Klasse Person ist lerntkennen(Person& p) (siehe unten). Beim Eintrag in die Menge der Bekannten wird festgestellt, ob der Eintrag vorher schon vorhanden war. Wenn nicht, wird er auch auf der Gegenseite vorgenommen. Wenn in der Menge der Bekannten nur die Namen als String gespeichert würden, könnten sich zwei Personen mit demselben Namen nicht kennenlernen. Deswegen werden die Adressen der Person-Objekte gespeichert (siehe Attribut set<Person*> in der Klasse unten).
#ifndef PERSON_H #define PERSON_H #include <iostream> #include <set> #include <string> #include <utility> class Person { public: Person(std::string name_) : name(std::move(name_)) {} auto getName() const { return name; } void lerntkennen(Person& p) { bool nichtvorhanden = bekannte.insert(&p).second; // siehe Hinweis im Text if (nichtvorhanden) { // falls unbekannt, auch bei p eintragen p.lerntkennen(*this); } } void bekannteZeigen() const { std::cout << "Die˽Bekannten˽von˽" << getName() << "˽sind:\n"; for (const auto& bekannt : bekannte) { std::cout << bekannt->getName() << ’\n’; } } private: std::string name; std::set<Person*> bekannte; }; #endif
Hinweis
Die Methode insert() eines Sets gibt ein pair-Objekt zurück. Das ist eine Struktur mit den Elementen first und second. second ist ein Wahrheitswert, der angibt, ob das Einfügen stattgefunden hat. Wenn nein, war das Element schon vorhanden. first ist ein Iterator auf das Element, ob gerade eingefügt oder schon vorhanden gewesen.
22.3.1 | Aggregation |
Die »Teil-Ganzes«-Beziehung (englisch part of ) wird auch Aggregation genannt. Sie besagt, dass ein Objekt aus mehreren Teilen besteht (die wiederum aus Teilen bestehen können). Die Abbildung 22.8 zeigt das UML-Diagramm. Die Struktur entspricht der gerichteten Assoziation, sodass deren Umsetzung in C++ hier Anwendung finden kann. Ein Teil kann für sich allein bestehen, also auch vom Ganzen gelöst werden. Letzteres geschieht in C++ durch Nullsetzen des entsprechenden Zeigers.
22.3.2 | Komposition |
Die Komposition ist eine spezielle Art der Aggregation, bei der die Existenz der Teile vom Ganzen abhängt. Damit ist gemeint, dass die Teile zusammen mit dem Ganzen erzeugt und auch wieder vernichtet werden. Ein Teil ist somit stets genau einem Ganzen zugeordnet; die Multiplizität kann also nur 1 sein. Formal ist auch 0 erlaubt. Für ein isoliertes Objekt ist jedoch der Begriff »Teil« nicht sinnvoll. Die Abbildung 22.9 zeigt das UML-Diagramm.
Es empfiehlt sich, bei der Umsetzung in C++ Werte statt Zeiger zu nehmen. Dann ist gewährleistet, dass die Lebensdauer der Teile an das Ganze gebunden ist:
class Ganzes { public: Ganzes(int datenFuerTeil1, int datenFuerTeil2) : ersterTeil(datenFuerTeil1), zweiterTeil(datenFuerTeil2) { // ... } // ... private: Teil ersterTeil; Teil zweiterTeil; };
1 Die UML erlaubt Bindestriche in Namen, C++ nicht.