Aufbau großer Programme
Im Intensivkurs C wurden bisher schon verschiedene Hinweise zum Aufbau von Programmen gegeben, insbesondere wurde die Verwendung abstrakter Datentypen empfohlen. Hier werden einige Punkte noch einmal kurz wiederholt und andere als Ergänzung genannt. Für dieses umfangreiche Thema muß aber sonst auf die Literatur verwiesen werden.
Ein Programm ist „groß“ im Sinne dieser Lektion, wenn es so groß ist, daß es ohne den Einsatz besonderer Strukturierungsmittel unübersichtlich oder unwartbar werden würde. Große Programme sind die wesentliche Herausforderung der angewandten Informatik.
Ein Problem der Lehre ist es, daß der Aufbau eines großen Programms von der Planung bis zur Auslieferung kaum realistisch in einem Kurs behandelt werden kann, so daß der Lernende damit oft erst nach seiner Ausbildung konfrontiert wird. So werden auch die folgende Ausführungen abstrakt bleiben müssen, ohne am Beispiel eines „großen Programms“ veranschaulicht werden zu können.
Programme werden mehr gewartet als neu geschrieben. Niemand kann vor der Änderung eines großen Programms alle Programmteile kennenlernen (wenn man beim letzten angekommen wäre, hätte man den ersten schon wieder vergessen: Das gesamte Programm paßt einfach nicht mehr in einen Kopf). Daher ist es von entscheidender Wichtigkeit, bei einer Änderungsanforderung schnell ermitteln zu können, welcher Programmteil verändert werden muß, und diesen alsdann isoliert ändern zu können, ohne andere Programmteile dabei kennen zu müssen. Dafür ist hohe Kohäsion und geringe Kopplung wichtig: Jeder Programmteil muß bestimmte dokumentierte Zuständigkeiten haben, so daß man weiß, in welchem Programmteil eine bestimmte Änderung vorzunehmen ist, und mit anderen Programmteilen möglichst wenig gekoppelt sein, damit sie dort und nur dort vorgenommen zu werden braucht. Vergleichsweise zerlegt man auch Maschinen gerne in Teile mit bestimmten Aufgaben und Schnittstellen: Beim Auto etwa in einen Motor, ein Getriebe, eine Lichtmaschine und so weiter.
Die Qualität von Quelltext wird daher im wesentlichen durch seine Wartbarkeit und Lesbarkeit (Verständlichkeit) bestimmt.
Definition der Aufgabenstellung (Abgrenzung nach außen)
Bevor ein Programm geschrieben werden kann, sollten die Anforderungen natürlich erst einmal festgelegt sein. Hierfür empfiehlt sich die Schriftform, da bloße gedankliche oder mündliche Festlegungen später nicht mehr für Kontrollen verfügbar sind.
Aus einer oft ambitionierten Liste aller möglicher Anforderungen sollten nur die übernommen werden, die für den Anfang unbedingt notwendig sind. Die anderen Anforderungen sind damit nicht ausgeschlossen, sondern nur auf eine spätere Überarbeitung verschoben.
Definition von Schnittstellen (Abgrenzung im Inneren)
Ein Programm wird grob in verschiedene Teile zerlegt, die einzelne Teilaufgaben übernehmen sollen. Zwischen solchen dieser Teile, welche während des Programmablaufs Informationen miteinander austauschen müssen, werden damit Schnittstellen nötig.
Programmteile hinter Schnittstellen (wie Module oder abstrakte Objekte) sollten bei späteren Überarbeitungen isoliert verändert werden können. Das gesamte Programm sollte nach einer solchen Überarbeitung weiterhin korrekt arbeiten, wenn auch der geänderte Programmteil alle Anforderungen seiner Schnittstellen erfüllt. Daher ist die Änderung solcher Programmteile mit einem relativ geringen Aufwand möglich.
Die folgende Abbildung zeigt eine Situation, in der ein Programm Klient einer Schnittstelle ist. Diese Schnittstelle kann durch eine verkettete Liste oder durch eine Liste auf Basis einer Reihung realisiert werden. Wenn der Klient sich nur an die Schnittstelle bindet, kann die eine Implementation leicht gegen die andere ausgetauscht werden, falls sich ihre Wahl als ungünstig erweisen sollte (oder, um beide Möglichkeiten vergleichen zu können, um alsdann die bessere zu wählen). Andererseits bedeutet die Definition der Schnittstelle und die Anbindung an diese auch mehr Aufwand, so daß ein Programm nur dann durch Schnittstellen zerlegt werden sollte, wenn sich dieser Aufwand voraussichtlich auch lohnen wird.
- Eine Liste als Schnittstelle und deren Implementationen (UML)
.-------------------. .--------------------------------------.
| «program» | «uses» | «interface» |
| client |- - - - - - - - - >| abstract list |
| | |--------------------------------------|
'-------------------' | + insertfirst( item ) |
| + insertlast( item ) |
| + remove( entry) |
| + insertafter( entry, item ) |
| ... |
'--------------------------------------'
^
/_\
«realizes» |
|
- - - - - - - - - - - - - - - - - - - - -
| |
.-----------------------. .-----------------------.
| «implementation» | | «implementation» |
| linked list | | array list |
'-----------------------' '-----------------------'
Bei der Änderung einer Schnittstelle sind hingegen normalerweise gleich mehrere (normalerweise mindestens zwei) Programmteile betroffen. Daher ist eine spätere Änderung von Schnittstellen oft relativ aufwendig, in manchen Fällen sogar so aufwendig, daß einmal getroffene Festlegungen von Schnittstellen später praktisch gar nicht mehr verändert werden können.
Da Schnittstellen später oft kaum noch geändert werden können, gehören sie zu den folgenschweren Festlegungen bei der Planung eines Programmes. Hier hilft meist eine gute Ausbildung und Erfahrung. Natürlich ist es kaum möglich, bei der Festlegung von Schnittstellen immer das Optimum zu treffen, aber selbst wenn dies deutlich verfehlt wird, ist ein Programm, das durch Schnittstellen untergliedert ist immer noch besser als ein „monolithisches“ Programm ohne Schnittstellen.
Manifestation von Schnittstellen Schnittstellen können einerseits durch bestimmte Aufrufe samt ihrer Dokumentation gegeben sein (Schnittstelle einer Funktion, Methode, Routine, …). Es ist aber auch möglich, daß die Zusammenfassung mehrerer solcher Aufrufe als Schnittstelle angesehen wird (Schnittstelle eines Moduls, abstrakten Objekts, Exemplars einer Klasse, …).
Anregungen zur Festlegung von Schnittstellen
Anzahl von Schnittstellen Jede Schnittstelle hat jedoch nicht nur einen Nutzen, sondern auch einen Preis, da Programmteile Aufwand treiben müssen, um sich an Schnittstellen anzupassen und die Schnittstellen gefunden, dokumentiert und erlernt werden müssen. Dieser Aufwand lohnt sich erst bei Programmen ab einer gewissen Größe. Schnittstellen werden also verwendet, um Programm in Einheiten handhabbarer Größe zu zerlegen. Eine noch feinere Zerlegung dieser Einheiten hätte aber keinen Vorteil.
Vorhersehbare Änderungen Es empfiehlt sich all das hinter einer Schnittstelle zu verstecken, von dem man schon absehen kann, daß es sich möglicherweise später ändern soll. Beispielsweise sind Bildschirme und Tastaturen selten direkt mit der Hauptplatine eines Rechners verlötet, sondern über einen Steckkontakt angeschlossen, weil es vorhersehbar ist, daß eines dieser Teile einmal ausgetauscht werden können soll, ohne daß deswegen die anderen Teile auch ausgetauscht werden müssen sollen. Entsprechend faßt man alle Dinge zu einem Programmteil zusammen, die sich voraussichtlich gemeinsam miteinander ändern (so daß eine Änderung an dem einen auch eine Änderung an dem anderen nötig macht).
Literatur zur Architektur und Wartung von Software
Die folgenden Bücher werden in verschiedenen Quellen immer wieder als Empfehlung genannt.
- [MCCONNELL 2004]
- Steve McConnell : Code Complete. Second Edition, Microsoft Press, 2004-06.
- [HUNT]
- Andrew Hunt : The Pragmatic Programmer.
- [Brooks]
- Frederick Brooks : The Mythical Man-Month.
- [Feathers]
- Michael Feathers : Working Effectively with Legacy Code.
- [Liskov 1974] (Schon zuvor genannt)
- Barbara Liskov, Stephen Zilles : Programming with abstract data types. In: ACM SIGPLAN Notices. (1974), Volume 9, Issue 4:50-59.
- http://www.cs.iastate.edu/courses/archive/f06/cs362/papers/p50-liskov.pdf
- [Meyer 1974] (Schon zuvor genannt)
- Bertrand Meyer : Applying "Design by Contract". In: Computer (IEEE). (1992), Volume 25, Issue 10:50-51.
- http://www.cs.iastate.edu/courses/archive/f06/cs362/papers/meyer92.pdf
- Einige Web-Quellen zur Architektur von Software:
- http://www.cs.cmu.edu/afs/cs/project/vit/ftp/pdf/intro_softarch.pdf
- http://martinfowler.com/ieeeSoftware/whoNeedsArchitect.pdf
- http://www.laputan.org/mud/
Jack W. Reeves: Code as Design
Die folgende Empfehlung beziehen sich zwar teilweise auf „Objektorientierte Programmierung“ (OOP), können aber sinngemäß oft auch auf die C -Programmierung angewendet werden, wobei OOP-Objekte in C teilweise durch abstrakte Objekte oder Module realisiert werden können.
- [Gamma 1994]
- Gamma, Helm, Johnson & Vlissides : Design Patterns. Addison-Wesley, 1994.
- [Fowler 1999]
- Fowler, Martin : Refactoring. Addison-Wesley, 1999.
- [Larman 2005]
- Craig Larman : Applying UML and Patterns. Prentice Hall PTR, 2005.
- Einige Web-Quellen zur Architektur objektorientierter Software:
- http://butunclebob.com/ArticleS
- http://www.objectmentor.com/resources/articles/ocp.pdf
Einige Empfehlungen und Vorgehensweisen
- Allgemein helfen natürlich Kenntnisse von Entwurfsmustern und Vorgehensweise aus der Literatur.
- Da der Aufbau eines Programms nicht immer sofort gelingt oder durch gelegentlich unter Zeitnot vorgenommenen Änderungen verschlechtert wird, sollte Software, die voraussichtlich zukünftig genutzt oder überarbeitet werden wird, immer wieder im Sinne einer Verbesserung der Les- und Wartbarkeit des Quellcodes überarbeitet werden (refactoring ).
- Größere Software kann in Schichten unterteilt werden, so kann eine „untere“ Schicht für Datenbankzugriffe zuständig sein und die Datenbank vom Rest des Programms isolieren. Für bestimmte Programmtypen gibt es etablierte Architekturmuster, etwa MVC für Programme mit graphischer Benutzeroberfläche.
- DRY Don't repeat yourself —Jede Festlegung soll nur an einer Stelle notiert werden, damit bei einer Änderung dieser Festlegung nur eine einzige Änderung nötig ist. Unnötige Wiederholung (Redundanz) führt zu Mehraufwand bei der Überarbeitung und Lektüre und könnte auch zu widersprüchlichen Festlegungen führen, wenn bei einer Änderung versehentlich nur ein Teil der mehrfachen Festlegungen geändert wird.
- YAGNI You ain't gonna need it —Zu ambitionierte Pläne führen manchmal dazu, daß Aufwand in Programmfähigkeiten gesteckt wird, die später gar nicht gebraucht werden. Insbesondere werden manchmal Programmteile auch in einer Allgemeinheit für erwartete Erweiterung geschrieben, die später gar nicht benötigt werden. (Dafür werden dann andere Erweiterungen benötigt, die nicht vorhergesehen wurden.) Es sollen nur Dinge umgesetzt werden, die auch sicher benötigt werden. Es soll das programmiert werden, was benötigt wird: Mehr zu machen als benötigt, wird bedeutet eine Verschlechterung, keine Verbesserung. Denn zusätzliche Komplexität für nicht Benötigtes ist eine nutzlose Last.
Einige Unteraspekte davon sind:
Perfektionismus Anstatt zu lange an einem Detail zu feilen, um etwas zu verbessern, das schon ausreichend implementiert ist, sollte geprüft werden, ob andere Projektteile nicht dringender einer Realisierung und Pflege benötigen.
Hammer-Effekt Für jemanden, der einen Hammer hat, ist alles ein Nagel—Wenn man eine Programmiertechnik gelernt hat, will man sie auch gerne anwenden (um sie nicht umsonst erlernt zu haben), dabei kann es sein, daß man nach Vorwänden für ihre Anwendung sucht und dann Zeit in etwas investiert, das gar nicht zu den Projektzielen gehört oder auf andere Weise einfacher erledigt werden kann.
Verspieltheit Einzelne Aspekte eines Projekts können reizvolle „Denksportaufgaben“ bieten, mit denen man sich „aus Interesse“ oder „aus Spaß“ mehr als nötig beschäftigt, obwohl diese gar nicht zum Erreichen des Projektziels dienen oder andere Projektteile aus Sicht des Projektziels dringender fertiggestellt werden müssen. - KISS Keep it simple, stupid! —Zu komplizierte Lösungen, so genial sie auch sonst sein mögen, können später nicht mehr nachvollzogen werden, so daß Programmteile unwartbar werden könnten. Daher sollte die einfachste Lösung verwendet werden, welche die Anforderungen gerade erfüllt. Wenn die Lösung als trivial und offensichtlich erscheint, ist sie gerade richtig.
http://en.wikipedia.org/wiki/KISS_principle - NIH Not invented here —Das Ablehnen von allen Bibliotheken, die man nicht selbst geschrieben hat, führt zu Mehrarbeit durch unnötiges „Neuerfinden“. Allerdings kann die Verwendung fremder Bibliotheken auch nicht uneingeschränkt empfohlen werden, da es sicher auch mangelhafte Fremdbibliotheken gibt und zu viele Abhängigkeiten Probleme bereiten können. Jedenfalls sollte die Verwendung fremder Bibliotheken aber immer geprüft werden, bevor etwas neu geschrieben wird.
- high cohesion/low coupling Die Teile einer Software-Einheit sollen untereinander eine hohe thematische Gemeinsamkeit haben, und möglichst wenige Abhängigkeiten von anderen Software-Einheiten haben. Etwas, das sich zusammen ändert (wenn eines geändert wird, muß auch das andere geändert werden) sollte zusammen aufbewahrt werden (innerhalb eines einzigen Programmteils). Die Möglichkeit zur isolierten Änderung eines Aspekts wird auch Orthogonalität genannt.
- design by contract Die Software-Einheiten sollen jeweils ein passende Bezeichnung und eine Dokumentation haben. Die Dokumentation enthält die nötigen Voraussetzung für die Aktivierung einer Einheit (Vorbedingungen) und die Folgen dieser Aktivierung (Nachbedingungen) sowie Invarianten.
http://de.wikipedia.org/wiki/Design_by_contract - Strukturierte Programmierung Ein Code-Block sollte möglichst nur einen Eingang und einen Ausgang haben. Im verallgemeinerten Sinne bedeutet ein gutstrukturierter Aufbau, daß die sichtbarer Oberflächenstruktur eines Quellcodes (einschließlich der gewählten Bezeichner) die damit ausgedrückte Tiefenstruktur möglichst erkennbar widerspiegelt (“Explicitness ”) und nicht hinsichtlich ihrer in die Irre führt. Das Gegenteil davon ist “obfuscated code ”.
- Law of Demeter Eine Code-Einheit sollte in kontrollierter Weise nur mit bestimmten „Nachbarn“ kommunizieren und nicht unkontrolliert mit beliebigen anderen Code-Einheiten. Das „Law of Demeter “ nennt einige detailliertere Regeln dazu.
http://en.wikipedia.org/wiki/Law_of_Demeter - top down, bottom up Beim top-down -Vorgehen werden höhere Operationen zuerst behandelt, dann die unteren Operationen, welche die höheren Operationen implementieren. Beim bottom-up -Vorgehen ist es umgekehrt. Natürlich ist Planung zunächst top-down , da die höheren Ziele ja vorgegeben sind und deren Implementation bestimmen. Beim Implementieren kann allerdings eine bottom-up -Vorgehensweise vorteilhaft sein, weil die tieferen Operationen oft schon ohne die höheren Funktionen getestet werden können, was umgekehrt nicht möglich wäre.
- Agile Development [ˈædʒaɪl dɪˈvɛləpmənt] Bei einer klassischen Vorgehensweise werden die zur realisierenden Fähigkeiten im voraus festgelegt. Kosten und Dauer eines Projekts ergeben sich dann daraus, aber können im voraus natürlich nur geschätzt werden. Beim Agile Development werden Kosten und Dauer von Projekt-Iterationen festgelegt, woraus sich dann im Laufe des Projekts die realisierbaren Fähigkeiten ergeben, die der Kunde jederzeit während der Projektlaufzeit ändern kann. (Ein „Kunde“ ist hierbei nicht unbedingt eine andere Person als der Programmierer. Es kann auch ein In-Haus-Kunde sein, also eine andere Person oder Abteilung aus derselben Organisation, welcher der Programmierer angehört.)
- Azyklische Abhängigkeiten Aus offensichtlichen Gründen sollten zyklische Abhängigkeiten von Software-Einheiten untereinander vermieden werden.
- Dokumentation Alle externen Bezeichner, also insbesondere Funktionen, die einigermaßen stabil sind (also nicht nur vorübergehend oder experimentell eingeführt wurden), sollten dokumentiert werden, etwa mit Doxygen. Bei Funktionen werden die notwendigen Vorbedingungen, die Wirkung, der Wert und die Parameter dokumentiert. Bei den Parametern werden jeweils die Bedeutungen und die zulässigen Werte angegeben. Bei Wert und Wirkung, wie diese sich aus den Vorzustande und den Parameterwerten ergeben. Außerdem werden Module und Schnittstellen und alle anderen Einheiten benannt und jeweils dokumentiert.
- Literate Programming Beim Literate Programming erscheinen die Kommentare eines Programms als das wesentliche Arbeitsergebnis eines Programmierers, zwischen die der Code eingebettet wird. Ein Programm wird wie ein Buch für einen menschlichen Leser geschrieben. Daraus wird der Quelltext dann generiert.
Einige Web-Quellen dazu:
An Object-Oriented Design Taxonomy, http://www.jonathanholloway.co.uk/thesis/MPhilThesis.pdf
http://en.wikipedia.org/wiki/List_of_software_development_philosophies
http://foundationsof.com/FoundationsOfProgramming.pdf
Techniken des Autors
Für die folgenden Techniken/Empfehlungen ist dem Autor dieses Textes kein allgemeiner Namen bekannt, sie erscheinen ihm allerdings als nützlich.
- Umhüllung Alle fremden, externen, nicht direkt kontrollierbaren Systeme, können durch eine Schicht von Aufrufen „umhüllt“ werden, um den Code nicht direkt an sie zu binden. So können sie bei Bedarf leicht modifiziert oder ausgetauscht werden.
void * mymalloc( size_t const size ){ return malloc( size ); }
void myfree( void * const block ){ free( block ); }
- Differenzierung Auch für gleiche Implementationen, werden unterschiedliche Aufrufe verwendet, sobald sich die Bedeutung unterscheidet. So können die Implementationen bei Bedarf später leicht differenziert werden.
int warning( char const * const text ){ fprintf( stderr, "%s\n", text ); }
int error( char const * const text ){ fprintf( stderr, "%s\n", text ); }