Projektabwicklung mit C + + - Implementierung in der halben Zeit

Programmkomplexität wird in Klassen und Objekte verpackt

04.01.1991

Objektorientierte Programmierung wird gelegentlich als das Allheilmittel gegen die Softwarekrise angepriesen. Die Erfahrungen, die Michael E. Klews* bei einem C + + Projekt machte, zeigen Chancen und Gefahren dieser objektorientierten Weiterentwicklung von C. Sein Fazit: Der Versuch lohnt sich.

Wesentlich bei diesem Projekt war, daß der öbjektorientierte (OO) Ansatz, auch das objektorientierte Paradigma genannt, durchgängig angewandt wurde.

Zwar waren die wissenschaftlichen Mitarbeiter aus früheren Projekten mit der OO-Denkweise vertraut, aber noch keiner hatte zuvor unter industriellen Bedingungen - eine OO-Programmiersprache eingesetzt. Die Vorgängerprojekte ließen uns eine leidvolle Erfahrung einbringen: Obgleich es in der Analyse und beim Entwurf stets gelungen war, objektorientiert vorzugehen, konnten die Konzepte nur höchst eingeschränkt in Programme konventioneller prozeduraler Sprachen umgesetzt werden. Spätestens nach den ersten Erweiterungen gegenüber dem ursprünglichen Funktionsumfang waren die Coder Pascal-Programme fast im gleichen Zustand wie alle größeren nicht-trivialen herkömmlichen Programme: Die Grenzen der wirtschaftlich vertretbaren Erweiter-, Änder- und Wartbarkeit traten deutlich zutage, an Wiederverwendbarkeit in größerem Umfang war nicht zu denken.

Um die genannten Schwierigkeiten zu überwinden, entschlossen wir uns, eine OO-Programmiersprache einzusetzen. Doch welche sollten wir wählen?

Unser Anwendungsgebiet war das Paket NET, ein Softwarewerkzeug zur integrierten Systemanalyse und Simulation mittels Petri-Netzen. Im Zuge der Umstellung von VMS und DEC-Windows auf Unix und OSF/Motif sollte die in Pascal geschriebene Software objektorientiert reimplementiert werden.

Da Simulationssoftware eine sehr geringe Laufzeit haben muß, wurde als OO-Programmiersprache C+ + gewählt. Diese Sprache ist ursprünglich eigens zum Schreiben von Simulationssoftware entwickelt worden; als Obermenge von C sollte sie zudem erlauben, das Paket problemlos in OSF/Motif zu integrieren.

Um die Portierbarkeit von unserer Entwicklungsmaschine, einer Decstation, auf andere Unix-Rechner zu erhöhen, benutzten wir den Compiler der Firma Glockenspiel aus Dublin, Irland, der die C + + Quellen in äquivalente C-Programme übersetzt, die dann mit dem normalen C-Compiler weiterverarbeitet werden.

Die Vorarbeiten bestanden darin, mittels make und Shell-Scripts eine Entwicklungsumgebung zu schaffen.

Der allererste Schritt eines jeden Softwareprojekts ist das Festlegen der Benutzeranforderungen. Dies geschieht unabhängig von dem Paradigma, unter dessen Führung die späteren Projektphasen ablaufen. In unserem Falle brauchte hier nichts mehr getan zu werden.

In der Systemanalyse, kurz: Analyse, der nächsten Phase sind die sich aus den Anforderungen ergebenden Probleme zu klären; diese Phase mündet in eine Spezifikation des beobachtbaren Verhaltens des künftigen Softwaresystems. Der Analytiker muß dabei zwei gegensätzliche Aufgaben meistern: Einerseits muß er die - über die Zeitachse gesehen - relativ stabilen Begriffe, Gegenstände und Beziehungen des Anwendungsgebiets verstehen, andererseits muß er prüfen, wie die sich bekanntlich ständig ändernden und erweiternden Benutzeranforderungen damit und mit den Randbedingungen harmonieren, um schließlich die obige Spezifikation formulieren zu können.

Hier nun begegnen wir erstmalig dem Phänomen, daß die Objektorientierung das Einhalten des Princips der Trennung der Belange (separation of concerns) stark fördert, ja eigentlich erst wirklich ermöglicht.

Der erste Teil der Aufgabenbeschreibung führt zum Aufstellen einer Hierarchie der essentiellen Klassen: Den Begriffen und Gegenständen werden Klassen zugeordnet, Gemeinsamkeiten manifestieren sich in Vererbungsbeziehungen. Mit anderen Worten: Die essentiellen Klassen sind jene, mit deren Objekten der Benutzer an der Systemoberfläche unmittelbar zu tun hat. Naturgemäß sind dies gerade jene Klassen, die das Anwendungsgebiet modellieren und damit das Wesen (lat. essentia) des Systems ausmachen. Die Attribute, die Eigenschaften der Objekte, werden durch die member variables der Klassen ausgedrückt.

Das Aufstellen der essentiellen Klässenhierarchie ist der Dreh- und Angelpunkt der Analyse; hier darf man nicht schludern, sondern muß alle Sorgfalt aufwenden. Allerdings ist dies auch der einfachste Einstieg: Hier weiß man am meisten, beziehungsweise kann man das Wissen am leichtesten erwerben. Hier läßt sich bereits arbeiten, wenn die Benutzeranforderungen noch höchst variabel, weil unklar, sind. Und egal wie die endgültigen Anforderungen aussehen, die beim Aufstellen der Hierarchie geleistete Arbeit ist niemals vergeblich, solange der durch die Software zu modellierende Weltausschnitt auch nur einigermaßen beibehalten wird.

Doch nun zum zweiten Teil der Aufgabenbeschreibung des Analytikers. Die Benutzeranforderungen resultieren schließlich in den Methoden der Klassen der essentiellen Klassenhierarchie. Sie sind es, die Bewegung in die bislang starre Welt unserer Objekte bringen.

Gemäß dem OO-Ansatz sind die Objekte aktiv, indem sie einander durch das Senden von Botschaften höflich auffordern, ihnen zugeordnete Methoden, das heißt (auf gut C + + ) member functions auszufahren. Die memberfunctions haben, von wenigen Ausnahmen abgesehen, den alleinigen Zugriff auf die member variables und verändern den Zustand des Systems.

An dieser Stelle sei ein Ausflug in die klassische Philologie erlaubt. Das Wort "Methode" stammt aus dem Griechischen: He methodos (= die Methode) entstand durch Zusammenziehen der Wörter meta (= nach) und he hodos (= der Weg) und bedeutet eigentlich "der Weg, den man gehen muß, um an sein Ziel zu gelangen". Ziel sind hier die Attribute der Objekte, die member variables. Die Methoden im Sinne des OO-Paradigmas sind damit die Zugriffspfade zu den Objektdaten.

Neben den essentiellen Klassen legten wir in der Analyse eine weitere Bibliothek von Klassen fest: jene der grafischen Benutzeroberfläche nach dem Prinzip What-You-See-Is-What- You-Get. Diese Klassen realisieren den Anschluß an X-Windows und das darauf aufbauende OSF/Motif. Sie sollen im folgenden GUI-Klassen heißen (GUI = graphical user interface).

Ferner stellten wir ein Konzept auf, wie mittels virtual functions, das heißt mit dynamischem Binden und Konstruktoren, die essentiellen Klassen persistent gemacht werden können. Eine Klasse ist persistent, wenn seine Objekte über die Dauer einer Sitzung hinaus aufbewahrbar werden.

Da in unserem Team die Analytiker zu den späteren Implementeuren gehörten, waren die Spezifikationsdokumente ein Satz von C + + -Header-Files: die Schnittstellen der essentiellen Klassen sowie eine grafische Darstellung ihrer Hierarchie.

Der Software-Entwurf geht bekanntlich vom 'Was' der Analyse zum 'Wie' der Implementierung über. Unser Entwurf beschränkte sich darauf, in einer Brainstorming-Sitzung Dienstleistungsklassen zu sammeln und anschließend zu spezifizieren.

Die durch die Objektorientierung ermöglichte klare Trennung der beiden Belange - der Beschreibung des zu modellierenden Weltausschnitts einerseits und des gewünschten Systemverhaltens, also der Benutzeranforderungen, andererseits - gab uns eine früher unbekannte Sicherheit beim Übergang in die Implementierungsphase.

Wie oben dargelegt, besteht ein OO-Softwaresystem aus drei Bibliotheken von Klassen:

- den essentiellen Klassen,

- den GUI-Klassen,

- den Dienstleistungsklassen.

Objektorientierte Implementierung heißt nun nichts weiter, als die essentiellen Klassen mittels der beiden anderen Bibliotheken zu realisieren.

Bei der Implementierung der GUI-Klassen, also in unserem Fall beim Anschluß von OSF/ Motif, springt man von der Welt der OOP in die der konventionellen Programmierung. Dabei ist viel zeitaufwendige Detailarbeit zu leisten, die mit der eigentlichen Anwendung nichts zu tun hat. Wann immer möglich, sollte man daher die GUI-Klassen nicht selbst schreiben, sondern fertige Bibliotheken kaufen, wie zum Beispiel Glockenspiels Common View. In unserem Fall war leider keine Bibliothek rechtzeitig verfügbar.

Bewegt man sich aber ausschließlich in der OO-Welt, so erstaunt immer wieder, in welch kurzer Zeit man mit wenig Code seine Klassen implementiert. Ohne mich auf exakte empirische Untersuchungen stützen zu können, glaube ich, daß man für die reine Implementierung in C + + nur halb soviel Zeit braucht, wie in reinem C. Ob sich Analyse und Entwurf objektorientiert schneller realisieren lassen als konventionell, läßt sich aus unserem Projekt nicht ableiten: Da alle Beteiligten mit Petri-Netzen schon über Jahre vertraut sind, läßt sich der Faktor "Dazulernen" nicht von dem Faktor "Objektorientierung" trennen.

Die passende Abstraktion direkt in Code umgesetzt

Das schnellere Programmieren in C + + hat mehrere Gründe:

- die strenge Typenprüfung, die viele C-übliche Fehler verhindert,

- den größeren Namensraum ("overloading" eigener und vordefinierter Namen),

- die Objektorientierung allgemein, die in C + + bis auf generische Klassen möglich ist: Sie ermöglicht es, die der Sache angemessene Abstraktion direkt in Code umzusetzen (siehe unten), wodurch zudem redundanter Code minimiert wird.

Entscheidend ist, stets darauf aus zu sein, neue Klassen zu entdecken, an die sich auftretende Teilaufgaben delegieren lassen. So findet man die dem Problem adäquate Abstraktion, die dann zu kompaktem, effizientem Code führt.

Die hier gemeinte Abstraktion geht über die dem Programmierer wohlvertraute prozedurale Abstraktion hinaus, vor deren Übermaß man sich beim OO-Programmieren sogar hüten muß. Führt man nämlich ständig nur zusätzliche Methoden, die ja letztlich Prozeduren sind, bei unverändertem Klassenverband ein, so entstehen schnell große unübersichtliche Klassen, und man verletzt leicht das Prinzip der Trennung der Belange.

Widersprüchliche Anforderungen an eine Klasse lassen sich ebenfalls durch Einführen weiterer Klassen auflösen und zu einem Ausgleich bringen. Zu alldem einige Beispiele: In unserem Petri-Netzwerkzeug NET spielen Marken, die während des Simulierens durch die Netze fließen, eine große Rolle. Unter Wahrung der Randbedingung geringer Programmlaufzeit müssen Marken vielfältig zu, Mengen zusammengefaßt werden. Also gibt es die Klasse Markenmenge, wobei folgende Anforderungen bestehen:

a) eine Markenmenge muß beliebig viele Marken enthalten können,

b) eine Marke muß Element beliebig vieler Markenmengen sein können,

c) das Hinzufügen und Entfernen einer Marke in eine Markenmenge beziehungsweise aus einer Markenmenge muß sehr schnell möglich sein, d) man muß sequentiell auf die Elemente einer Markenmenge zugreifen können,

e) man muß zufällig m Marken aus einer n-elementigen Markenmenge auswählen können.

Die drei ersten Forderungen legten es nahe, eine Markenmenge baumartig zu implementieren; allerdings erschienen dadurch die letzten beiden Forderungen nur mangelhaft erfüllbar zu sein. Da zudem die Elementezahl einer Markenmenge während deren Lebensdauer stark schwankt, führten die Forderungen a) bis c) letztlich zur Implementierung mittels der schon vorhandenen Klassen Set und Setelem, hinter denen sich die Algorithmen der selbstbalancierenden Bäume verbergen (algorithm hiding).

Bei dem Versuch, zwischen a) bis c) einerseits sowie d) und e) andererseits zu vermitteln und zu einem Ausgleich zu kommen, erbrachte rein prozedurales Denken keine Lösung. Klar war zunächst nur, daß die Erzeugung des Zufalls und die zufällige Auswahl kein Spezifikum der Markenmengen und auch nicht der Set-Klasse ist: zu diesem Zweck gab es bereits die Klasse Rondom-Generator, die Methoden zum Erzeugen von Zufallszahlen und zum zufälligen Auswählen bereitstellte; ausgewählt wurden selbstverständlich Zahlen.

Also galt es, die Marken mit natürlichen Zahlen in Verbindung zu bringen: Es wurde eine Klasse Markenindex mit einem Konstruktor

Markenindex (Markenmenge & M) und den Methoden Marke* operator [] (init i); // < = i < card(M) sowie Marke* operator ()(); // Iteration eingeführt.

Bei Bedarf erzeugte sich der Programmierer also ein Objekt der Klasse Markenindex, dessen Lebensdauer er durch geschicktes Ausnutzen der Blockstruktur von C + + oder durch new und delete genau steuerte.

Doch damit nicht genug. In einigen Konstellationen gibt es beim Simulieren Markenmengen, deren Elemente eindeutig durch den Wert einer Member-Variable x vom Typ X gekennzeichnet sind und auf die man genau mittels jenes Wertes zugreifen muß. Abstrakt gesprochen muß eine mathematische Abbildung f derart konstruiert werden, daß sich f(x) schnell aus x "berechnen" läßt.

Gesagt, getan - C+ + macht's möglich: Flugs wurde die Klasse Map eingeführt, die Abbildungen zwischen long integers realisiert. Die Paare (x, y) werden mittels Quicksort nach x sortiert. Die"Berechnung" des Bildes beziehungsweise Funktionswertes geschieht über die Methode

long operator()(long), die natürlich mittels binärem Suchen implementiert wurde. Die beiden Algorithmen sind nach außen unsichtbar, das heißt, der Klient der Klasse Map weiß nichts von ihnen und braucht auch nichts darüber zu wissen: Sie gehören zur Implementierung und nicht zur Schnittstelle der Klasse.

Unter Ausnutzen der Konvertierbarkeit von long in void* und zurück, definierten wir schließlich die Klasse Markenabbildung mit dem Konstruktor Markenabbildung (Markenmenge&). (Diese Klasse wurde aus Markenindex und Map abgeleitet.)

Wir bemerken hier ein Paradoxon: Obwohl Algorithmen

beim OO-Programmieren scheinbar keine zentrale Rolle spielen, sondern den Objekten lediglich beigeordnet sind, kann man effiziente Algorithmen hier leichter und wirkungsvoller einsetzen als beim konventionellen Vorgehen. Insbesondere kann man sie dazu benutzen, den Objekten im Konstruktor sozusagen angebotene Eigenschaften mitzugeben.

Hier wird ein weiterer Unterschied zum Arbeiten in reinem C oder einer anderen konventionellen Sprache deutlich. Das Definieren einer Variablen, die kein Zeiger ist, und die Wertzuweisung an eine Zeigervariable bewirkten mehr als lediglich eine Speicherplatzallokation: Mittels der Konstruktoren kann dabei viel nützlicher Code ablaufen (etwa die Initialisierung). Sich daran zu gewöhnen heißt, die objektorientierte Denkweise, das OO-Paradigma, zu verinnerlichen.

Auch in der OO-Programmierung muß man gelegentlich den Entwurf revidieren. So hatten wir ursprünglich die Klassen Modul, Baustein und Transition aus Rechteck und Doppelrechteck sowie p-Knoten und Kreis abgeleitet. Aus hier nicht näher zu erläuternden Gründen mußte diese Vererbung gelöscht und durch Zeiger von Modul auf Doppelrechteck und so weiter ersetzt werden. Das Ändern der Klassen dauerte mit allen Nebenarbeiten einen halben Tag, wobei die Klassen schon weitestgehend programmiert waren.

Nicht verschwiegen werden soll, daß auch in C + + ein nicht zu unterschätzendes Restrisiko besteht, C-typische Fehler zu begehen, die erst zur Laufzeit auftreten und nur schwer lokalisiert werden können. Abhängig von der Einhaltung des OO-Paradigmas, das ja umgangen werden kann, läßt sich das Risiko jedoch minimieren.