Objektorientiertes Design

Geschwurbel von Daniel Schwamm (09.04.1994)

Inhalt

1. Einführung

1.1. Motivation des objektorientierten Designs

In bisherigen Modellierungsprozessen wurden unterschiedliche Techniken in jeder Phase verwendet. So wurden z.B. für die Analyse Entity Relationship-Modelle (ERM), Datenflussdiagramme (DFD) und Zustandsdiagramme verwendet, während in der Design-Phase normalisierte Tabellen und Structure Charts zum Einsatz kamen. Auch die abschliessende Programmierung bzw. Implementierung verlangte ein Umdenken der Modellierer dahingehend, dass die Designergebnisse erst an die Implementierungstechniken angepasst werden mussten.

Bei einem objektorientiertem Ansatz kommt es nicht zu Brüchen beim Übergang von einer Phase in die andere. Das objektorientierte Design (OOD), welches ein Teil der objektorientierten Entwicklung (OOE) repräsentiert, bietet damit grundsätzlich zwei Vorteile gegenüber herkömmlichen Modellierungsmethoden:

  1. Das OOD-Modell kann die Semantik des Problembereichs besser abbilden als seine Vorgängermodelle.
  2. es besteht Konsistenz zwischen den Modellierungsphasen objektorientierte Analyse (OOA), OOD und objektorientierte Programmierung (OOP). Aber wodurch wird diese Konsistenz erreicht? Das OOA-Modell ist direkt sukzessiv erweiterbar mit den Methoden des OOD, und die verwendete Terminologie ist in allen OOE-Phasen die gleiche.

Was zeichnet nun eine objektorientierte Modellierung aus? Sehen wir uns dazu die folgenden Kriterien an, die für alle objektorientierten Vorgänge gelten:

  • Daten und Funktionen werden als Einheiten gesehen (die zusammen in Objektklassen verpackt werden). Bei den herkömmlichen Methoden wurden die Daten und ihre Strukturen z.B. in einem ERM modelliert, während die Funktionen getrennt davon in Flussdiagrammen grafisch dargestellt wurden.
  • Allgemeinen Strukturen können explizit betont werden. Dies wird dadurch erreicht, dass die allgemeinen Strukturen (Objekte) ihrer Eigenschaften an andere Strukturen weitervererben können. Nur die in der abgeleiteten Klasse verwendete Semantik muss explizit erklärt werden, während die vererbten Methoden nicht neu zu codieren sind. Die Vererbung erlaubt eine stufenweise Verfeinerung des Modellentwurfs, setzt aber gute Kenntnisse des Problembereichs voraus. Um die Vererbungstechnik effizient auszunutzen, sollten alle Methoden und Attribute so niedrig als möglich und so hoch als nötig in der Hierarchie angeordnet sein.
  • Die OOE erlaubt es, Module wiederzuverwenden und einzeln abzuändern, wodurch eine Evolution bestehender Systeme wesentlich erleichtert wird. Dadurch steigert sich nicht nur die Produktivität der OOP-Ergebnisse, sondern auch deren Qualität.
  • Besonders der letzte Punkt macht deutlich, dass sich das Risiko einer grossen SW-Entwicklung unter Einsatz von OOE-Methoden minimiert, da stets Teile davon für andere Programme verwendet werden können und nachträgliche Änderungen keinen Neuentwurf verlangen.

1.2. Abgrenzung der OOA von der OOE

Die OOA-Phase ist der OOD-Phase vorgelagert, d.h. die OOD-Phase arbeitet mit den Ergebnissen der OOA-Phase unter anderer Zielsetzung weiter. Die Zielsetzung der Analyse war es, zu untersuchen, WAS für Vorgaben zu erfüllen sind, um einen Problembereichs bearbeiten zu können. Die technischen Rahmenbedingungen werden dabei völlig unterschlagen, denn diese sind erst Thema der OOD-Phase. Hier gilt die Zielsetzung: WIE sind die Vorgaben der OOA zu erfüllen? Dabei richtete sich die OOD nach der Strategie, so wenig als möglich neu zu entwickeln und so viel als möglich wiederzuverwenden. Als Ergebnis der OOD erhält man genaue Spezifikationen der neu zu entwickelnden Komponenten und zudem Vorgaben, inwieweit bestehende Komponenten angepasst und verwendet werden können, um das gegebene Problem lösen zu können. Die Implementierung mittels einer OOP ist dann nur noch ein kleiner Schritt.

1.3. OOA in aller Kürze

Sehen wir uns zunächst einmal an, was die OOA für Arbeitsschritte durchläuft, wobei wir von dem Fünf-Schichten-Modell von Coad und Yourdon ausgehen wollen. Die Subjekt-, Klassen-, Struktur-, Attribut- und Methoden-Schichten werden mit den folgenden fünf Schritten modelliert:

  1. Festlegung der Objekte und Klassen

    Objekte sind Abbildungen der realen Welt, die über Zustände (Attributsausprägungen) und Verhalten (anwendbare Methoden) beschrieben werden können. Stimmen Objekte mit anderen Objekten in Verhalten und Datenstrukturen überein, so lassen sich aus ihnen Klassen bilden. Ein Beispiel für zwei Objekte, die sich zu einer Klasse "sprechende_Ente" zusammenfassen lassen, sind "Dagobert Duck" und "Donald Duck".

  2. Identifizierung der Vererbungsstrukturen

    Wenn eine Klasse einer anderen Klasse bestimmte Eigenschaften vererbt, dann spricht man von einer "is a"- bzw. "kind of"-Beziehung oder einer Generalisierungsstruktur bzw. Spezialisierungsstruktur zwischen diesen beiden Klassen. Die vererbende Klasse nennt man Basisklasse und die erbende Klasse abgeleitete Klasse. Durch Instanziierung der abgeleiteten Klasse wird automatisch auch die zugehörige Basisklasse instanziiert. Ein Beispiel für solch eine Struktur ist zwischen den Klassen "sprechende_Ente" und "sprechende_geizige_Ente" gegeben, d.h. unter sprechenden Enten gibt es auch welche, die geizig sind.

In C++ können Elementfunktionen (Methoden) einer Klasse auf drei Arten "is a"-vererbt werden:

  1. Vererbung nur des Interface: die Basisklasse enthält nur "pure virtual"-deklarierte Methoden ohne Implementierung; sie ist also abstrakt. Die Methoden MÜSSEN von der abgeleiteten Klasse definiert werden.
  2. Vererbung des Interfaces und Default-Implementierungen: die Basisklasse enthält virtuelle definierte Methoden, die die abgeleitete Klasse benutzen oder durch eigen überschreiben kann.
  3. Vererbung des Interfaces und der Implementierungen: die Basisklasse enthält nicht-virtuelle definierte Methoden, die die abgeleitete Klasse nicht durch eigene überschreiben sollte.

    Baut sich eine Klasse aus Eigenschaften anderer Klassen auf, dann besteht zwischen diesen Klassen eine "part of"-, "has a"- bzw. "is implemented in terms of"-Beziehung oder eine Whole-Part- bzw. Aggregation-/Zerlegungsstruktur. Diese Struktur kann man in C++ durch Layering erreichen, d.h. die Aggregat-Klasse enthält die Zerlegungsklasse als Attribut, einen Pointer darauf oder als geschachtelte Klasse (Nested Class). Eine Whole-Part-Struktur besteht z.B. zwischen den Klassen "Supermarkt" und "Ravioli", d.h. jeder Supermarkt enthält verschiedene Sorten Ravioli.

    Objekte können auch miteinander in Beziehung stehen, ohne dass es sich dabei um eine Vererbungsstruktur handeln muss (auch wenn sie in C++ wie Whole-Part-Strukturen durch Layering implementiert werden). Die aus dem ERM bekannten Beziehungen lassen sich folgendermassen implementieren (es ist jeweils eine Vorwärtsdeklarationen nötig):

    • 1:1-Beziehung: Zeiger in beiden Klassen.
    • 1:M-Beziehung: Zeiger in einer Klasse, Liste (Anzahl unbekannt) oder Feld (Anzahl bekannt) in der anderen Klasse.
    • N:M-Beziehung: 1:M-Beziehungen in beiden Klassen.
  4. Identifizierung der Subjekte
  5. Definition der Attribute
  6. Definition der Methoden

1.4. OOD in aller Kürze

Sehen wir uns jetzt an, was die OOD für Arbeitsschritte durchläuft, wobei wir uns an das Vier-Komponenten-Modell von Coad und Yourdon halten wollen. Diese vier Komponenten sind im Einzelnen:

  1. Problembereichskomponente: beim Design der Problembereichskomponente werden die Ergebnisse der OOA um designspezifische Aspekte erweitert.
  2. Datenmanagement-Komponente: Abstimmung der Ergebnisse auf das gewünschte Ergebnis. Hierunter fällt insbesondere die Verwaltung persistenter Objekte.
  3. Benutzeroberflächen: Anpassung der Benutzeroberfläche zur Kommunikation mit dem späteren Endbenutzer.
  4. Task-Management-Komponente: Diese Komponente designt die nötige Koordination der einzelnen Tasks bei Multitasking-Systemen.

2. Allgemeines zum Design

2.1. "Optimales" Design?!

Normalerweise gibt es nicht nur einen Weg, ein (durch OOA) gut analysiertes und spezifiziertes Problem zu designen. So muss z.B. eine Wahl getroffen werden, ob man eine objektorientierte Sprache wie C++ oder eine funktionale Sprache wie PASCAL verwenden möchte, ob man grafische Oberflächen einsetzen will, ob man multiple Vererbung benötigt, oder ob man dem Modell eine relationale, hierarchische oder netzwerkartige Datenbank zugrunde legen will. Auch die Verwendung von statischen (Feldern) oder dynamischen Strukturen (Listen) muss entscheiden werden, genauso wie die Modellierung einzelner Eigenschaften von Klassen als Attribute oder eigenständigen neuen Klassen.

Hat man sich für einen Designer-Weg entschieden, kann dieser durch eine einheitliche Dokumentation der Design-Entscheidungen formalisiert werden. Insbesondere bei komplexen Systemen gewinnt das formale Design an Bedeutung. Es muss daher:

  • übersichtlich/leicht erlernbar sein - z.B. durch grafische Darstellung.
  • alle für die nächsten Schritte relevanten Informationen enthalten.
  • durch CASE-Tools unterstützbar sein.

Dadurch wird ein "gemeinsamer Nenner" geschaffen, wodurch auch ausserhalb des Entwicklungsteams stehende Personen mit den Designergebnissen umgehen können. Leider wird bisher auf ein formales Design wenig Wert gelegt, da der Aufwand dafür trotz CASE-Tools erheblich ist und die Ergebnisse so wenig greifbar.

2.2. Was ist gutes Design?

Nach Coad und Yourdon drückt sich gutes Design v.a. darin aus, dass es hilft, die anfallenden Kosten eines Systems während seiner gesamten Lebensdauer insgesamt zu minimieren - auch wenn die Entwicklung selbst etwas teuer gewesen sein sollte. Solche Kosten fallen an:

  • bei der Analyse und dem Design des Systems
  • bei der Umsetzung des Designs (Implementierung)
  • bei den nötigen Tests
  • bei den Betriebskosten des Systems (evtl. auch Anschaffungskosten!)
  • und v.a. bei der Wartung des Systems.

Das Design sollte die allgemein geltenden Ziele der SW-Entwicklung nicht übersehen. Sogenannte SW-Metriken helfen, zu überprüfen, in wieweit die folgenden Ziele erreicht wurden:

  • Korrektheit/Überprüfbarkeit
  • Robustheit in Ausnahmefällen
  • Erweiterbarkeit/Änderbarkeit (insbesondere grosser Systeme)
  • Wiederverwendbarkeit
  • Integrierbarkeit
  • Kompatibilität (mit ähnlichen Produkten)
  • Effizienz
  • Portabilität
  • Benutzerfreundlichkeit
  • Wartungsfreundlichkeit

Durch die Modularisierungsmöglichkeiten von OOP, die eng mit dem Klassenkonzept gekoppelt sind, wird ausserdem das Ziel erreicht, die Abhängigkeit zwischen einzelnen Modulen möglichst gering zu halten (Weak Coupling), die im Modul zusammengefassten Strukturen aber sehr stark zusammenhängen zu lassen (High Cohesion). Darüber hinaus ermöglichen Module es, die Implementierungsdetails vor den Anwender zu verbergen, und ihnen nur eine (Standard-)Schnittstelle zur Verfügung zu stellen (Information Hiding). Und zu guter Letzt gestatten Module noch die Wiederverwendbarkeit von Systemteilen, besonders wenn Klassenbibliotheken angelegt werden aus Klassen, die geschlossen für Veränderungen, aber offen für Erweiterungen sind (Open-Closed-Prinzip). Diese Ziele des SW-Designs sehen wir uns nun in den nächsten Abschnitten noch einmal einzeln an.

2.3. Coupling - erstes Beurteilungsmass für OOD

Wie im Abschnitt zuvor erklärt wurde, erlaubt es das Modulkonzept von OOP, dass Coupling, das ist die äussere Verflechtung (keine Vererbungsstruktur!) von Klassen bzw. Bibliotheksfunktionen, möglichst gering zu halten. Dies ist v.a. deswegen wichtig, damit es nicht zu einem sogenannten Domino-Effekt kommt, wenn man eine Klasse ändert: ist das Coupling stark ausgeprägt, folgt aus einer Änderung der Methodenaufrufe einer Klasse eine Kettenreaktion von Änderung an anderen Klassen, die diesen Methodenaufruf verwenden. Um den Domino-Effekt zu minimieren, sollte Folgendes beachtet werden:

  • Interface vorausschauend designen.
  • Datenkapselung (v.a. Zugriffsrechte) konsequent betreiben.
  • Auf friend-Funktionen verzichten (bei "cout"-Überladung aber nötig).

Je mehr Informationen zwischen den Objekten/Modulen übermittelt werden müssen, desto höher ist der Coupling-Grad des Systems. Die äussere Verflechtung in Bezug auf OOD kommt auf dreierlei Arten zustande:

  1. Verflechtung von Objekten durch Methodenaufrufe: ein Objekt sollte so wenig Nachrichten empfangen bzw. senden wie möglich und so viele wie nötig. Nach Möglichkeit sollten pro Methodenaufruf nicht mehr als drei Argumente übergeben werden. Es ist z.B. nicht sinnvoll, wenn die Methode der Klasse A eine Methode der Klasse B aufruft, um eine Methode der Klasse C zu aktivieren, wenn die Methode der Klasse C auch direkt von der Klasse A aufgerufen werden kann.
  2. Verflechtung von Objekten durch Objekt-Beziehungen: ein Objekt sollte so wenig Objekt-Beziehungen zu anderen Objekten haben wie möglich und so viele wie nötig.
  3. Verflechtung von Klassen durch Whole-Part-Strukturen: das Layering sollte so gering gehalten werden wie möglich und so stark wie nötig.

2.4. Cohesion - zweites Beurteilungsmass für OOD

Im Gegensatz zum Coupling, das zu minimieren ist, ist die Cohesion, also der innere Zusammenhang der Strukturen eines Objektes/Moduls, zu maximieren. Jeder Teil des Ganzen soll eine wohldefinierte Aufgabe leisten. In Bezug auf OOD ist Cohesion unter drei Aspekten zu beachten:

  1. Cohesion einer Methode: eine Methode sollte genau eine Aufgabe erfüllen und nicht mehr. Solche Methode kennzeichnen sich durch zwei Kriterien: ihre Implementierung verlangt nur wenige Zeilen Code, und ihre Aufruf lässt sich mit einem imperativen Satz aus Verb+Objekt beschreiben (z.B. Fuelle_Feld).
  2. Cohesion einer Klasse: in einer Klasse sollten nur die Eigenschaften modelliert sein, die problemrelevant sind. Von Fuzzy-Definitionen ist Abstand zu nehmen. So hat es z.B. wenig Sinn der Klasse "Auto" das Attribut "Fluggeschwindigkeit" oder die Methode "Starte_Paarungsphase" zuzuweisen.
  3. Cohesion einer Vererbungsstruktur: eine abgeleitete Klasse sollte möglichst alle virtuellen Elementfunktionen und Attribute der Basisklasse sinnvoll benutzen können, darüber hinaus aber auch eigene Methoden und Attribute besitzen. In Zukunft werden sich auch selektive Vererbungen realisieren lassen, die zwar dem "is a"-Gedanken widersprechen und zu Unübersichtlichkeiten führen können, die abgeleiteten Klassen aber vor unnötigem Basismethoden-Ballast schützen.

2.5. Weitere Kriterien zur Beurteilung von OOD

Um die Qualität eines OOD zu beurteilen, eigenen sich neben der "Messung" des Coupling- und Kohäsionsgrades die folgenden Methoden:

  • Überprüfung der Module auf Wiederverwendungseignung
  • Überprüfung, ob ein einheitliches/ausdrucksstarkes Vokabular benutzt wurde
  • Überprüfung, ob CASE-Tools zum Einsatz kamen
  • Die maximale Ebene der Vererbungsstrukturen bestimmen (<8?)

Wiederverwendbarkeit ist ein wichtiges Thema der OOE, daher sehen wir sie uns noch einmal näher an. Die OOE sieht nicht nur vor Programme zu schaffen, sondern auch deren Module so allgemein zu entwerfen, dass sie sie auch noch in anderen Programmen verwendet werden können. Dadurch mindert sich der Entwicklungsaufwand ganz erheblich. Wie schon erwähnt eignen sich hierfür besonders Klassenbibliotheken, die nach dem Open-Closed-Prinzip konzipiert werden.

2.6. Software-Qualitätsmasse

Eine SW-Metrik ist ein Mass zur Quantifizierung von Eigenschaften von Programmen (insbesondere der Programmkomplexität), ausgedrückt als reelle Zahl. Meistens werden sie anhand des Programmtextes bestimmt, wobei v.a. Programmlänge und Schnittstellen berücksichtigt werden. SW-Metriken dienen dazu:

  • die oben vorgestellten Ziele der SW-Entwicklung zu überprüfen.
  • Wartbarkeit/Fehleranfälligkeit/Aufwand der Systeme abzuschätzen.
  • Kosten und Terminplanung eines SW-Projektes einzuschätzen.
  • die Produktivitätssteigerungen durch Tools zu beurteilen.
  • Produktivitätstrends im Zeitablauf nachzuweisen.
  • die SW-Qualität zu verbessern.
  • zukünftigen Personalbedarf eines Projektes abzuschätzen.
  • zukünftige Wartungsmassnahmen zu reduzieren.

Doch SW-Metriken sind auch mit Nachteilen behaftet. So lassen sie z.B. stilistische Möglichkeiten von Programmautoren, die die Übersichtlichkeit der Programme(-Dokumentation) wesentlich erhöhen kann, ausser Acht. Ausserdem kann die unüberlegte Anwendung von SW-Metriken zu Fehlurteilen führen, die grosse Schäden anrichten können - z.B. wenn herkömmliche SW-Metriken auf objektorientierte Programme angewendet werden. Weitere Kritikpunkte sind: SW-Metriken haben nur eine eingeschränkte Aussagekraft, können erst spät eingesetzt werden und nicht-strukturelle Einflussfaktoren (wie z.B. die Problemlösungsqualität) können nicht gemessen werden. Aber häufig lassen sich SW-Metriken automatisch erstellen, sie können Feedback-Wirkung haben und sind relativ objektiv.

Die Interpretation der Ergebnisse der SW-Metriken hängt ab:

  • von der Skalierung des Wertebereichs: Nominal-, Ordinal- oder Kardinal-Skalen.
  • Mehrdeutigkeit, wenn verschiedene SW-Metriken die gleiche Ausprägung haben.

Als Beispiel einer sehr einfachen herkömmlichen SW-Metrik sei hier die Anzahl der Zeilen des Programmcodes (Line of Code, LOC) genannt. Weitere SW-Metriken stammen z.B. von Halstead und McCabe: das SW-Science-Mass zur Operanden-/Operatoren-Analyse und die zyklomatischen Zahlen zur Iterations-/Verzweigungskomplexitätsmessung. Als letztes sei noch das Informationsfluss-Mass von S. Henry und D. Kafura genannt, wobei gemessen wird, wie sich Methoden gegenseitig aufrufen und dadurch die Programmkomplexität bestimmen (lässt sich auch als objektorientiertes SW-Mass nutzen).

Thema der aktuellen Forschung sind jedoch spezielle objektorientierte SW-Metriken, denn auf objektorientierte Programme sind herkömmliche Masse oft nicht anwendbar. Grund: neue Konzepte wie Klassen und Vererbung werden in herkömmlichen SW-Metriken nicht berücksichtigt, und der Schwerpunkt bei objektorientierter SW liegt stärker auf der Schaffung wiederverwendbarer Komponenten. Beurteilt werden können Klassen (z.B. durch Anzahl Datenelemente), laufzeitbedingte Objekte (z.B. durch erzeugte Anzahl) und das System (z.B. durch gesamte Klassenanzahl).

Sehen wir uns ein paar Beispiele für objektorientierte SW-Metriken an:

  1. Gewichtete Methode einer Klasse: hier wird jede Methode einer Klasse mit ihrer statischen Komplexität aufsummiert. So erhält man mit GMK(K) pro Klasse ein Mass für den Erstellungsaufwand, den Wartungsaufwand und die Komplexität, die Einfluss v.a. auf abgeleitete Klassen haben kann.
  2. Tiefe einer Vererbungsstruktur (TEV): pro Klasse kann ihre Höhe im Vererbungsbaum bestimmt werden. TEV(K) ergibt ein Mass für die Komplexität der Klasse. Ausserdem erfährt man die Anzahl der Basisklassen, die K beeinflussen können. Aber Achtung! Bei selektiver oder mehrfacher Vererbung gilt dies nicht in dieser einfachen Form. Zudem ist die TEV relativ schwer zu bestimmen, wenn Klassenbibliotheken verwendet wurden, die ihrerseits bereits abgeleitete Klassen enthalten.
  3. Anzahl der abgeleiteten Klassen: AAK(K) gibt an, wie viele Klassen von einer Klasse K direkt abgeleitet sind. Dadurch kann man in etwa einschätzen, welchen Status die Klasse im System einnimmt: je grösser AAK(K) ist, desto wichtiger scheint sie zu sein, jedoch muss der Unterbaum von K diesbezüglich erst noch untersucht werden.
  4. Coupling zwischen Objekten: Anzahl der Beziehungen, die nicht vererbungsbedingt sind, die ein Objekt zu einem anderen unterhält, gekennzeichnet durch die Zahl CZO(K). Diese Beziehungen können Methodenaufrufe, Whole-Part-Strukturen und Objekt-Beziehungen sein. Sie ist wie bereits erwähnt ein gutes Mass für die Sensibilität gegenüber Veränderungen im System.
  5. Antwortmenge der Klasse K: Menge aller Methoden (eigene und in Methoden enthaltende), die eine Klasse aufzuweisen hat. Die direkten und indirekten Methoden werden dabei i.d.R. gleich gewichtet. Die Zahl ADK(K) gibt dabei an, welchen Einzugsbereich die Klasse innehat; sie entspricht damit dem Grad der äusseren Verflechtung durch Methodenaufrufe, woraus die Kommunikationsfreudigkeit und Komplexität einer Klasse ersehen werden kann.

Die oben aufgezeigten objektorientierten Metriken sind lediglich Indikatoren für Eigenschaften von Klassen, nicht von ganzen Programmen. Ihr jeweiliges Mass kann in keiner Weise als anerkannte Richtlinie geltend gemacht werden, d.h. eine Aussage wie z.B. "GMK(K)>5 ist schlecht" kann nur subjektiv als WAHR angenommen werden. Dies schränkt die Aussagekraft der Metriken natürlich erheblich ein. Allerdings, eines können die Metriken gut: sie verhelfen zu einem relativem Mass, d.h. man kann damit Klassen eines Programms untereinander vergleichbar machen.

3. Design der Problembereichs-Komponente

Die Problembereichskomponente ist der Programmteil, der die eigentliche Problemlösung enthält. Hier werden die OOA-Ergebnisse umgesetzt, wobei grundsätzlich eine 1:1-Abbildung anzustreben ist, d.h. der Kern der OOA bleibt stabil, sofern keine Fehler gemacht wurden. Folgende Punkte, die Thema der nächsten Abschnitte sind, sind beim Design der Problembereichs-Komponente zu berücksichtigen:

  • Einführung abstrakter "Protokollklassen".
  • Wiederverwendung früherer Designergebnisse und Klassen.
  • Verbesserung des Laufzeitverhaltens.
  • Hinzufügen von Klassen für systemnahe Aufgaben.

3.1. Einführung abstrakter "Protokollklassen"

Eine Protokollklasse wird in C++ durch eine abstrakte Basisklasse realisiert, d.h. sie besitzt Pure Virtual Functions, die in allen davon abgeleiteten Klassen benötigt und definiert werden müssen. Achtung! Protokollklassen tauchen nicht in den Schemata der OOA auf, sondern erst in den Schemata der OOD.

Beispiel: eine mögliche Protokollklasse für die Modellierung eines Schachspiels ist die abstrakte Basisklasse "Figur" mit den rein virtuellen Funktionen "anzeigen", "löschen", "ziehen" und "zug_OK", und den gemeinsamen Attributen "Feld" und "Farbe". Der Zug einer Figur besteht darin, den gewünschten Zug zu überprüfen, die Figur zu löschen, das Attribut "Feld" auf die neue Position zu setzen und die Figur auf dem neuen Feld anzuzeigen. Zu beachten ist noch, dass die Klasse "Bauer" ein zusätzliches Attribute-Flag "erster_Zug" benötigt und die Methode "umwandeln". Die Klasse "Dame" erbt die Methoden und Attribute der Klassen "Läufer" und "Turm". Und die Klasse "König" benötigt die zwei Kontrollattribute "rochade_lang_möglich" und "rochade_kurz_möglich", sowie die zusätzliche Methode "rochieren".

3.2. Wiederverwendung früherer Designergebnisse und Klassen

Die Wiederverwendung von Klassen wird v.a. durch die Verwendung von Klassenbibliotheken realisiert. Doch Klassenbibliotheken sind mit einigen Problemen verbunden, denn ihre Klassen müssen:

  • verwaltet bzw. gespeichert werden, z.B. über eine Datenbank.
  • klassifiziert werden, damit sie wiederauffindbar sind.
  • dokumentiert werden, am besten mit den OOA- und OOD-Ergebnissen.
  • Aspekte wie Copyright, Fehlerhaftung und Bezahlung beachten.

Doch die Vorteile von Klassenbibliotheken überwiegen. So werden Fehler in den Klassen durch den breiten Einsatz früh bemerkt und ausgemerzt (Korrektheitssteigerung). Die Klassen werden i.d.R. von Spezialisten geschrieben (Qualitätssteigerung). Und der Einsatz von Klassen aus Klassenbibliotheken spart natürlich die Neuentwicklung (Produktivitätssteigerung).

Wegen des Einsatzes von Klassenbibliotheken, geht man in der OOD nicht nach der "Top-down"-, sondern nach der "Bottom-up"-Entwurfsmethode vor: man untersucht die Ergebnisse der OOA, in wieweit sie sich durch bereits existierende Klassen realisieren lassen. Falls nötig, scheut man dabei auch nicht, die OOA-Ergebnisse einer adaptiven Bearbeitung zu unterwerfen.

Die Klassenbibliotheken, die man heute beziehen kann (z.B. von Borlands oder der Universität Mannheim), enthalten i.d.R. relativ einfache, abstrakte Datenstrukturen wie z.B. Listen, Bäume und Strings, aber z.T. auch grafische Tools wie z.B. Push-Buttons, Scroll-Bars und File-Selection-Boxes.

Ein breites Anwendungsfeld besitzen insbesondere Container-Klassen, die Datenstrukturen wie z.B. Schlangen, Listen, Stacks und Bäume anbieten, die für nahezu beliebige Datentypen verwendet werden können. D.h. man kann mit ein und derselben Klasse "Liste" Integer-Zahlen oder Binary Large Objects verwalten. Die Implementierung lässt sich auf drei Wegen realisieren: entweder mittels Template-Klassen oder indem Zeiger auf eine "genormte" Basisklasse (z.B. "Object") verwaltet werden oder indem void*-Typen im Container verwaltet werden. Die Lösung mittels Templates ist die beste, denn:

  • Template-Klassen sind typsicher, d.h. "Down-Casts" (Umwandlung eines Zeigers von der Basis- auf die abgeleitete Klasse) und Typ-Überprüfungen während der Laufzeit können entfallen.
  • Die vordefinierten Typen (wie z.B. int) können genauso benutzt werden wie benutzerdefinierte Typen (wie z.B. "sprechende_Enten"). Bei der Zeiger-Lösung müssten dagegen auch der Standard-Integer-Typ int von der Basisklasse "Object" abgeleitet werden.
  • Die Performance ist besser, weil kein Overhead durch virtuelle Elementfunktionen entsteht (die nicht "inline" umgesetzt werden können).
  • void*Container sind C-typisch, aber nicht C++-typisch: Eine Laufzeit-Typ-Prüfung in Eigenregie ist (über ein den Typ anzeigendes static-Datenelement, über eine Index-Klasse oder Klassennamen-String-Vergleiche) umständlich, und leichter über die virtuellen Aufrufe der Object-Methode zu realisieren.

Nachteilig an Templates ist, dass sie nicht ohne Weiteres ausgebaut werden können, und dass der Sourcecode für jeden Typ eines Template-Containers dupliziert werden muss. Diese Eigenschaften können den Programmumfang erheblich erweitern und macht ihre Ablegung in Bibliotheken fragwürdig.

3.3. Verbesserung des Laufzeitverhaltens

Folgende Punkte können zu einer Verbesserung des Laufzeitverhaltens von OOP führen:

  1. Aufnahme von Datenelementen, die ableitbare Informationen zwischenspeichern: Darunter versteht man z.B., dass die Anzahl der Listenelemente nicht jedes Mal langwierig durch Auszählen ermittelt werden muss, sondern als Datenelement ständig aktualisiert vorliegt.
  2. Gezielter Einsatz virtueller Funktionen: Das "late Binding" durch virtuelle Funktionen kostet Zeit, daher sollte man so viele Funktionen wie möglich als nicht-virtuell deklarieren - auch wenn sie dann später nicht so leicht ableitbar sind.
  3. Verwendung von "inline"-Funktionen: Funktionsaufrufe bringen Overhead mit sich, z.B. Stack-Verwaltungsaktionen, die durch "inline"-Funktionen vermieden werden können - allerdings kommt es dann zu Code-Duplizierungen, die das Programm verlängern.
  4. "Register"-Deklaration: Daten werden nicht im Hauptspeicher, sondern in den schnellen Registern abgelegt, sofern möglich.
  5. Verwendung von "friends": Über "friend"-Funktionen kann direkt auf "private"-Elemente von Klassen zugegriffen werden.
  6. Referenzenzählen: Statt Objekte z.B. bei der Parameterübergabe zu kopieren, kann einfach ein Zähler übergeben werden, der ein Feld einer Referenzliste für Objekte im selbst verwalteten Speicher adressiert. So kann z.B. Objekt1+Objekt2=Objekt3 dahin gehend realisiert werden, dass Objekt3 Objekt1 einfach überschreibt, wodurch Speicherplatz gespart wird.

3.4. Hinzufügen von Klassen für systemnahe Aufgaben

Um die Performance eines Programms auf einem bestimmten System zu erhöhen, verzichten Programmierer bisweilen auf Standardklassen und kreieren eigene systemnahe Klassen mit spezifizierten Code und einsichtigeren Schnittstelle. Dies wird allerdings für den Preis einer Milderung der Portabilität erkauft. Statt z.B. die Standardprozeduren zur Erreichung eines "dir"-Aufrufs zu erreichen, kann einfach eine Klasse "dir" entwickelt werden, die speziell auf das verwendete System zugeschnitten ist.

4. Design der Datenmanagementkomponente

4.1. Einleitung

Der Entwickler eines Programms muss dafür sorgen, dass die im Programm verwendeten Daten in den korrekten Zuständen vorliegen, wozu er sie in bestimmter Weise managen muss. Dies bedarf besonders bei grossen Projekten einiger Vorbereitung.

  1. Externe Datenhaltung: Datenmanagement betrifft eigentlich nur die Verwaltung von Daten/Objekten ausserhalb eines Programms. Eine Datenhaltung ausserhalb von Programmen hat folgende Gründe: Die Datenmenge kann sehr gross sein, die Daten sind lange Zeit verfügbar, die Daten können von mehreren Programmen verwendet werden und die Daten können zur IPC genutzt werden.
  2. Aspekte externer Datenhaltung: Bei der Benutzung externer Medien zur Ablegung externer Daten sind folgende "Design"-Aspekte für DBMS zu berücksichtigen:
    • Die Zugriffsgeschwindigkeit ist langsamer als bei Hauptspeichern.
    • Es sollten Abfragesprachen wie SQL vorhanden sein.
    • Die Daten müssen für das Programm interpretierbar sein.
    • Die Datenkonsistenz ist zu gewährleisten.
  3. Grundlagen: Basis für das Datenmanagement bildet ein konzeptionelles Datenmodell, welches z.B. durch ein ERM oder eine OOA gebildet werden kann.
  4. Objektorientierung und externe Datenhaltung: Die herkömmliche Datenverwaltung managt nur "passive" Daten, aber OOP müssen auch "aktive" Objekte mit ihren Methoden verwalten können (sogenannte persistente Objekte).

4.2. Datenmanagement in der objektorientierten Programmentwicklung

  1. Besonderheiten: Objekte zu verwalten ist weit schwieriger als nur Daten zu verwalten. Es müssen Vererbungsstrukturen und Beziehungen zwischen den Klassen beachtet werden, ausserdem muss Objektidentität unabhängig von einem Schlüssel gegeben sein (d.h. der Schlüssel kann sich bei einem Objekt ändern!). Die Integrität dagegen ist leichter zu erreichen, da nur die Elementfunktionen die Objekte in definierter Weise ändern können.
  2. Die Datenmanagementkomponente: Die externe Datenhaltung wird bei Coad/Yourdon in der Datenmanagementkomponente modelliert, wobei es hier besonders auf die Art der externen Speicherung der Objekte ankommt. I.d.R. werden dazu spezielle Dienste entworfen. Wichtig (und i.d.R. in OODBS realisiert) ist dabei für das Design der Datenmanagementkomponente:
    • Das Anwendungsprofil: Sollen mehrere Anwender gleichzeitig die Objekte benutzen können? Dann sind Sperrmechanismen und Autorisierungsprozesse nötig, und ausserdem Effekte der Nebenläufigkeit zu beachten.
    • Das Zugriffsprofil: Wie wird hauptsächlich zugegriffen (lesend oder schreibend)?
    • Die Zugriffsmuster: Sind sie starr oder flexibel?
    • Die Datenablegung: Soll sie über DBS oder das Programm erfolgen?
    • Die Sicherheit: Müssen sichere Transaktionen garantiert werden
    • Die Objekte selbst: Welche müssen persistent sein?
    • Die Versionen: Müssen verschiedene Versionen von Klassen verwaltet werden?
    • Die Verteilung: Ist eine Verteilung in einem Netz nötig/sinnvoll?
    • Die Objektidentität: Ist sie für den Benutzer sichtbar oder nicht?
  3. Operationales Anforderungsprofil:
    • Persistenz: Die Abspeicherung wird erreicht durch (a) spezielle Hilfsklassen zur Benutzung der externen Speicher, durch (b) klassenabhängige Persistenz, d.h. alle Objekte sind immer persistent, oder durch (c) objektabhängige Persistenz, d.h. es kann persistente und nicht-persistente Instanzen einer Klasse geben, was die beste Persistenz darstellt.
    • Objektidentität: Sie muss (a) Objekte eindeutig identifizieren (auch wenn sie nicht gleichzeitig existieren), (b) Objekte dauerhaft identifizierbar machen. Die Identität muss also mit dem Objekt erzeugt werden und existiert so lange, bis es zerstört wird.
    • Elementare Funktionalität: (a) Abspeichern von persistenten Objekten, wobei auch alle Objekte, die durch das abgespeicherte Objekt erreichbar sind, persistent sein müssen, (b) selektives Lesen von Objekten, von Objektteilen und von ganzen Vererbungsstrukturen bzw. allen Objekten auf einmal. Sinnvoll ist auch die Funktion (c) Löschen von Objekten, wobei hier v.a. Konsistenzaspekte zu beachten sind.
  4. Folgerungen für das Design: Die Objektidentität kann mittels eines sogenannten Surrogat-Attributs, welches z.B. den Erzeugungszeitpunkt festhält, realisiert werden. Klassen, mit einzelnen persistenten Objekten benötigen ein "Persistenz"-Flag, das angibt, ob eine Objekt persistent oder transient ist. Von der Einführung von Modification-Flags muss abgeraten werden, dass dieses von sämtlichen Klassenmethoden zu berücksichtigen wäre. Sinnvoll ist eine spezielle Klasse namens "Object_Server", die:
    • die Surrogate vergibt.
    • die Zugriffsfunktionen für alle Objekte anbietet.
    • die Daten/Objekte automatisch abspeichert.
    • die unnötige Neueinlesungen eines Objektes verhindert.

    Bei der Speicherung von Objekten können mehrere Strategien verfolgt werden:

    • Ein Objekt speichert sich selbst. Während der Laufzeit entscheidet Objekt, ob eine externe Speicherung nötig ist. Diese von Coad/Yourdon vorgeschlagene Methode bedarf einer zusätzlichen Elementfunktion im Objekt (die von "Object_Server" virtuell aufgerufen wird). Nur hier wird das Datenkapselungsprinzip nicht verletzt.
    • Objekte werden alleine über die "Object_Server"-Klasse abgespeichert; nur dort ist das Detailwissen dazu nötig.
    • Ein Objekt fordert die "Object_Server"-Klasse auf, es abzuspeichern. Die Details werden dazu untereinander ausgetauscht.
  5. Entwurf der Datenstruktur in relationaler Form mit Berücksichtigung der Objektstrukturen:
    • Abbildung von Klassen auf Tabellen: Alle Attribute einer Klasse werden in einer Relation zusammengefasst, z.B. PERSONEN(#Surrogat, Name, Hobby), oder auf mehrere Tabellen verteilt, z.B. PERSONEN(#Surrogat, Name) und HOBBIES('Surrogat, Hobby).
    • Abbildung von Beziehungen/Teilobjekten auf Tabellen: Je nach Kardinalität müssen eigene Assoziationstabellen gebildet werden oder bestehende um zusätzliche Attribute erweitert werden. Bei eigenen Tabellen müssen die Surrogate beider Objekte darin aufgenommen werden. Bei Zusammenfassungen in einer Tabelle dient ein Surrogat als Sekundärschlüssel. Bei 1:1-Beziehungen lassen sich auch beide Objekte in nur einer Tabelle darstellen.

    Wie bei der Umsetzung von ERM-Strukturen in relationale Modelle sollen nach Coad/Yourdon auch mit den OOA-Ergebnisse vorgegangen werden. D.h. es wird bis zur dritten Normalform normalisiert, um Redundanzen zu minimieren, und dann zur Leistungsverbesserung evtl. wieder bis zu einem gewissen Grad denormalisiert. I.d.R. führen OODBS sogar gar keine Normalisierung durch, da dies Nachteile bei Clusterbildung und Stabilität eines Systems mit sich bringt.

    Abbildung der Klassenhierarchien: Hierfür eignen sich die folgenden Methoden:

    • Repeat Class Modell: Objekte werden in jeder Klasse, an der sie teilnehmen, gespeichert, d.h. in den Basisklassen und in allen beteiligten abgeleiteten Klassen, wobei die abgeleiteten Klassen die vererbten plus die eigenen Attribute enthalten.
    • Leaf Overlap Model: Objekte werden nur in den tiefsten Klasse, zu denen sie gehören, gespeichert, mit ihren Attributen plus den vererbten Attributen.
    • Split Instance Model: Objekte werden in jeder Klasse, an der sie teilnehmen, gespeichert, wobei die abgeleiteten Klassen nur ihre Attribute plus dem Surrogat enthalten.
    • Universal Class Model: Die gesamte Vererbungshierarchie wird durch eine Klasse repräsentiert. Besitzt ein Objekt eines der abgeleiteten Attribute nicht, wird es mit NULL gefüllt.
  6. Entwurf der Datenstruktur in relationaler Form ohne Berücksichtigung der Objektstrukturen: Hierbei werden alle Attribute zusammen mit dem Surrogat in eine binäre Relation gespeichert, wodurch jegliche Redundanz verloren geht (bis auf die Surrogate-Redundanz!). Leider gehen dabei alle Strukturinformationen verloren (weil ja keine abgeleiteten Attribute zusammen in einer Relation stehen können).
  7. Entwurf der Datenstruktur in einfachen Dateien: Objekte lassen sich in Textdateien speichern (Attribut: Attributeausprägungschema) oder in spezielle Dateien, die neben den Attributen auch noch Hinweise auf die Strukturen beinhalten, so z.B. die Gesamtlänge, die Attributenamen und die Länge der einzelnen Attributefelder.

5. Design der Benutzerschnittstelle

Die ergonomische Gestaltung der Benutzerschnittstelle ist entscheidend für die Akzeptanz einer SW-Entwicklung. Hierunter fallen Fenster, Icons, Handbücher, Fehlermeldungen, Hilfssysteme usw. Insbesondere den grafischen Benutzerschnittstellen (Graphical User Interface, GUI) werden wir unsere Aufmerksamkeit in den nächsten Abschnitten schenken.

5.1. Motivation

Ein Programm, welches das Geburtsdatum des Anwenders zu wissen verlangt, aber nirgends vermerkt, dass dies in amerikanischer Schreibweise einzugeben ist, bevor es zum nächsten Programmpunkt übergeht, wird dem unwissenden Anwender vermutlich wenig Freude bereiten, denn aus Sicht des Benutzers verhält sich das System sehr rätselhaft, wenn es ständig seine Eingaben unbegründet ablehnt.

Benutzerschnittstellen, die Fragen im EDV-Jargon stellen, die mit Abkürzungen arbeiten, die arbeitsablaufuntypische Antworten verlangen und die Fehler in Nummernform verkünden, sollten eigentlich längst der Vergangenheit angehören. Daher muss bereits in der OOA- und OOD-Phase eng mit dem Anwender zusammengearbeitet werden, und erst wenn der Prototyp mit der kompletten Benutzerschnittstelle steht, dann sollte mit den Implementierungsdetails begonnen werden.

5.2. Benutzerprofile

Für ergonomische Benutzerschnittstellen benötigt man ein Benutzerprofil, bei dem spezifische Informationen für den Endanwender gesammelt werden, wie

  • Altersprofil: Junge Benutzer sind z.B. meist flexibler.
  • Vorkenntnisse: Die Anwender sollen nicht überfordert werden.
  • Motivation zur Benutzung des Systems: Beruf, Hobby, ...?
  • Die zu erreichenden Ziele des Systems
  • Die Persönlichkeit des Endanwenders

Eine gebräuchliche Einteilung der Benutzergruppen ist die in Anfänger, Fortgeschrittene und Experten, wobei die erste Gruppe Hilfssysteme benötigt und die letzte Gruppe schnelle Antwortzeiten verlangt. Besonders wenn eine Schnittstelle für mehrere Gruppen Geltung haben soll, erschwert dies das Design. Ein System kann angepasst, anpassbar oder anpassungsfähig an seine Benutzer sein.

5.3. Der Dialog

  1. Dialogformen:
    • Benutzergeführter Dialog: Benutzer agiert, System reagiert; z.B. Kommandosprachen.
    • Systemgeführter Dialog: System agiert, Benutzer reagiert; z.B. Menüs.
    • Gemischter Dialog: System/Benutzer agieren/reagieren abwechselnd; z.B. direkte Manipulation.
  2. Dialogarten:
    • Menüs: langsam, aber Eingabeaufwand minimal.
    • Eingabemasken: Tools einsetzbar, aber benötigt viel Bildschirmplatz
    • Kommandosprachen: mächtig, aber komplex; z.B. UNIX
    • Natürliche Sprache
    • Direkte Manipulation: visualisiert, aber schwer zu programmieren; z.B. Maus
  3. Die "acht goldenen Regeln" des Dialogdesigns:
    1. Bemühung um Konsistenz: Einheitlichkeit der Symbole/Bezeichnungen
    2. Abkürzungen ermöglichen: z.B. Macros
    3. Feedback anzeigen nach Aktionen
    4. Zusammenhängende Aktionen gruppieren
    5. Einfache Fehlerbehandlung: Schwere Fehler ausschliessen
    6. UNDO-Funktion<(LI>
    7. Interne Kontrolle
    8. Hilfssysteme anbieten
  4. Fehlervermeidung: Selbst erfahrene Anwender bedienen Systeme fehlerhaft. Daher ist es wichtig, eine ansprechende Fehlerbehandlung durchzuführen. So sollen z.B. nicht nur Fehlernummer ausgegeben werden oder Texte der Form "Syntax Error", sondern präzise Fehlerbeschreibungen wie "Linke Klammer nicht geschlossen". Auch hier ist auf Konsistenz zu achten. Fehler können auf drei Arten vermieden werden, indem garantiert wird:
    • Korrekte Klammerebenen: z.B. durch automatisches Einrücken
    • Vollständige Befehlsfolgen: z.B. durch Macros
    • Korrekte Befehle: z.B. durch Kommandoauswahl durch Anklicken
  5. Anzeige und Eingabe von Daten: Die Eingabe von Daten sollte dem Anwender so einfach wie möglich gemacht werden. Ableitbare Informationen sollte das System selbst stellen und auch bestimmte Eingaben vorgeben oder auswählbar gestalten. Der Anwender sollte sich keine längeren Eingaben merken müssen. Und die Texte werden so angezeigt, wie sie eingegeben wurden.

5.4. Bereitstellung von Online-Unterstützung

  • Online-Handbücher: z.B. "man"-System von UNIX oder CD-ROM-Handbücher.
  • Funktionstasten- und Kommandoübersichten: Für erfahrene Anwender.
  • Tutorials, Demos etc.
  • Hilfssysteme: Hilfssysteme können integriert in das System oder zusätzlich dabei sein, sie könne aktiv sein (laufende Hilfeanzeige) oder inaktiv, und sie können spezifiziert Anfragen zum Aufruf benötigen oder kontextsensitiv, d.h. je nach dem aktuellen Programmpunkt, erscheinen. Üblicherweise sind Hilfssysteme in Form eines Albums oder vernetzt strukturiert, d.h. eine Information weist auf eine andere. Beispiel: Das Borland C++-Hilfssystem.

5.5. Dialog über Menüs

  1. Menüarten:
    • Menümasken: Das Menü bedeckt den gesamten Bildschirm.
    • Pop-Up-Menüs: Das Menü erscheint als Fenster bei der Mausposition.
    • Pull-Down-Menüs: Das Menü wird von Menüleiste "herunter gezogen".
    • Stichwort-Menüs: Meistens am unteren Rand in der Form "1=Drucken".
  2. Menüstrukturen:
    • Einzelmenü: z.B. ein Fenster "END" mit den Buttons "CANCEL"/"OK".
    • Lineare Sequenzen: Entweder-Oder-Feldern, z.B. "digital" "analog".
    • Baumstruktur: Menüs, die jeweils Nebenmenüs anzeigen.

5.6. Werkzeuge zur Oberflächenherstellung

Das Seeheim-Modell ist ein Architekturmodell für User Interface Management Systeme (UIMS) von grafischen, interaktiven Systemen. Es unterteilt Programme in die folgenden vier Komponenten:

  • Präsentationskomponente: Oberflächendarstellung für Benutzer
  • Dialogsteuerung: Eingabe-/Ausgabe-Interpretation
  • Anwendungsschnittstelle
  • Anwendungskomponente: Enthält eigentlichen Anwendungsobjekte.

Ein User Interface Management System (UIMS; wie z.B. DevGuide von SUN) trennt die Applikationen (die Anwendungskomponenten), die z.B. über 4GL-Tools erstellt wurden, von der Benutzerschnittstelle. Die Präsentationskomponente wird dabei durch eine deskriptive Beschreibungssprache oder ein grafisch-interaktives Tools generiert. Die Spezifikation wird interpretativ abgearbeitet. Es sind mehrere verschiedene Schnittstellen zu einer Applikation generierbar, was besonders im Rahmen des Prototyping von Vorteil ist. Probleme:

  • Die Oberflächenobjekte müssen auf die Applikationsobjekte passen.
  • Die Spezifikationssprachen müssen vom Entwickler erst erlernt werden.
  • Interpretative Spezifikationssprachen gehen mit Effizienzverlust einher.
  • Nur durch die Spezifikationssprachen-Erweiterung ist eine UIMS-Erweiterung möglich.

Anwendungsrahmen (Application Frameworks) sind erweiterte UIMS, denn neben Oberflächenobjekten können hier auch generische Klassen für die eigentliche Applikationsentwicklung genutzt werden. Der Schwerpunkt der Framework-Entwicklung liegt dann weniger auf den Objekten selbst, sondern stärker auf den Beziehungen zwischen den Objekten. Klassen werden wiederverwendet und an die Applikation angepasst, indem man abgeleitete Klassen generiert. Wird etwas an den Klassen der Framework-Bibliothek geändert, so wirkt sich dies natürlich auch auf alle abgeleiteten Klassen der Applikationen aus. Ein Beispiel für einen Anwendungsrahmen: XVT++ mit Klassenstrukturen wie z.B. XVT_Base abgeleitet XVT_Container abgeleitet XVT_Dialog oder Standalone-Klassen wie z.B. XVT_Brush, XVT_Menu und XVT_Timer.

6. Design der Task-Managementkomponente

6.1. Was ist ein Prozess (Task)?

Als Prozess wollen wir die Instanz eines Programms bezeichnen, das vom Betriebssystem ausgeführt wird. Er hat folgende Eigenschaften:

  • Er besteht aus zeitlich einander nicht überlappender Schritte.
  • Er hat eine zeitlich begrenzte Lebensdauer.
  • Er hat i.d.R. eine disjunkte (Speicherplatz-)Umgebung.
  • Er kann zu Gunsten eines anderen Prozesses unterbrochen werden.

Bei einem Multiuser-Betriebssystem wie UNIX z.B., wird mit dem Einloggen ein Prozess geboren, der den Benutzer durch eine Shell zur schrittweisen Weitereingabe auffordert, wobei jede Eingabe einen neuen Prozess mit eigenem Stack und Heap generiert, der den Login-Prozess unterbricht, bis man schliesslich die UNIX-Sitzung beendet und den Login-Prozess wieder sterben lässt.

6.2. Erzeugen von Prozessen mit fork() unter UNIX

Alle Prozesse in UNIX werden mit dem Systemcall fork() erzeugt. Der Vater-Prozess, der fork() aufruft, wird dabei mit seiner Umgebung in einen neuen Speicherbereich kopiert, in dem er als relativ eigenständiger Sohn-Prozess weiter läuft. Durch die Kopie werden dem Sohn-Prozess auch alle offenen File-Deskriptoren des Vater-Prozesses vererbt - dies ist in sofern etwas besonderes, da File-Deskriptoren sonst nicht zwischen zwei Prozessen ausgetauscht werden können. Wie der Vater-Prozess, so erhält auch der Sohn-Prozess eine eindeutige Prozessidentifikationsnummer (PID) vom Betriebssystem zugewiesen. Zu beachten ist noch, dass der fork()-Systemcall zwar einmal aufgerufen, aber zweimal beendet wird, nämlich vom Sohn- und vom Vater-Prozess. Einziger Unterschied: Der Vater-Prozess liefert die Sohn-PID zurück und der Sohn-Prozess eine NULL. Abgesehen von der PID ist dieser Rückgabewert die einzige Möglichkeit, den Vater- vom Sohn-Prozess zu unterscheiden; alle anderen Variablen und die Verarbeitungsposition sind identisch.

Nach der Prozessgenerierung laufen Vater und Sohn asynchron zueinander ab. Durch den wait()-Aufruf kann ein Vater-Prozess auf die Beendigung des Sohn-Prozesses warten. Der Sohn-Prozess - noch eine identische Kopie des Vater-Prozesses - ruft i.d.R. den exec()-Systemcall auf, um ein eigenes Programm ausführen zu können, wozu er neue Segmente (Stack-, Daten- und Textsegment) benötigt. Der Systemaufruf exit() beendet den Sohn-Prozess und übergibt ein Statuswort an den (wartenden) Vater-Prozess.

Die eben ausgeführten Beschreibungen beziehen sich auf sogenannte Heavyweight-Prozesse, die bei UNIX üblich sind. Später werden wir uns aber auch noch den Lightweight-Prozessen zuwenden.

6.3. Design der Task-Managementkomponente nach Coad und Yourdon

Coad und Yourdon schlagen zum Design der Task-Managementkomponente vor, zunächst mögliche Tasks zu identifizieren, diese in ereignisabhängige und zeitabhängige Tasks zu klassifizieren, dann die Prioritäten der einzelnen Tasks festzulegen und abzuwägen, ob ein Task-Koordinator (Scheduler) benötigt wird. Schliesslich sind alle Tasks nach einer vorgegebenen Schablone zu beschreiben.

  1. Identifizieren ereignisabhängiger Tasks: Ein ereignisabhängiger Task wartet bei minimalen Verbrauch von Systemressourcen auf ein Ereignis, z.B. auf das Ende einer Tastatureingabe, und aktiviert sich bei Eintritt desselben. Danach terminiert er oder geht wieder in den Wartezustand über. Z.B. wartet ein Prozess durch den folgenden Systemcall auf ein Signal-Ereignis: signal(SIG_INT, Sig_Handler);
  2. Identifizieren zeitabhängiger Tasks: Ein zeitabhängiger Task wartet bei minimalen Verbrauch von Systemressourcen auf den Zeitpunkt seiner nächsten Aktivität, z.B. auf die Bestimmung der Mausposition, und aktiviert sich bei Eintritt derselben. Danach terminiert er oder geht wieder in den Wartezustand über. Z.B. wartet ein Prozess durch den folgenden Systemcall ein paar Sekunden: sleep(Sleep_Sek);
  3. Identifizieren von Task-Prioritäten: Terminiert/deaktiviert sich ein Task, dann wird der Task mit der nächsthöheren Priorität gestartet. Als kritische Task werden diejenigen Tasks bezeichnet, die eine hohe Prioritäten benötigen, um die Gesamtfunktionalität des Systems zu gewährleisten.
  4. Identifizieren eines Task-Koordinators: Bei mehr als zwei Tasks ist die Einrichtung eines Scheduler zu erwägen, besonders wenn Tasks unterschiedlicher Prioritäten koordiniert werden müssen. Der Task-Koordinator legt dabei nur fest, in welcher Reihenfolge wartende Tasks gestartet werden. Problembezogene Methodenaufrufe sind nicht seine Aufgabe.

6.4. Task-Spezifikation

Die identifizierten Tasks müssen nach folgenden Aspekten beschrieben werden:

  • Was wird vom Task geleistet? Name, Methoden, Beschreibung
  • Wie wird der Task koordiniert? Ereignisabhängig oder zeitabhängig?
  • Wie kommuniziert der Task? IPC-Beschreibung

Folgende Schablone bietet sich zur Beschreibung an:

Task j:
      Name:              Temperaturpruefer
      Beschreibung:      Verantwortlich fuer Temperaturueberwachung
      Methoden:          Thermometer.GibTemperatur()
      Prioritaet:        Mittel
      Koordinierung:     Zeitabhaengig, alle 100 ms aktiv
      Kommunikation:     Erhaelt Informationen vom Thermometer
                         Liefert Informationen an Erfassungsdatei

6.5. Task-Management mit der NIH-Klassenbibliothek

Die Tasks von System V arbeiten mit Lightweight-Prozessen (LWP). Im Gegensatz zu den Heavyweight-Prozessen verfügen die LWP über keinen eigenen Adressraum, sondern nur über einen eigenen Stack. Die NIHCL (National Institute of Health Class Library) unterstützt dieses moderne Konzept durch die Klassen "Process", "Scheduler", "Semaphore" und "Shared_Queue".

  1. Tasks und Klassen der NIHCL: Alle NIH-Tasks sind Objekte von Klassen, die von der abstrakten Klasse "Process" abgeleitet wurden. Diese Tasks werden alle von einem Objekt der Klasse "Scheduler" koordiniert. Um Signale zwischen den Tasks auszutauschen, muss ein Objekt der Klasse "Semaphore" benutzt werden. Und mithilfe einer Instanz der Klasse "Shared_Queue" können sogar ganze Objekte zwischen Tasks ausgetauscht werden.

    Die LWP der NIHCL sind mit folgender Charakteristika realisiert:

    • Innerhalb eines Programms sind beliebig viele Tasks erzeugbar. Die "Prozess"-Instanzen benutzen dabei jeweils einen eigenen Stack, aber denselben Datenteil und dieselben Datei-Deskriptoren.
    • Jeder Task verwaltet seinen eigenen Stack. Dadurch sind sie rekursiv abzuarbeiten und verfügen über eigene lokale Variablen.
    • Jeder Task befindet sich im Zustand SUSPENDED, RUNNING oder TERMINATED. Nach der Erzeugung ist ein Task im Zustand RUNNING und kann vom Scheduler aktiviert werden. Es gilt: Es ist immer nur ein Task aktiv. Wird ein Task deaktiviert, dann kann er in den SUSPENDED-Zustand übergehen und mit Hilfe des resume()-Systemcalls durch einen anderen (aktiven) Task wieder RUNNING gesetzt werden. Oder er geht in den TERMINATED-Zustand über, der nicht mehr verlassen werden kann.
    • Ein Task heisst aktiv, wenn er die CPU benutzt. Es kann immer nur genau ein Task zu einem Zeitpunkt aktiv sein. Die Aktivierung der RUNNING-Tasks wird vom Scheduler nach dem First-Come-First-Serve-Prinzip und unter Beachtung der Prioritäten (0-7) vorgenommen. Für jede Priorität führt der Scheduler eine eigene "run_List" und für SUSPENDED-Tasks eine "wait_List".
    • Aktive Tasks können vom Scheduler nicht unterbrochen werden (im Gegensatz zu Heavyweight-Prozessen). Ein Task muss sich mittels suspend()- oder terminate()-Aufrufe selbst deaktivieren oder anderen Tasks über einen yield()-Aufruf die Möglichkeit geben, aktiv zu werden.
    • Mittels der "Semaphore"-Klasse ist bes möglich, Tasks im SUSPENDED-Zustand auf Ereignisse warten zu lassen, z.B. externe UNIX-Signale oder einen bestimmten Semaphoren-Wert.
  2. Die Klassen "Process", "Stack_Proc" und "Heap_Proc": Von der abstrakten "Process"-Klasse sind die Klassen "Stack_Proc" und "Heap_Proc" abgeleitet, von denen wiederum die konkreten Task-Klassen abgeleitet werden. Normalerweise werden Tasks auf den effizienten Heap gelegt, doch durch die Verwendung der "Stack_Proc"-Klasse kann man sie auf den Stack umkopieren, wo sie leichter auf Fehler hin zu untersuchen sind. Ein Task-Klasse erbt dabei folgende Eigenschaften:
    • Den Zustand (SUSPENDED; RUNNING, TERMINATED).
    • Die Elementfunktion Process::suspend()
    • Die Elementfunktion Process::terminate()
    • Die Elementfunktion Process::resume()

    Standardvorgehensweise:

    class Test_Task:public Heap_Proc {
    public:
      static void create(int priority) {
        stack_Typ t1;
        new Test_Task(&t1, priority);
      };
      Test_Task(stack_Typ *t1, int priority) : HeapProc("test", t1, priority); {};
    };
    
  3. Eine Schablone für den Task-Konstruktor:

    Standard-Implementation:

    Test_Task::Test_Task(stack_Typ *t1,int priority):Heap_Proc("test",t1,priority);
    {
      if(FORK()!=0) {
        Scheduler::yield();    // starte Sohn, bevor selber fortfahren
        return;
      };
      // Sohn-Task-Anweisungen
      terminate();
    };
    

    Der Task-Konstruktor hat dabei folgendes zu leisten: Er muss FORK() aufrufen, um einen neuen LWP zu erzeugen. FORK() ist eine Elementfunktion von "Heap_Proc"/"Stack_Proc" und arbeitet ähnlich wie UNIX-fork(). Er muss ausserdem die Funktionalität des Sohn-Prozesses für FORK()==0 enthalten, und er muss den Sohn-Prozess mit terminate() beenden.

  4. Task-Koordination mit der Klasse "Scheduler": Jedes C++-Programm kann maximal einen Task-Koordinator erzeugen. Dazu wird die globale Funktion MAIN_PROCESS(int priority) aufgerufen. Der gerade aktive Task wird mit einem static-Datenelement gespeichert:
    Process *Scheduler::active_process;
    

    Verwaltet werden eine "run_List"-Struktur pro Priorität und eine "wait_List"-Struktur. Über einen terminate()-Aufruf wird ein Task aus allen Listen entfernt. Jeder Task kann die Koordinierung durch den Scheduler durch einen einfachen Scheduler::schedule()-Aufruf auslösen. So wie er an der Reihe wird, wird er aktiviert und muss sich dann selbst deaktivieren. Über yield() kann ein Task auch Tasks mit bestimmter oder höherer Priorität aktivieren - ist kein solcher in der "run_List", dann fährt er selbst fort. Und auch über den wait()-Aufruf kann sich ein Task deaktivieren; in diesem Falle wartet er auf ein Semaphore-Ereignis. Die letzte Möglichkeit zur Deaktivierung besteht darin, dass sich der Task über den Aufruf Process::select() auf ein File-Deskriptor-Ereignis wartet.

  5. Message Passing mit der Klasse Shared_Queue: Tasks können Nachrichten als "Object"-Objekte über Instanzen der Klasse "Shared_Queue" austauschen. Dabei fügen Tasks Nachrichten an das Ende der Queue mit nextput(Object&) an oder entnehmen sie mit next() am Anfang. Will ein Task eine Nachricht in eine volle Queue eingeben, dann wird er suspendiert, bis dies möglich ist. Zur Entnahme wird die "Semaphore"-Klasse benötigt. Auch ein entnehmender Task kann suspendiert werden, falls die Queue leer sein sollte.
  6. Task-Synchronisation mit der Klasse Semaphore: Mit Objekten der Klasse "Semaphore" werden Tasks synchronisiert. Dazu wird eine "wait_List" geführt, die alle Tasks speichert, die SUSPENDED sind und auf ein Semaphore-Ereignis warten. Dieses Ereignis wird allerdings nicht von der Semaphore, sondern von anderen Tasks generiert. Eine Count-Variable inkrementiert, wenn ein Signal bei einem Semaphor ankommt und dekrementiert, wenn ein Task wait() aufruft und in die "wait_List" aufgenommen wird. Bei negativem Count müssen Tasks warten, bei positivem Count können sie sofort fortfahren. Ruft ein Task für ein Semaphore-Objekt signal() auf, dann wird dessen Count inkrementiert.