Tutorial Softwareentwicklung3

Aus DGL Wiki
Wechseln zu: Navigation, Suche

Vorwort

Willkommen beim dritten und letzten Teil dieser Tutorialreihe.

Im Laufe der Zeit haben wir schon eine Menge gelernt und ihr habt vor allem schon eine Menge gelesen.

Dieses Tutorial basiert wie die ersten beiden Teile auf dem Buch "Visual Modeling with Rational Rose 2000 and UML" (ISBN 0201699613) von Terry Quatrani.


Wir werden uns diesmal mit Begriffen wie Vererbung, Zuständen und Konsistenz beschäftigen, bevor wir an das eigentliche Design der Software gehen. Abschließend werden wir noch einen Blick auf die Implementation selbst werden.


Vererbungshierarchien

Vererbung wird durch dreieckige Pfeilspitzen angezeigt, und zeigt immer zur Vorfahr-/Superklasse.

Vererbung ist wohl die hervorstechendste Eigenschaft der OOP. Aber was ist Vererbung?

Klassen erben von ihren Vorfahren alle Eigenschaften und das Verhalten (Methoden). Dabei existiert eine besondere Beziehung zwischen diesen Klassen, die auch als "Ist-Ein"-Beziehung bezeichnet wird. (Ein Laptop ist ein Computer. -> Laptop ist von Computer abgeleitet.) Diese Form der Beziehung ist keine Beziehung zwischen Objekten, und wird weder benannt noch mit Rollen oder Multiplizitätsindikatoren versehen.

Durch das Vererben entsteht eine so genannte Vererbungshierarchie, also eine Art Stammbaum. Da die Nachfahren alle Eigenschaften und Methoden erben definiert man Eigenschaften soweit oben im Stammbaum, wie möglich, damit alle Nachfahren diese ebenfalls besitzen. Vorraussetzung dafür ist eine durchdachte Hierarchie. Wenn dies nicht der Fall ist, werden einige Klassen der Hierarchie eine Menge Ballast in Form von nicht benötigten Methoden und Attributen mit schleppen. Klassen sollten jedoch möglichst einfach und übersichtlich gehalten werden. (Eine Klasse die Unmengen an Funktionen mitbringt sieht zwar vielleicht wichtig aus, hat aber nur den Effekt, dass der Programmierer der sie benutzt bald den Überblick darüber verliert, was die Klasse überhaupt leisten kann.)

Es gibt keine Beschränkung für die Tiefe einer Hierarchie. Die Praxis zeigt aber, dass meist nur zwischen 3 und 5 Stufen gebräuchlich sind (in C++). Die Anzahl der Stufen ist direkt abhängig von der Komplexität der Objekte in der Hierarchie. Falls die spezialisiertesten Elemente (die der untersten Ebene) sehr viele Eigenschaften besitzen kann eine feinere Gliederung der Hierarchie zweckmäßig sein. (Die Visual Component Library (VCL) von Delphi hat mindestens 6 Stufen, eventuell auch 7.)

Nachfolger in der Hierarchie bieten gegenüber ihren Vorgängern immer etwas neues. Niemals fehlt etwas. Das heißt, die Menge an Attributen und Methoden steigt, je weiter man in der Hierarchie absteigt. Das mindeste was eine Subklasse bieten sollte (wenn schon keine neuen Attribute bzw. Funktionen hinzukommen) ist ein geändertes Verhalten, also eine geerbte Methode mit neuem Inhalt zu überschreiben. Wenn selbst dies nicht der Fall ist, die Klassen also identisch sind, ist es logischerweise Sinnlos eine neue Hierarchieebene dafür einzuführen. (Es wird trotzdem recht häufig gemacht, und zwar immer dann, wenn absehbar ist, dass in Zukunft neue Eigenschaften hinzukommen werden.)

Vererbung ist der Schlüssel zur Wiederverwendbarkeit. Eine Klasse welche in einem Programm geschrieben wurde, kann als Basis einer spezialisierteren Klasse in einem neuen Programm benutzt werden.

Die UML-Notation für Vererbung ist eine durchgezogene Linie mit einem Pfeil zur Superklasse (zum Vorgänger, nicht zum Nachfolger!). Anstatt der symbolischen Darstellung für die Stereotypen der Klassen sollte man in Klassendiagrammen lieber auf die einfachere Darstellung mit einfachen Rechtecken zurückgreifen, wobei der Typ im Kopf der Klasse beschriebe ist.

Klassenbäume wachsen meist nach unten. Das heißt je weiter oben sich eine Klasse befindet, desto primitiver ist diese Klasse und desto weniger wurde sie bezüglich der Basisklasse verändert bzw. erweitert. Die "übergeordnete Ebene" ist also die Ebene der Vorfahren.


Es gibt zwei Methoden um Vererbungshierarchien zu erstellen: Generalisierung und Spezialisierung. Beide werden im Entwicklungsprozess eingesetzt.

Generalisierung

Bei der Generalisierung bildet man Superklassen welche Eigenschaften besitzen die mehrere andere Klassen gemein haben. Man hat also quasi die Nachfolger und konstruiert sich dazu den Vorgänger. Diese Methode wird überwiegend am Anfang der Analysephase eingesetzt, da zu diesem Zeitpunkt die existierenden Klassen Repräsentationen der realen Welt sind. Diese existieren ohne das auf Verwandtschaft zwischen ihnen geachtet wurde. Durch die Generalisierung schafft man nun quasi Bindeglieder zwischen den Klassen, und baut sich eine Hierarchie rückwärts auf.

Um diese Bindeglieder zu finden untersucht man die bestehenden Klassen auf Ähnlichkeiten in Verhalten und Attributen. Man sollte dabei auch die Augen nach Synonymen offen halten. Dabei ist die Schwierigkeit die, dass manches Verhalten auf den ersten Blick speziell wirkt, aber in Wahrheit auch bei anderen Klassen gefunden werden kann. Man sollte sich also von allzu kleinen Details nicht ablenken lassen.

Ein Beispiel: In einem Spiel existieren "Gebäude" und "Einheiten". Um sie unterscheiden zu können besitzen die einen eine "GebäudeID" die anderen eine "EinheitenID". Durch das Einfügen einer Superklasse "SpielObjekt" welche eine Eigenschaft "ID" hat, kann man beide Klassen in eine Hierarchie bringen. Wenn, aus welchen Gründen auch immer, die Form der ID sich unterscheidet (8Bit Integer vs. 64Bit Integer), sollte man die Klassen lieber getrennt lassen.


Spezialisierung

Spezialisierung geht genau den anderen Weg. Ausgehend von einer Superklasse findet man Nachfolger die sich nur durch neue Eigenschaften bzw. Verhaltensweisen von der Superklasse unterscheiden.

Dieses Vorgehen wird hauptsächlich angewandt um bestehende Klassen an neue Aufgaben anzupassen (Wiederverwendbarkeit).

Zu beachten ist, dass Subklassen niemals weniger Funktionalität haben dürfen als ihre Vorgänger. Dadurch würde das gesamte hierarchische Konzept sinnlos. (Viele Sprachen bieten dieses Feature, also die Verringerung von Klasseneigenschaften deshalb auch gar nicht an.)


Der Diskriminator

Dieses sehr böse klingende Wort hat einen ganz simplen Hintergrund: Um eine neue Subklasse zu erstellen muss es mindestens eine Eigenschaft in der neuen Klasse geben, welche in der Vorgängerklasse so nicht vorhanden war. Diese Eigenschaft, also die welche den Unterschied macht, nennt man den Diskriminator. Jede differierende Eigenschaft entspricht einem Diskriminator.

Jeder Diskriminator kann eine begrenzte Menge an Werten annehmen. Diese Werte geben dann vor, wie viele Subklassen von der Superklasse abgeleitet werden können.


Ein Beispiel: In einem Spiel existieren Einheiten. Ein möglicher Diskriminator könnte der Typ sein, also ob die Einheiten militärisch oder zivil sind. Es lassen sich also aufgrund dieses Diskriminators zwei potentielle Subklassen bilden: MilitärischeEinheit und ZivileEinheit.

Meistens gibt es aber nicht nur einen Diskriminator. Was macht man mit den anderen? Beispielsweise könnte man die Einheiten auch danach unterscheiden ob sie andere Einheiten aufnehmen/transportieren können oder nicht.

Fakt ist, dass ein einzelner Diskriminator schöner ist als mehrere. Was machen wir also? Wir wählen uns den aus, der aussagekräftiger ist, und die Nachfolger besser klassifiziert (Ein Wort aus der Biologie was hier durchaus passend ist). Eine Folge von solchen Diskriminator-Wahlen ergibt eine Hierarchie. Unterschiedliche Diskriminatoren die von einer Klasse ausgehen, kommen nicht in eine Hierarchie.

Aber was mache ich nun mit gleich guten Diskriminatoren?

Zwei grundsätzliche Lösungen gibt es für dieses Problem:

  1. Aggregation
  2. Mehrfachvererbung (Alternative: Interfaces)

Zuerst ein Wort zur Aggregation. Diese Form der Beziehung haben wir ja bereits kennen gelernt ("Teil-Ganzes"-Beziehung). Wie kann man mit dieser Beziehung das Diskriminator-Problem lösen? Ganz einfach. Die Eigenschaften welche zur Subklasse neu hinzugekommen wären, werden in eine Extraklasse außerhalb der Hierarchie verpackt und dann als Teil der "Bruderklasse" eingebunden. Es ist dadurch sogar möglich eine Hierarchie mit der Extraklasse als Ausgangspunkt aufzubauen.

Aggregation ist üblicherweise das Mittel der Wahl. Immer? Nein! Entscheidungen wie diese machen den Designprozess aus. Durch Analyse der Anforderungen und durch Analyse der Use-Cases bzw. Szenarien kann man mitunter absehen welche Methode funktionieren kann. Die Struktur des Modells hängt von derlei Entscheidungen ab. Wie gut der Designer die Anforderungen an die Klassen abschätzen konnte bestimmt damit, wie gut bzw. übersichtlich die Struktur des gesamten Codes einmal sein wird.


Mehrfachvererbung

Mehrfachvererbung scheint auf den ersten Blick der einfache Weg zu sein um das Diskriminator-Problem zu lösen. Und ja, das ist er auch. Doch leider ist er nicht ganz unproblematisch.

Bei der einfachen Vererbung hat jede Klasse genau einen Vorfahren. Bei der Mehrfachvererbung mehrere. In unserem oben genannten Beispiel könnte eine Einheit zum Beispiel militärisch sein, und andere Einheiten auf nehmen können. Diese Klasse, nennen wir sie einmal "MilTransporter" erbt von der Transport-Klasse und von der Militär-Klasse.

Was ist der Nachteil? Nicht selten gerät man in Namenskonflikte. Denn beide Vorfahren könnten mitunter ähnliche (nicht gleiche!) Eigenschaften mit dem gleichen Namen haben. Schlimmer noch. Es könnten redundante Eigenschaften geben. Also Eigenschaften die eigentlich das selbe bewirken sollen, aber unterschiedlich heißen.

Mehrfachvererbung führt auch zu weniger gut wartbaren Code. Denn je mehr ich Vorgänger habe, desto weniger kann ich nachvollziehen welche Eigenschaft woher kommt und vor allem was passiert wenn ich eine Eigenschaft ändere.

Die Auswirkungen sind nicht immer so drastisch. Dies hängt zum Großteil auch von der verwendeten Sprache ab. Viele Sprachen erlauben Mehrfachvererbung prinzipiell nicht.

Ich möchte dazu Terry Quatrani zitieren: "Use mulitple inheritance only when it is needed and always use it with care!" Auf deutsch: Nutzt Mehrfachvererbung nur mit Bedacht und wenn es nicht anders geht.


Interfaces

Mehrfachvererbung ist wie bereits erwähnt in den meisten Sprachen konzeptionell ausgeschlossen. Doch kein Sprachentwickler möchte die Nutzer seiner Sprache wirklich zu stark reglementieren. Dies ist wohl ein Grund wieso man Interfaces dazu benutzen kann, um Mehrfachvererbung zu "simulieren".

Alle Klassen welche das Personentransport-Interface implementieren bieten die Funktion getAnzahlSitze() an, ganz egal ob es sich um Pkws oder Flugzeuge handelt.

Was sind Interfaces?
Interfaces sind Schnittstellen zwischen abgetrennten Bereichen. Um dies etwas zu verdeutlichen muss man sich vorstellen wieso OOP überhaupt entwickelt wurde. OOP hat zum einen das Ziel die Daten mit den auf ihnen wirkenden Operationen zu bündeln, zum anderen, und das ist wohl noch wichtiger, die Daten vor unerlaubten Zugriffen zu schützen. Dieses Konzept wird als abstrakter Datentyp bezeichnet. Klassen haben bekanntlich einen öffentlichen Teil. Dieser Teil ist im weitesten Sinne durchaus auch ein Interface. Mit Interface bezeichnet man aber ein anderes sprachliches Konstrukt.

Interfaces sind eine Art virtuelle Klasse. Eine virtuelle Klasse besteht nur aus Funktionsköpfen und einfachen Variablen. "Echte" Klassen können beliebig viele solcher Interfaces implementieren (man spricht hier nicht von erben. Wieso erkläre ich später.). Aber was bedeutet implementieren? Es bedeutet, dass die "echte" Klasse den Rumpf zu den Funktionsköpfen enthält. Also, dass die Klasse die Funktionalität welche das Interface nach außen anbietet, tatsächlich auch verwirklichen kann.

Die Klasse PKW erbt nicht nur die Eigenschaften von Kraftfahrzeug sondern bietet auch die der beiden Interfaces an.

Was hat das für einen Vorteil. Man stelle sich einmal unsere Klasse "MilTransporter" vor. Anstatt wie vorhin per Mehrfachvererbung bauen wir uns diese Klasse aus der Basis-Einheitsklasse und 2 Interfaces "Militär" und "Transport". Nehmen wir nun eine andere, völlig fremde Klasse welche eine Eigenschaft vom Typ "Transport" hat. Diese andere Klasse interessiert sich also für alle Klassen welche das Interface "Transport" implementieren. "MilTransporter" könnte also an diese Eigenschaft andocken (einfach per Zuweisung ":=" bzw. "="). Die fremde Klasse könnte dann auf die Transportfunktionen zugreifen, weiß aber nichts von der Tatsache, dass die angedockte Klasse auch noch militärische Funktionen besitzt. Selbst wenn sie es "wüsste", sie käme nicht ran. Ein Interface stellt also sicher, dass die Klassen welche das Interface implementieren bestimmte Funktionalitäten anbieten. Die Umsetzung dieser Funktionen bleibt den Klassen überlassen.

Und wie simuliert man damit Mehrfachvererbung? Ganz einfach! Man gibt der "fremden Klasse" noch eine Eigenschaft vom Typ des "Militär"-Interfaces und dockt auch dort unseren Militärtransporter an. Nun hat die "fremde Klasse" Zugriff auf beiden Funktionsgruppen. Doch wie werden die Namenskollisionen verhindert? Dies ist üblicherweise Sprachabhängig. Meist wird einfach bei einem uneindeutigen Namen der Interfacetyp davor geschrieben (Klasse.Interfacetyp.Eigenschaft). Dadurch ist die Eindeutigkeit wieder gewährleistet.

Wenn die "fremde Klasse" auf alle Eigenschaften (die öffentlich sind) Zugriff haben soll, ist eine Aggregation wohl wieder angebrachter. Aber der Sinn von Interfaces sollte trotzdem klar sein.

Interfaces sind ein geniales Mittel um seinen Code zu strukturieren. Leider kennen viele Programmieranfänger Interfaces nicht, da es meistens erst in den späteren Tutorials vorgestellt wird - und diese werden weniger häufig gelesen. Wer Interfaces verstanden hat, kann Programme wie aus Bauklötzchen zusammenbauen. Es lohnt sich definitiv sich mit diesem Thema auseinander zu setzen.

Java ist z.B. eine Sprache die massiv auf Interfaces setzt, und gut damit fährt.


Feintuning

Nachdem oder während das Diagramm entsteht sollte man sich Gedanken darüber machen, ob das Diagramm nicht vereinfacht werden kann. Zum Beispiel sollten gleiche Eigenschaften bzw. Methoden von Klassen der gleichen Hierarchieebene lieber der übergeordneten Superklasse zugeordnet werden. Dies gilt natürlich nur dann, wenn Funktionsweise und Typ dieser Eigenschaften bzw. Methoden wirklich gleich sind. Wenn die ID einer Klasse nur Zahlen, und die ID einer Geschwisterklasse alphanumerische Zeichen enthalten kann sollte man sich nicht entschließen beide zusammen zu legen (eine String-Eigenschaft könnte dies locker schaffen), denn ganz offensichtlich sind beide IDs in der Realwelt auch unterschiedlich. Außerdem ist der Umgang mit Zahlen meist einfacher als mit Strings. (Nebenbei können Strings in manchen Sprachen massive Speicherfresser sein - aber das nur am Rande.)

Ebenso können Geschwisterklassen zu anderen Klassen Beziehungen haben. Sollten diese Beziehungen (die ja meist durch eine Eigenschaft vom Typ der fremden Klasse realisiert sind) auch in die Superklasse verlagert werden? - Unter Umständen ja. Man kann dies tun, wenn die Multiplizität für beide Geschwister gleich ist. Wenn dies nicht der Fall ist, kann man die größere Multiplizität annehmen, und dann über interne Prüfungen sicherstellen, dass die Vorgaben der Analyse im Design beachtet werden. Es kann aber bedeutend weniger Aufwand bedeuten, wenn man solche Beziehungen auf niedrigerem Level belässt. Auch hier hilft es wenn man sich den späteren Einsatz der Klasse anhand der gemachten Szenarios noch einmal durchdenkt. Erfahrung ist hier sicherlich die Eigenschaft welche einem Designer hilft das Richtige zu tun. Es gibt leider kein Patentrezept. Bei dem einen Fall ist das Zusammenfassen billig, im nächsten Fall wird man sich dafür hassen.


Zustände und Verhalten von Klassen analysieren

Use-Cases und Szenarios sind probate Mittel um das Verhalte von Klassen untereinander zu erkennen. Hin und Wieder ist es aber nötig auch das Verhalten in einem Objekt zu verstehen. Zustandsdiagramme (statechart diagrams) zeigen den Zustand eines Objekts und die Ereignisse bzw. Nachrichten welche Zustandswechsel hervorrufen. (Wer schon einmal mit OpenGL programmiert hat sollte wissen was eine Zustandsmaschine (Statemachine) ist. Hier sind die Objekte/Klassen selbst kleine Zustandsmaschinen.)

Zustandsdiagramme werden nicht für alle Klassen erstellt (Das wäre ein bürokratischer Overkill) sondern nur für die Klassen welche ein "bedeutsames" (significant) Verhalten aufweisen. (Es hierbei nicht um die Wichtigkeit der Klasse im System, sondern darum wie komplex das Verhalten ist. Wenn eine zentrale Klasse bei jedem Aufruf das selbe macht hat sie kein wirklich bedeutsames Verhalten. Wenn aber eine kleine Klasse am Rand je nach innerem Zustand andere Ausgaben produziert ist eine Dokumentation sicherlich hilfreich. Sonst: Ich kapier echt net wieso da oben der Aufruf funktioniert und hier unten net! ;) )

Zustandsdiagramme sind vor allem für die Überwachung von Kontrollklassen und Klassen nützlich, welche in einer Aggregation ("Teil-Ganzes"-Beziehung) den Part des "Ganzen" spielen.

Als Basis für diese Dokumentation kann es hilfreich sein Interaktionsdiagramme zu durchstöbern. Bei aller Dokumentation sollte nicht vergessen werden, dass man sich in der Analysephase befindet. Die Dokumentation bzw. die Diagramme sollen das "WAS" aufzeigen, nicht das "WIE".

Zustände

Zustände werden durch Rechtecke mit abgerundeten Ecke dargestellt.

Während des "Lebens" eines Objektes durchläuft es verschiedene Zustände (States). Somit sind Zustände eine Zeitabschnitt im Leben des Objekts. Der Zustand wird repräsentiert durch die Gesamtheit aller Werte (auch ob z.B. eine Beziehung zu einer anderen Klasse besteht oder nicht) inklusive der Tatsache ob ein Objekt etwas tut oder auf etwas wartet. Zum Beispiel könnte unser "MilTransporter" still stehen, oder in Bewegung, beladen oder leer oder beschädigt bzw. heil sein. Jeder dieser Zustände ist durch einen Wert oder eine Kombination von Werten definiert.

Ein Zustand wird als gültig bezeichnet, wenn er aus den Wertebereichen der Daten und den Beschränkungen dieser ableitbar ist. In Zustandsdiagrammen werden nur gültige Zustände verzeichnet. Bei der Implementierung ist dann sicherzustellen, dass nur diese Zustände eingenommen werden dürfen.

Weiterhin enthalten Zustandsdiagramme alle Nachrichten die ein Objekt sendet und empfängt. Szenarios repräsentieren einen Pfad durch ein Zustandsdiagramm. Die Zeitintervalle zwischen zwei Nachrichten entsprechen normalerweise einem Zustand. Deshalb ist es hilfreich Sequenzdiagramme zu untersuchen. Sucht dabei nach Lücken zwischen den Pfeilen. Dies deutet auf einen Zustand hin.

Man sollte nicht jede Wertänderung gleich als neuen Status interpretieren, auch wenn dies möglich wäre. Zustandsdiagramme werden erst dann aussagekräftig (=nützlich) wenn eine gewisse Abstraktion gegeben ist. Versucht deshalb ähnliche Zustände zusammen zu fassen, falls diese nicht das Verhalten der Klasse effektiv beeinflussen.


Zustandsübergänge

Zustandsübergänge (state transitions) nennt man den Wechsel zwischen 2 Zuständen. Aktionen aller Art können Auslöser sein für Zustandsübergänge. Es gibt zwei mögliche Wege einen Zustand zu wechseln: automatisch oder manuell (nonautomatic).

Automatisch wird ein Zustand gewechselt wenn der Sinn/Zweck des aktuellen Zustands erfüllt wurde. Es bedarf also keiner namentlich bekannten Aktion um den Zustand zu wechseln. Als Beispiel nehmen wir wieder unseren "MilTransporter". Angenommen er könnte 5 Einheiten aufnehmen, dann würde sich beim Zustieg der 1-4 Einheit der Zustand nicht ändern. Wenn aber die 5 Einheit zugestiegen ist, gilt der Transporter implizit als "voll".

Ein manueller Zustandswechsel erfolgt durch ein namentlich bekanntes Ereignis (entweder durch ein fremdes Objekt ausgelöst, oder von außerhalb des Systems kommend (Akteure)).

Es wird angenommen, dass beide Übergänge keine Zeit benötigen und nicht unterbrochen werden können. Zustandsübergänge werden durch Pfeile vom alten zum neuen Zustand angezeigt.

Spezielle Zustände

Wie bei den Aktivitätsdiagrammen gibt es auch in Zustandsdiagrammen Anfangs(/Start)- und End(/Stop)-Zustände. Intelligenter Weise sind die Symbole auch noch identisch. Somit muss man sich nichts Neues einprägen. Auch hier gilt: Ein Diagramm hat genau einen Startzustand." (Weil das Objekt in einem konsistenten Zustand starten muss.)

Endzustände kann ein Objekt mehrere haben.

Geht's auch genauer? - Details

Ein Zustandsdiagramm für die Fahrzeugbelegung. An den Kanten sieht man Detailangaben wie z.B. Wächterbedingungen.

Details bei Zustandswechsel

Zustandswechsel können eine Wächterbedingung (guard condition) besitzen oder mit Aktionen verbunden sein bzw. Ereignisse auslösen.

Aktion 
Als Aktion bezeichnet man die Handlung/Verhalten welche beim Zustandswechsel ausgeführt wird.
Ereignis 
Ereignisse sind Nachrichten die an andere Objekte der Anwendung verschickt werden.
Wächterbedingung
Eine Wächterbedingung ist eine Bedingung die erfüllt sein muss bevor eine Zustandswechsel tatsächlich durchgeführt werden kann. (z.B. das die fünfte(!) Einheit im Transporter ist.)

Diese Details schlagen sich meist als private Operationen der Klasse wieder. Deshalb wäre es schön, wenn man diese im Zustandsdiagram abbilden könnte. Die UML-Notation dafür seht ihr im rechten Bild. (Wächterbedingung in eckigen Klammern, und Ereignisse mit vorgestelltem Dach.)


Details von Zuständen

In Zuständen gibt es häufig Aktionen welche bei Betreten des Zustands ausgeführt werden sollen. Man nennt die Eingangsaktion (entry action). Genauso gibt es Aktionen welche beim Verlassen des Zustands ausgeführt werden sollen (exit action).

Man kann den Zuständen 3 verschiedene Aktionen zuweisen, und zwar Aktionen die beim Betreten, beim Verlassen oder innerhalb des Zustands ausgeführt werden sollen.

Was genau die Aktion macht ist variabel, und kann vom einfachen Setzen von Variablen bis zum Senden von Nachrichten/Ereignissen an andere Objekte gehen (Beachte hierbei die Markierung der fremden Klasse mit dem Dach ^).

Auch Aktivitäten schlagen sich als (private) Operationen in den Klassen nieder. Somit ist ein Auftauchen im Diagramm wünschenswert. UML stellt dazu folgende Schreibweise zur Verfügung (Zur besseren Erklärung wurden die Details nicht in obigen Diagramm untergebracht sondern gesondert dargestellt.):

Detailangaben von Zuständen. Hier wird bei Betreten und Verlassen jeweils eine Methode gerufen während bei der Verarbeitung die Methode einer Fremden Klasse gerufen wurde (=Nachricht).


Konsistenz

Wir wissen nun schon recht viel über unsere Software. Wir haben unsere Problemstellung umfassend analysiert und daraus unsere Klassen geformt. Aber sind die Klassen wirklich designed? Wurde ihre Eigenschaften und Methoden wirklich bewusst so gestaltet wie sie es sind?

Der Prozess der Homogenisierung hat die Aufgabe, innerhalb des Projektes ein einheitliches Design (vor allem einheitliche Bezeichner) durchzusetzen.

Dies ist vor allem dann wichtig, wenn mehrere Teams am Projekt arbeiten. Die Teams entwickeln ihre Projektteile ausgehend von den gefundenen Use-Cases (Siehe Tutorial_Softwareentwicklung1). Use-Cases werden aber in normaler Sprache beschrieben. Das heißt, dass ähnliche Vorgänge durchaus mit unterschiedlichen Begriffen bezeichnet werden konnten. Wie man sich leicht vorstellen kann, sind solche Synonyme eine latente Stolperstelle, wenn man etwas im Code des anderen Teams sucht, oder erweitern möchte.

Die Homogenisierung sollte nicht zu einem speziellen Zeitpunkt durchgeführt werden, sondern ständig. Vorraussetzung dafür ist, dass die beteiligten Teams nicht isoliert voneinander arbeiten sondern sich austauschen. Einige Bezeichnungen können vom Styleguide (Stilvorlage) vorgeschrieben werden, doch viele Bezeichner können verhältnismäßig frei gewählt werden. Es muss bei diesen geprüft werden, dass sich keine Redundanzen ins Projekt geschlichen haben.

3 Dinge können mit den Klassen bei der Homogenisierung passieren:

  • Kombination
  • Teilung
  • Löschung


Klassen kombinieren

Es kann passieren, dass die verschiedenen Teams identische Klassen erstellt haben, die sich nur im Namen unterscheiden.

Dies kann prinzipiell nur durch Überprüfen des gesamten Softwaremodells geschehen (model walk-throughs). Da die identischen Klassen für die Teams unterschiedlich bedeutend gewesen sein können, sind Unterschiede im Umfang der Klassen nicht ungewöhnlich. Das heißt, Klassen die auf den ersten Blick rein vom Umfang verschieden sind, könnten am Ende doch identische Sachverhalte beschreiben. Dies kann auch Methoden und Attribute betreffen.

Falls zwei solche Klassen gefunden werden, sollte man beide vereinigen und als Namen den benutzen, welcher näher an der Sprache des Nutzers ist.

Besondere Aufmerksamkeit sollte man den Control-Klassen (Siehe Tutorial_Softwareentwicklung2) zukommen lassen.

Anfangs wurde jedem Use-Case eine eigene Control-Klasse zugestanden. Das kann überflüssig sein. Auch hier sollte man ähnliche Klassen zusammen fassen. Die Ähnlichkeit ergibt sich aus der Ablauflogik (Was passiert wann).


Klassen teilen

Könnt ihr euch noch an die goldene Regel der Objektorientierung erinnern?

Jede Klasse hat genau eine Aufgabe die sie umfassen bearbeitet.

Viele Dinge die Anfangs wie eine abgeschlossene Sache aussehen haben am Ende doch noch Struktur und ein eigenes Verhalten. Solche Dinge sollten - müssen(!) - abgespalten werden.

Ein Beispiel: Eine Einheit kann eine Waffe tragen und für diese Munition besitzen.

Schon an dieser Formulierung sieht man, dass etwas nicht stimmen kann. Einheit ist eine Klasse. Was kümmert sich die Einheit um die Munition für eine Waffe?

Es wäre wesentlich flexibler die Waffe als eigene Klasse zu implementieren, und dieser den Munitionsstand mitzugeben. Man könnte plötzlich noch viel mehr mit dieser Klasse Waffe machen. Ein Zielfernrohr könnte die Präzision erhöhen, oder andere Munition die Durchschlagskraft. Nichts davon würde die Klasse Einheit belasten, und trotzdem wäre es da.

Die Klasse Einheit würde nur noch eine Eigenschaft Waffe haben, über die sie dann auf alle anderen Informationen zugreifen kann.

(Man sollte auch die neu entstandene Klasse Waffe noch einmal kritisch untersuchen, ob nicht auch sie mehr macht als sie soll. Eventuell könnte man wiederum eine neue Subklasse ausmachen, z.B. Magazin.)


Klassen löschen

Was!? Erst quäl ich mich durch die Analyse, und dann werf' ich die Klassen weg!?

Jein. Klassen bzw. Klassenkandidaten können aus dem Modell entfernt werden, wenn

  • sie keinen Inhalt (also weder Struktur noch Verhalten) haben.
  • sie nicht Teil eines Use-Cases sind.

Dies kann passieren, wenn man die "Substantiv-wird-Klasse"-Methode anwendet. Man findet nämlich meist viel mehr Klassenkandidaten als man später für die Szenarios tatsächlich braucht.

Auch hier sollte man Control-Klassen speziell unter die Lupe nehmen. Falls Control-Klassen wenig bis keine Verantwortung (wir erinnern uns: responsebility) haben, können diese gelöscht werden. Das trifft vor allem dann zu, wenn die Klassen so genannte pass-through-Klassen sind. Also einfach Anfragen einer Boundary-Klasse an eine Entity-Klasse weiter leiten. In solchen Fällen ist das Löschen unproblematisch. Sollte doch später noch einmal mehr Verhalten hinzugefügt werden, dann würde es keine Arbeit machen die Control-Klasse wieder zu erzeugen und einzufügen. (Dies gilt nicht für Klassen welche von mehreren Stellen im Code gerufen werden und quasi "Türsteher" für die Entity sind. Diese sollten wirklich nur gelöscht werden, wenn absolut sicher ist, dass sie nicht doch noch irgendwann Zusatzeigenschaften erhalten werden. Es macht nämlich verhältnismäßig viel Mühe alle Stellen wo die Entity verwendet wird auf den Controller umzustellen. Ein "Türsteher" hat zumindest den Vorteil, dass der Datenfluss zu einer Entity an einer wohl definierten Stelle vorbei kommen muss.)


Möglichkeiten der Konsistenzprüfung

Die Konsistenzprüfung wird benötigt, da sich die statische Sicht auf das System (Klassendiagramme) und die dynamische Sicht (Use-Case-Diagramme) gleichzeitig weiter entwickeln. Dabei ist es aber wichtig, dass beide Diagramme konsistent zueinander bleiben.


Die Konsistenzprüfung erfolgt wie gesagt nicht in einer speziellen Phase, sondern kontinuierlich.

Am besten wird diese durch ein kleines Team (maximal 5-6 Personen) realisiert welches aus Mitgliedern verschiedener Fachbereiche (Analysten, Designer, Kunden bzw. Kundenvertreter, Sachverständige und Tester) zusammengesetzt wird. Es stehen dabei folgende 3 Methoden zur Auswahl:

Szenariodurchlauf

Der Szenariodurchlauf (scenario walk-through) ist die bevorzugte Methode zum Konsistenzcheck. Dabei werden die wichtigsten (high-risk scenarios) Szenarios anhand von Sequenz oder Kollaborationsdiagrammen durchlaufen. Dabei wird geprüft ob tatsächlich jede Nachricht, die ja Verhalten der empfangenden Klasse darstellt, tatsächlich auch im Klassendiagramm wieder auftaucht. Weiterhin wird geprüft ob zwei interagierende Objekte tatsächliche eine Möglichkeit (entweder per Association oder per Aggregation) haben miteinander zu kommunizieren.

Vor allem wird geprüft, ob reflexive Beziehungen (Siehe Tutorial_Softwareentwicklung2) nötig sind, da diese in der Analyse leicht übersehen werden können.

Abschließend muss sichergestellt werden, dass jede Klasse im Klassendiagramm zumindest in einem Szenario wieder auftaucht. Ebenso muss geprüft werden, ob jede Operation einer Klasse in einem Szenario benötigt wird (abgesehen von Operationen die aus Vollständigkeitsgründen benötigt werden) und dass jedes Objekt eines Sequenz- oder Kollaborationsdiagramms auch als Klasse in einem Klassendiagramm wieder auftaucht.

Ereignisverfolgung

Die Ereignisverfolgung (event tracing) ist die zweite Methode des Konsistenzprüfung. Bei dieser Methode werden alle Nachrichten die in Sequenz- oder Kollaborationsdiagrammen verzeichnet sind dahingehend untersucht, ob die sendende Klasse tatsächlich die Nachricht auslöst, und das bei der empfangenden Klasse tatsächlich eine Operation für das Empfangen und Abarbeiten dieser Nachricht zuständig ist.

Weiterhin muss sicher gestellt werden, dass eine Assoziation bzw. Aggregation zwischen diesen Klassen im Klassendiagramm verzeichnet ist.

Abschließend muss noch geprüft werden, ob in einem eventuell vorhandenen Zustandsdiagramm für die empfangende Klasse die Nachricht verzeichnet ist.

Das ist deshalb wichtig, da die Zustandsdiagramm alle empfangenen Nachrichten enthalten müssen. (Da ja Nachrichten Zustandswechsel auslösen)

Dokumentationsprüfung

Die Dokumentationsprüfung (documentation review) ist der ungeliebteste Teil der Konsistenzprüfung. Obwohl, oder gerade weil er am wichtigsten für die teaminterne Arbeit und für den Kunden ist.

Wer jetzt fragt "Welche Dokumentation?" hat anscheinend die Tutorialreihe nicht gelesen. Denn wir wissen ja, dass so ziemlich Alles dokumentiert wurde. Und genau das wird jetzt geprüft:

  • Haben alle Klassen einen einzigartigen Namen?
  • Ist die Dokumentation bezüglich der Aufgaben der Klasse vollständig?
  • Haben alle Attribute und Methoden (inklusive aller Übergabeparameter) eine vollständige Dokumentation?
  • Wurde die für das Projekt vereinbarten Standards, Formatspezifiaktionen und Inhaltsregeln eingehalten?

Wird eine der Fragen mit Nein beantwortet, muss der Misstand entsprechend korrigiert werden.


Design der Systemarchitektur

Design? - Ich dachte das machen wir schon der ganzen Zeit...

Was haben wir bisher gemacht? Die ersten Schritte bestanden daraus, herauszufinden was überhaupt programmiert werden soll.

Danach haben wir analysiert welche Bestandteile die Software haben soll, und welche Bestandteile wann und wie zusammen arbeiten.

Die Kunst des Softwaredesigns ist es nun diese Bestandteile so zu strukturieren, dass der entstehende Code im besten Fall nicht nur leicht zu verstehen und damit zu warten ist, sondern auch (zumindest partiell) wieder verwertbar ist.


Softwarearchitektur

Was ist eigentlich Softwarearchitektur? Eine konkrete Definition fällt (vor allem den Beteiligten) schwer. Terry Quatrani faste deshalb kurz zusammen wozu Softwarearchitektur dient. 3 Punkte können herausgestellt werden:

"Softwarearchitektur dient dem fassen von strategischen Entscheidungen über

  • die Struktur und das Verhalten des Systems.
  • die Zusammenarbeit der Bestandteile des Systems.
  • die physikalische Zusammen-/Aufstellung des Systems.

Softwarearchitektur ist ein ziemlich kompliziertes Problem. Man beginnt damit schon in einer sehr frühen Phase während man die ersten Use-Cases analysiert und verbessert es iterativ.

Im Kern der Softwarearchitektur stehen immer wieder ausführbare Prototypen welche benutzt werden um zu zeigen, dass die Architektur funktioniert. In jedem Projektteam sollte es eine Person geben, welche den Architekturentwurf durchführt und eventuell von einem kleinen Team unterstützt wird. Der Architekt sollte nach Möglichkeit schon etwas Erfahrung mitbringen. Da dies aber vor allem im privaten Bereich meist nicht der Fall ist, sollten die Entscheidungsträger im Projekt sich zusammensetzen und gemeinsam die Architektur entwerfen. Was dabei zu beachten ist, wird nachfolgend erklärt.


Views

Die Softwarearchitektur ist nicht eindimensional. Soll heißen, man kann aus verschiedenen Richtungen an die Architektur herangehen, und wird jedes Mal eine andere Gliederung finden. Man unterscheidet im RUP 5 verschiedene Sichten auf ein Projekt: Die logische Sicht, die Use-Case Sicht, Implementations-Sicht, Ablauf-Sicht und die Aufstellungs-Sicht. Hauptaufgabe ist es nun diese verschiedenen Gliederungen so unter einen Hut zu bekommen, dass keine der Sichtweisen völlig verloren geht.

In Projektmanagementsoftware wie z.B. "Rational Rose 2000" sind diese Sichten einzeln vorhanden, und man kann in diesen tatsächlich sein Projekt spezifizieren. UML Editoren bieten nur die logische Sicht an und es liegt an den Entwicklern die anderen Sichten mit dieser in Einklang zu bringen.


Die logische Sicht

Die erste Sichtweise ist die so genannte "logische Sicht". Diese Sicht beschreibt die Funktionen der Software. Die logische Architektur wird in Klassendiagrammen hinterlegt. Das meiste was wir in der Tutorialreihe bisher gemacht haben, bezog sich auf die logische Sicht.

Diese Sichtweise wird schon früh in der Ausarbeitungsphase begonnen, nämlich als man anfing Klassen und Pakete zu definieren. Mit der Zeit wächst diese Sicht da neue Klassen und Pakete hinzukommen welche die Entscheidungen widerspiegeln die man bezüglich der Schlüsselmechanismen in der Software getroffen hat.

Ein Schlüsselmechanismus ist eine Art Grundsatzentscheidung die man bezüglich bestimmter Probleme fällt. Man legt quasi die Vorgehensweise, Praktiken und Standards fest die im System gelten sollen. Die Auswahl dieser Mechanismen wird auch als taktisches Design bezeichnet.

Einige Schlüsselmechanismen machen es notwendig zu entscheiden welche Programmiersprache, Datenspeicherung, Fehlerbehandlungsmechanismen oder das GUI-Design ("Look and Feel") benutzt werden soll. Zu diesem Zweck gibt es so genannte Patterns (quasi ein Mustercode). Wir kommen später noch einmal darauf zurück.


Der UseCase-View

Diese Sicht bedarf eigentlich keiner weiteren Erklärung, da sich ja der erste Teil des Tutorials ausschließlich mit der Sicht auf die Use-Cases beschäftigte.

Der UseCase-View zeigt also einen Überblick der Funktionalität des Systems - eben die Anwendungsfälle.


Die Implementationssicht

Die bisherige Sichtweise (die logische) hatte nichts mit dem zu tun was später einmal in eurem Projektordner liegen soll. Ihr habt zwar die Klassen und Pakete aber über die physische Organisation wurde noch nichts ausgesagt. Genau dazu ist die Implementationssicht gedacht.


Auch in dieser Sichtweise gibt es Pakete. Diese haben sogar die selbe UML-Notation wie Pakete in der logischen Sicht, allerdings bedeuten sie etwas anderes. Pakete in der Implementationssicht unterteilen eure Software physisch. Man muss sich ein Packet quasi wie ein Verzeichnis vorstellen, indem dann Quellcodefiles liegen. (Genau so wird es übrigens in Java gemacht.)

Eine typische Schichtstruktur

Die Pakete selbst sind hierarchisch organisiert. Man erhält dadurch eine Schichtstruktur wobei jede Schicht eine wohl definierte Schnittstelle zu den höher liegenden Schichten hat. Im Bild rechts sieht man eine häufig vorkommende Schichtstruktur.

Die UML-Notation für Komponenten

Da die Pakete Behälter sind, muss es natürlich auch noch etwas geben, was dort hineingehört. Das sind die so genannten Komponenten. Diese stellen physische Quellcodefiles dar (*.pas oder *.h und *.cpp oder *.java oder ...). In Sprachen wie C++ und Java ist die Abbildung von Klassen auf Komponenten meistens 1 zu 1. Das heißt, eine Klasse steht in einem Quellcodefile. Es ist allerdings auch möglich mehrere Klassen in einer Komponente zusammenzubringen. Das ist vor allem dann sinnvoll, wenn die Klassen eng miteinander zu tun haben (z.B. Container und zugehöriger Iterator).


Die Ablaufsichtsicht

Die Ablaufsicht (engl: Process View) beschreibt das System zur Laufzeit. Auch in dieser Sicht werden Komponenten verwendet, allerdings beschreiben diese nun ausführbare Dateien, oder Bibliotheken.

Eine Komponente die über eine API auf eine DLL zugreift.

In dieser Sicht kann man verdeutlichen welche Abhängigkeiten für die ausführbaren Dateien bestehen. Im Bild rechts sieht man zum Beispiel, dass das Hauptprogramm über eine API auf eine DLL zugreift. (Die API könnte hier eine oder mehrere Klassen sein welche bestimmte Funktionen aus der DLL rufen.)

Interfaces (im Bild die API) werden mittels der so genannten Lolipop Notation verdeutlicht.

Die Ablaufsicht wird vor allem dann wichtig, wenn mehrere ausführbare Dateien für eine Software existieren (für verschiedene Nutzergruppen, wie z.B. Admin, Normalo-User).


Die Aufstellungssicht

Die Aufstellungssicht (Deployment View) ist für Spiele meist weniger von Bedeutung. Diese Sicht zeigt wie die Software über z.B. ein Unternehmen verteilt ist. Also welche Softwareteile in welchen Abteilungen oder an welchen Standorten positioniert sind. Die Softwareteile werden als Knoten (engl: nodes) dargestellt (die allerdings ziemlich nach Kisten aussehen), welche untereinander Verbunden sind. Diese Verbindungen zeigen Kommunikationswege zwischen den Knoten an. D.h. wenn zwei Knoten miteinander verbunden sind, dann muss es auch eine reale physikalische Verbindung zwischen diesen geben.

Diese Sicht spiegelt vor allem Entscheidungen wieder welche die Zuverlässigkeit und Erreichbarkeit (reliability, availability) der Komponenten betrifft. Das heißt, wenn erwartet wird, dass sehr viel Kommunikation zwischen bestimmten Knoten herrschen wird, kann man das in dem Diagram entsprechend vermerken.


Schritt für Schritt - Iterationen

Der gesamte bisher besprochene Prozess führt nicht in einem Schritt zum fertigen Programm. Um dahin zu kommen werden all diese Abläufe mehrfach durchgeführt werden müssen. Man spricht von so genannten Iterationen.

Wenn man Verantwortung für ein Projekt inne hat, hat man auch die Aufgabe diese Iterationen zu planen. Dabei geht es darum Ziele zu definieren welche am Ende einer Iteration erreicht worden sein müssen (Milestones / Meilensteine) und auch wie man diese Ziele erreichen will. Zu diesem Zweck stellt man einen so genannten Iterationsplan auf.


Der Iterationsplan

Der Iterationsplan ist ein Werkzeug des Projektmanagements. Dieser Plan definiert die zu erreichenden Ziele und die Produkte die eine Iteration liefern soll.

Ziele

  • Funktionen, Eigenschaften oder Pläne die zu entwickeln sind.
Dies betrifft echten Code genauso wie Ablaufpläne und Diagramme.
  • Risiken die während des Prozesses bewertet bzw. ausgeräumt werden sollen.
Alle Aufgaben werden danach bewertet wie Kritisch (wichtig) sie für ein Projekt sind. Man muss dann sicherstellen, dass die Aufgaben mit dem größten Risiko tatsächlich geschafft werden können. (Sollte dies nicht möglich sein, kann man entweder den Funktionsumfang entsprechend ändern, oder aber, wenn dadurch der Sinn der Software verloren geht, das Projekt einstellen.)
  • Defekte/Bugs die während dieser Iteration behoben werden sollen.
Diese sind meist Produkte vorangegangener Iterationsschritte und wurden z.B. durch Tests entdeckt.

Produkte

  • Aktualisierte Funktionsinformationen
Irgendwo sollte/muss verzeichnet sein, was die Software bereits kann. Dies ist vor allem wichtig um den Kunden über Fortschritte zu informieren.
  • Aktualisierter Risikobearbeitungsplan
Durch Prototypen und Risikoanalysen kann das Risiko einiger Aufgaben neu eingeschätzt werden. Der Risikobearbeitungsplan ist vor allem für den nächsten Iterationsschritt wichtig.
  • Ein Release-Description-Dokument
Das RDD beschreibt detailliert welche Funktionen neu hinzukamen bzw. was sich seit dem letzten RDD getan hat.
  • Ein Iterationsplan für den nächsten Iterationsschritt
Dieser sollte auch messbare Kriterien für den Fortschritt des Projektes festlegen um den Projektfortschritt festzustellen.
Der Iterations-Planungsprozess.

Um eine Iteration zu planen werden vor allem die Szenarios herangezogen die man aus den Use-Cases entwickelt hat. Diese werden nach Wichtigkeit (für den Kunden bzw. für das Projekt) sortiert und nach ihrem Risiko bewertet. Um dies Durchzuführen sollte man ein kleines Team zusammenstellen welches aus

  • einem Architekten (der herausfindet welche Szenarios für das Projekt wichtig sind und welche das größte Risiko bergen),
  • einem Domain-Experten und Analysten (also jemanden der weiß welche Funktionen für das Anwendungsgebiet/den Auftraggeber am wichtigsten sind),
  • und einigen Testern besteht (da diese die Testfälle entwickeln müssen).

Die riskantesten Aufgaben werden immer zuerst bearbeitet, da dadurch sichergestellt werden kann, dass Probleme frühzeitig angegangen werden.

Der Prozess der Iterationsplanung kann wie im Bild rechts dargestellt werden.


User Interface Design

Wir haben bereits vor einiger Zeit Platzhalterklassen für die Boundary-Klassen erstellt. Nun ist es an der Zeit diese zu designen. Sequenz-Diagramme sind eine gute Quelle um die Anforderungen für diese Klassen zu erhalten. Irgend etwas im User Interface muss die Nachrichten die an die Boundary-Klasse gehen entgegennehmen (anzeigen). Ebenso muss es etwas geben welches die Nachrichten von der Boundary-Klasse aus losschicken kann.

Man nimmt sich also ein Sequenzdiagramm eines Szenarios und schaut was das Szenario an Ein- und Ausgabemöglichkeiten fordert. Dann erstellt man die dazugehörenden Klassen.

In Businessapplications verwendet man zum erstellen meist einen GUI-Builder. Nachdem man das Design der GUI abgeschlossen hat kann man per reverse engineering diese Klassen in die bestehenden Designdokumente aufnehmen.

Es sollte auch beachtet werden, dass nicht aus jedem Szenario eine Boundary-Klasse entsteht. Wenn mehr Szenarios ausgewertet werden, ist es durchaus nicht ungewöhnlich, wenn die neuen Funktionen mit in eine bestehende Boundary-Klasse untergebracht werden.

Spieleprogrammierer verwenden sehr viel Zeit auf das Gestalten der Oberfläche - zurecht. Aber auch für sie ist das Auswerten der Szenarios hilfreich, denn so kann man erkennen, welche Funktionalität die Oberfläche überhaupt anbieten muss. Es gibt andere Softwareentwicklungsprozesse bei denen man rückwärts entwirft. Dort wird zuerst die GUI samt Handbuch geschrieben (und eventuell dem Kunden als Prototyp zum Test übergeben), und dann von dort aus die darunter liegenden Controller und Entitäten erstellt.

Durch das erstellen der GUI-Klassen werdet ihr vielleicht weitere Benötigte Klassen finden. Z.B. Passwortvalidatoren, Plausibilitätsprüfer etc. Diese Klasse werden entsprechend in den Klassendiagrammen ergänzt.


Patterns

An dieser Stelle im Buch wird auf Patterns (Entwurfsmuster) hingewiesen. Der ein oder andere von euch hat bestimmt schon davon gehört. Für die anderen hier eine kurze Erklärung. Das Thema an sich ist aber zu umfassend um es hier abzuhandeln. Tatsächlich gibt es einige Bücher zu diesem Thema. Das Standardwerk ist von der "Gang of Four" (E. Gamma et al.) und trägt den Namen "Design Patterns: Elements of Reusable Object-Oriented Software" (ISBN: 0-201-63361-2).

Beim Programmieren treten immer wieder die selben Designprobleme auf. Z.B. könnte ich einen(!) Controller brauchen, der alles koordiniert, und der wirklich nur als eine Instanz im gesamten Programm vorkommt. Wie mach ich das? Die Lösung hierfür wäre das "Singleton Pattern". Patterns sind Codetemplates welche die benötigte Struktur vorgeben. Man muss im Grunde dann nur noch die Klassennamen austauschen und kann dann die Funktionalität entsprechend nutzen. Das gute an Patterns ist, dass sie

  1. keine lange Denkphase erfordern, denn sie sind schon fertig designed.
  2. erprobt sind, und deshalb tatsächlich das bieten was gefordert ist.
  3. sie einem neuen Teammitglied vielleicht bekannt vorkommen, und er nicht erst eigenwillige Lösungsansätze studieren muss.

Es lohnt definitiv sich einmal mit diesem Thema auseinander zu setzen.


Design der Beziehungen zwischen Klassen

Navigation

Bisher wurden Beziehungen (Assoziationen und Aggregationen) als bidirektionale Beziehungen in das Modell aufgenommen. Während des Designs prüft man nun, ob dies so wirklich notwendig ist. Nach Möglichkeit wird versucht die Beziehung unidirektional zu gestalten. Das hat einen einfachen Grund: unidirektionale Beziehungen sind einfacher zu implementieren und zu pflegen.

Zugehörigkeit

Darstellung der beiden Zugehörigkeitsarten.

Aggregationen (A ist Teil von B) bedürfen einer zusätzlichen Überarbeitung. Man unterscheidet nämlich "Zugehörigkeit durch Wert" (by value) und "Zugehörigkeit per Referenz" (by refernce). Beim ersteren (durch Wert) wird die aggregierte Klasse exklusiv von einer Klasse besessen. Dies wird durch einen ausgemalten "Diamanten" am Ende der Beziehungslinie dargestellt. Bei der Zugehörigkeit durch Referenz hat die besitzende Klasse nicht das alleinige "Besitzrecht". D.h. mehrere Klassen verwenden die aggregierte Klasse. Diese Form der Beziehung wird durch einen leeren "Diamanten" am Ende der Beziehungslinie dargestellt.

Assoziationen verfeinern

Darstellung einer Abhängigkeit.

In manchen Fällen können Assoziationen auch noch in einen anderen Typ umgewandelt werden: Die Abhängigkeit (engl. dependency). Eine Abhängigkeit zeigt an, dass die Klasse welche einen Dienst benutzt, den Anbieter des Dienstes nicht direkt kennt, sondern diesen entweder per Funktionsparameter oder lokale Variable erhält. (Zum Vergleich: Assoziationen sind direkt in der Klassenstruktur hinterlegt) Dieser neue Beziehungstyp wird durch einen gestrichelten Pfeil dargestellt.

(Noch mal zum Verdeutlichen: Ist direkt in der "Klasse A" eine Eigenschaft (in Delphi property) vom Typ "Klasse X" dann ist dies eine Assoziation. Wird in einer Funktion der "Klasse A" per Parameter oder als Rückgabewert von irgendeiner Funktion die "Klasse X" zurückgeliefert ist das "nur" eine Abhängigkeit.

Multiplizität

Wie bereits angesprochen können Beziehungen mit verschiedenen Multiplizitäten angegeben werden. In der Praxis bedeutet dies, dass Beziehung mit der Multiplizität von 1 als eingebettetes Objekt, als Referenz oder als Pointer umgesetzt werden kann. Hat man es mit Multiplizitäten größer 1 zu tun wird meistens eine Containerklasse wie z.B. Vector oder List verwendet. (Um diesen Container zu referenzieren stehen wieder die Möglichkeiten zur Verfügung wie bei Multiplizität-1-Verbindungen.)

Ob man die (Standard-) Container in den Diagrammen zeigt ist projektabhängig. Wenn es nicht aus bestimmten Gründen nötig ist, würde ich es weglassen, da sonst das Modell schnell zu unübersichtlich wird. Selbsterstellte Container würde ich hingegen schon eintragen.


Design der Attribute und Operationen

Während der Analyse haben wir uns damit begnügt nur die Namen von Attributen und Operationen zu spezifizieren. In der Designphase bestimmt man für die Attribute nun Datentypen und Anfangswerte. Für dir Operationen werden die Signaturen bestimmt (Die Signatur einer Funktion besteht aus der Sichtbarkeit (private, public, ...), dem Namen der Funktion, dem Rückgabewert und den Parametertypen).

Attribute sollten eigentlich immer "private" sein und über Getter- und Setter-Methoden zugegriffen werden. Falls die Klasse Teil einer Vererbungshierarchie ist sollte man sie eventuell als "protected" deklarieren. Falls ihr mit den Begriffen protected, private und public nichts anfangen könnt, solltet ihr euch darüber informieren, da Sichtbarkeiten ein wichtiger Teil moderner OO-Sprachen sind.


Design der Vererbung

Die während der Analyse aufgestellten Vererbungshierarchien bezogen sich auf die tatsächlich gefunden Klassen(-kandidaten). Diese Hierarchien werden jetzt einer Verfeinerung unterzogen welche 3 Ziele verfolgt:

  • Wiederverwendbarkeit sicherstellen bzw. verbessern
  • Einbauen von Design-Level-Klassen (Klassen welche die logische Struktur und damit die Erweiterbarkeit verbessern)
  • Einbauen von Klassen der gewählten Bibliotheken.

Ziel ist es gemeinsame Eigenschaften in der Hierarchie "nach oben" zu bringen, ohne aber anderen Klassen der Hierarchie zu viel Ballast mitzugeben. Deshalb werden Designklassen, quasi als Zwischenetage, in die Hierarchie eingeführt. Man sollte sich auch Gedanken darüber machen, ob man nicht manche Klassen "abstact" definieren möchte. Wer dieses Schlüsselwort nicht kennt sollte sich auch dazu belesen. Ein Ziel dieser Arbeiten ist es den Testaufwand zu minimieren. Eine zentral liegende Funktion muss nur einmal getestet werden auch wenn sie von 100 Klassen geerbt wird.

Den Code abrunden

Es sollten nun noch die üblichen nötigen Sprachbestandteile wie Kontruktoren, Destruktoren und dergleichen eingebaut werden. An dieser Stelle wird im Buch darauf hingewiesen, dass dies viel Arbeit ist, und deshalb Rational Rose dies automatisch machen kann. Es gibt mittlerweile bestimmt auch kostenlose Tools die eine derartige Unterstützung bieten.


Codieren, Testen und Dokumentieren der Iteration

Eine Iteration wird komplettiert durch die Implementation der Methoden (method bodys). Dies wird unterstützt durch Diagramme z.B. Sequenz- oder Kollaborationsdiagramme, denn diese zeigen ja wer welche Informationen mit wem austauscht.

Terry Quatrani weist an dieser Stelle darauf hin, dass das Testen nicht zur Sprache kam, obwohl es ein sehr wichtiger Bestandteil ist. Das Testen ist eine Kunst für sich. Ich gebe hier ganz klar zu, dass ich keinerlei Erfahrung und wissen bezüglich strukturierter Tests habe. Große Firmen wie IBM leisten sich ganze Testabteilungen welche Software gezielt testen. Um derartig vorzugehen muss der Test entsprechend geplant werden. Wie man so etwas macht, sollte man in der passenden Zusatzliteratur nachlesen. Fakt ist, dass die Iteration am Ende getestet werden soll. Dabei auftauchende Fehler werden in der nächsten Iteration behoben.

An dieser Stelle möchte ich allerdings noch auf ein anderes Testtool hinweisen, welches in der Javawelt verwendet wird. JUnit ist ein Unit-Test-Tool. Damit kann man hervorragend die Funktionen einzelner Klassen testen. Dabei erstellt man eine Testklasse für jede zu testende Klasse in welcher man Testfälle definiert und dann prüft ob die Klasse die erwarteten Ergebnisse wirklich liefert. Hat man eine Menge solcher Testklassen und Fälle zusammen kann man immer wieder zwischendurch diese Tests laufen lassen, um zu prüfen, dass gemachte Änderungen nicht den Ablauf der Funktionen verfälscht haben. Anfänglich kosten diese Test einfach nur Zeit. Aber je größer das Projekt wird, desto mehr Zeit spart man, wenn man diese Testfälle schnell mal durchlaufen lassen kann. JUnit ist nicht allzu kompliziert. Ich vermute, dass es entsprechende Testbibliotheken auch für andere Sprachen als JAVA gibt.


Reverse Engineering

Durch die Änderungen im Code kann und wird es vorkommen, dass Dinge anders gelöst wurden als ursprünglich im Modell dargelegt. Da das Modell aber dazu dienen soll das Verständnis vom Code zu verbessern muss das Modell aktualisiert werden. Es wäre recht aufwändig alle im Code gemachten Abweichungen zu finden. Deshalb gibt es "Reverse Engineering" welches von immer mehr freien UML-Modellern unterstützt wird. Dabei generiert das Tool aus dem Quellcode die Modelle. Gute Tools lesen dabei nicht nur die Abhängigkeiten und den Aufbau der Klassen aus, sondern auch die im Code gemachte Doku (z.B. Javadoc bzw. Doxygen-Dokumentation). Einige Tools (meist kommerzielle) können sogar Sequenzdiagramme erstellen und den Datenfluss abbilden.

Wenn ihr euch jetzt fragt "Wenn ich mit Reverse Engineering die Modelle erstellen kann, wieso soll ich mich dann erst mit den Modellen aufhalten und fang nicht gleich zu coden an? Die Tools machen dann die Doku." - Na wieso?

Ganz einfach. In diesem Tutorial ging es darum, euch einen Weg zu besser strukturierten Projekten bzw. Codes zu zeigen. Wenn ihr eure Projekte schreibt wie bisher und dann einfach nur paar Modelle draus zaubert habt ihr gar nichts gekonnt. Vielleicht findet ihr euch in den Fitz schneller rein, aber die Fehlersuche, Erweiterbarkeit und Wartbarkeit bleibt unverändert mäßig. Das Bauen der Modelle gibt euch Zeit vorher zu überlegen was ihr machen werdet. Wenn man einfach drauf los coded kommen einem immer "tolle Ideen" wie man was machen könnte. Diese Ideen sind vielleicht wirklich toll, nur leider passen sie nicht zu andern tollen Ideen im Programm, und über kurz oder lang wird aus diesen tollen Ideen ein toller Schlamassel. Das ist nebenbei noch etwas was man z.B. in den Softwaretechnologievorlesungen an der Uni lernt: "Schreibt nur das in den Code was gefordert ist. Extras und Zusatzfeatures stehen ganz hinten auf der Prioritätsliste.". Diese Haltung hat zwei Gründe.

  1. Ein eventueller Kunde bezahlt für die garantierten Features, nicht für die zusätzlichen. Die Zeit für die Umsetzung von Projekten ist immer zu kurz als das man noch Extras einbauen kann.
  2. Das Design wurde ohne die Zusatzfeatures erstellt. Niemand kann wissen, ob dadurch nicht irgendein wichtiger Codeteil leidet.


Nachwort

Ich hoffe dieser Teil des Tutorial war wirklich kürzer als der 2.Teil - aber man will ja auch nichts vergessen. ;)

Ihr habt mit der Tutorialreihe die Prinzipien des RUP (Rational Unified Process) kennen gelernt. Er ist ein iterativer, modellgetriebener Prozess. Es gibt durchaus auch andere Herangehensweisen. Z.B. werden bei einem anderen Prozessen zuerst die Bedienungsanleitung geschrieben, dann die Oberflächen passend gestaltet und dann der Rest. Ihr seht, es gibt nicht "die Wahrheit" beim Programmieren. Ich würde es als Erfolg bezeichnen, wenn ihr bei eurem nächsten Projekt zumindest mit Papier und Bleistift anfangt euch Gedanken zu machen wie Teile miteinander arbeiten sollen. Wenn ihr es dann tatsächlich schafft ein UML-Modell zu erstellen und aktuell zu halten wäre das schon ein Quantensprung gegenüber dem einfachen "Draufloshacken".

Was bei dem Tutorial leider nicht so gut rüber kam ist, dass alle genannten Abläufe nicht einmal sondern immer wieder ablaufen. Dies wurde zwar mehrfach angesprochen, ist aber schwer zu verstehen. Stellt euch das ganze so vor. Wenn ihr mit dem Projekt anfangt, zerlegt ihr es nach Wichtigkeit (Risiko) in verschiedene Teile. Ihr durchlauft den ganzen Prozess dann für den wichtigsten Teil. Am Ende habt ihr einen Prototyp der nur Weniges kann und auch das noch nicht vollständig. Aber ihr habt die wichtigsten Sachen fertig. Dann macht ihr das nächste Stück. Das ist die Idee hinter der Iteration. Am Ende jeder Iteration ist euer Prototyp größer und besser. Manche Features (in einem Spiel z.B. das Statistikmenü) sind verhältnismäßig unwichtig. Der Prototyp wächst also erstmal ohne diese Teile. Irgendwann kommen die dann dazu. Es ist einfach wichtig Prioritäten zu setzen. Wenn man die Iterationen schön klein hält sieht man auch den Fortschritt und man hat nicht das Gefühl von der Doku behindert zu werden.

Ich danke für eure Geduld. Von einer Generation die Bücher nur ungern ließt (Vorurteile, ich weiß. ;) ) habt ihr mit den 3. Teilen ein ganz schönes Pensum abgearbeitet.

Ich freue mich auf euer Feedback.

Euer

Kevin Fleischer aka Flash

Vorhergehendes Tutorial:
Tutorial Softwareentwicklung2
Nächstes Tutorial:
-

Schreibt was ihr zu diesem Tutorial denkt ins Feedbackforum von DelphiGL.com.
Lob, Verbesserungsvorschläge, Hinweise und Tutorialwünsche sind stets willkommen.