Strukturierte Programmierung

Aus Wikibooks
Wechseln zu: Navigation, Suche
Gnome-applications-office.svg Dieses Buch steht im Regal Programmierung.

Einleitung[Bearbeiten]

Das Buch Strukturierte Programmierung ist kein Lehrbuch zum Erlernen einer Programmiersprache, sondern soll als kleiner Leitfaden dazu dienen, auch mit Programmiersprachen, die weniger stark strukturiert sind, besser strukturierte Programme erstellen zu können.

Warum lohnt es sich überhaupt, strukturiert zu programmieren?

Es gibt eine ganze Reihe von naheliegenden Gründen, aber auch einige Vorteile, die nicht auf der Hand liegen und jedermann sofort ersichtlich sind. Anfänger und Fortgeschrittene profitieren gleichermaßen von gut strukturierter Programmierung. Die Vorteile sind so erheblich, dass es unbedingt sinnvoll ist, gut strukturiert zu programmieren. Im Folgenden werden einige wichtige Vorteile aufgeführt und erläutert:

  • Strukturierte Programme sind leichter nachvollziehbar. Dies erleichtert die Arbeit im Team, vereinfacht aber auch die Wartung der Programme, wenn der Quellcode zum Beispiel nach längerer Zeit angepasst, geändert oder korrigiert werden muss.
  • Strukturierte Programme haben weniger Programmierfehler. Dies reduziert die Entwicklungszeiten und erhöht die Akzeptanz beim Auftraggeber.
  • Strukturierte Programme können ohne Laufzeit-Debugger erstellt werden. Dies spart enorm viel Zeit und Nerven bei der Entwicklung von Software.

Die geringen Laufzeiteinbußen, die bei hoch strukturierter Programmierung gegenüber laufzeitoptimiertem Code gelegentlich entstehen können, spielen bei den heutzutage zur Verfügung stehenden modernen und schnellen Rechenmaschinen praktisch keine Rolle mehr.

Die Ratschläge aus diesem Buch beruhen auf jahrzehntelanger Erfahrung mit der Softwareentwicklung komplexer Systeme und dem Hochschulunterricht im Fach Programmierung mit vielen verschiedenartigen Programmiersprachen.

Übrigens: In der Kürze der Quelltextdatei liegt nicht die wahre Würze eines Programmierers!

Und noch wichtiger:

So ists mit aller Bildung auch beschaffen:
Vergebens werden ungebundne Geister
Nach der Vollendung reiner Höhe streben.
Wer Großes will, muß sich zusammenraffen;
In der Beschränkung zeigt sich erst der Meister,
Und das Gesetz nur kann uns Freiheit geben.
Johann Wolfgang von Goethe, Ende von Das Sonett

Viel Erfolg beim strukturierten Programmieren wünscht Benutzer:Bautsch!

Quelltextgestaltung[Bearbeiten]

In diesem Abschnitt stehen einige Vorschläge zur allgemeinen Gestaltung der Quelltexte, die keine unmittelbare Auswirkung auf die Lauffähigkeit und die Funktion der Programme haben, aber dazu führen, dass der Quelltext besser verständlich und nachvollziehbar ist.

Kommentare[Bearbeiten]

Jeder Quelltext sollte zu Beginn der Datei in einem von Compiler zu ignorierenden Kommentar einige Mindestangaben zum Inhalt und Ursprung machen. Dazu gehören der Dateiname, der Modulname (respektive Klassenname), die Autoren, Urheber oder Rechteinhaber, deren beabsichtigte Nutzungsarten/-rechte und Nutzungsbedingungen und weitere Angaben zur Lizensierung, das Datum, eine Versionsangabe und die Angabe der verwendeten Programmiersprache (gegebenenfalls ebenfalls mit einer Versionsangabe).

Beispiel:

/*
  Source file: editor.java
  Program: editor
  Author: Bautsch
  Lizenz: gemeinfreies Werk
  Date: 7th January 2011
  Version: 1.0
  Programming language: Java
*/

Alle Methoden und Variablen werden ausreichend kommentiert, sofern sie nicht durch die Wahl „sprechender” Bezeichner selbsterklärend sind.

Bei Methoden werden insbesondere die Bedeutung aller Parameter und Rückgabewerte dokumentiert:

/* The method "add" computes and returns the sum of "summand1" and "summand2" */
double add (double summand1, double summand2)
{
   double sum = summand1 + summand2;
   return sum;
}

Viele Entwicklungssysteme bieten Funktionen, die die Dokumentation der Quelltexte mit Kommentaren unterstützen.

Leerräume[Bearbeiten]

Leerräume, also zum Beispiel Leerzeichen, Zeilen- und Seitenumbrüche oder Tabulatoren, werden - genauso wie Kommentare - vom Compiler überlesen und dienen daher ausschließlich zur Verbesserung der Lesbarkeit für die Programmierer. Daher sollten diese Leerräume sorgfältig eingesetzt werden, um die Nachvollziehbarkeit des Quellcodes zu erleichtern. Viele Entwicklungssysteme bieten sehr nützliche, unterstützende Funktionen zur einheitlichen Formatierung der Quelltexte, die sehr einfach anzuwenden sind und daher auch unbedingt benutzt werden sollten.

Hier folgen noch Vorschläge für das Verwenden von Umbrüchen und das Einrücken von Quellcode-Zeilen.

Schreibweise von Bezeichnern[Bearbeiten]

In den meisten Programmiersprachen gibt es Bezeichner für ganz unterschiedliche Dinge, wie symbolische Konstanten, Variablen (inklusive Parametern und Attributen), Methoden (respektive Prozeduren oder Funktionen), Module (respektive Klassen) und Bibliotheken. Meist bildet sich für eine Gruppe von Programmiersprachen ein bestimmter Usus aus, wie die entsprechenden Bezeichner gestaltet werden sollen. Der Compiler stellt häufig keine Ansprüche an die Schreibweise von Bezeichnern, jedoch ist es sehr hilfreich, wenn einem Bezeichner sofort angesehen kann, für welches Konstrukt er steht.

In der Regel stehen alle Buchstaben ohne Diakritika zur Verfügung. Oft sind auch noch Ziffern und der Unterstrich "_" erlaubt. Das erste Zeichen muss jedoch üblicherweise immer ein Buchstabe sein. Leerzeichen sind innerhalb von Bezeichnern im Allgemeinen nicht zulässig.

Variablen und Methoden[Bearbeiten]

So ist es zum Beispiel üblich, Variablen und Methoden mit Kleinbuchstaben zu benennen. Dabei ist der Unterschied zwischen Variable und Methode immer und einfach anhand der obligatorischen Parameterliste von Methoden zu erkennen, die beim Fehlen von Parametern leer ist und in vielen Programmiersprachen durch runde Klammern begrenzt ist und direkt hinter dem Methodennamen steht:

int diameter; /* diameter is variable */
int calcDiameter (int radius); /* radius is variable, calcDiameter is method */

"calcDiameter" ist hierbei mit einem Binnenmajuskel "D" versehen, um den Anfang eines neuen Wortes ohne die Verwendung eines Leerzeichens erkennbar zu machen.

Ferner ist es hilfreich, für die Bezeichner von Attribut-Variablen Substantive, für die Bezeichner von Booleschen Variablen Partizipien und für die Bezeichner von Methoden Verben im Imperativ zu wählen.

In manchen Programmiersprachen ist es üblich, lokale Variablen mit einem Kleinbuchstaben zu beginnen und globale Variablen - also in mehreren Methoden oder Klassen sichtbare Variablen - mit einem Großbuchstaben zu beginnen, um die Sichtbarkeit auch nach der Deklaration an jeder Stelle des Quelltextes erkennbar zu machen.

Konstanten[Bearbeiten]

Konstanten können zur Laufzeit nicht mehr verändert werden. Dieser Umstand wird dem Compiler bei der Deklaration der Konstanten durch entsprechende Modifikatoren (wie zum Beispiel "const" oder "final") mitgeteilt. Damit an jeder Stelle des Quelltextes, also auch nach der Deklaration, erkannt werden kann, dass es sich um eine Konstante handelt, ist es hilfreich Konstanten mit einem Großbuchstaben beginnen zu lassen; manchmal werden für Konstanten sogar ausschließlich Großbuchstaben verwendet.

Klassen und Module[Bearbeiten]

Auch Klassen beziehungsweise Module werden meist mit einem Bezeichner benannt, der mit einem Großbuchstaben beginnt. Im Kontext des Quellcodes ist es immer möglich, diese Bezeichner von denen für Konstanten zu unterscheiden, weil sie zum Beispiel immer von einer Blockanweisung (zum Beispiel geschweifte Klammern) oder auch von einem Separator (beispielsweise ".") gefolgt werden, was bei Konstanten nicht der Fall sein kann.

„Sprechende” Bezeichner[Bearbeiten]

Die Wahl „sprechender“ Bezeichner hilft beim Lesen, Verstehen und Nachvollziehen von Quelltext ungemein. Häufig erübrigt sich sogar ein erläuternder Kommentar, wenn mit hinreichend „sprechenden“ Variablen- beziehungsweise Methodennamen gearbeitet wird.

Also nicht:

h = t – b;

Sondern besser:

height = top - bottom;

Eine verpasste Chance einen Bezeichner sprechend zu benennen, kann in vielen Entwicklungssystemen durch sogenanntes Refactoring zentral für den gesamten Quelltext durch Umbenennung geheilt werden.

Programmgestaltung[Bearbeiten]

Idealerweise kann die kontextfreie Grammatik der verwendeten Programmiersprache mit einer strukturierten Metasprache, wie zum Beispiel der Erweiterten Backus-Naur-Form (EBNF) nach der Norm ISO/IEC 14977 dargestellt werden. Jedes strukturierte Programm und jede Datenstruktur kann damit eindeutig definiert werden.

Programme können als Struktogramm (auch Nassi-Shneidermann-Diagramm genannt) nach Norm DIN 66261 notiert werden. Alle Teilprogramme sind dabei so geartet, dass sie ausgehend von einem einfachen Hauptblock, der für das gesamte Programm und somit für einen entsprechenden Unterprogrammaufruf steht, durch schrittweise Verfeinerung hierarchisch zusammengesetzt werden können. Am Ende der Hierarchie stehen dann elementare Teilprogramme, die nicht weiter zerlegt werden können. Die zyklomatische Komplexität der Software kann zum Beispiel mit der McCabe-Metrik untersucht und analysiert werden. Hierbei sollte darauf geachtet werden, dass die Komplexität beschränkt bleibt - zum Beispiel mit einer McCabe-Metrik bis maximal 10-, was durch geeignete Maßnahmen der Stukturierung immer möglich ist.

Dies gilt gleichermaßen für Datenstrukturen, bei denen komplexe Datentypen aus elementaren Datentypen hierarchisch zusammengesetzt werden können.

Wichtig ist, dass die Anzahl der Programmzeilen (Lines of Code) zwar gut als Maß für das zeitliche Wachstum einer bestimmten Software herangezogen werden kann, diese Zahl ist jedoch nicht geeignet, um eine Aussage über die Qualität oder Strukturiertheit des Programmcodes zu treffen. Weder eine besonders kleine noch eine besonders große Anzahl von Programmzeilen sind ein Garant für guten oder strukturierten Code. Das Optimum ist nicht erreicht, wenn nichts mehr hinzugefügt werden kann, weil schon alles implementiert ist, sondern wenn nichts mehr entfernt werden kann, ohne dass die Implementierung hiervon beeinträchtigt wird (frei nach Antoine de Saint-Exupéry in Wind, Sand und Sterne - Terre des Hommes (1939)).

Programmieren ist nicht nur ein einfaches Handwerk, sondern eine anspruchsvolle Kunstfertigkeit (vergleiche auch Donald E. Knuth: The Art of Computer Programming).

Modularisierung[Bearbeiten]

Teilprogramme können Methoden oder ganze Sammlungen von Datenstrukturen und Methoden sein. Letztere werden oft Module oder Pakete genannt. Alle Teilprogramme sollen eindeutige und sprechende Bezeichnungen (Modulnamen oder Methodennamen) und streng definierte Schnittstellen für die Namen und die Datentypen aller Parameter beziehungsweise Klassen- und Instanzvariablen haben. Alle Klassenvariablen, Instanzvariablen, Parameter und auch die Rückgabewerte werden in Bezug auf ihre Teilprogramme als lokale Variablen behandelt.

Methoden[Bearbeiten]

Methoden haben häufig einen Rückgabewert, bei dem lediglich der Datentyp festgelegt werden muss. Parameter von Methoden können unterschieden werden in:

  • Eingangsparameter, die nur innerhalb der Methode verwendet werden
  • Ausgangsparameter, die im der Methode ermittelt und am Ende der Methode zurückgegeben werden
  • Durchgangsparameter, die innerhalb der Methode verwendet werden und nach einer möglichen Veränderung am Ende der Methode zurückgegeben werden

Anweisungsstrukturen[Bearbeiten]

Zu den elementaren Anweisungsstrukturen für Teilprogramme gehören:

  • Anweisungsfolgen
  • Verzweigungen
    • einfache Verzweigungen (if - then - else)
    • mehrfache Verzeigungen (switch - case - else)
  • Wiederholungen
    • kopfgesteuerte Wiederholungen (while-Schleifen, for-Anweisungen)
    • fußgesteuerte Wiederholungen (repeat-until / do-while)

Bei jedem elementaren Teilprogramm (respektive jeder Methode, Prozedur oder Funktion) kann der Quelltext bequem und vollständig auf dem Bildschirm gelesen werden, ohne dass der Text verschoben werden muss. Dabei empfiehlt es sich, Methodenaufrufe und übersichtliche Blockanweisungen zu verwenden, mit denen der Quellcode in noch kleinere Unterabschnitte gegliedert werden kann (Verfeinerung).

Im folgenden Beispiel werden drei geschachtelte Blockanweisungen durch jeweils ein Paar geschweifter Klammern begrenzt. Die äußersten Klammern dienen zur Begrenzung der Implementation der Methode "printMonth", die inneren Blockanweisungen sind ebenso wie alle anderen Anweisungen nach rechts eingerückt:

void printMonth ()
{
   const int numberOfWeekdays = 7;
   const int lastDay = 31;
   
   int column;
   int day = 1;
   
   while (day <= lastDay)
   {
      printInt (day);
      column = day % numberOfWeekdays;
      if (column == 0)
      {
         printLine ();
      }
      day++;
   }
}

Wächst die Länge einer Methode zu sehr an, können und sollen einzelne Blockanweisungen unter Berücksichtigung der entsprechenden Übergabeparameter in eigene, aufzurufende Methoden ausgelagert werden, wodurch der Code geringfügig länger, aber wesentlich besser verständlich wird:

void optionalNewLine (int day)
{
   const int numberOfWeekdays = 7;
   
   int column = day % numberOfWeekdays;
   
   if (column == 0)
   {
      printLine ();
   }
}

void printMonth ()
{
   const int lastDay = 31;
   int day = 1;
   
   while (day <= lastDay)
   {
      printInt (day);
      optionalNewLine (day);
      day++;
   }
}

Hierbei ist es hilfreich, wenn die aufzurufenden Programmteile vor ihrer ersten Verwendung implementiert werden, im Quelltext also zuerst definiert (deklariert und implementiert) und erst weiter unten benutzt (respektive aufgerufen) werden.

Codewiederholungen[Bearbeiten]

Codewiederholungen gehören insbesondere bei Anfängern sehr häufig zu den kapitalen Fehlern beim Softwareentwurf. Es ist nur scheinbar bequem, bereits vorhandenen Quelltext zu kopieren und für eine ähnliche Aufgabe geringfügig anzupassen. Es ist Größenordnungen besser, den bereits vorhandenen Quelltext so anzupassen, dass er für alle ähnlichen oder zumindest mehrere ähnliche Aufgabenstellungen eingesetzt werden kann. Erfahrene Programmierer wittern schon von Anfang an, dass eine bestimmte Methode auch in einem ähnlichen Kontext eingesetzt werden könnte und entwerfen den Code von vornherein so allgemein wie möglich.

Symbolische Konstanten[Bearbeiten]

Konstante Ausdrücke werden in der Regel als symbolische Konstanten definiert:

const double Pi = 3.141592654;
const String Title = "Programmierung";

Statt konstante Ausdrücke zu wiederholen – und sei es nur eine ganze Zahl – ist es erheblich besser, stattdessen eine symbolische Konstante mit einem „sprechenden“ Bezeichner zu verwenden.

Also nicht:

inputNumber = 3;
...
if (inputNumber == 3)
{
   ...
}

Sondern besser:

const int Exit = 3;

inputNumber = Exit;
...
if (inputNumber == Exit)
{
   ...
}

Oder nicht:

if (a > 3) && (b > 3)

Sondern besser:

const Limit_a = 3;
const Limit_b = 3;

if (a > Limit_a) && (b > Limit_b)

Auf diese Weise kann auch leicht vermieden werden, dass gleichlautende Ausdrücke mit unterschiedlicher Bedeutung verwechselt werden können, insbesondere wenn sie später einmal geändert werden:

if (numberOfConstellation > 12) → Ausgabe ("Diese Sternbildnummer ist ungültig.")
{
   ...
}

if (numberOfHalftones > 12) → Ausgabe ("Das Intervall ist größer als eine Oktave.")
{
   ...
}

Die beiden konstanten Zahlen haben nichts außer ihrem Wert gemeinsam, daher ist der folgende Code erheblich besser:

const int Number_Of_Constellations = 12;
const int Number_Of_Halftones_Per_Octave = 12;

...

if (numberOfConstellation > Number_Of_Constellations) → Ausgabe ("Diese Sternbildnummer ist ungültig.")
{
   ...
}

if (numberOfHalftones > Number_Of_Halftones_Per_Octave) → Ausgabe ("Das Intervall ist größer als eine Oktave.");
{
   ...
}

Methodenaufrufe[Bearbeiten]

Methoden, die in einigen Programmiersprachen auch Prozeduren oder Funktionen genannt werden, beinhalten sequenzielle Rechenvorschriften (Algorithmen) zum Bearbeiten von Daten, die zu einer Einheit zusammengefasst sind.

So kann zum Beispiel die Rechenvorschrift für die Berechnung des Kreisumfangs aus dem Kreisradius als Folge von Programmanweisungen formuliert werden, aber auch in eine Methode ausgelagert werden. Diese Methode kann dann irgendwo im Programmcode aufgerufen werden (Methodenaufruf). Dies gewinnt besonders dann Bedeutung, wenn die Methode an verschiedenen Stellen aufgerufen werden soll, so dass dann diese Programmanweisungen nicht mehrfach programmiert oder kopiert werden müssen. Insbesondere wenn die ursprünglichen Programmanweisungen Fehler enthalten, müssen diese nach dem Entdecken der Fehler - also möglicherweise zu einem viel späteren Zeitpunkt - zur Fehlerbehebung an mehreren Stellen im Programmcode korrigiert werden. Das ist nicht nur mühsam, sondern einige relevante Stellen können auch leicht übersehen werden, so dass der entdeckte Fehler nicht vollständig ausgemerzt wird.

Die mehrfach eingegebenen Programmanweisungen zur Berechnung des Kreisumfangs werden beim Auftreten von Codewiederholung an mehreren Stellen des Quelltextes programmiert:

perimeter1 = radius1 * 2 * pi;
perimeter2 = radius2 * 2 * pi;
perimeter3 = radius3 * 2 * pi;

Mithilfe der Methode "perimeter" zur Berechnung des Kreisumfangs und deren dreimaligem Aufruf kann diese Codewiederholung leicht vermieden werden:

/* The method "perimeter" computes and returns the perimeter of a circle with radius "radius" */
double perimeter (radius)
{
   const double Pi = 3.141592654;
   double perimeter = radius * 2 * Pi;
   return perimeter;
}

perimeter1 = perimeter (radius1);
perimeter2 = perimeter (radius2);
perimeter3 = perimeter (radius3);

Zuweisungskompatibilität[Bearbeiten]

Komplexe Ausdrücke[Bearbeiten]

Zusammengesetzte Ausdrücke mit verschiedenartigen Operatoren können sehr unübersichtlich und somit fehleranfällig sein. Manche Programmiersprachen haben sehr viele Hierarchieebenen für Operatoren, die auch durch erfahrene Programmierer kaum durchschaut werden können, oder sogar dafür sorgen, dass bestimmte Teile des Quellcodes zur Laufzeit gar nicht erreicht werden können. Daher ist es dringend empfehlenswert, Anweisungen in möglichst kleine Einheiten zu untergliedern. In einigen Programmiersprachen ist es sogar möglich, die Zuweisung in andere Anweisungen zu integrieren, da sie selber als ein Ergebniswert interpretiert werden darf. Ferner ist nicht immer offensichtlich welchen Datentyp ein Ergebnis hat, was insbesondere in Ermangelung eines zweiwertigen Datentyps Boolean zu Missverständnissen führen kann.

Also zum Beispiel nicht:

if (a = b – c == 0) ...

Sondern besser:

a = b – c;
if (a == 0) ...

Die folgenden beiden Beispiele zeigen Programmsequenzen in der Programmiersprache C, die beide sehr leicht zu übersehenden Programmierfehlern führen können:

int i = 0;
if (i = 1)
{
   Dieser Block wird immer ausgeführt,
   weil die Zuweisung i = 1 immer das numerische Ergebnis 1 hat,
   was als der boolesche Wert "wahr" interpretiert wird.
}
int i = 0;
if (i == 1)
{
   Dieser Block wird nie ausgeführt,
   weil die Vergleichsoperation i == 1 immer das numerische Ergebnis 0 hat,
   was als der boolesche Wert "falsch" interpretiert wird.
}

Die folgende Return-Anweisung ist nicht nur verwirrend, sondern sinnfrei:

x = 2.0;
return = 1 / x--;

Der Dekrement-Operator wird in vielen Programmiersprachen erst nach der Return-Anweisung ausgeführt, deswegen ist die folgende Ausführung nicht nur strukturierter, sondern auch sinnvoll und nachvollziehbarer:

x = 2.0;
y = 1 / x;
y--;
return y;

Beeindruckend sinnlos und komplex sind Monster-Ausdrücke, die in einigen Programmiersprachen erlaubt sind, wie zum Beispiel bei der Kombination einer Rücksprunganweisung mit einer Zuweisung, zwei verschiedenen Inkrement-Operatoren und einem Additionsoperator:

int i = 1;
return i = ++i+i++;

Blockanweisungen[Bearbeiten]

Blockanweisungen sind ein elegantes Mittel, um Programmcode zu strukturieren. In der Regel werden Klassen- und Methodenrümpfe sowie Kontrollstrukturen daher mit Blockanweisungen programmiert. In einigen Programmiersprachen werden eindeutige Symbole für die Kennzeichnung von Programmblöcken verwendet, wie zum Beispiel geschweifte Klammern:

{
   ...
}

In anderen Programmiersprachen werden Schlüsselwörter für die Begrenzung von Blockanweisungen verwendet, wie zum Beispiel:

BEGIN
   ...
END

Blockanweisungen können geschachtelt, dürfen - sofern überhaupt möglich - jedoch nicht verschränkt werden. Dies bedeutet, dass bei geschachtelten Blöcken immer zuerst die innersten Blöcke vollständig abgearbeitet werden müssen, bevor die äußeren abgeschlossen werden können:

{
   ... /* Äußerer Block */
   {
      ... /* Innerer Block */
   }
   ... /* Äußerer Block */
}

Verschränkte Blockanweisungen sind unstrukturiert und daher in den meisten Programmiersprachen nicht möglich:

BEGIN1
   ...
   BEGIN2
      ...
   ...
END1
...   ...
   END2

Wertebereiche[Bearbeiten]

Hier folgen noch einige Vorschläge zur Vermeidung von Wertebereichsüberschreitungen.

Division durch null[Bearbeiten]

Im Zusammenhang mit dem Divisionsoperator gibt es in allen Programmiersprachen das Problem, dass der Divisor nicht null werden darf.

Die Division durch null kann und sollte kategorisch durch eine geeignete Kontrollstruktur verhindert werden:

if (divisor != 0)
{
   quotient = dividend / divisor;
}
else
{
   /* Ausnahmebehandlung / Fehlermeldung */
}

Schnittstellen[Bearbeiten]

Schnittstellen definieren die Sichtbarkeits- und Zugriffsregeln zwischen verschiedenen Bestandteilen eines Programms und ermöglichen so die Interaktion zwischen diesen. Dabei ist es keineswegs sinnvoll, alle Bezeichner überall sichtbar zu machen, da dadurch die Übersichtlichkeit und Nachvollziehbarkeit insgesamt drastisch eingeschränkt wird. Dies führt letztlich zu Programmfehlern, da der Programmierer wegen der großen zu berücksichtigenden Datenmenge nicht mehr in der Lage ist, alle Implikationen seiner Arbeit zu überschauen.

Alle Eigenschaften (Attribute) und Methoden (Werkzeuge), die zusammengehören (aber auch nur diese), sollen in jeweils einer Einheit zusammengefasst werden, wie zum Beispiel einer Klasse oder einem Modul. Oft wird eine solche Einheit in einer Quelltextdatei zusammengefasst, was sinnvoll ist und die Nachvollziehbarkeit erleichtert.

Nur diejenigen Eigenschaften und Methoden, die außerhalb dieser Einheiten benutzt werden sollen oder müssen, dürfen mit einem Modifikator versehen werden, der dies ermöglicht (zum Beispiel "public"). Alle anderen Eigenschaften und Methoden sollten explizit als intern (zum Beispiel "private") deklariert sein. packages sind wegen der unübersichtlichen Sichtbarkeitsregeln (zum Beispiel durch den Modifikator "protected") als Zwischenebene entbehrlich und eher zu vermeiden. Alternativ können ohne weiteres längere, zusammengesetzte Klassennamen verwendet werden, um die Zugehörigkeit zu einem bestimmten Themenbereich zu kennzeichnen, wie zum Beispiel:

StatisticsMyEvaluation
StatisticsMyAssessment

statt

package statistics;

statistics.MyEvaluation
statistics.MyAssessment

Prinzip der Lokalität[Bearbeiten]

Variablen sollen immer so lokal wie möglich definiert werden. Am besten sind Variablen lokalisiert, wenn sie in einer Blockanweisung definiert werden, die keine weiteren Blockanweisungen enthält, wo die Variablen üblicherweise auch „sichtbar” (und demzufolge verwendbar) sind. Außerhalb der Blöcke sind diese Variablen dann „unsichtbar” und somit auch nicht benutzbar.

Für Programmiersprachen die keine explizite Blockanweisung haben, ist die am stärksten lokalisierte Definition in der Regel innerhalb einer Methode respektive einer Prozedur oder einer Funktion. Die nächstgrößere Strukturebene ist dann - sofern möglich - das Modul beziehungsweise die Klasse (dies ist keineswegs zu verwechseln mit einer Datei!). Innerhalb von Modulen und Klassen sollten Variablen möglichst häufig mit dem Sichtbarkeitsmodifikator für die ausschließlich modul- oder klasseninterne Sichtbarkeit (zum Beispiel private) deklariert werden. Solche internen Variablen können dann mit den entsprechend zu implementierenden Konstruktoren und den sogenannten Getter- und Settern-'Methoden) initialisiert, abgefragt und verändert werden.

Globale Variablen, die exportiert werden, also außerhalb von Klassen und Modulen verwendet werden können, sind immer vermeidbar und sollten nur in Ausnahmefällen definiert und benutzt werden. Je weniger lokal eine Variable definiert ist, desto größer ist die Gefahr, dass diese unbeabsichtigt oder sogar außerhalb der Kontrolle des Programmierers verändert werden kann, was dann zu entsprechend dramatischen und schwer identifizierbaren Programmfehlern führen kann, die zudem erst zur Laufzeit auftreten und oft nur zufällig und somit umso schwerer zu entdecken sind.

Importe[Bearbeiten]

Import-Anweisungen[Bearbeiten]

Import-Anweisungen werden häufig nicht dazu benutzt anzumelden und anzuzeigen, welche externen Module (respektive Klassen) in einer Quelldatei verwendet werden, sondern werden als Möglichkeit missbraucht, den Quelltext möglichst kurz zu fassen.

Nicht:

import MyModule;
...
drawLine ();
...

Sondern eindeutig:

...
MyModule.drawLine ();
...

So ist es dann auch einfach und eindeutig möglich, gleichnamige Bezeichner, wie zum Beispiel drawLine, aus verschiedenen Klassen zu benutzen:

...
MyModule.drawLine ();
YourModule.drawLine ();
...

Die Erkennbarkeit der Herkunft eines importierten Bezeichners an jeder Stelle des Auftretens in einem Quelltext ist in der Regel von großer Nützlichkeit, insbesondere wenn andere Programmierer den Quelltext nachvollziehen können sollen oder wenn der Quellcode nach längerer Zeit gewartet werden soll.

Insbesondere Import-Anweisungen mit Wildcards sind schlecht nachvollziehbar (auch wenn viele Entwicklungssysteme Funktionen für eine bessere Transparenz bieten), so wie zum Beispiel:

import myPackage.*;
import yourPackage.*;

drawLine (); /* To which package does the method "drawLine" belong? */

Class var = new Class (); /* To which package does the class "Class" belong? */

Zyklische Importe[Bearbeiten]

Zyklische Importe beziehungsweise Zirkelbezüge sind nicht nur unübersichtlich, sondern auch unstrukturiert, weil das Ergebnis des Compilates unter Umständen von der Reihenfolge der Übersetzung der Quelltexte abhängen kann. Beispiel:

public class A;
...
B.drawLine (x, y);
...
public class B;
...
A.setKoordinates (x, y);
...

In der Regel ist es auch bei rekursiven Programmiertechniken immer möglich, ohne zyklische Modulabhängigkeiten auszukommen.

Kontrollstrukturen[Bearbeiten]

Sprunganweisungen[Bearbeiten]

Sprunganweisungen, wie zum Beispiel Goto- oder Break-Anweisungen, sind unstrukturiert und überflüssig.

Im Falle der Switch-Case-Anweisung handelt es sich bei der in manchen Programmiersprachen verwendeten Break-Anweisung nicht um eine Sprunganweisung, sondern um einen obligatorischen Begrenzer (englisch: delimiter), der zur Herstellung der Programmstruktur erforderlich ist. In einigen Programmiersprachen darf dieser Begrenzer (break) jedoch weggelassen werden, um den Code in bestimmten aber vereinzelten Fällen etwas kürzer gestalten zu können, was aber gleichzeitig und unabdingbar zu unstrukturierter Programmierung führt und die Programmabläufe gegebenenfalls nur noch schwierig nachvollziehbar macht.

Die Sprunganweisung stellt eine Methode mit dem Holzhammer dar und führt daher zu sogenanntem Spaghetti-Code: Im Quellcode ist der Programmablauf nicht mehr ohne weiteres nachvollziehbar, beispielsweise von welcher Stelle des Programms an die entsprechenden Stellen gesprungen wird. Programmkonstruktionen mit Sprunganweisungen können immer und ohne großen Aufwand mit strukturierten Kontrollstrukturen, also Schleifen und Fallunterscheidungen, ausgedrückt werden.

Rücksprunganweisungen[Bearbeiten]

Jede Methode oder Funktion mit Rückgabewert hat exakt eine Rücksprunganweisung (oft mit dem Schlüsselwort return gekennzeichnet), die logischerweise die letzte Anweisung ist. Mehrfache Rücksprunganweisungen sind unstrukturiert, und vorzeitige Rücksprunganweisungen außerhalb von Kontrollstrukturen sind sogar sinnlos, da der nachfolgende Programmcode nie erreicht werden kann.

Schleifen[Bearbeiten]

Bei Schleifen wird eine Anweisungsfolge nur dann ausgeführt, wenn die entsprechende boolesche, im Sinne der Schleife lokale Laufvariable den Wert "wahr" hat. Alle Schleifen können auf eine grundegende Form zurückgeführt werden, bei der ein wesentliches Unterscheidungsmekrmal ist, ob die Laufvariable zu Beginn den konstanten Wert "wahr" erhält oder in der Anfangsbedingung durch einen booleschen Ausdruck bestimmt ist. Es ist sinnvoll, dass die Laufvariable ausschließlich im Zusammenhang mit der Schleife verwendet wird. Bei wohlstrukturierter Programmierung wird die Laufvariable innerhalb der Schleife ausschließlich in der letzten Anweisung der Schleife gesetzt.

Kopfgesteuerte Schleifen[Bearbeiten]

Kopfgesteuerte Schleifen werden auch als While-Anweisungen bezeichnet.

Kopfgesteuerte Schleife Setze Laufvariable auf boolesche Anfangsbedingung
Solange wie die Laufvariable den Wert "wahr" hat führe aus
Anweisungsfolge
Setze Laufvariable auf boolesche Endbedingung

Wenn die boolesche Anfangsbedingung zu Beginn den Wert "falsch" hat, wird die Schleife nicht durchlaufen.

Fußgesteuerte Schleifen[Bearbeiten]

Die fußgesteuerte Schleife, die auch Repeat-Anweisung genannt wird, ist ein Sonderfall der kopfgesteuerten Schleife, bei der die boolesche Anfangsbedingung immer auf "wahr" gesetzt wird. Daher wird eine fußgesteuerte Schleife immer mindestens einmal durchlaufen.

Fußgesteuerte Schleife Setze Laufvariable auf "wahr"
Solange wie die Laufvariable den Wert "wahr" hat führe aus
Anweisungsfolge
Setze Laufvariable auf boolesche Endbedingung

Da der Ausdruck der booleschen Anfangs- und Endbedingung oft identisch formuliert ist, bietet es sich an, dafür einen Funktionsaufruf zu verwenden, um eine Codewiederholung zu vermeiden.

Aus Gründen der Laufzeiteffizienz wird das Setzen der Laufvariablen zu Beginn und die erstmalige Überprüfung der Laufvariablen oft weggelassen, was für die allermeisten Anwendungen heute jedoch unwesentlich ist. Da die Laufvariable in diesem Fall zu Beginn jedoch nicht definiert werden muss und daher gegebenenfalls auch gar nicht definiert wird, birgt dieses Vorgehen die Gefahr in sich, dass die Laufvariable ihren undefinierten Zustand behält. Insbesondere tritt dies ein, wenn das Setzten der Laufvariable auf eine boolesche Endbedingung nicht erfolgt, weil dies in der verwendeten Programmiersprache nicht obligatorisch ist beziehungsweise vom Programmierer vergessen wurde.

Endlosschleifen[Bearbeiten]

Endlosschleifen sind unstrukturiert, da das Programm nicht regelgerecht beendet werden kann. Daher sind diese sogenannten Loop-Anweisungen, wie zum Beispiel

for (;;);
while (true);
repeat
{
   ...
}
until (false);

oder

loop
{
   ...
}

zu unterlassen. Insbesondere das Verlassen von Endlosschleifen mit einer Sprunganweisung oder gar mehreren potentiellen Sprunganweisungen, wie zum Beispiel break oder exit, ist hochgradig unstrukturierte Programmierung, da der Ausstiegszeitpunkt oder die Stelle des Ausstiegs aus der Schleife (wenn überhaupt) nur schwierig nachzuvollziehen oder zu bestimmen ist.

For-Schleifen[Bearbeiten]

Die For-Schleifen-Anweisung

int i;
for (i = 0; i < max; i++)
{
 ...
}

ist identisch mit der kopfgesteuerten while-Anweisung:

int i;
i = 0;
while (i < max)
{
 ...
 i++;
}

Es ist im Sinne eines einfachen Sprachumfangs und eines einheitlichen Sprachstils unter Umständen nützlich, für kopfgesteuerte Schleifen keine For-Schleifen, sondern ausschließlich While-Schleifen zu benutzen.

Wenn die Programmiersprache es erlaubt, Zählvariablen ausschließlich für eine Schleife zu definieren, dann hat dies den Vorteil, dass das Prinzip der Lokalität für diese Zählvariablen sehr gut erfüllt ist, da die Zählvariable dann außerhalb der Schleife nicht sichtbar ist und insbesondere auch nicht verändert werden kann:

for (int i = 0; i < max; i++)
{
 ...
}

Bei Algorithmen, die aus mehreren Kontrollstrukturen bestehen, ist es im Sinne der besseren Strukturierung jedoch vorzuziehen, eine solche Schleife in eine eigene Methode auszulagern, die aufgerufen wird und deren Schnittstelle über ihre Parameter eindeutig festgelegt ist. In diesem Fall sind alle Parameter und Variablen (also auch die Zählvariable), die die Schleife betreffen, innerhalb dieser Methode lokal.

Nebeneffekte[Bearbeiten]

Nebeneffekte treten auf, wenn der Programmierer von naheliegenden, jedoch falschen Annahmen ausgeht, die die Programmiersprache betreffen.

Durch Reihenfolge[Bearbeiten]

In einigen Programmiersprachen ist die Reihenfolge der Abarbeitung von kombinierten Ausdrücken nicht explizit definiert und führt daher zu einem solchen Nebeneffekt.

Die Anweisungen

h = f (x) + g (x);

oder

h = g (x) + f (x);

können je nach Compiler zu unterschiedlichen Ergebnissen für die Summe h führen. Die Methodenaufrufe f oder g können nämlich unter Umständen die als Parameter verwendete Variable x verändern und somit gegebenenfalls verschiedene Werte für h erzeugen, je nachdem, ob zuerst f (x) oder g (x) ausgewertet wird. In solchen Programmiersprachen sind sogenannte Durchgangsparameter in kombinierten Ausdrücken zu vermeiden, was ohne weiteres durch eine entsprechende Code-Sequenz möglich ist:

result_f = f (x);
result_g = g (x);
h = result_f + result_g;

Dieses Vorgehen erzeugt darüberhinaus den günstigen Umstand, dass die Zwischenergebnisse in lokalen Variablen abgefragt werden können. Diese sind nach einem Programmabbruch auch leicht mit einem Post-Mortem-Debugger analysierbar.

Durch falsche Spezifikation[Bearbeiten]

In Programmiersprachen, die dynamische Variablen ausschließlich als Zeiger behandeln (wie zum Beispiel C oder C++), kann trotz exakter Übereinstimmung der referenzierten Datentypen bei einer Zuweisung des Ergebnisses einer Funktion ein Zeiger auf den lokalen Stapelspeicher der Funktion zurückgegeben werden, der nur während der Ausführung der Funktion, aber nicht mehr nach dem Rücksprung aus der Funktion gültig ist. Während der weiteren Programmausführung kann der Speicherbereich jederzeit überschrieben werden, ohne dass der Programmierer dies wünscht oder absehen kann.

Im folgenden Beispiel in der Programmiersprache C wird innerhalb der Funktion function der Wert 5 dem Datenfeld a der Variablen data zwar korrekt zugewiesen, kann aber nach dem Rücksprung aus der Funktion im Stapelspeicher jederzeit unbeabsichtigt verändert werden, wie zum Beispiel beim erneuten Aufruf einer Funktion oder anderen Operationen, die den Stapelspeicher verwenden:

struct DataType { int a }; // Definition des Datentyps ''DataType'' mit einem ganzzahligen Datenfeld ''a''
 
DataType* function ()      // Deklaration der Funktion ''function'' mit einem Zeiger auf eine Variable
                           // vom Datantyp ''DataType'' als Speicheradresse für den Rückgabewert
{
  DataType data;           // Deklaration der lokalen Variable ''data'' vom Datentyp ''DataType''
  data.a = 5;              // Zuweisung des Wertes ''5'' zum Datenfeld ''a'' der Variablen ''data''
 
  return &data;            // Rückgabe der lokalen, temporären Speicheradresse von ''data''
}

Der Programmierer muss zur Abwendung dieses Übels darauf achten, dass Rückgabewerte durch Allokation einer entsprechenden Variablen in einem dauerhaft verfügbaren dynamischen Speicherbereich (also zum Beispiel im Heap-Speicher) auch nach dem Aufruf der Funktion noch gültig und korrekt aufrufbar sind.

Bei der Verwendung von vollständig typsicheren Programmiersprachen ist die Rückgabe von lokal definierten Adressen nicht zulässig, und die Übersetzung des entsprechenden Codes wird vom Complier von vornherein verweigert, so dass es gar nicht zu einem solchen Nebeneffekt kommen kann.

Strukturierte objektorientierte Programmierung[Bearbeiten]

Vermeidung von Codewiederholung durch Vererbung[Bearbeiten]

Die Vermeidung von Codewiederholung durch Vererbung kann beispielsweise an den beiden graphischen Objekten Kreis und Dreieck deutlich gemacht werden. Diese beiden Objekte können unabhängig voneinander modelliert werden, wobei ihre gemeinsamen Eigenschaften Farbe und Strichstärke, sowie die jeweilige Methode zum Zeichnen beide Male unabhängig behandelt werden (dies kann eindeutig durch Hat-Beziehungen ausgedrückt werden), was eine Codewiederholung darstellt. Der Kreis hat zusätzlich das Attribut Radius, und das Dreieck hat zusätzlich die drei Attribute SeiteA, SeiteB und SeiteC:

Kreis   hat: Farbe, Strichstärke, Methode zum Zeichnen, Radius
Dreieck hat: Farbe, Strichstärke, Methode zum Zeichnen, SeiteA, SeiteB, SeiteC

Mithilfe von Vererbung kann die Codewiederholung vermieden werden, indem die Attribute Farbe und Strichstärke, sowie die Methode zum Zeichnen nur einmal mithilfe des abstrakten Objekts GraphischesObjekt deklariert werden. Die konkreten Objekte Kreis und Dreieck erben alle gemeinsamen Eigenschaften und Methoden von GraphischesObjekt (dies kann eindeutig durch Ist-Beziehungen ausgedrückt werden) und werden nur durch die jeweils fehlenden Attribute ergänzt:

GraphischesObjekt hat: Farbe, Strichstärke, Methode zum Zeichnen
Kreis   ist GraphischesObjekt, hat zusätzlich: Radius
Dreieck ist GraphischesObjekt, hat zusätzlich: SeiteA, SeiteB, SeiteC

Überladung[Bearbeiten]

Das Überladen von Methoden, Konstruktoren oder Variablen ist nicht nur überflüssig, sondern erschwert die Nachvollziehbarkeit vom Quellcode und birgt die Gefahr von Programmierfehlern, die unter Umständen erst lange nach der ersten Entwicklung bei der Wartung der Software entstehen.

Deswegen werden Methoden oder Attribute nicht überladen, auch nicht, wenn die Programmiersprache dies zulässt. Als günstiger Nebeneffekt werden die Übersetzungszeiten kürzer.

Das Überschreiben von geerbten Methoden oder Konstruktoren ist etwas völlig anderes und sinnvoll. Jede Klasse bekommt daher maximal einen einzigen Konstruktor, der alle erforderlichen Parameter zur Initialisierung der Instanzvariablen enthält.

Überladung des Divisionsoperators[Bearbeiten]

Die Divisionsoperatoren sind in vielen - auch bei nicht-objektorientierten - Programmiersprachen leider überladen, da formal keine Unterscheidung zwischen ganzzahliger Division und Gleitkommadivision gemacht wird. In diesen Fällen muss der Divisionsoperator sehr aufmerksam verwendet werden:

int i = 2;
int j = 1;
double k = j / i;

Diese Code-Sequenz hat zur Folge, dass eine ganzzahlige Division durchgeführt wird und k den Wert null erhält.

Einige Programmiersprachen unterscheiden daher explizit zwischen einem Operator für die ganzzahlige Division ("DIV") und einem Operator für die Gleitkommadivision ("/").

int i = 2;
int j = 1;
double k = j / i; → k ist 0,5
int i = 2;
int j = 1;
double k = j DIV i; → k ist 0,0

Bei Programmiersprachen, die dies nicht unterstützen ist die Verwendung der expliziten Datentypumwandlung (englisch: type cast) nicht nur sinnvoll, sondern sogar notwendig:

int i = 2;
int j = 1;
double k = (double) j / (double) i; → k ist 0,5

Entsprechende Überlegungen gelten natürlich auch für alle Modulo-Operatoren.

Mehrfachvererbung[Bearbeiten]

Siehe:

Initialisierung von Instanzvariablen[Bearbeiten]

Hier folgt noch die Beschreibung der Verwendung von Methoden zur Initialisierung von Instanzvariablen (manchmal auch Konstruktoren genannt).

Nachwort[Bearbeiten]

Ein sehr häufig auftretender „Programmierfehler“ - wiederum insbesondere bei Anfängern - ist das Unterlassen der Herstellung von Sicherungskopien der Quelltexte. Noch besser ist eventuell sogar eine Versionierung der Quelldateien, damit gegebenenfalls auch auf ältere Versionen zurückgegriffen werden kann. Die Auswirkungen dieses Fehlers sind hinreichend naheliegend, so dass hier nicht weiter darauf eingegangen werden muss.

Sollte der Leser nach der Lektüre dieser Beiträge zu dem verständlichen und naheliegenden Schluss gekommen sein, dass die von ihm präferierten Programmiersprachen C oder C++ ziemlich schlecht strukturiert sind, möge er sich auch einmal andere Programmiersprachen näher ansehen, wie zum Beispiel C#, Component Pascal oder auch Java.

Mit der Beherzigung der Vorschläge aus diesem Buch möge es dem Leser und seinen Teamkollegen in jeder Programmiersprache gelingen, in kürzerer Entwicklungszeit besser strukturierte und funktionierende Programme zu schreiben.

Vergleich[Bearbeiten]

In der folgenden Tabelle werde einige imperative, objektorientiere Programmiersprachen hinsichtlich ihrer Strukturiertheit verglichen:

Veröffentlichungsdatum 1985 1994 1995 2001
Programmiersprache C++ Component
Pascal
Java C#
Vollständig strukturierte Syntax nein ja nein nein
Datentypsicherheit nein ja ja ja
Modulsicherheit nein ja nein ja
Keine zyklischen Importe nein ja nein nein
Keine mehrfache Schnittstellenvererbung nein ja nein nein
Keine mehrfache Implementationsvererbung nein ja nein ja

Literatur[Bearbeiten]

  • Niklaus Wirth
    • Algorithmen und Datenstrukturen - Pascal-Version, Teubner Leitfäden der Informatik, 5. Auflage, Verlag Vieweg und Teubner, Stuttgart (1999), ISBN 9783519222507
  • Hanspeter Mössenböck
  • Markus Bautsch

Weblinks[Bearbeiten]