Programming is a craft. It is dependent on individual skill, attention to detail, and knowledge of how to use available tools in the best way. Craftsmen must know their materials, understand the principles of their craft and learn by experience. Thus, programmers must understand both host and target computer systems, must know some theory of programming, and must practise programming.
Ian Sommerville (1989, S. 308)
Die Codierung wird in diesem Buch nur kurz behandelt. Das bedeutet jedoch nicht, dass sie für das Software Engineering unwichtig ist. Vielmehr wird vorausgesetzt, dass die grundlegenden Techniken der Codierung bekannt sind und im Sinne des Zitats von Sommerville auch umgesetzt werden. Die Maxime der Codierung sollte das »ego-less-programming« sein (siehe Weinberg, 1971, 1999, und die nachfolgende IEEE-Definition), also ein Programmierstil, der nicht den Künstler in seiner Einzigartigkeit zeigt, sondern das normgerechte Ingenieurprodukt, dessen Erscheinungsbild jedem kompetenten Leser ein schnelles Verständnis verschafft. Nur solche Programme sind mit akzeptablem Aufwand wartbar.
egoless programming — A software development technique based on the concept of team, rather than individual, responsibility for program development. Its purpose is to prevent individual programmers from identifying so closely with their work that objective evaluation is impaired.
IEEE Std 610.12 (1990)
Egoless Programming bedeutet aber nicht, dass sich der Programmierer in den Schutz der Anonymität zurückzieht. Jeder Entwickler ist für seine Resultate verantwortlich. Darum werden die Urheber jeder Software-Einheit registriert, ebenso die Prüfer, denn auch sie haften im Rahmen ihres Prüfauftrags dafür, dass das Produkt in Ordnung ist. Durch Egoless Programming wird die Prüfung erleichtert, weil sich die Prüfer nicht auf den individuellen Stil der Entwickler einstellen müssen.
Die Programmiersprache ist der Werkstoff, aus dem das Programm gebaut wird. (In diesem Kapitel bezeichnet »Programm« sowohl vollständige Programme als auch Teile davon, die separate Software-Einheiten bilden.) Die Wahl des Werkstoffs (oder der Werkstoffe) ist für jedes Produkt wichtig, auch für ein Programm. Denn der Werkstoff macht gewisse Konstruktionen möglich oder unmöglich und hat Einfluss auf die Qualität und die Haltbarkeit.
Die Entwickler können in aller Regel nicht wählen, welche Programmiersprache eingesetzt wird. Denn es gibt sehr verschiedene Gründe, um eine Sprache zu favorisieren oder abzulehnen. Der wichtigste Grund ist die Beschränkung der Vielfalt in einem Unternehmen. Wenn bislang alle Systeme in der Sprache L implementiert wurden, spricht viel dafür, auch weiterhin L einzusetzen. Denn für L gibt es routinierte Entwickler und Wartungsprogrammierer, Übersetzer und andere Werkzeuge.
Trotzdem ist es wichtig, zentrale Eigenschaften und Konzepte, Stärken und Schwächen von Programmiersprachen zu kennen, die sich auf die Leistungen der Programmierer und auf die Qualität der Programme auswirken. Dabei spielen auch die Entwicklungssysteme und Bibliotheken eine Rolle, die mit den Sprachen verknüpft sind. Folgende Eigenschaften von Programmiersprachen sind in diesem Sinne wichtig:
Strukturelemente zur Konstruktion modularer Programmeinheiten
Große Programme bestehen aus vielen überschaubaren, in sich abgeschlossenen Einheiten. Damit diese Einheiten modular und von mehreren Personen zugleich codiert werden können, muss die Programmiersprache ein geeignetes Bausteinkonzept anbieten.
Trennung von Schnittstelle und Implementierung
Alle Schnittstellen eines Programms müssen syntaktisch und semantisch sauber definiert sein, damit andere Programme ohne Kenntnis seiner Implementierung die Dienste des Programms in Anspruch nehmen können. Wenn die Realisierung der Dienste für andere Einheiten unsichtbar ist, kann das Programm leicht durch ein anderes, aber an der Schnittstelle gleiches Programm ersetzt werden.
Mächtiges Typsystem mit strenger Typprüfung
Das Typsystem der Programmiersprache sollte die Definition neuer, adäquater Datentypen für die Entitäten der Anwendung unterstützen. Dadurch wird der Code gut lesbar und verständlich. Ob Zuweisungen und Parameterübergaben hinsichtlich der Typen korrekt sind, sollte so weit wie möglich statisch, also bereits bei der Übersetzung geprüft werden. Inkonsistenzen, die der Übersetzer entdeckt, können nicht zu teuren Laufzeitfehlern führen.
Syntax, die zur Lesbarkeit des Codes beiträgt
Bei der Software-Wartung ist der Programmcode das wichtigste, oft das einzige Dokument, das dem Wartungsprogrammierer einen Zugang ermöglicht. Darum ist die Lesbarkeit des Codes für die Wartung von überragender Bedeutung. Die Syntax der Programmiersprache hat darauf erheblichen Einfluss; Codierregeln oder ein Style Guide können die Lesbarkeit ebenfalls verbessern. Nachdem FORTRAN, die erste höhere Programmiersprache, mit dem Ziel entwickelt worden war, den Schreibaufwand zu reduzieren, stand bei ALGOL60 die Lesbarkeit im Vordergrund. Darum sind die Sprachen der ALGOL-Familie, beispielsweise ADA, unter diesem Gesichtspunkt besonders gut für die Codierung geeignet.
Automatische Zeigerverwaltung
Zeigertypen und Zeiger stellen dem Programmierer einen mächtigen und flexiblen, aber auch gefährlichen Mechanismus zur Verfügung. Die Gefahren entstehen vor allem durch Fehler bei der Zeigerverwaltung. Übernimmt die Programmiersprache und deren Laufzeitsystem diese Aufgabe, dann ist diese Fehlerquelle eliminiert.
Ausnahmebehandlung
Ausnahmen sind Situationen im Programmablauf, die nicht auftreten sollten; sie können durch Fehlbedienungen, Defekte oder sehr ungewöhnliche Ereignisse entstehen. Bietet die Programmiersprache die Möglichkeit, Ausnahmen und die erforderlichen Reaktionen zu definieren, dann bleibt der Code für die regulären Situationen unverändert (und damit übersichtlich); trotzdem ist durch die systematische Ausnahmebehandlung ein robustes Verhalten der Software gewährleistet (siehe Abschnitt 18.5).
Jede Programmiersprache stellt einen Kompromiss dar zwischen den Anforderungen des Programmierers und Lesers einerseits, der Hardware und den Software-Werkzeugen andererseits. Während in den ersten Jahrzehnten der Informatik die Effizienz der Übersetzung und der Programmausführung großes Gewicht hatte, ist inzwischen völlig klar, dass die Bedürfnisse der Menschen im Vordergrund stehen sollten. Zudem ist es durch Fortschritte der Übersetzertechnologie möglich geworden, auch (im Sinne des Übersetzers) komplizierte Konstrukte in hocheffizienten Code umzusetzen.
In jeder Organisation sollte es Richtlinien geben, nach denen Programme codiert werden. Diese sollten insbesondere festlegen,
wie die Bezeichner zu wählen sind,
wie das Layout zu gestalten ist,
wie die Schnittstellen zwischen den Einheiten zu gestalten sind, z. B. ob globale Variablen zugelassen sind oder wie Parameter geordnet werden,
welche Standardkommentare im Code enthalten sein sollen.
Der »GNU Coding Standard« für die Sprache C (Stallmann et al., 2005) ist ein Beispiel für solche Regelwerke, für die Programmiersprache JAVA wird häufig der von SUN entwickelte »JAVA Coding Style Guide« verwendet (Reddy, 2000).
Da die Verständlichkeit eines Programms mit steigender Größe rapide abnimmt, sind Größenbeschränkungen sinnvoll. Sie sind natürlich von der Programmiersprache abhängig. Bei einer Sprache wie ADA kann man beispielsweise fordern:
|
≤ 10 ELOC |
|
≤ 50 ELOC |
|
≤ 1400 ELOC |
ELOC (executable lines of code) erfasst alle Zeilen, die mehr als nur Kommentare und Deklarationen enthalten.
Für JAVA definiert das Metrikwerkzeug SOTOGRAPH (siehe Abschnitt 23.3.2) die folgenden Beschränkungen:
|
≤ 20 |
|
≤ 20 |
|
≤ 1000 ELOC |
Bei diesen Richtlinien kommt es – wie auch sonst oft – weniger darauf an, wie die einzelnen Aspekte geregelt sind, als darauf, dass sie geregelt sind. Genau so wichtig ist es, dass ständig überwacht wird, ob die Richtlinien auch eingehalten werden. Diese Prüfung muss für Regeln mit inhaltlichen Aussagen (»Eine Prozedur wird durch ein Verb im Infinitiv bezeichnet«) durch Reviews erfolgen. Die Einhaltung einfacher Regeln wie die oben genannten Größenbeschränkungen können Werkzeuge kontrollieren, die Teil der Entwicklungsumgebung sind.
Bei der Durchsetzung ist Radikalität geboten, denn wenn man es den Entwicklern überlässt, ob sie die Richtlinie einhalten oder nicht, kann man sich den Aufwand gleich sparen. Aber natürlich muss es auch Mechanismen geben, um die Richtlinien regelmäßig zu revidieren.
Zu den ältesten Regeln für die Codierung gehört der Rat, auf Sprungbefehle, also auf das »go to« und seine Varianten, zu verzichten, weil es die Programme unübersichtlich und schwer verständlich macht. Dijkstra hat sein berühmtes Verdikt »goto considered harmful« (in einem Leserbrief an die Communications of the ACM, Dijkstra, 1968b) auch auf die Arbeit von Böhm und Jacobini (1966) gestützt, die bewiesen hatten, dass der Sprungbefehl entbehrlich ist. In der von Dijkstra ausgelösten Diskussion gab es zwar noch einige Gegenstimmen, die prominenteste von Knuth (1974), aber die weitere Entwicklung war, wie von Wulf (1972) propagiert, durch neuere Programmiersprachen bestimmt, in denen das goto nicht mehr angeboten wurde. Aber noch 1987, fast zwanzig Jahre nach dem Brief von Dijkstra, führte ein Angriff von Frank Rubin an gleicher Stelle (»GOTO Considered Harmful« Considered Harmful) zu einer monatelangen Leserbrief-Schlacht, zu der auch Dijkstra beitrug.
Fairley (1985) gibt die folgenden Regeln für die Codierung an, die unabhängig von einer Programmiersprache gelten und darauf abzielen, dass der Code verständlich und wartbar geschrieben wird.
Don’t be too clever.
Mit sogenannten genialen Programmiertricks kann möglicherweise eine Lösung sehr geschickt implementiert werden. Sicher ist jedoch, dass sie später nicht mehr nachvollzogen und verstanden werden kann, auch nicht mehr vom Autor selbst.
Avoid null Then-statements und Avoid Then-If-statements.
Wenn moderne Kontrollstrukturen zur Verfügung stehen, dann sollten keine leeren Zweige in Bedingungen auftreten. Ebenso sollten Then-If-Konstruktionen durch Then-Elsif-Konstruktionen ersetzt werden. Das führt dazu, dass die ganze Kontrollanweisung leichter erfasst und nachvollzogen werden kann.
Don’t nest too deeply.
Stark verschachtelte Kontrollanweisungen sind komplex, schwer verständlich, schwer zu testen und sollten deshalb vermieden werden.
Avoid obscure side effects ... avoid side effects!
Seiteneffekte sind, wie der Name schon sagt, Effekte, deren Wirkung nicht an dem Ort, an dem sie verursacht wurden, festzustellen sind. Seiteneffekte führen dazu, dass der Effekt eines Code-Stücks nicht mehr lokal begrenzt ist (also z. B. innerhalb eines Moduls oder einer Klasse), sondern darüber hinaus wirkt. Dadurch ist der Code schwerer zu verstehen und zu warten.
Don’t suboptimize.
Wer ein Programm so codiert oder ändert, dass es möglichst rasch arbeitet, spricht von Optimierung; in Wahrheit handelt es sich um Tuning, wie man esauch bei Autos macht. Denn der Code wird dadurch vielleicht etwas schneller, aber kaum besser (lat. optimus = der Beste), sondern fast sicher schlechter lesbar und wartbar.
The only result of optimization you can usually be sure of without measuring performance is that you’ve made your code harder to read.
Steve McConnell
Man muss also zunächst die Leistung des Programms messen. Anschließend kann man, wenn es überhaupt nötig ist, die Leistung durch gezielte Maßnahmen verbessern. Da man erst messen kann, wenn die Implementierung fertig ist, kommt ein »vorsorgliches Tuning« (das Fairley suboptimization nennt, also Optimierung ohne Überblick) nicht in Frage. Jackson hat das sehr pointiert formuliert:
1. Don’t do it.
2. (For experts only) Don’t do it yet — that is until you have a perfectly clear and unoptimized solution.
Michael A. Jackson
Wichtigstes und erstes Ziel der Codierung ist, ein effektives Programm zu schaffen, also ein Programm, das die Aufgabe korrekt löst. Zeigen Versuche oder Messungen, dass die erzielte Effizienz nicht ausreicht, müssen die Code-Teile identifiziert werden, die der Effizienz schaden. Diese werden dann lokal optimiert. Beispielsweise hat einer der Verfasser erlebt, wie die Leistung eines sehr komplexen Programms durch 14 Assembleranweisungen an Stelle einer kurzen Schleife im PASCAL-Code um den Faktor 4 gesteigert wurde. Messungen hatten gezeigt, dass in dieser Schleife 95 % der Rechenzeit entstanden.
Routines having more than five parameters?
Operationen, die sehr viele Parameter haben, sind ein Indiz dafür, dass beim Entwurf irgendetwas schief gelaufen ist. Möglicherweise realisiert die Operation zu viel Funktionalität, die besser auf mehrere Operationen verteilt werden sollte. Ein anderer Grund für viele Parameter kann darin liegen, dass Fehler beim Entwurf der Datentypen gemacht wurden.
Don’t use an identifier for multiple purposes.
Jeder Bezeichner darf nur für einen Zweck eingeführt und benutzt werden. Die Wahl des Bezeichners soll helfen, seinen Zweck zu erkennen. Wird ein Bezeichner für verschiedene Zwecke benutzt, dann ist das für den Leser des Codes irreführend.
Diese grundlegenden Regeln können um weitere ergänzt werden.
Logische Aussagen so einfach wie möglich halten!
Logische Bedingungen sind leichter verständlich, wenn TRUE den aktiven, positiven Fall bezeichnet; also sollte man eine Zustandsvariable für den Drucker nicht »DruckerAusgeschaltet«, sondern »DruckerEingeschaltet« nennen.
Negationen sind schwer verständlich, besonders, wenn sie mit anderen, womöglich negierten Bedingungen verknüpft sind. Eine bedingte Anweisung
if not (Bildschirmausgabe or DruckerAusgeschaltet) then ...
ist geradezu eine Garantie für Missverständnisse in der Wartung.
Text- und Zahlen-Literale (außer 0 und 1) gehören nicht in das (ausführbare) Programm!
Alle Literale, Meldungen usw. müssen als Konstanten deklariert werden. In aller Regel ist es sinnvoll, die Meldungen in separate Einheiten zu verlagern, damit eine Umstellung für ein anderes Land ohne Eingriffe in das Programm möglich ist.
Jedes Programm muss ausreichend kommentiert sein!
Zur Kommentierung sollte ein festes Schema verwendet werden (z. B. für Kopfkommentare von Operationen).
Auch damit bleiben wir natürlich von einer vollständigen Aufzählung aller Regeln, die beim Programmieren zu beachten sind, weit entfernt. Das sehr alte, aber grundsätzlich weiterhin aktuelle und sehr lesenswerte Buch von Kernighan und Plauger (1978) ist eine Fundgrube für sinnvolle Ratschläge.
Die Definition des Begriffs Software schließt alle Dokumente ein, die zur Entwicklung, zum Einsatz und zur Wartung notwendig sind. Ein Satz wie »Die Software ist fertig, sie muss nur noch dokumentiert werden« ist also ein Widerspruch in sich!
Wir wollen hier darauf eingehen, wie und in welchem Umfang der entwickelte Code dokumentiert werden sollte.
In der Zeit der Assemblerprogrammierung war es üblich und sinnvoll, die Algorithmen abstrakt zu formulieren, bevor sie codiert wurden. Dieser Schritt war der Feinentwurf. Heute ist er in der Regel in die Codierung integriert, denn die hohen Programmiersprachen erlauben es, Algorithmen direkt und gut lesbar zu formulieren. (Man kann aber natürlich in jeder Sprache auch schlecht programmieren!) Eine grafische Darstellung von Algorithmen ist angesichts der Strukturierungsmöglichkeiten, die hohe Programmiersprachen zur Verfügung stellen, heute kaum mehr sinnvoll.
Nur dort, wo man beim Codieren »Uhrmacherei« betreibt, also z. B. bei raffinierten Algorithmen oder bei Verwendung von Zeigern, ist eine separate Darstellung noch sinnvoll. Natürlich ist sie nach wie vor geboten, wenn die Programmiersprache exotisch oder schlecht lesbar ist. Abbildung 18–1 zeigt zwei Implementierungen eines iterativen Algorithmus, um den größten gemeinsamen Teiler zweier Zahlen zu bestimmen. Im linken Teil ist eine 8086-Assembler-, im rechten Teil eine JAVA-Fassung abgebildet. Es wird deutlich, dass die JAVA-Fassung auf Grund der Sprachkonstrukte einfacher nachvollzogen werden kann, während das bei der 8086-Assembler-Fassung ohne eine detaillierte Kommentierung unmöglich ist.
Abb. 18–1 8068-Assembler- und JAVA-Fassung des GGT-Algorithmus
Die objektorientierte Programmierung führt dazu, dass Objekte verschiedener Klassen zusammenarbeiten, um eine Dienstleistung oder einen Algorithmus zu realisieren. Dieses Zusammenspiel kann sehr komplex sein und ist dann alleine auf der Basis der Implementierung nur schwer zu verstehen. Aus diesem Grund ist es notwendig, komplexe Interaktionen der Objekte detailliert zu beschreiben. Dazu können beispielsweise Sequenzdiagramme aus UML verwendet werden (siehe z. B. Rupp et al., 2005). Sequenzdiagramme modellieren die Kommunikation der Objekte dadurch, dass die Nachrichten zwischen den Objekten an der Zeitachse dargestellt werden. So kann recht übersichtlich gezeigt werden, welche Objekte mit welchen Nachrichten an der Realisierung eines Algorithmus beteiligt sind. Abbildung 18–2 zeigt ein Sequenzdiagramm, das die Interaktion zwischen den am MVC-Architekturmuster beteiligten Objekten modelliert (Abschnitt 17.5.1). Dargestellt ist die Nachrichtenfolge, mit der ein View-Objekt über eine Änderung des Model-Objekts informiert wird, damit es sich anpasst.
Abb. 18–2 Beispiel eines Sequenzdiagramms
Die integrierte Dokumentation ist der Teil der Dokumentation, der in Form von Kommentaren, Leerzeilen und Gestaltung (Layout) im Programmcode enthalten ist (Abschnitt 12.1).
Kommentare im Programm sind für die Wartung sehr wichtig, zumal alte Software meist auf die Programme zusammengeschmolzen ist. Denn die separaten Dokumente werden, falls sie überhaupt je entstanden sind, kaum seriös nachgeführt und verlieren daher rasch ihren Wert. Die Programme sollten also möglichst »autark« sein. In ADA (1995) werden die folgenden generellen Anforderungen an die Kommentierung von Code aufgestellt:
1. Make the code as clear as possible to reduce the need for comments!
Damit das Programm verständlich und lesbar ist, müssen die Bezeichner »sprechend« gewählt, die Code-Abschnitte übersichtlich strukturiert und unnötig komplexe Konstruktionen vermieden werden.
2. Never repeat information in a comment that is readily available in the code!
Für Kommentare gilt: Nicht das im Code ohnehin sichtbare wiederholen, sondern das nicht oder nur schwer sichtbare ergänzen! Schwer sichtbar sind Bedingungen, die an einem Punkt erfüllt sind oder erfüllt werden müssen. Nicht sichtbar sind die Absichten, die der Programmierer mit seinen Anweisungen in der Problemwelt verfolgt.
3. Where a comment is required, make it concise and complete!
Kommentare sollen immer so knapp wie möglich formuliert werden. Es sind jedoch immer alle Informationen anzugeben, die notwendig sind, um den Code zu verstehen.
4. Use proper grammar and spelling in comments!
Viele Programmierer (und leider auch solche mit Hochschulabschluss) begreifen nicht, dass korrekte Syntax nicht nur für den Code wichtig ist. Jedes fehlende (oder falsche) Komma, jeder falsche Plural, jedes verkrüppelte Wort macht den Kommentar schlechter lesbar, erhöht die Wahrscheinlichkeit von Missverständnissen und senkt das Vertrauen der Leser in die Zuverlässigkeit der Aussagen.
5. Make comments visually distinct from the code!
Eine geeignete Formatierung, die die Kommentare gut vom Code abhebt, hilft dem Leser, Code und Kommentar visuell zu trennen, und unterstützt dadurch die Lesbarkeit der Programme.
6. Structure comments in headers so that information can be automatically extracted by a tool!
Nur so kann ein Werkzeug wie Javadoc aus der integrierten Dokumentation automatisch ein kompaktes Dokument generieren.
Die nachfolgenden Beispiele zeigen, wie Regel 2 verstanden werden sollte.
Schlechtes Beispiel:
x := x / Float (n); -- x wird durch n geteilt
-- Falls Dateiende erreicht, Ruecksprung,
-- sonst wird NaechsteEingabeHolen ausgefuehrt.
if End_of_File then return 0 else NaechsteEingabeHolen() end if;
Gutes Beispiel:
-- n > 0
x := x / Float (n); -- x wird normiert
-- 0 <= x <= 1
-- Jetzt ist die Eingabe entweder erschoepft,
-- oder es steht noch mindestens ein Datensatz an
if End_of_File then return 0 else NaechsteEingabeHolen() end if;
-- der Eingabepuffer ist neu gefuellt
Kommentare in Programmen haben immer eine von drei Formen:
Kopfkommentare stehen am Anfang der Programmeinheiten, insbesondere solcher, die separat verwaltet werden, also Module, Klassen o. Ä., aber auch über Prozeduren und Funktionen.
Eingeschobene Kommentare erläutern Zeilen oder Abschnitte einer Programmeinheit.
Erläuterungen sind kurze Kommentare, die unmittelbar beim kommentierten Code stehen, z. B. hinter einer Deklaration oder einer Wertzuweisung.
Im Kopfkommentar jeder separaten Programmeinheit (typischerweise jeder Datei, die Code enthält) sind mindestens folgende Informationen anzugeben:
Verfasser und Datum,
Programmiersprache (mit Versionsangabe),
Umgebung (Betriebssystem, andere verwendete Software-Komponenten, ggf. auch die Rechnerkonfiguration, falls sie eine Rolle spielt),
Kurzbeschreibung,
Beziehung zu anderen Programmeinheiten, z. B. welche Programmeinheiten werden benutzt (wenn das nicht aus dem Code hervorgeht),
Verweise auf externe Dokumente,
Verfasser, Datum, Anlass und Effekt von Änderungen (sofern diese Information nicht das Versionsverwaltungssystem liefert).
Für viele Programmiersprachen gibt es Konventionen, um obligatorische Angaben in Kopfkommentaren in standardisierter Form anzugeben. Diese Informationen können von einem Werkzeug extrahiert und zu einer Code-Dokumentation zusammengebunden werden; für JAVA leistet das JAVADOC. So entsteht eine externe Sicht auf die interne Code-Dokumentation, die immer aktuell generiert werden kann.
In Abschnitt 17.3.3 haben wir Information Hiding als ein wichtiges Entwurfsprinzip vorgestellt und gesehen, dass es zwei unterschiedliche Ausprägungsformen gibt, die Kapselung und der Abstrakte Datentyp.
Die Kapselung, also der Schutz der Daten vor unbefugtem Zugriff, wird wie folgt realisiert:
Die zu schützende Datenstruktur wird in einen speziellen Baustein gelegt und anderen Programmteilen nicht zugänglich gemacht.
Alle Operationen, die die Daten lesen oder modifizieren, werden identifiziert, ihre Schnittstellen werden festgelegt und exportiert.
Die Implementierung dieser Operationen bleibt für alle Programmteile außerhalb dieses Bausteins unsichtbar.
Änderungen der geschützten Datenstruktur, die keine Auswirkungen auf die Schnittstelle haben, können rein lokal durchgeführt werden. Nur die Zugriffsoperationen sind anzupassen.
Wenn sich eine Änderung der Schnittstelle nicht vermeiden lässt, sollte sie sich in der Syntax niederschlagen, selbst dann, wenn es technisch möglich wäre, die Syntax beizubehalten. Wenn beispielsweise an Stelle des Alters einer Person neu ihr Jahrgang gespeichert wird, könnte dazu weiterhin der Parameter »Alter« vom Typ integer verwendet (missbraucht) werden. Das hätte aber sehr wahrscheinlich zur Folge, dass notwendige Änderungen anderer Programmteile unterblieben. Darum sollte der neue Parameter einen anderen Typ haben. Der Übersetzer meldet dann alle inkompatiblen Aufrufe und damit alle Stellen, die der neuen Semantik angepasst werden müssen.
Um die Kapselung sauber in einer Programmiersprache codieren zu können, muss die Sprache
ein Modulkonzept anbieten,
es erlauben, dass Datenstrukturen nur im deklarierenden Modul sichtbar sind,
eine Export-Möglichkeit für die öffentliche Schnittstelle zur Verfügung stellen.
Für (konzeptionell ältere) imperative Programmiersprachen (z. B. C, FORTRAN95) gibt es Richtlinien und Konventionen, um die Module angemessen zu codieren. In objektorientierten Programmiersprachen werden Datentypmodule durch Klassen realisiert. Da diese Sprachen ausschließlich das Klassenkonzept zur Verfügung stellen, ist die Realisierung Abstrakter Datentypen (siehe Abschnitt 18.4.2) konzeptionell einfacher als die der »einfachen« Kapselung. Darum ist bei Datenobjektmodulen und bei funktionalen Modulen darauf zu achten, dass – beispielsweise durch Anwendung des Entwurfsmusters Singleton (siehe Abschnitt 17.5.2) – nur ein Exemplar davon erzeugt und benutzt wird.
In einer Kapselung soll eine Menge von Datensätzen verwaltet werden, die jeweils durch einen eindeutigen Schlüssel, eine höchstens dreistellige natürliche Zahl, identifiziert sind. Ein Datensatz ist eine Zeichenkette aus höchstens 200 Zeichen. Die Menge ist zu Beginn leer; mögliche Operationen sind Einfügen, Lesen und Löschen eines Datensatzes.
Die Zahl der Datensätze, die gespeichert werden können, ist begrenzt. Um das Problem möglichst einfach zu halten, spezifizieren wir, dass das Einfügen keinen Effekt hat, wenn die Obergrenze erreicht ist. Für eine praktische Anwendung ist das natürlich nicht brauchbar.
Anmerkung: Man kann an diesem Beispiel leicht zeigen, dass die Spezifikation unvollständig und missverständlich ist. Ist die Zahl 0 als Schlüssel zulässig? Sind auch Zeichenreihen mit 0 Zeichen zulässige Datensätze? Was passiert, wenn ein nicht vorhandener Datensatz gelesen oder gelöscht werden soll? Oder wenn ein schon vorhandener Schlüssel erneut verwendet wird? Diese und weitere Fragen wären natürlich zu klären. Für die Diskussion der Kapselung sind sie aber ohne Bedeutung.
Da diese Sprache die für die Implementierung einer Kapselung notwendigen Konzepte zur Verfügung stellt, ist die folgende Export-Schnittstelle vorgezeichnet:
package PDatenListe is
-- Kapselung einer Datenliste
DatenLng : constant Integer := 200;
MaxSchluessel : constant Integer := 999;
type Schluessel is range 1 .. MaxSchluessel;
subtype Datenteil is String (1 .. DatenLng);
type Datensatz is
record
Nr : Schluessel;
Inhalt : Datenteil;
end record;
procedure Einfuegen (Neu : in Datensatz);
-- fuegt einen neuen Datensatz in die Liste ein.
-- Hat keinen Effekt, falls die Liste voll ist
-- oder falls der Schluessel schon vorhanden ist
procedure Loeschen (Nr : in Schluessel);
-- loescht ein Element der Liste.
-- Das Element mit Schluesssel = Nr muss vorhanden sein
procedure Lesen (Nr : in Schluessel;
Vorh : out Boolean;
Inh : out Datenteil);
-- liefert Vorh = TRUE und den Inhalt des Datenteils zu Nr in Inh
-- oder Vorh = FALSE und Inh = undefiniert,
-- falls Nr in der Liste nicht als Schluessel vorkommt.
end PDatenListe;
Man beachte, dass der Typ des einzelnen Datensatzes exportiert wird, damit die Parameter, z. B. für die Prozedur Einfuegen, deklariert werden können, nicht jedoch der Typ der Datenmenge insgesamt. Es ist damit völlig offen, ob sie als Feld, als verkettete Zeigerstruktur oder sonst wie realisiert wird. Es ist auch nicht festgelegt, ob die Menge geordnet oder ungeordnet gespeichert wird. Im nachfolgenden Ausschnitt wird eine ungeordnete, dichte, d. h. lückenlose Speicherung im Feld gewählt. Sind wesentlich mehr als die hier vorgesehenen 50 Einträge zu verwalten, so würde man auf eine geordnete Liste übergehen oder auf andere Weise einen schnellen Zugriff ermöglichen.
Im Rumpf des Paketes, der von außen nur durch die oben gezeigte Schnittstelle sichtbar ist, wird die geschützte Datenstruktur realisiert.
Wir zeigen die Deklarationen und eine der Prozeduren:
package body PDatenListe is
MaxZahlDS : constant Integer := 50;
type ListenFeld is array (1 .. MaxZahlDS) of Datensatz;
type DatenListe is
record
LngListe : Integer := 0;
DSListe : ListenFeld;
end record;
Dl : DatenListe; -- geschuetzte Variable
procedure Einfuegen (Neu: in Datensatz) is
I : Positive;
begin
if Dl.LngListe < MaxZahlDS then -- neuer Eintrag moeglich
Dl.DSListe(Dl.LngListe + 1) := Neu;
I := 1; -- Suche mit Sentinel
while Dl.DSListe(I).Nr /= Neu.Nr loop
I := I + 1;
end loop;
if I > Dl.LngListe then Dl.LngListe := I; end if;
end if;
end Einfuegen;
...
end PDatenListe;
Ein Kundenmodul von PDatenListe importiert die Operationen und wendet sie an, ohne Information über die Implementierung zu haben. Selbst wenn der Programmierer weiß, wie die Kapsel realisiert ist, kann er dieses Wissen nicht ausnutzen, denn die Bezeichner des Implementationsteils sind nur lokal gültig.
C bietet keine explizite Möglichkeit, Daten oder Datenstrukturen zu schützen, wir müssen uns darum behelfen. Die Schnittstelle der Kapsel beschreiben wir in einer Header-Datei (DatenListe.h). Dort deklarieren wir die Typen, die bekannt sein sollen, und geben die Funktionsprototypen für die Manipulationsoperationen an. Die Header-Datei hat damit folgendes Aussehen: (Die Kopfkommentare der Operationen sind aus Platzgründen nicht wiedergegeben.)
#define DATENLNG 200
typedef char datenteil[DATENLNG];
typedef struct {
int nr;
datenteil inhalt;
} datensatz;
void einfuegen(datensatz* neu);
void loeschen(int nr);
datenteil* lesen(int nr);
In der dazugehörenden Code-Datei wird diese Header-Datei importiert, die zu schützende Datenstruktur wird deklariert und die Operationen werden implementiert.
#include "DatenListe.h"
#define MAXZAHLDS 50 // max. Zahl der Eintraege
static datensatz* DSListe[MAXZAHLDS]; // Speicherung dicht, ungeordnet
static int lngListe = 0; // real belegte Eintraege
void einfuegen(datensatz* neu) {
int i;
if (lngListe<MAXZAHLDS) { // neuer Eintrag moeglich
DSListe[lngListe] = neu;
lngListe++;
i = 0; // Suche mit Sentinel
while (DSListe[i]->nr != neu->nr) i++;
if (i<lngListe-1) lngListe--; // Eintrag rueckgaengig machen,
} // wenn nr schon vorhanden ist
}
...
Diese Konstruktion verhindert, dass ein Kundenmodul der DatenListe die Code-Datei importieren kann; ein direkter Zugriff auf die geschützte Datenstruktur DSListe ist von außen nicht möglich. Eine Anweisung der Art DSListe[0]->nr = 3 führt zu einer Fehlermeldung des Übersetzers.
Wenn es nicht um eine spezielle Datenstruktur geht, sondern um mehrere Strukturen gleichen Typs, beispielsweise um mehrere Puffer oder um die Beschreibung einer unbestimmten Zahl von Flugzeugen, die von einer Leitstelle aus überwacht werden, dann genügt die Kapselung einer Datenstruktur nicht mehr; vielmehr muss jetzt der Typ gekapselt werden, es entsteht ein Abstrakter Datentyp (ADT).
Ein ADT wird formal durch den Namen eines Typs, dessen Struktur nicht sichtbar ist, und durch die Menge zulässiger Operationen auf den Exemplaren dieses Typs beschrieben. Der Typname wird exportiert und kann in anderen Modulen zur Deklaration von Variablen oder zur Definition weiterer Typen verwendet werden. Die konkrete Bearbeitung sämtlicher Variablen dieses Typs bleibt aber Sache des Moduls, das den ADT anbietet.
Da jedes einzelne Exemplar initialisiert werden muss, erfordert dieses in jedem Fall eine aufrufbare Operation. Wo die Initialisierung eine Speicherbelegung auf der Halde impliziert, ist auch eine Lösch-Operation nötig.
Während man die Kapselung auch in dafür ungeeigneten Sprachen noch leidlich gut realisieren kann, muss die Sprache spezielle Möglichkeiten vorsehen, um ADTs implementieren zu können. Damit kommen nur moderne Sprachen in Betracht. In Sprachen wie ADA und FORTRAN-90 lassen sich ADTs elegant und sicher realisieren, in objektorientierten Sprachen werden ADTs als Klassen implementiert.
Das folgende Beispiel zeigt einen ADT, der einen Typ für die Datenliste bereitstellt. Die Realisierung in ADA erfolgt (meist) so, dass die geschützte Typinformation im sogenannten privaten Teil der Schnittstellendefinition steht. Der Inhalt des privaten Teils dient nur dem Übersetzer, um die Speicherplatzbelegung zu ermitteln. Andere Module (packages) haben keinen Zugriff darauf.
package PDatenListe_ADT is
-- Abstrakter Datentyp fuer eine Datenliste
DatenLng : constant Integer := 200;
MaxSchluessel : constant Integer := 999;
type Schluessel is range 1 .. MaxSchluessel;
subtype Datenteil is String (1..DatenLng);
type Datensatz is
record Nr : Schluessel;
Inhalt : Datenteil;
end record;
type DatenListe is private;
function InitListe return DatenListe;
procedure Einfuegen (Dl : in out DatenListe; Neu : Datensatz);
procedure Loeschen (Dl : in out DatenListe; Nr : Schluessel);
procedure Lesen (Dl : in DatenListe; Nr : Schluessel;
Vorh : out Boolean; Inh : out Datenteil);
private
MaxZahlDS : constant Integer := 50;
type ListenFeld is array (1 .. MaxZahlDS) of Datensatz;
type DatenListe is
record LngListe : Integer := 0;
DsListe : ListenFeld;
end record;
end PDatenListe_ADT;
Man beachte, dass im privaten Teil ebenso gut eine Speicherung in einer verketteten Liste stehen könnte:
private
type Knoten;
type KnotenZeiger is access Knoten;
type Knoten is
record Ds: Datensatz;
Naechster: KnotenZeiger;
end record;
type DatenListe is
record Kopf: KnotenZeiger;
end record;
Natürlich bietet es sich in unserem Beispiel auch an, die Datenstruktur Datensatz als ADT zu realisieren.
Die Kapselung einzelner Datenstrukturen und die Bildung Abstrakter Datentypen folgen demselben Prinzip; wo es um eine einzelne Datenstruktur geht, müssen nur die Prozeduren exportiert werden, die zu bearbeitende Datenstruktur ist implizit durch die Prozeduren ausgewählt und bleibt ganz intern. Bei den ADTs muss dagegen ein Exemplar des Typs als Parameter an die Operationen übergeben werden.
Kapselung und ADTs sind nicht einfach Implementierungstricks, sondern ein Denkansatz. Darum ist es nicht überraschend, dass man zunächst kaum Anwendungen dafür sieht. Dies ändert sich rasch, wenn man diesen Denkansatz verinnerlicht hat. Als Faustregeln kann man sich merken:
Wo eine komplexe Datenstruktur existiert, sollte sie gekapselt sein. Kommen mehrere Exemplare dieser Art vor, so entsteht ein ADT.
Alle Schnittstellen der Software zur Umgebung (Ausgabegeräte, technischer Prozess, Bedienung) und zum Betriebssystem werden gekapselt.
Auch primitive Variablen werden gekapselt, wenn sie im System zentrale Bedeutung haben.
Die Forderung nach Robustheit liegt am Rand der funktionalen Anforderungen: Einerseits geht es um Funktionalität, nämlich um die Reaktion auf konkrete Bedienungen und Eingaben. Andererseits werden die Bedienungen und Eingaben, auf die die Software robust reagieren soll, nicht präzise beschrieben, sondern meist nur vage umrissen (»Fehleingaben«, »andere Bedienungen«). Das ist für die nichtfunktionalen Anforderungen typisch. Auch das IEEE definiert die Robustheit als nichtfunktionale Anforderung:
robustness — The degree to which a system or component can function correctly in the presence of invalid inputs or stressful environmental conditions.
IEEE Std 610.12 (1990)
Bei der Codierung ist es zweckmäßig, die Robustheit als funktionale Anforderung zu behandeln. Dazu wird möglichst präzise erfasst, welche Eingaben durch die Spezifikation explizit abgedeckt sind. Dies sind typisch die Normal- und Fehlerfälle, an die bei der Analyse gedacht wurde. Alle anderen Fälle kann man dann durch entsprechende Bedingungen abfangen und behandeln.
Tritt eine Fehlersituation auf, dann reagiert robuster Code, indem er eine Fehlermeldung erzeugt, ein »Fehler-Flag« setzt oder eine Ausnahme generiert, die den Fehler behandelt. Es gibt viele mögliche Fehlersituationen, auf die der Code robust reagieren sollte, beispielsweise
fehlerhafte Bedienung durch den Anwender (ein Datum wird im falschen Format eingegeben),
fehlerhafte Umgebungssituation (Daten können nicht gespeichert werden, weil die Verbindung zum Server unterbrochen wurde).
Betrachten wir als Beispiel eine Anwendung, mit der ein Kunde einer Online-Versicherung seinen Datenbestand einsehen und ändern kann. Die Komponenten der Präsentationsschicht sind robust implementiert, wenn sie die eingegebenen Daten des Kunden (z. B. die Vertragsnummer oder eine Postleitzahl) prüfen und auf Fehler hinweisen und auch bei groben Bedienungsfehlern sinnvoll reagieren.
Die Komponenten der Anwendungsschicht verhalten sich robust, indem sie verständliche und hilfreiche Mitteilungen erzeugen, wenn beispielsweise Referenzdateien oder Datenbanken nicht erreichbar sind.
Der Code für die Fehlerbehandlung kann sehr umfangreich sein; bei größeren Systemen kann er 25 % bis 50 % des gesamten Codes ausmachen. Deshalb ist darauf zu achten, dass die Prüfungen den Code nicht unübersichtlich und schwer verständlich machen. Der Code zur Fehlerbehandlung sollte klar als solcher erkennbar und optisch vom funktional notwendigen Code getrennt sein. Werden in mehreren Operationen gleiche oder ähnliche Prüfsequenzen benötigt, dann sollten sie nur einmal implementiert und mehrfach verwendet werden.
C.A.R. Hoare hat in den Sechzigerjahren eine Technik entwickelt, um Programme zu verifizieren (Hoare, 1969). Grundbaustein dieser Technik ist das sogenannte Hoare-Tripel:
{ P } S { Q }
Darin ist S eine Operation (z. B. eine Anweisung des Programms), P und Q sind Vor- und Nachbedingungen der Operation S. Das Hoare-Tripel besagt, dass, wenn vor der Ausführung von S die Vorbedingung P galt und S terminiert, nach der Ausführung von S die Nachbedingung Q gilt. Beispielsweise ist das folgende Tripel nachweislich korrekt, wenn mit ganzen Zahlen gerechnet wird:
{ x = y } y := y - x + 1; { y = 1 }
Wenn sich hinter der Operation S keine elementare Anweisung verbirgt, sondern eine Iteration, z. B. eine WHILE-Schleife, ist es erforderlich, auch den Zustand nach jedem Iterationsschritt zu betrachten. Dazu dienen die Schleifeninvarianten.
{a ≥ 0 ∧ a + b = k}
while a > 0 do a := a-1; b := b+1; { a + b = k } end while;
{a = 0}
In diesem Fall garantiert die Invariante a + b = k zusammen mit der Nachbedingung a = 0, dass der Wert von b am Ende der Schleife der Summe von a und b zu Beginn entspricht.
Bertrand Meyer (1997) hat dieses Konzept auf Klassen und Methoden übertragen und als Vertragsmodell bezeichnet (Design by Contract). Wenn die Klasse K1 eine Methode M der Klasse K2 in Anspruch nimmt, muss K1 sicherstellen, dass vor Ausführung von M deren Vorbedingung erfüllt ist. K2 garantiert dann, dass nach Abschluss der Methode M die Nachbedingung gilt.
Stellt beispielsweise die Klasse Ordner eine Methode verschieben (Datei d, Ordner ziel) zur Verfügung, mit der eine Datei vom aktuellen Ordner in einen anderen Ordner verlagert werden kann, so ist die Vorbedingung, dass die Datei im Quellordner vorhanden und nicht geschützt ist und im Zielordner keine Datei mit demselben Namen liegt. Die Nachbedingung ist, dass die inhaltlich unveränderte Datei im Ziel-, aber nicht mehr im Quellordner liegt. Jede Klasse, die diese Methode nutzt, ist dafür verantwortlich, dass die Vorbedingung gilt. Die Klasse Ordner garantiert die Nachbedingung.
Das Vertragsmodell präzisiert somit die Benutzt-Beziehung zwischen Klassen durch »formale« Verträge. Die Verträge sind in der Anbieterklasse beschrieben und werden in Form von sogenannten Zusicherungen formuliert.
Eine Zusicherung im Kontext des Vertragsmodells ist eine logische Aussage über Elemente des Codes (z. B. über die Parameterbelegung oder die Werte von Exemplarvariablen). Um eine Klasse und deren Methoden zu spezifizieren, werden drei Arten von Zusicherungen verwendet:
Eine Vorbedingung muss die Kundenklasse einhalten, damit die aufgerufene Methode der Anbieterklasse auch korrekt arbeiten kann. Die Prüfung der Vorbedingung ist keine Aufgabe der Anbieterklasse.
Eine Nachbedingung definiert den Zustand nach Ausführung der Methode durch die Anbieterklasse. Diesen Zustand garantiert die Anbieterklasse.
Eine Klasseninvariante ist die Bedingung, die während der gesamten Lebensdauer der Objekte der Klasse gilt; nur während der Ausführung einer Methode kann sie vorübergehend verletzt sein. Die Klasseninvariante muss also von allen Methoden der Anbieterklasse eingehalten werden, d. h., die Invariante gilt vor und nach jeder Ausführung einer Methode der Klasse.
Wird ein Vertrag verletzt, dann kann leicht festgestellt werden, wo die Ursache liegt, da Verpflichtungen und Rechte klar definiert sind.
Wird die Vorbedingung verletzt, dann hat die Kundenklasse ihre Verpflichtungen nicht eingehalten.
Wird die Nachbedingung oder die Klasseninvariante verletzt, nachdem die Vorbedingung erfüllt war, liegt der Fehler in der Anbieterklasse.
Wendet man das Vertragsmodell bei der Codierung der Klassen systematisch an, indem man sie durch Klasseninvarianten, Vor- und Nachbedingungen spezifiziert, so kann die Klasse lokal korrekt implementiert werden. Eine Klasse A ist nach Frick, Zimmer und Zimmermann (1995) lokal korrekt, wenn
1. die Invariante invA gilt, nachdem ein Objekt der Klasse A erzeugt wurde,
2. vor und nach jedem Aufruf jeder beliebigen Methode m der Klasse A die Invariante invA gilt und
3. nach Aufruf jeder Methode m der Klasse A die Nachbedingung postm gilt, sofern vorher die Vorbedingung prem erfüllt war.
Da Zusicherungen logische Aussagen über Programmelemente sind, bietet es sich an, sie formal oder semiformal in der Definition der Schnittstelle zu beschreiben. Die Vor- und Nachbedingung der Methode verschieben der Klasse Ordner könnten semiformal etwa folgendermaßen formuliert werden:
verschieben (Datei d, Ordner ziel)
Vorbedingung:
(d ist im Quellordner enthalten) AND
(d ist nicht geschützt) AND
(ziel enthält keine Datei mit gleichem Namen)
Nachbedingung:
(d ist nicht im Quellordner enthalten) AND
(d ist in ziel enthalten) AND
(d ist unverändert)
Diese Schreibweise ist zwar leicht verständlich, aber nicht präzise und ungeeignet, um die Zusicherungen automatisch zu prüfen. Dies leistet nur eine formale Notation wie die standardisierte Sprache OCL (Object Constraint Language). OCL ist Teil der UML-Dokumentation; sie wird immer dann verwendet, wenn sich notwendige Einschränkungen in der grafischen UML-Notation nicht (sinnvoll) darstellen lassen. OCL ist deklarativ, Ausdrücke haben keine Nebenwirkungen. Die Sprache ist detailliert in OMG-UML (o.J.) und in Warmer und Kleppe (2003) beschrieben. Wir gehen hier nur auf Sprachelemente ein, die für die Formulierung von Zusicherungen benötigt werden.
Jedem OCL-Ausdruck muss ein sogenannter Kontext zugewiesen werden. Für Invarianten ist das eine Klasse, für Vor- und Nachbedingungen die Methode, deren Verhalten spezifiziert wird.
Nehmen wir an, dass die Klasse Datei die Attribute name, inhalt und istGeschuetzt definiert und dass die Klasse Ordner das Attribut dateien besitzt, um die Dateien zu verwalten. Als Invariante der Klasse Ordner legen wir fest, dass in jedem Ordner die Namen aller darin abgelegten Dateien unterschiedlich sein müssen. Diese Invariante kann in OCL folgendermaßen formuliert werden:
context Ordner inv:
Ordner.allInstances()->forAll
(o | o.dateien->isUnique(d | d.name))
Die Vor- und Nachbedingung von Methoden werden mit dem Schlüsselwort pre: bzw. post: eingeführt; auf Werte von Attributen vor Ausführung einer Methode kann mit dem Operator @pre zugegriffen werden. Die bereits vorher semiformal beschriebenen Vor- und Nachbedingung der Operation verschieben können in OCL folgendermaßen formuliert werden:
context Ordner::verschieben(Datei d, Ordner ziel)
pre:
self.dateien->exists(quelle_d | quelle_d.name = d.name) and
not d.istGeschuetzt and
not ziel.dateien->exists(ziel_d | ziel_d.name = d.name)
post:
not self.dateien->exists(quelle_d | quelle_d.name = d.name) and
ziel.dateien->exists(ziel_d | ziel_d.name = d.name) and
d.name = d.name@pre and
d.inhalt = d.inhalt@pre
Mit OCL-Zusicherungen kann das Verhalten der Methoden nicht nur spezifiziert, sondern auch bei der Ausführung des Programms kontrolliert werden. Für JAVA gibt es bereits (experimentelle) Werkzeuge, die OCL-Ausdrücke in entsprechenden Prüfcode übersetzen. Damit wird die Spezifikation automatisch für die Prüfung herangezogen, bleibt aber dennoch implementierungsunabhängig.
In OCL kann man positive Aussagen machen, aber keine negativen. Darum lässt sich formal nicht ausschließen, dass eine Operation zwar die definierten Zusicherungen erfüllt, aber auch nicht beabsichtigte Effekte hervorruft.
Die erste (und bisher einzige) Programmiersprache, die das Vertragsmodell explizit unterstützt und als Teil der Sprache enthält, ist EIFFEL (Meyer, 1992). EIFFEL definiert eine eigene Teilsprache für Zusicherungen und sieht entsprechende Möglichkeiten vor, die Prüfung der Zusicherungen ein- und auszuschalten.
Für JAVA wurden in Form von JASS (JAVA with Assertions; Bartetzko et al., 2001) und JML (JAVA Modeling Language; Leavens, Baker, Ruby, 1999) Erweiterungen vorgeschlagen, die das Vertragsmodell in die Sprache integrieren. Champlain (1997) beschreibt im »Contract-Pattern«, wie das Vertragsmodell in JAVA mit Hilfe des vorhandenen Ausnahmenmechanismus eingebracht werden kann.
Von der Version 1.4 an unterstützt JAVA die Programmierung nach dem Vertragsmodell dadurch, dass Zusicherungen – in eingeschränkter Form – mit Hilfe der assert-Anweisung und der Ausnahme AssertError direkt formuliert werden können. Die assert-Anweisung enthält einen logischen Ausdruck, der die zu prüfende Bedingung beschreibt. Als zweiter, optionaler Ausdruck kann ein Fehlertext angegeben werden, der protokolliert wird, wenn die zu prüfende Bedingung nicht (!) erfüllt ist.
Betrachten wir dazu wieder die Klasse Ordner und deren Methode verschieben. Wie sehen nun Vor- und Nachbedingung und die Klasseninvariante aus?
Als Klasseninvariante definieren wir folgende Bedingung: Die Namen aller Dateien in einem Ordner sind verschieden. Diese Bedingung wird in der privaten Operation invariante implementiert.
Die Vorbedingung der Methode verschieben besteht aus den bereits bekannten Bedingungen: Die zu verschiebende Datei muss im Quellordner vorhanden und darf nicht geschützt sein, im Zielordner darf keine Datei gleichen Namens enthalten sein.
Als Nachbedingung muss die Methode sicherstellen, dass die Datei nicht mehr im Quell-, sondern im Zielordner enthalten ist und nicht verändert wurde.
Nachfolgend ist der Code der Operation verschieben abgebildet, in dem die Zusicherungen durch Assert-Anweisungen realisiert sind.
public void verschieben(Datei d, Ordner ziel) {
assert !d.istGeschuetzt() : "Datei ist geschützt";
assert this.istEnthalten(d) : "Datei existiert nicht";
assert invariante() : "Invariante verletzt";
Datei orgDatei = d.kopiere();
ziel.aufnehmen(d);
this.loeschen(d);
assert !this.istEnthalten(d) : "Datei nicht geloescht";
assert ziel.istEnthalten(d) : "Datei nicht angelegt";
assert orgDatei.istGleich(d) : "Datei korrupt";
assert invariante() : "Invariante verletzt";
}
Um die einzelnen Bedingungen zu prüfen, werden wie im Beispiel gezeigt spezielle Prüffunktionen eingeführt und verwendet (z. B. istEnthalten). Die Prüffunktionen werden auch an der Schnittstelle der Klasse angeboten, damit sie von Kundenklassen genutzt werden können.
Dieses Beispiel zeigt auch, dass in Nachbedingungen Objekte und auch Variablenbelegungen benötigt werden, die vor der Ausführung der Methode vorlagen. Da JAVA keine direkte Möglichkeit dazu anbietet, müssen sie zwischengespeichert werden (in unserem Beispiel in der Variablen orgDatei). Das ist umständlich und fehleranfällig; der dazu notwendige Code wird immer ausgeführt, auch wenn die Prüfung der Zusicherungen ausgeschaltet ist.
Erweitern wir Methoden oder Klassen auf diese oder andere Weise um Code, der die spezifizierten Vor- und Nachbedingungen und die Klasseninvariante zusätzlich prüft, dann bezeichnet man diesen als defensiven Code, die Vorgehensweise als defensive Programmierung.
Das Vertragsmodell ist ein pragmatischer Ansatz, um Programmeinheiten Codenah zu spezifizieren. Kombiniert man das Vertragsmodell mit der defensiven Programmierung, dann hat das mehrere Vorteile:
Ein Vertrag auf der Basis von Zusicherungen definiert genau, was die Programmeinheit leistet, und erleichtert damit eine korrekte Nutzung und Wiederverwendung.
Die Programmeinheit lässt sich auch leichter testen, weil sich aus den Zusicherungen Testfälle für Normal- und Sonderfälle direkt ablesen lassen.
Die Prüfung der Zusicherungen kann für den Test aktiviert werden. Die Fehlersuche wird dadurch einfacher, weil die Aufgaben zwischen Kunden- und Anbieterklasse klar abgegrenzt sind und Fehler damit leicht der einen oder anderen Seite zugeordnet werden können.
Die Prüfung der Zusicherungen kann im operativen Betrieb abgeschaltet werden, da diese natürlich das Laufzeitverhalten der Programme beeinflusst.
Zusätzlich hat sich gezeigt, dass eine Programmeinheit besser und prägnanter codiert wird, wenn der Entwickler sie zuvor mit Hilfe von Zusicherungen spezifiziert hat. Das Vertragsmodell hat sich generell bewährt.
Allerdings ist dieses Konzept nicht geeignet, um »gewöhnliche« Ausnahmen wie Fehlbedienungen, Papierstau im Drucker oder Ähnliches zu behandeln; das sollte in der Programmlogik oder in der Ausnahmebehandlung geschehen. Wenn im Programmablauf ein Konflikt mit einer Zusicherung festgestellt wird, ist ein gravierender, in der Regel nicht automatisch behebbarer Fehler aufgetreten, also ein Fehler in der Software oder in der Hardware des Systems.
Das Thema Werkzeuge und Entwicklungsumgebungen haben wir allgemein bereits in Kapitel 15 behandelt. Nachfolgend gehen wir kurz auf die wichtigsten Werkzeuge für die Codierung ein.
Die minimale Werkzeugausstattung eines Programmierers besteht aus einem einfachen Editor und einem Übersetzer, Binder und Laufzeitsystem für die verwendete Programmiersprache. Noch immer werden mit dieser kargen Ausrüstung Projekte durchgeführt. Dabei gibt es eine Reihe von Programmierwerkzeugen – zu denen die oben genannten nicht gerechnet werden –, die die Arbeit des Programmierers erleichtern. Dazu gehören
ein sprachsensitiver Editor, der die syntaktischen Strukturen erzeugt und anzeigt und einfachste Fehler erkennt,
ein Werkzeug, das den Übersetzungs- und Bindeprozess steuert (z. B. make oder ant),
ein Werkzeug für die Verwaltung der Quellprogramme, auch aller anderen Software-Einheiten und ihrer Konfigurationen (z. B. CVS oder Subversion),
Werkzeuge, die den Test unterstützen und die Testüberdeckung messen (z. B. JUnit, Logiscope),
ein Werkzeug, das den Code gegen die Codierregeln prüft (z. B. für JAVA CheckStyle),
ein Debugger, der bei der Fehlersuche auf unterster Ebene hilft – wenn diese Ebene denn wirklich bearbeitet werden muss, wie es etwa bei vielen »embedded systems« der Fall ist.
Sind Programmierwerkzeuge unter einer gemeinsamen Bedienoberfläche integriert, dann sprechen wir von einer integrierten Entwicklungsumgebung. Welche Entwicklungsumgebung verwendet werden kann, hängt entscheidend von der Projektsituation ab.
Müssen grafische Bedienoberflächen entwickelt werden, dann kann ein sogenannter »GUI-Builder« eingesetzt werden (GUI = Graphical User Interface). Er gestattet es, die Bedienoberfläche interaktiv und komfortabel zu entwickeln; der Programmcode, der die Darstellungen erzeugt und die Interaktion implementiert, wird automatisch generiert. Wichtig ist dabei, dass die funktionalen Programmeinheiten, die nicht mit diesem Werkzeug erstellt werden, einfach und architektonisch sauber mit dem generierten Code verbunden werden können. Andernfalls besteht die Gefahr, dass der GUI-Code und der funktionale Code eng gekoppelt sind und damit ein schlecht wartbares Programm entsteht.
Zeller und Krinke (2003) geben eine sehr gute Einführung in eine Vielzahl von Programmierwerkzeugen, die alle erprobt, bewährt und frei verfügbar sind.