Strukturierte Programmierung

Dieses Buch steht im Regal Programmierung.

Bildschirmaufnahme des mit Tastatursteuerung interaktiven Java-Programms FraktaleMengeAusgabe zur Berechnung und Darstellung von Julia-Mengen oder der Mandelbrot-Menge. Siehe auch Wikibook Das Apfelmännchen.
Bildschirmaufnahme des Java-Programms Maze zur rekursiven Erstellung und Darstellung von Labyrinthen. Siehe auch Wikibook Rekursive Labyrinthe.
Bildschirmaufnahme des Java-Programms SimForestFrame zur Simulation und Darstellung von sich ausbreitenden Waldbränden. Siehe auch Wikibook Waldbrandsimulation.
Bildschirmaufnahme des mit Maussteuerung interaktiven Java-Programms CampingplatzGraphs zur Erstellung und Darstellung von Campingplatzrätseln. Siehe auch Wikibook Campingplatzrätsel.
Bildschirmaufnahme des Java-Programms "GameOfLife" zur Anzeige von Conways Spiel des Lebens. Siehe auch Wikibook Game of Life.

Einleitung

Bearbeiten

Das Buch Strukturierte Programmierung ist kein Lehrbuch zum Erlernen einer Programmiersprache, sondern soll als kleiner Leitfaden dazu dienen, besser strukturierte Programme erstellen zu können, selbst wenn die eingesetzten Programmiersprachen die strukturierte Programmierung weniger stark unterstützen.

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 oder 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 bei den Auftraggebern und Nutzern der Software.
  • 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 von Anwendungssoftware gegenüber laufzeitoptimiertem Code entstehen können, spielen bei den heutzutage zur Verfügung stehenden modernen und schnellen Rechenmaschinen praktisch keine Rolle mehr. Programmcode wegzulassen, der der inhärenten Betriebssicherheit von Software oder der inhärenten Datenintegrität dient, macht nur in sehr wenigen, extrem zeitkritischen Anwendungen Sinn, keineswegs jedoch bei herkömmlichen oder gar sicherheitskritischen Anwendungsprogrammen.

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

Übrigens: In der Kürze der Quelltextdatei liegt nicht die wahre Würze des Programmierens !

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.

Anweisungen

Bearbeiten

Der Quelltext wird bei imperativen Programmiersprachen durch Anweisungen gestaltet, die ganz unterschiedlich geartet sein können. Zu den typischen und wichtigen Anweisungen gehören:

  • Deklaration (declaration)
  • Blockanweisung (block)
  • Zuweisung (assignment)
  • Aufruf (call)
  • Rücksprung (return)
  • Verzweigung (branch)
  • Schleife (loop)
  • Sicherstellung (assertion)

Bei Kommentaren und Leerräumen (Leerzeichen, Tabulatoren, Zeilenumbrüche, ...) in Quelltexten handelt es sich nicht um Anweisungen, da sie vom Übersetzer (compiler) beziehungsweise Interpreter des Programmcodes ignoriert werden.

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 Lizenzierung, das Datum, eine Versionsangabe und die Angabe der verwendeten Programmiersprache (gegebenenfalls ebenfalls mit einer Versionsangabe).

Der Kommentartext wird bei vielen Programmiersprachen im Quelltext mit dem Symbolpaar "/*" und "*/" oder dem Symbolpaar "(*" und "*)" eingeschlossen.

Beispiel:

/*
  Source file: editor.java
  Program: editor
  Author: Bautsch
  License: public domain
  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.

Bei einigen Programmiersprachen ist Aufmerksamkeit geboten, wenn in der Sprachdefinition geschachtelte Kommentare nicht vorgesehen sind. Wird zum Beispiel während der Programmentwicklung Quelltext auskommentiert, um das Verhalten des modifizierten Programms zu überprüfen, und enthält dieser Quelltext einen Kommentar, ist dann nicht sofort erkennbar, welcher Abschnitt des Quelltextes tatsächlich auskommentiert werden soll. Dabei kann es auch vorkommen, dass einige Compiler die geschachtelten Kommentare erkennen und im eigentlichen Sinne des Programmierers berücksichtigen; andere Compiler, die sich streng an die standardisierten Sprachdefinitionen halten, jedoch nicht, so dass es bei der Portierung von Quellcode unweigerlich zu Übersetzungsfehlern kommt.

Das folgende Beispiel zeigt einen Programmabschnitt, bei dem hinter der letzten Anweisung zwischen den Zeichenfolgen "/*" und "*/" ein Textkommentar hinzugefügt wurde, der vom Compiler ignoriert werden soll.

int i ← 1
i ← i * i /* die Variable i wird quadriert. */

Wird zusätzlich mit den gleichen Zeichenfolgen "/*" und "*/" die gesamte Programmzeile auskommentiert, gibt es einen Übersetzungsfehler, wenn der Compiler die allerletzte Zeichenfolge "*/" als Kommentarende ohne Kommentaranfang interpretiert, sofern der zweite und nunmehr auskommentierte Kommentaranfang "/*" in der Zeile mit dem Textkommentar ignoriert wurde und die erste auftretende Zeichenfolge "*/" bereits als Kommentarende interpretiert wurde:

int i ← 1
/*
   i ← i * i /* die Variable i wird quadriert. */
*/

Leerräume

Bearbeiten

Leerräume, also zum Beispiel Leerzeichen, Zeilen- und Seitenumbrüche oder Tabulatoren, werden - genauso wie Kommentare - von vielen Compilern überlesen und dienen in diesen Fällen ausschließlich zur Verbesserung der Lesbarkeit für die Programmierer. Daher sollten diese Leerräume sorgfältig eingesetzt werden, um die Nachvollziehbarkeit des Quellcodes für Programmierer zu erleichtern. Bei einigen Programmiersprachen werden allerdings bestimmte Formatierungen in der Sprachdefinition gefordert und müssen dann natürlich den Vorgaben entsprechend eingehalten werden.

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. So ist es zum Beispiel allgemein üblich, Programmblöcke so zu formatieren, dass die Inhalte gegenüber dem Kopf und dem Fuß etwas (meist um einen Tabulator) eingerückt und durch Zeilenumbrüche voneinander getrennt werden:

Blockkopf
   eingerückter Inhalt 1
   eingerückter Inhalt 2
Blockfuß

Siehe hierzu auch Blockanweisungen.

Bezeichner

Bearbeiten

In den meisten Programmiersprachen gibt es Bezeichner (oder Identifikatoren, englisch: identifier) für ganz unterschiedliche Dinge, wie für symbolische Konstanten, für Variablen, für Parameter oder Attribute, für Methoden (respektive für Prozeduren oder für Funktionen), für Module (respektive für Klassen) oder für Bibliotheken. Zur Strukturierung von Daten werden auch Pakete (englisch: packages) eingesetzt.

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

Beim Lesen und Analysieren von Quelltexten ist es sehr hilfreich, wenn einem Bezeichner nicht nur beim ersten Auftauchen bei der Deklaration, sondern an jeder Stelle im Programm sofort angesehen werden kann, wofür er steht. Meist bildet sich für eine Gruppe von Programmiersprachen ein bestimmter Usus aus, wie die entsprechenden Bezeichner gestaltet werden sollen. Der Compiler stellt in der Regel keine Ansprüche an die Schreibweise von Bezeichnern, solange der definierte Zeichenvorrat verwendet wird. Eine Ausnahme stellen die vorgegebenen Schlüsselwörter dar, die häufig und je nach Programmiersprache nur aus Großbuchstaben oder nur aus Kleinbuchstaben bestehen, wie zum Beispiel:

  • IMPORT, CONST, TYPE, VAR, PROCEDURE, NIL, LONG, REAL, BEGIN, END, WHILE

versus

  • import, final, void, static, null, long, double, while

In vielen Programmiersprachen haben sich für die frei definierbaren Bezeichner bestimmte Praktiken herausgebildet, damit die Bedeutung der Bezeichner im Quelltext von den beteiligten Programmierern leichter erkannt werden kann. Dieses Vorgehen ist allerdings nicht immer einheitlich gestaltet, wie anhand der folgenden, beispielhaften Liste gesehen werden kann:

  • Die Bezeichner von übergeordnet verfügbaren Konstanten, Variablen, Datentypen, Klassen oder Modulen beginnen mit einem Großbuchstaben.
  • Die Bezeichner von Konstanten werden vollständig mit Großbuchstaben geschrieben.
  • Die Bezeichner von lokal verfügbaren Variablen, Attributen oder Parametern werden vollständig mit Kleinbuchstaben geschrieben.
  • Die Bezeichner von Methoden werden vollständig mit Kleinbuchstaben geschrieben.

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:

/* "diameter" is variable of the data type integer */
int diameter

/* "radius" is variable (parameter of the function "calcDiameter") of the data type integer */
/* "calcDiameter" is a function */
/* the result of the function call has the data type integer */
int calcDiameter (int radius)

"calcDiameter" ist hierbei mit dem Binnenmajuskel "D" versehen (umgangssprachlich auch "Kamelhöcker-Notation" genannt, englisch "camel case"), um den Anfang eines neuen Wortes ohne die Verwendung eines Leerzeichens erkennbar zu machen.

Es ist im Sinne der guten Lesbarkeit des Quelltextes allgemein hilfreich, in Bezeichnern immer passende grammatische Formen zu verwenden, wie zum Beispiel:

  • für booleschen Variablen und Funktionen: Partizipien oder Adjektive
  • für andere Variablen und Funktionen: Substantive
  • für Methoden und Kommandos: Verben im Imperativ

In manchen Programmiersprachen ist es üblich, lokale Variablen mit einem Kleinbuchstaben zu beginnen und globale Variablen - also in mehreren Programmodulen, Klassen oder Methoden sichtbare Variablen - mit einem Großbuchstaben zu beginnen, um deren Sichtbarkeiten unmittelbar erkennbar zu machen.

Konstanten

Bearbeiten

Die Werte von Konstanten können zur Laufzeit nicht mehr verändert werden. Dieser Umstand wird dem Compiler bei der Deklaration der Konstanten durch entsprechende Deklarationen (wie zum Beispiel "CONST") oder Modifikatoren (wie zum Beispiel "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.

Es ist empfehlenswert, die Initialisierung einer Konstanten immer unmittelbar im Kontext der Deklaration vorzunehmen, damit es keine Mehrdeutigkeiten und somit auch keine Verwechslungen durch undefinierte Werte geben kann. Wenn dies nicht sinnvoll erscheint, sollte vorzugsweise keine Konstante verwendet werden. In vielen modernen Programmiersprachen ist es möglich, Klassenvariablen beziehungsweise globale Variablen zu schützen, indem diese nur innerhalb einer Klasse beziehungsweise innerhalb eines Moduls verändert werden dürfen. In diesem Fall gibt es von Außerhalb nur einen Lesezugriff auf den Wert der Variablen ("read-only"), oder der aktuelle Wert der Variablen kann durch den Aufruf eines Unterprogramms zurückgegeben werden ("get"-Methoden).

(* Programmiersprache Component Pascal*)

MODULE Zahlen;

(* Auf die globale ganzzahlige Konstante "Konstante" kann nur lesend zugegriffen werden. *)
(* Die globale Konstante "Konstante" ist durch Deklaration und Initialisierung vollständig definiert. *)
CONST
	Konstante = 7;

(* Auf die globale ganzzahlige Variable "zahl" kann von außerhalb des Moduls "Zahlen" nur lesend zugegriffen werden. *)
(* Die globale Variable "zahl" wird mit dem Zusatz "-" als "read-only" deklariert. *)
VAR
	zahl-: LONGINT;

BEGIN
	(* Die globale Variable "zahl" wird mit einem Wert initialisiert. *)
	zahl := 8;
END Zahlen.
/* Programmiersprache Java*/

public class Zahlen;
{
	// Auf die öffentliche ganzzahlige Klassenkonstante "Konstante" kann nur lesend zugegriffen werden.
	// Die Klassenkonstante "Konstante" ist durch Deklaration und Initialisierung vollständig definiert.
	// Die Klassenkonstante "Konstante" wird mit dem Zusatz "final" deklariert.
	public static final long Konstante = 7;

	// Auf die nicht-öffentliche ganzzahlige Klassenvariable "zahl" kann von außerhalb der Klasse
	// nur über die öffentliche Methode "getZahl ()" lesend zugegriffen werden.
	// Die Klassenvariable "zahl" wird mit dem Zusatz "private" deklariert.
	private static long zahl = 8;

	public static long getZahl ()
	{
		return zahl;
	}
}

Im folgenden Java-Beispiel wird eine als konstant deklarierte lokale Variable erst innerhalb einer Fallunterscheidung (if-Anweisung) und zudem mit zwei verschiedenen optionalen Werten initialisiert. Wenn die Deklaration und die optionalen Initialisierungen im Quelltext weiter auseinanderliegen, ist es schwierig, den definierten Zustand der vermeintlich eindeutig definierten Variablen vollständig zu erfassen.

private static boolean boolescherAusdruck ()
{
	java.util.Random zufall = new java.util.Random ();
	boolean zufaelligerBoolescherWert = zufall.nextBoolean ();
	return zufaelligerBoolescherWert;
}

public static void main (java.lang.String [] arguments)
{
	// Deklaration der lokalen Konstante "Zahl",
	// die wegen fehlender Initialisierung nicht definiert ist.
	final long Zahl;

	if (boolescherAusdruck ())
	{
		Zahl = 7;
	}
	else
	{
		Zahl = 8;
	}
	java.lang.System.out.println ("Konstante Zahl = " + Zahl);
}

Klassen und Module

Bearbeiten

Auch dauerhaft speicherbare Unterprogrammeinheiten wie Module oder Klassen werden meist mit einem Bezeichner benannt, der mit einem Großbuchstaben beginnt. Im Kontext des Quellcodes ist es immer möglich, diese Bezeichner von anderen zu unterscheiden, die ebenfalls mit einem Großbuchstaben beginnen, weil sie zum Beispiel immer von einer Blockanweisung (zum Beispiel geschweifte Klammern) oder auch von einem Separator (beispielsweise ".") gefolgt werden.

(* Das Unterprogramm "Programm" in der Programmiersprache Pascal *)
program Programm;
begin
end.
(* Das Unterprogramm "Programm" in den Programmiersprachen Modula-2, Oberon oder Component Pascal *)
MODULE Programm;
BEGIN
END Programm.
// Das Unterprogramm "Programm" in der Programmiersprache Java
public class Programm;
{
}

„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 eine solche Anweisung:

h ← (t – b)

Sondern besser:

height ← (top - bottom)

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

Qualifizierte Bezeichner

Bearbeiten

Damit der sich hinter einem Bezeichner verborgene Inhalt eindeutig einem Programmteil zugeordnet werden kann, muss dieser qualifiziert bezeichnet werden.

In manchen Programmiersprachen geschieht dies für bestimmte Bezeichner inhärent, obwohl es eine explizite import-Anweisung für die entsprechenden Bezeichner gibt, so dass die Programmierer in diesen Sonderfällen also wissen müssen, worauf sich der unqualifizierte Bezeichner bezieht. In der Programmiersprache Java dürfen häufig verwendete Bezeichner wie beispielsweise die zur Textausgabe verwendete Methode "print" aus der Klasse "System" oder die für Zeichenketten verwendete Klasse "String" ohne eine vollständige und qualifizierte Bezeichnung in den Programmtext geschrieben werden:

   String text = "Hallo Welt!";
   System.out.print (text);

In der Variablen "text" der Klasse "String" wird die Zeichenkette "Hallo Welt!" gespeichert und mit der klassengebundenen Methode "print" der Klassenvariablen "out" aus der Klasse "System" ausgegeben. Hier ist allerdings nicht ohne weiteres ersichtlich, wo sich die Deklarationen oder die Implementierungen beiden Klassen "String" und "System" befinden.

Die qualifizierte Bezeichnung dieser beiden Anweisungen hat folgendes Aussehen:

   java.lang.String text = "Hallo Welt!";
   java.lang.System.out.print (text);

Durch die qualifizierte Bezeichnung wird klar, dass sich beide Klassen im Programmpaket "java.lang" des Programmmoduls "java.base" befinden. Jedes Modul (englisch "module") und jedes Paket (englisch "package") kann in der Systembibliothek der Programmiersprache beziehungsweise in der Programmbibliothek der Laufzeitumgebung eindeutig zugeordnet werden.

Alternativ wird die Qualifikation von bestimmten Bezeichnern durch eine Import-Anweisung zu Beginn des Programms vorgenommen:

   import java.lang.String;
   import java.lang.System;

//...

      String text = "Hallo Welt!";
      System.out.print (text);

Dieses Vorgehen erlaubt innerhalb einer Programmdatei zwar grundsätzlich eine korrekte und eindeutige Zuordnung der weiter unten im Programmtext verwendeten unqualifizierten Bezeichner, bei der Analyse des Programmtextes sind sämtliche Import-Anweisungen jedoch stets und vollständig zu berücksichtigen, was die Sache für die Programmierer insbesondere bei langen oder komplexen Quelltexten sehr erschweren kann. Dies kann durch die ausschließliche und obligatorische Verwendung von qualifizierten Bezeichnern ausgeschlossen werden, und deswegen wird von streng strukturierten Programmiersprachen überall und immer eine qualifizierte Bezeichnung gefordert.

Auch bei Datenstrukturen müssen qualifizierte Bezeichner verwendet werden, damit eindeutig auf bestimmte Datenfelder zugegriffen werden kann. Im folgenden Beispiel in der Syntax der Pascal-Programmiersprachenfamilie wird dies anhand des komplexen Datentyps "Postadresse" mit den sechs Attributen "vorname", "nachname", "strasse", "hausnummer", "postleitzahl" und "ort" dargestellt:

   (* Datentyp "Postadresse" *)
   TYPE Postadresse =
      RECORD
         vorname: ARRAY OF CHAR;
         nachname: ARRAY OF CHAR;
         strasse: ARRAY OF CHAR;
         hausnummer: ARRAY OF CHAR;
         postleitzahl: LONGINT;
         ort: ARRAY OF CHAR;
      END;

In den Deklarationen der Attribute steht "ARRAY OF CHAR" für den Datentyp Zeichenkette, der zur Speicherung von Zeichenfolgen verwendet wird. Der Datentyp "LONGINT" dient zur Speicherung ganzer Zahlen.

Eine Instanz "adresse" dieses Datentyps "Postadresse" kann wie folgt mit der NEW-Prozedur erzeugt werden, wobei der dafür erforderliche Speicherplatz festgelegt und für andere Verwendungen gesperrt wird. Auf die sechs einzelnen Datenfelder der in "adresse" gespeicherten sechs Attribute des Datentyps "Postadresse" kann danach im Programm mit den entsprechenden qualifizierten Bezeichnern beispielsweise zugegriffen werden, indem die jeweiligen initialen Werte mithilfe des Zuweisungsoperators := zugewiesen werden. Auf die Prozeduren "String" und "Int" aus dem Modul "Out" wird über "Out.String" und "Out.Int" ebenfalls qualifiziert zugegriffen:

   IMPORT
      Out; (* Import des Moduls "Out" mit den Textausgabe-Prozeduren "String" für Zeichenketten und "Int" für ganze Zahlen *)
   VAR
      adresse: Postadresse; (* globale Variable "adresse" *)
   BEGIN
      NEW (adresse);  (* Speicherreservierung für den Bezeichner "adresse" *)
      adresse.vorname := "Irgend";
      adresse.nachname := "Jemand";
      adresse.strasse := "Allee";
      adresse.hausnummer := "100";
      adresse.postleitzahl := 10000;
      adresse.ort := "Irgendwo";

      Out.String (adresse.vorname);
      Out.String (adresse.nachname);
      Out.String (adresse.strasse);
      Out.String (adresse.hausnummer);
      Out.Int (adresse.postleitzahl);
      Out.String (adresse.ort);
   END;

In einem weiteren Beispiel mit der Syntax der Programmiersprache Java wird die Datenstruktur dieses komplexen Datentyps als Klasse "Postadresse" mit den sechs Instanzvariablen "vorname", "nachname", "strasse", "hausnummer", "postleitzahl" und "ort" für diese sechs Attribute gebildet. Eine Instanz dieses Datentyps kann hier mit dem new-Operator erzeugt werden. Der öffentliche Konstruktor "Postadresse ()" ist eine Methode mit derselben Bezeichnung wie die Klasse selbst, die aufgerufen werden muss, um die sechs Datenfelder der jeweiligen Instanz "this" zu initialisieren. Auf die einzelnen Datenfelder der in der nicht-öffentlichen Klassenvariable "adresse" gespeicherten sechs Attribute des Datentyps "Postadresse" kann danach über die entsprechenden sechs qualifizierten Bezeichner zugegriffen werden. In der Methode "main" werden die Attribute zwischen den runden Klammern über die qualifizierten Bezeichner als Parameter bei den Aufrufen der allgemeinen Textausgabe-Methode "println" verwendet, die zur Klassenvariable "out" der Klasse "System" im Programmpaket "java.lang" gehört:

public class Postadresse // Klasse "Postadresse"
{
   // Instanzvariablen
   java.lang.String vorname;
   java.lang.String  nachname;
   java.lang.String strasse;
   java.lang.String hausnummer;
   long postleitzahl;
   java.lang.String ort;

   private static Postadresse adresse = new Postadresse (); // Klassenvariable "adresse"

   public Postadresse () // Konstruktor der Klasse "Postadresse"
   {
      this.vorname = "Irgend";
      this.nachname = "Jemand";
      this.strasse = "Allee";
      this.hausnummer = "100";
      this.postleitzahl = 10000;
      this.ort = "Irgendwo";
   }

   public static void main (java.lang.String [] argumente) // main-Methode der Klasse "Postadresse"
   {
      java.lang.System.out.println (adresse.vorname);
      java.lang.System.out.println (adresse.nachname);
      java.lang.System.out.println (adresse.strasse);
      java.lang.System.out.println (adresse.hausnummer);
      java.lang.System.out.println (adresse.postleitzahl);
      java.lang.System.out.println (adresse.ort);
   }
}

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. Leider trifft dies für viele Programmiersprachen nicht zu. Die Darstellung beliebiger ganzer Zahlen (sowohl negative, als auch positive und die Null) mit Zeichen kann in der Erweiterten Backus-Naur-Form zum Beispiel schrittweise so definiert werden:

 NatuerlicheZiffer = "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9";
 Ziffer = "0" | NatuerlicheZiffer;
 NatuerlicheZahl = NatuerlicheZiffer{Ziffer};
 GanzeZahl = "0" | ["-"]NatuerlicheZahl;
 
Beispiel für einen Algorithmus mit zwei geschachtelten, kopfgesteuerten Schleifen in der Darstellung als Nassi-Shneidermann-Diagramm.

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 mindestens einen 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, damit der Quelltext überschaubar bleibt und gut nachvollzogen werden kann. Durch geeignete Strukturierung ist dies in modernen Programmiersprachen immer möglich, und mit einer McCabe-Metrik bis maximal 10 ist die Komplexität meist hinreichend niedrig.

Dies gilt nicht nur für den prozeduralen Programmablauf, sondern gleichermaßen für Datenstrukturen, bei denen komplexe Datentypen aus elementaren Datentypen übersichtlich und 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, dies jedoch nicht geeignet ist, 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).

Sichtbarkeiten

Bearbeiten

Grundsätzlich gilt immer das Prinzip der Lokalität. Dies bedeutet, dass auf Programmkonstrukte nur dort zugegriffen werden kann und darf, wo es unbedingt erforderlich ist. Zum Datenaustausch zwischen verschiedenen Programmteilen dienen unter diesen Voraussetzungen Schnittstellen, die in der strukturierten Programmierung exakt definiert sein müssen.

Alle Klassenvariablen, Instanzvariablen und Parametervariablen sowie Rückgabewerte werden in Bezug auf ihre Teilprogramme zum Beispiel als lokale Variablen behandelt, so dass sie nur innerhalb dieser Teilprogramme aufgerufen und verändert werden können. Auf diese Weise können unbeabsichtigte und unerwünschte Seiteneffekte nachhaltig vermieden werden.

Je weniger lokal eine Variable definiert ist, desto größer ist die Gefahr, dass diese unbeabsichtigt oder sogar zuwider den Absichten 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. Variablen sollen also immer so lokal wie möglich definiert werden. Am besten sind Variablen lokalisiert, wenn sie innerhalb der Teilstruktur definiert werden, wo die Variablen üblicherweise „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 für Teilprogramme haben, ist die am stärksten lokalisierte Definition in der Regel innerhalb einer Methode respektive einer Prozedur oder einer Funktion. Die nächsthöhere Strukturebene ist dann - sofern möglich - das Modul beziehungsweise die Klasse (dies ist zwar häufig eine vom Compiler zu übersetzende Einheit, ist jedoch nicht unbedingt identisch mit einer Quelltextdatei). Innerhalb von Programmstrukturen sollten Variablen möglichst mit dem Sichtbarkeitsmodifikator für die ausschließlich interne Verwendbarkeit (zum Beispiel mit dem Modifikator private oder limited) deklariert werden. Solche internen Variablen können dann gegebenenfalls mit entsprechend zu implementierenden Konstruktoren initialisiert, mit sogenannten Getter-Methoden abgefragt und mit Setter-Methoden verändert werden. Falls diese exportiert werden (beispielsweise mit dem Modifikator public oder export), ist auch außerhalb der Deklarationsstruktur ein definierter indirekter Zugriff auf die internen Variablen möglich.

Manche Programmiersprachen erlauben eine Deklaration, die außerhalb des Deklarationsbereiches nur gesehen respektive gelesen werden können (zum Beispiel mit dem Modifikator read-only für Variablen oder implement-only für Methoden). In diesem Fall können die entsprechenden Variablen oder Methoden außerhalb der Deklarationsstruktur also nicht verändert, aber zumindest abgefragt oder aufgerufen werden.

Globale Variablen, die überall innerhalb von großen Programmeinheiten verändert werden können, sind immer vermeidbar, erhöhen die Gefahr von Programmfehlern und erleichtern unter Umständen Cyber-Attacken.

Besondere Probleme ergeben sich, wenn innerhalb eines Sichtbarkeitsbereiches für verschiedene Dinge gleichlautende Bezeichner verwendet werden dürfen. Dies kann wegen der Wahlfreiheit bei der Benennung sehr leicht vermieden werden, indem einfach keine gleichlautenden Bezeichner benutzt werden. Im folgenden Beispiel wird verdeutlicht, wie in einem Java-Programm zwischen den Bezeichnern von lokalen und globalen Variablen sowie von Methoden formal dennoch eindeutig unterschieden werden kann:

public class Klasse
{
	// globale Variable "bezeichner" (Klassenvariable)
	private static long bezeichner = 1;

	// Methode "bezeichner" (Unterprogramm)
	private static long bezeichner ()
	{
		// lokale Variable "bezeichner" in der Methode "bezeichner"
		long bezeichner = 3;
		return bezeichner;
	}

	// Hauptprogramm (Methode "main")
	public static void main (java.lang.String [] argumente)
	{
		// Ausgabe der globalen Variable aus der Klasse "Klasse"
		java.lang.System.out.println ("Wert der globalen Variable = " + Klasse.bezeichner);

		// lokale Variable "bezeichner" in der Methode "main"
		long bezeichner = 2;
		// Ausgabe der lokalen Variable aus der Methode "main"
		java.lang.System.out.println ("Wert der lokalen Variable = " + bezeichner);

		// Ausgabe des Ergebnisses des Aufrufs der Methode "bezeichner"
		java.lang.System.out.println ("Wert des Unterprogramms = " + bezeichner ());
	}
}

In dieser Java-Klasse "Klasse" gibt es vier gleichlautende Bezeichner "bezeichner":

  • Der Name einer globalen Klassenvariable.
  • Der Name einer Methode.
  • Der Name einer lokalen Variable in der Methode "bezeichner".
  • Der Name einer lokalen Variable in der Methode "main".

Nach den Regeln der Programmiersprache Java haben lokale Bezeichner bei der Referenzierung innerhalb einer Blockanweisung Vorrang, so dass bei der Verwendung dieser Bezeichner immer auf die lokale Variable zugegriffen wird. Im obigen Beispiel haben die beiden lokalen Variablen "bezeichner" nichts miteinander zu tun und können nur in ihrer entsprechenden Methode referenziert werden. Soll in einem lokalen Sichtbarkeitsbereich auf die globale Klassenvariable referenziert werden, so kann dies durch einen expliziten und qualifizierten Bezeichner erwirkt und sichergestellt werden, im obigen Beispiel mit "Klasse.bezeichner". Der Bezeichner einer Methode kann durch das stets folgende runde Klammerpaar identifiziert werden, im obigen Beispiel "bezeichner ()".

Modularisierung

Bearbeiten

Teilprogramme können Methoden oder ganze Sammlungen von Datenstrukturen und Methoden sein. Diese werden oft Klassen oder Module genannt und können in Paketen gruppiert werden. Alle Teilprogramme sollen eindeutige und sprechende Bezeichner und streng definierte Signaturen und Schnittstellen für die Namen und die Datentypen aller Parameter beziehungsweise Klassen- und Instanzvariablen haben. Bei diesen Teilprogrammen handelt es sich in der Regel um die kleinsten dauerhaft speicherbaren Programmeinheiten, die zum Beispiel in einer Datenbank oder einem Dateisystem zu größeren Einheiten wie Verzeichnissen, Paketen oder Bibliotheken zusammengefasst werden.

Solche Programmeinheiten werden durch ihre Signatur eindeutig gekennzeichnet. Die Signatur besteht zunächst aus dem Namen der Programmeinheit. Ferner kann mit einem Modifikator explizit definiert werden, dass diese Programmeinheit allgemein, also von allen und beliebigen anderen Programmeinheiten, verfügbar sein soll (Modifikator public / öffentlich). Für eine Beschränkung nur auf die nächst höhere Programmeinheit, wie beispielsweise einem Paket (englisch "package"), kann der Modifikator private verwendet werden.

Eine typische Programmbibliothek hat in der Programmiersprache Java am Beispiel des Moduls "java.base" und der beiden Pakete "java.io" und "java.lang" folgende ausschnittsweise Struktur und Hierarchie:

module java.base;

   package java.io;

      class Reader;
      {
         // Implementation der Klasse Reader
      }

      class Writer;
      {
         // Implementation der Klasse Writer
      }

   package java.lang;

      class String;
      {
         // Implementation der Klasse String
      }

      class System;
      {
         // Implementation der Klasse System
      }

Methoden

Bearbeiten

Methoden beziehungsweise Prozeduren werden ebenfalls durch ihre Signatur eindeutig deklariert, und alle Methodenaufrufe müssen sich streng an diese Deklaration halten. Die Signatur besteht zunächst aus dem Namen der Methode.

Methoden haben optional einen Rückgabewert, für die der Datentyp ebenfalls festgelegt werden muss und der in streng strukturierten Programmiersprachen ebenfalls zur Signatur der Methode gehört und verwendet werden muss. Solche Methoden werden auch Funktionen genannt. Leider ist es in manchen Programmiersprachen erlaubt, Rückgabewerte von Funktionen einfach zu ignorieren und diese nicht in einer Variablen zu speichern oder im Rahmen eines Ausdrucks auszuwerten, da dies zu leicht zu übersehenden Programmierfehlern führen kann.

Ferner gibt es innerhalb der Signatur optionale Modifikatoren, die die Regeln für die Sichtbarkeit (zum Beispiel öffentlich / privat / eingeschränkt, englisch: public / private / limited) festlegen.

Die Überschreibbarkeit einer Methode wird mit einem weiteren Modifikator festgelegt (wie zum Beispiel mit statisch / erweiterbar / abstrakt / abgeschlossen, englisch: static / extensible / abstract / final).

Methoden haben keinen, einen oder mehrere Parameter. Methoden ohne Parameter werden auch parameterlose Methoden genannt. Parameter sind innerhalb der Methode lokale Variablen, die beim Aufruf der Methode angegeben werden müssen und gegebenenfalls zusammen mit dem Rückgabewert die Schnittstelle für den Datenaustausch zum aufrufenden Programm darstellen. Die Anzahl, die Namen, die Datentypen und die Reihenfolge der Parameter gehören ebenfalls zur Signatur einer Methode. Beim Aufruf einer Methode müssen alle Parameter in der richtigen Reihenfolge und zuweisungskompatibel angegeben werden. Parameter können unterschieden werden in:

  • Eingangsparameter (in), die als Wert (englisch value) übergeben und nur innerhalb der Methode verwendet werden. Nach Beendigung des Methodenaufrufs sind sie ungültig und dürfen nicht weiterhin referenziert werden.
  • Ausgangsparameter (out), die als Referenzen (Zeiger auf einen Speicherbereich, englisch pointer) übergeben und deren Werte erst innerhalb der Methode ermittelt und zugewiesen werden. Nach Beendigung des Methodenaufrufs sind ihre Werte über die Referenzen abrufbar. Die referenzierten Speicherbereiche müssen vor dem Methodenaufruf allokiert worden sein, aber die Speicherinhalte müssen nicht festgelegt werden, da sie innerhalb der Methode nicht verwendet, sondern bestimmt und zugewiesen werden. Beim Programmieren ist große Sorgfalt darauf zu legen, dass die entsprechenden Zuweisungen innerhalb der Methode in jedem Fall erfolgen, falls die verwendete Programmiersprache dies nicht sowieso vorschreibt und erzwingt.
  • Durchgangsparameter (variable), die als Referenzen mit definierten Speicherinhalten übergeben, innerhalb der Methode verwendet und nach einer möglichen Veränderung (respektive Variation) während des Methodenaufrufs weiterverwendet werden können. Nach Beendigung des Methodenaufrufs sind ihre aktuellen Werte in den aufrufenden Programmteilen über die Referenzen abrufbar.

Grundlegende Anweisungen

Bearbeiten

Grundsätzlich kommt die strukturierte Programmierung in imperativen Programmiersprachen mit folgenden grundlegenden Anweisungen aus:

  • Deklaration, zum Beispiel bei Klassen, Methoden, Variablen oder Konstanten mit einer eindeutigen Signatur:
    • Modifikatoren für die Sichtbarkeit, Verwendbarkeit oder Veränderbarkeit
    • Bezeichner
    • Optional (bei Methoden, Funktionen, Prozeduren): Parameter mit Deklaration der Bezeichner, der Veränderbarkeiten und der Datentypen
    • Optional (bei Funktionen): Datentyp des Rückgabewertes
  • Blockanweisung, zum Beispiel BEGIN ... END oder { ... }
  • Zuweisung, zum Beispiel a := b - c; (das Gleichheitseichen ist nicht zu verwechseln mit einem Vergleichsoperator)
  • Aufruf von Unterprogrammen:
    • Kommandos (ohne Parameter und ohne Rückgabewert)
    • Prozeduren oder Methoden (ohne Rückgabewert)
    • Funktionen (mit Rückgabewert)
  • Rückgabe bei Funktionen, zum Beispiel return x;

Anweisungen werden häufig durch ein reserviertes Zeichen abgeschlossen, wie zum Beispiel mit einem Semikolon.

Das folgende Beispiel zeigt eine Java-Klasse mit 15 grundlegenden Anweisungen:

// Deklaration der oeffentlichen Klasse "Anweisungen"
public class Anweisungen
// Implementation der Klasse mit einer Blockanweisung "{}"
{	
	// Deklaration der privaten, globalen Klassenvariable "flaeche" vom Datentyp "double"
	private static double flaeche;

	// Deklaration der privaten statischen Methode "kreisflaeche" (Unterprogramm) zur Berechnung der Flaeche eines Kreises mit dem Radius "radius"
	// mit dem Parameter "radius" vom Datentyp "double"
	// und mit einer Gleitkommazahl vom Datentyp "double" als Rueckgabewert
	private static double kreisflaeche (double radius)
	// Implementation der Methode "kreisflaeche" mit einer Blockanweisung "{}"
	{
		// Deklaration der lokalen Variable "ergebnis" vom Datentyp "double"
		double ergebnis;

		// Zuweisung eines Ausdrucks an die Variable "ergebnis" mit dem Zuweisungsoperator "="
		// Syntax: "Variablenname Zuweisungsoperator Ausdruck Semikolon"
		// Der arithmetische Ausdruck verwendet zwei Multiplikationsoperatoren "*"
		// Die Kreiszahl pi aus der Klasse "java.lang.Math" wird qualifiziert bezeichnet: "java.lang.Math.PI"
		ergebnis = java.lang.Math.PI * radius * radius;

		// Ruecksprunganweisung "return" mit der Rueckgabe der Gleitkommazahl "ergebnis"
		return ergebnis;
	}

	// Deklaration der oeffentlichen statischen Methode main (Hauptprogramm)
	public static void main (java.lang.String [] arguments)
	// Implementation der Methode "main" mit einer Blockanweisung "{}"
	{
		// Deklaration der lokalen Variable "raddurchmesser" vom Datentyp double
		double raddurchmesser;

		// Initialisierung der lokalen Variable "raddurchmesser" durch Zuweisung des konstanten arithmetischen Zahlenausdrucks "1.5"
		raddurchmesser = 1.5;

		// Aufruf der Methode "kreisflaeche" mit dem arithmetischen Ausdruck "raddurchmesser / 2" als Parameter
		// Der Rueckgabewert des Methodenaufrufs ist ein Ausdruck und wird der globalen Klassenvariablen "flaeche" zugewiesen
		flaeche = kreisflaeche (raddurchmesser / 2);

		// Aufruf der Methode "println" mit dem Parameter "flaeche" zur Ausgabe der berechneten Kreisflaeche
		// Die Methode aus der Klasse "java.lang.System" wird qualifiziert bezeichnet: "java.lang.System.out.println"
		java.lang.System.out.println (flaeche);
	}
}

Diese Anweisungen sind in der Reihenfolge des Auftretens:

  1. Deklaration der Klasse "Anweisungen"
  2. Blockanweisung zur Implementation der Klasse "Anweisungen"
  3. Deklaration der Klassenvariable "flaeche"
  4. Deklaration der Methode "kreisflaeche" (Unterprogramm)
  5. Blockanweisung zur Implementation der Methode "kreisflaeche"
  6. Deklaration einer lokalen Variable "ergebnis" in der Methode "kreisflaeche"
  7. Zuweisung an die lokale Variable "ergebnis" in der Methode "kreisflaeche"
  8. Rücksprung vom Unterprogramm "kreisflaeche" zum Hauptprogramm "main"
  9. Deklaration der Methode "main" (Hauptprogramm)
  10. Blockanweisung zur Implementation der Methode "main"
  11. Deklaration der lokalen Variable "raddurchmesser"
  12. Zuweisung an die lokale Variable "raddurchmesser"
  13. Aufruf des Unterprogramms "kreisflaeche"
  14. Zuweisung an die globale Klassenvariable "flaeche"
  15. Aufruf des Unterprogramms "println"

Anweisungsstrukturen

Bearbeiten

Anweisungesstrukturen setzen sich aus mehreren Anweisungen zusammen. Eine Methode besteht zum Beispiel aus einer Deklaration mit der Definition der Schnittstelle, der unmittelbar eine Blockanweisung mit der Implementierung folgt.

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

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

Bei jedem elementaren Teilprogramm (respektive jeder Methode, Prozedur oder Funktion, aber auch bei jeder Definition von Datenstrukturen) sollte der Quelltext bequem und vollständig auf einer Bildschirmseite gelesen werden können, ohne dass der Text im Betrachtungsfenster verschoben werden muss. Dabei empfiehlt es sich, Methodenaufrufe und übersichtliche Blockanweisungen zu verwenden, mit denen der Quellcode in 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:

printMonth ()
{
   const int NumberOfWeekdays ← 7
   const int LastDay ← 31

   int column
   int day ← 1

   while (day <= LastDay)
   {
      printInt (day)
      column ← day modulo NumberOfWeekdays
      if (column = 0)
      {
         printLine ()
      }
      day ← day + 1
   }
}

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:

optionalNewLine (int day)
{
   const int NumberOfWeekdays ← 7

   int column ← day modulo NumberOfWeekdays

   if (column = 0)
   {
      printLine ()
   }
}

void printMonth ()
{
   const int LastDay ← 31
   int day ← 1

   while (day <= LastDay)
   {
      printInt (day)
      optionalNewLine (day)
      day ← day + 1
   }
}

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

Häufig wird behauptet, dass die Performanz der ausgeführten Programme durch die Aufteilung in solche Unterprogramme leiden würde, da die zahlreichen Aufrufe und Rücksprünge Rechenzeit und Speicherressourcen kosten. In den allermeisten Fällen ist dies auf modernen Rechenmaschinen jedoch zu vernachlässigen. Bei den meisten Anwendungen wird am Speicherbedarf und an der Rechenzeit nicht bemerkt werden können, ob ein strukturiertes oder ein unstrukturiertes Programm vorliegt. Bestenfalls bei extrem rechenintensiven Aufgaben (wie zum Beispiel beim sogenannten "number crunching" ("Zahlenfressen"), bei Monte-Carlo-Simulationen oder Big-Data-Analysen) kann dies bei den extrem häufig aufgerufenen Unterprogrammen einen nennenswerten Effekt haben. Hierbei kann eine wohlstrukturierte Parallelisierung von Programmen oder die Ausgliederung von Rechenaufgaben in spezialisierte Hardware (Graphikprozessoren, digitale Signalprozessoren (DSP), Field Programmable Gate Arrays (FPGA) oder Quantencomputer) wesentlich zu einer Beschleunigung der Programmabläufe beitragen.

Eine Software, die von den Anwendern als zu langsam empfunden wird, ist meist nur schlecht programmiert. Ferner kann gar nicht häufig genug betont werden, dass die Entwicklung und Wartung unstrukturierter Programme erheblich länger dauert und wesentlich fehleranfälliger ist.

Schrittweise Verfeinerung

Bearbeiten

Die Implementierung von Software geschieht in der Regel vom Großen ins Kleine. Grob entworfene Anweisungsfolgen und Datenstrukturen werden dabei im Rahmen einer schrittweisen Verfeinerung immer genauer den Anforderungen angepasst.

Die folgenden Aspekte sind bei der schrittweisen Verfeinerung nach wie vor typisch:[1]

  • In jedem Schritt wird eine Aufgabe (ein Programmteil / ein Datensatz) in Unteraufgaben (in Unterprogramme / in Unterdatensätze) aufgeteilt.
  • Der Grad der Abkapselung von Unteraufgaben bestimmt, wie leicht oder schwer Programme und Datenstrukturen angepasst oder übertragen werden können.
  • Die Notation für Programme und Daten sollte stets so weit wie möglich sowohl der natürlichen Sprache und der Natur der Sache als auch der Hardware und den Software-Werkzeugen angepasst sein.
  • Die Berücksichtigung der Kriterien Laufzeiteffizienz und Speichereffizienz sowie Klarheit und Regelmäßigkeit der Strukturen ist in allen Entwicklungsschritten bis zur Fertigstellung relevant.
  • Es muss immer erwogen werden, dass ein korrekt funktionierendes Programm durch eine bessere Version ersetzt werden kann und dass frühere Entscheidungen aus allen Entwicklungsschritten revidiert werden können.
  • Die Entwicklung und Wartung guter Programme ist alles andere als trivial, wird aber durch den Einsatz streng strukturierter Programmiersprachen deutlich erleichtert.

Datenstrukturen

Bearbeiten

Nicht nur der Programmcode, sondern auch die zu verarbeitenden Daten müssen gut strukturiert werden, um die Entwicklungszeiten zu reduzieren, die Qualität der Programme zu erhöhen und die Wartung der Quelltexte zu erleichtern. Gehören zum Beispiel ganz verschiedene Attribute zu einer Sache, sollen diese Attribute zu einer Datenstruktur zusammengefasst werden. Datenstrukturen können auch geschachtelt eingesetzt werden, so dass sehr umfangreiche und komplexe Datenstrukturen abgebildet werden können.

Aufzählungen

Bearbeiten

Eine Aufzählung (englisch "enumeration") wird verwendet, wenn bestimmte Eigenschaften von Datenstrukturen abzählbar und endlich sind. Mit diesen thematisch zusammengehörigen Aufzählungen können im Programmtext an allen entsprechenden Stellen statt abstrakt zugeordneter Zahlen konkret zugeordnete symbolische Konstanten mit sprechenden und selbsterklärenden Bezeichnern verwendet werden. Manche Programmiersprachen bieten dafür sogar die Möglichkeit an, dafür eigene Datentypen zu erstellen, in vielen Programmiersprachen wird das jedoch auf sehr simple Weise mit ganzzahligen Basisdaten nachgebildet.

Im folgenden Beispiel wird erläutert, wie verschiedene Kalendersysteme als Aufzählung behandelt werden können. Hierbei werden die folgenden vier Kalendersysteme zu Auswahl:

  • Jüdisches Kalendersystem, Kennzahl = 1
  • Julianisches Kalendersystem, Kennzahl = 2
  • Gregorianisches Kalendersystem, Kennzahl = 3
  • Islamisches Kalendersystem, Kennzahl = 4
/* Programmiersprache Java */

public class Kalendersystem
{
   public final static long JUEDISCH = 1; // Lunisolarkalender
   public final static long JULIANISCH = 2; // Solarkalender bis 4. Oktober 1582 (Donnerstag)
   public final static long GREGORIANISCH = 3; // Solarkalender seit 15. Oktober 1582 (Freitag)
   public final static long ISLAMISCH = 4; // Lunarkalender
}
(* Programmiersprache Oberon *)

 MODULE Kalendersystem;

   CONST
      JUEDISCH = 1; (* Lunisolarkalender *)
      JULIANISCH = 2; (* Solarkalender bis 4. Oktober 1582 (Donnerstag) *)
      GREGORIANISCH = 3; (* Solarkalender seit 15. Oktober 1582 (Freitag) *)
      ISLAMISCH = 4; (* Lunarkalender *)

In manchen, meist älteren Programmiersprachen gibt es explizite Aufzählungstypen, bei denen der Compiler automatisch die dazugehörigen ganzen Kennzahlen festlegt, ohne dass diese im Quelltext auftauchen, weil ausschließlich die symbolischen Konstanten aus der Deklaration des Aufzählungstyps verwendet werden. Variablen des Datentyps "Kalendersystem" im folgenden Beispiel dürfen nur die vier zwischen den runden Klammern explizit angegebenen respektive aufgezählten symbolischen Konstanten und keine beliebigen ganzen Zahlen verwenden:

(* Programmiersprache Modula-2 *)

   TYPE Kalendersystem = (JUEDISCH, JULIANISCH, GREGORIANISCH, ISLAMISCH);
/* Programmiersprache C++ */

   enum Kalendersystem = {JUEDISCH, JULIANISCH, GREGORIANISCH, ISLAMISCH};

Verbunde

Bearbeiten

Gehören mehrere verschiede Attribute zu einer Datenstruktur, spricht man auch von einem Verbund. Diese Datenstrukturen werden je nach Programmiersprache häufig "struct" oder "record" genannt. Alle Attribute können und müssen über einen zentralen Zugang adressiert werden. Dies soll im Folgenden anhand der Datenstruktur "Kalenderdatum" beispielhaft erläutert werden.

Ein Kalenderdatum möge aus einem Tag, einem Monat, einem Jahr und einem Kalendersystem bestehen:

Alle vier Attribute werden in vier unabhängigen Datenfeldern gespeichert. Im vorliegenden Beispiel sind zwar alle vier Datenfelder vom Basisdatentyp "ganze Zahl" ("long" oder "INTEGER"), die Bedeutung und die gültige Zahlenbereiche unterscheiden sich jedoch:

  • Tag: ganze Zahl im Intervall [1..31]
  • Monat: ganze Zahl im Intervall [1..12]
  • Jahr: ganze Zahl
  • Kalendersystem. ganze Zahl des Aufzählungstyps "Kalendersystem" mit den vier Optionen (JUEDISCH, JULIANISCH, GREGORIANISCH, ISLAMISCH)
/* Programmiersprache Java */

public class Kalenderdatum
{
   // Instanzvariablen
   private long tag;
   private long monat;
   private long jahr;
   private long kalendersystem;

   public Kalenderdatum (long tag, long monat, long jahr, long kalendersystem) // Konstruktor zur Initialisierung von Instanzvariablen der Klasse Kalenderdatum
   {
      this.tag = tag;
      this.monat = monat;
      this.jahr = jahr;
      this.kalendersystem = kalendersystem;
   }

   public static void main (java.lang.String [] argumente) // main-Methode der Klasse "Kalenderdatum"
   {
      Kalenderdatum kalenderdatum = new Kalenderdatum (10, 4, 2023, Kalendersystem.GREGORIANISCH); // Eine neue Instanz wird erzeugt und durch den Aufruf des Konstruktors initialisiert
      java.lang.System.out.print (kalenderdatum.tag);
      java.lang.System.out.print (".");
      java.lang.System.out.print (kalendekalenderdatum.monat);
      java.lang.System.out.print (".");
      java.lang.System.out.print (kalenderdatum.jahr);
      java.lang.System.out.println (); // Zeilenumbruch
   }
}
(* Programmiersprache Oberon *)
   IMPORT Out; (* Import des Moduls "Out" für die Textausgabe *)

   TYPE Kalenderdatum =
   RECORD
      tag: INTEGER;
      monat: INTEGER;
      jahr: INTEGER;
      kalendersystem: INTEGER;
   END;

   VAR kalenderdatum; (* Variable *)

   BEGIN
      kalenderdatum.tag = 10;
      kalenderdatum.monat = 4;
      kalenderdatum.jahr = 2023;
      kalenderdatum.kalendersystem = Kalendersystem.GREGORIANISCH;

      Out.Int (kalenderdatum.tag);
      Out.String (".");
      Out.Int (kalenderdatum.monat);
      Out.String (".");
      Out.Int (kalenderdatum.jahr);
      Out.Ln; (* Zeilenumbruch *)
   END;

Die Textausgabe lautet jeweils:

23.4.2023

In Arrays werden endlich viele und abzählbare Elemente eines bestimmten Datentyps in einer geordneten Reihe gespeichert. Die einzelnen Elemente können über einen ganzzahligen Index angesprochen werden. Der niedrigste Index ist meistens der Index Null, und dieser zeigt auf die erste Speicheradresse des Arrays. Da alle Elemente vom gleichen Datentyp sind, wird für jedes Element immer der gleiche Speicherplatz benötigt. Elemente mit komplexen Datentypen, werden nicht direkt im Array gespeichert, sondern dieses enthält als Verweise Zeiger mit den Speicheradressen der Inhalte der Elemente. Wenn der Speicherbedarf für ein Element (oder dessen Zeiger)   Bytes beträgt und das Array insgesamt   Elemente hat, dann berechnet sich der Speicherbedarf   für das ganze Array aus dem Produkt:

 

Die Speicheradresse   des i-ten Elements des Arrays berechnet sich dann mit einfacher und effizient ausführbarer Arithmetik aus der Speicheradresse des Arrays  , dem Speicherbedarf für ein Element (oder dessen Zeiger)   und dem Index   :

 

Die Speicheradresse des ersten Elements mit dem Index Null   ist also stets identisch mit der Speicheradresse des Arrays  .

Im folgenden Beispiel wird ein Array mit acht zufällig verteilten Gleitkommazahlen dargestellt:

Länge des Arrays Speicheradresse des Arrays Speicherbedarf für eine Gleitkommazahl
8 10000000 4
Index Speicheradresse des Elements im Array Gespeicherter Inhalt des Elements (Gleitkommazahl)
0 10000000 678,1495238
1 10000004 317,4610959
2 10000008 574,3131347
3 10000012 673,9323679
4 10000016 854,6637912
5 10000020 764,4845853
6 10000024 335,5146962
7 10000028 545,0787382

Das folgende Java-Programm implementiert ein solches Array von Gleitkommazahlen:

/* Programmiersprache Java */

public class Array
{
	
	// Klassenvariable zufallszahlen als Array mit acht Gleitkommazahlen
   private static double zufallszahlen [] = new double [8];

   public static void setzeZufallszahlen (long startwert) // Methode zur Bestimmung aller Gleitkommazahlen
   {
	   // Variable zufallszahl
	   java.util.Random zufallszahl = new java.util.Random (startwert); // Startwert für erste Zufallszahl = 1000

	   long anzahl = zufallszahlen.length;
	   int zaehler = 0; // der Index von Arrays darf nicht vom Datentyp long sein
	   while (zaehler < anzahl)
	   {
		   zufallszahlen [zaehler] = zufallszahl.nextDouble ();
		   zaehler++;
	   }
   }

   public static void ausgabeZufallszahlen () // Methode zur Ausgabe aller Gleitkommazahlen
   {
	   long anzahl = zufallszahlen.length;
	   int zaehler = 0; // der Index von Arrays darf nicht vom Datentyp long sein
	   while (zaehler < anzahl)
	   {
		   java.lang.System.out.println (zufallszahlen [zaehler]);
		   zaehler++;
	   }
   }

   public static void main (java.lang.String [] argumente) // Hauptprogramm "main" zum Aufruf der beiden Unterprogramme
   {
	   setzeZufallszahlen (1000); // Aufruf des Unterprogramms setzeZufallszahlen mit dem Parameter 1000 als Startwert
	   ausgabeZufallszahlen (); // Aufruf des Unterprogramms ausgabeMonatsname ohne Parameter
   }
}

Mit dem Aufruf des Hauptprogramms main erfolgt die Ausgabe von acht Pseudozufallszahlen:

0.7101849056320707
0.574836350385667
0.9464192094792073
0.039405954311386604
0.4864098780914311
0.4457367367074283
0.6008140654988429
0.550376169584217

Im nächsten Beispiel mit einem Array für die zwölf Monatsnamen ist der Datentyp eines Arrayelements jeweils eine Zeichenkette, die je nach ihrer Länge verschieden große Speicherbereiche belegen:

Länge des Arrays Speicheradresse des Arrays Speicherbedarf für eine Speicheradresse Speicherbedarf für ein Zeichen
13 10000000 4 2
Index Speicheradresse des Elements im Array Gespeicherter Inhalt des Elements (Speicheradresse) Länge der Zeichenkette Speicherbedarf der Zeichenkette Gespeicherter Inhalt des Elements (Zeichenkette)
0 10000000 20000000 8 16 "deutsch"
1 10000004 20000016 7 14 "Januar"
2 10000008 20000030 8 16 "Februar"
3 10000012 20000046 5 10 "März"
4 10000016 20000056 6 12 "April"
5 10000020 20000068 4 8 "Mai"
6 10000024 20000076 5 10 "Juni"
7 10000028 20000086 5 10 "Juli"
8 10000032 20000096 7 14 "August"
9 10000036 20000110 10 20 "September"
10 10000040 20000130 8 16 "Oktober"
11 10000044 20000146 9 18 "November"
12 10000048 20000164 9 18 "Dezember"

In den folgenden Beispielen in der Programmiersprache werden die zwölf Monatsnamen in Arrays mit Zeichenketten gespeichert. Hierzu wird die Sprache der Monatsnamen im ersten Arrayfeld mit dem Index 0 und die zwölf Monatsnamen in den folgenden Arrayfeldern mit den Indizes 1 bis 12 gespeichert:

/* Programmiersprache Java */

public class Monatsnamen
{
   // Klassenvariable
   private static java.lang.String monatsnamen [] = {"deutsch", "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"};

   public static void ausgabeMonatsname (int monat) // Methode zur Textausgabe von Monatsnamen
   {
      java.lang.System.out.println (monatsnamen [monat]);
   }

   public static void main (java.lang.String [] argumente) // Hauptprogramm "main" zum Aufruf der Methode ausgabeMonatsname
   {
      ausgabeMonatsname (1); // Aufruf des Unterprogramms ausgabeMonatsname mit dem Parameter 1
   }
}

Mit dem Aufruf des Hauptprogramms main erfolgt die Ausgabe mit dem ersten Monatsnamen:

Januar

In einigen Programmiersprachen muss die Größe der Array vor der Initialisierung festgelegt werden, und die Zuordnung zwischen den Indizes und den Arrayfeldern ist dann auch bei der Initialisierung explizit erkennbar:

(* Programmiersprache Component Pascal *)

MODULE Monatsnamen;

   IMPORT Out; (* Import des Moduls "Out" für die Textausgabe *)

   TYPE Monatsnamen = POINTER TO ARRAY OF ARRAY OF CHAR;

   VAR monatsnamen: Monatsnamen;

   PROCEDURE InittialisiereMonatsnamen (); (* Prozedur zur Initialisierung von Monatsnamen *)
   BEGIN
      NEW (monatsnamen, 13, 10); (* Reservierung von 13 Zeichenketten mit je 10 Zeichen) *)
      monatsnamen [0] := "deutsch";
      monatsnamen [1] := "Januar";
      monatsnamen [2] := "Februar";
      monatsnamen [3] := "März";
      monatsnamen [4] := "April";
      monatsnamen [5] := "Mai";
      monatsnamen [6] := "Juni";
      monatsnamen [7] := "Juli";
      monatsnamen [8] := "August";
      monatsnamen [9] := "September";
      monatsnamen [10] := "Oktober";
      monatsnamen [11] := "November";
      monatsnamen [12] := "Dezember";
   END InittialisiereMonatsnamen;

   PROCEDURE AusgabeMonatsname (monat: INTEGER); (* Prozedur zur Textausgabe von Monatsnamen *)
   BEGIN
      Out.String (monatsnamen [monat]);
   END AusgabeMonatsname;

   PROCEDURE Hauptprogramm*;
   BEGIN
      InittialisiereMonatsnamen (); (* Initialisierung beim Laden des Moduls "Monatsnamen" *)
      AusgabeMonatsname (1); (* Aufruf des Unterprogramms AusgabeMonatsname mit dem Parameter 1 *)
   END Hauptprogramm;

END Monatsnamen.

Durch den Aufruf von Monatsnamen.Hauptprogramm erfolgt die Ausgabe mit dem ersten Monatsnamen:

Januar

Arrays können mehrdimensional gestaltet werden. Um zum Beispiel zwei Sprachen mit Monatsnamen zu speichern, kann eine weitere Dimension mit einem Index für die gewünschte Sprache implementiert werden:

/* Programmiersprache Java */

public class Monatsnamen
{
   // Konstanten für Sprachaufzählung
   public final static int DEUTSCH = 0;
   public final static int ENGLISCH = 1;

   // Klassenvariablen
   private static java.lang.String monatsnamen [] [] =
   {
      {"deutsch", "Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"},
      {"english", "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"}
   };

   public static void ausgabeMonatsname (int monat) // Methode zur Textausgabe von Monatsnamen
   {
      java.lang.System.out.println ("Deutschsprachiger Monatsname = " + monatsnamen [DEUTSCH] [monat]);
      java.lang.System.out.println ("Englischsprachiger Monatsname = " + monatsnamen [ENGLISCH] [monat]);
   }

   public static void main (java.lang.String [] argumente) // Hauptprogramm "main" zum Aufruf der Methode ausgabeMonatsname
   {
      ausgabeMonatsname (2);
   }
}

Mit dem Aufruf des Hauptprogramms main erfolgt die Ausgabe mit den beiden zweiten Monatsnamen:

Deutschsprachiger Monatsname = Februar
Englischsprachiger Monatsname = February

Bei anderen Programmiersprachen ist durch die obligatorische Verwendung von symbolischen Konstanten (im Beispiel unten "DEUTSCH" und "ENGLISCH") bei jeder erforderlichen, also auch bei allen initialen Zuweisungen zu Array-Elementen übersichtlich und klar erkennbar, welches Feld angesprochen wird:

(* Programmiersprache Component Pascal *)

MODULE Monatsnamen;

   IMPORT Out; (* Import des Moduls "Out" für die Textausgabe *)

   CONST
      DEUTSCH = 0;
      ENGLISCH = 1;

   TYPE Monatsnamen = POINTER TO ARRAY OF ARRAY OF ARRAY OF CHAR;

   VAR monatsnamen: Monatsnamen;

   PROCEDURE InittialisiereMonatsnamen ();  (* Prozedur zur Initialisierung von Monatsnamen *)
   BEGIN
      NEW (monatsnamen, 2, 13, 10); (* Reservierung von 2 mal 13 Zeichenketten mit je 10 Zeichen) *)
      monatsnamen [DEUTSCH, 0] := "deutsch";
      monatsnamen [DEUTSCH, 1] := "Januar";
      monatsnamen [DEUTSCH, 2] := "Februar";
      monatsnamen [DEUTSCH, 3] := "März";
      monatsnamen [DEUTSCH, 4] := "April";
      monatsnamen [DEUTSCH, 5] := "Mai";
      monatsnamen [DEUTSCH, 6] := "Juni";
      monatsnamen [DEUTSCH, 7] := "Juli";
      monatsnamen [DEUTSCH, 8] := "August";
      monatsnamen [DEUTSCH, 9] := "September";
      monatsnamen [DEUTSCH, 10] := "Oktober";
      monatsnamen [DEUTSCH, 11] := "November";
      monatsnamen [DEUTSCH, 12] := "Dezember";

      monatsnamen [ENGLISCH, 0] := "english";
      monatsnamen [ENGLISCH, 1] := "January";
      monatsnamen [ENGLISCH, 2] := "February";
      monatsnamen [ENGLISCH, 3] := "March";
      monatsnamen [ENGLISCH, 4] := "April";
      monatsnamen [ENGLISCH, 5] := "May";
      monatsnamen [ENGLISCH, 6] := "June";
      monatsnamen [ENGLISCH, 7] := "July";
      monatsnamen [ENGLISCH, 8] := "August";
      monatsnamen [ENGLISCH, 9] := "September";
      monatsnamen [ENGLISCH, 10] := "October";
      monatsnamen [ENGLISCH, 11] := "November";
      monatsnamen [ENGLISCH, 12] := "December";
   END InittialisiereMonatsnamen;

   PROCEDURE AusgabeMonatsname (monat: INTEGER); (* Prozedur zur Textausgabe von Monatsnamen *)
   BEGIN
      Out.String ("Sprache = " + monatsnamen [DEUTSCH, 0] + ": " + monatsnamen [DEUTSCH, monat]);
      Out.Ln;
      Out.String ("Sprache = " + monatsnamen [ENGLISCH, 0] + ": " + monatsnamen [ENGLISCH, monat]);
      Out.Ln;
   END AusgabeMonatsname;

   PROCEDURE Hauptprogramm*;
   BEGIN
      InittialisiereMonatsnamen (); (* Initialisierung beim Laden des Moduls "Monatsnamen" *)
      AusgabeMonatsname (2);
   END Hauptprogramm;

END Monatsnamen.

Durch den Aufruf von Monatsnamen.Hauptprogramm erfolgt die Ausgabe mit den beiden zweiten Monatsnamen:

Sprache = deutsch: Februar
Sprache = english: February

Die Verwendung von Indizes außerhalb der deklarierten oder angeforderten Array-Größen verursachen in streng strukturierten Programmiersprachen zur Laufzeit einen Programmabbruch. Bei sorgfältiger Programmierung ist deswegen darauf zu achten, dass nur gültige Indizes zur Anwendung kommen können. In schlecht strukturierten Programmiersprachen wie C oder C++ werden die Indizes von Arrays in der Regel nicht automatisch geprüft, so dass unbemerkt auf ungültige Speicheradressen zugegriffen werden kann und bei entsprechenden Angriffen Daten verfälscht und schadhafter Binärcode in die Programme eingeschleust sowie zur Ausführung gebracht werden kann.

Kontrollstrukturen

Bearbeiten

Kontrollstrukturen dienen dazu, den Programmablauf in wohlstrukturierter Weise im Sinne eines Algorithmus zu beeinflussen. Hierfür können Unterprogramme aufgerufen, Fallunterscheidungen vorgenommen oder Programmteile mehrfach durchlaufen werden (Schleifen).

Sprunganweisungen

Bearbeiten

Sprünge an andere Programmstellen ergeben sich inhärent beim Aufruf von Unterprogrammen. Geschieht ein solcher Sprung durch eine explizite Sprunganweisung im Programmcode, wie zum Beispiel mit Goto- oder Break-Anweisungen, ist dies unstrukturiert und im Übrigen auch völlig überflüssig, denn Programme mit Sprunganweisungen können immer und ohne großen Aufwand durch Kontrollstrukturen, also mit Hilfe von Unterprogrammen, Schleifen oder Fallunterscheidungen, gestaltet werden.

Explizite Sprunganweisungen stellen eine "Programmiertechnik mit dem Holzhammer" und wegen der daraus resultierenden verschlungenen Pfade während des Programmablaufs einen sogenannten Spaghetti-Code dar. Im Quellcode ist der Programmablauf nicht mehr ohne weiteres nachvollziehbar, beispielsweise bei der Untersuchung, von welchen Stellen des Programms an welche anderen Stellen gesprungen werden soll oder worden sein kann.

Im Falle der Switch-Case-Anweisung handelt es sich bei der in manchen Programmiersprachen verwendeten Break-Anweisung eigentlich 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, die Programmabläufe unübersichtlich macht und dazu führen kann, dass die Programme gegebenenfalls nur noch schwierig nachzuvollziehen und zu warten sind.

Unterprogramme

Bearbeiten
 
Von der Hauptroutine "Procedure main" eines gestarteten Programms wird nach Ausführung der Anweisungen "Instructions 1" ein Unterprogramm "Procedure sub" aufgerufen ("Call sub"), und der Programmablauf wird mit den dortigen Anweisungen "Instructions" fortgeführt. Wenn die letzte Anweisung "Return" des Unterprogramms erreicht worden ist, wird in das Hauptprogramm zurückgesprungen und der Programmablauf an der Stelle direkt hinter dem Aufruf des Unterprogramms mit den Anweisungen "Instructions 2" fortgesetzt.

Eine besonders häufig angewendete Programmiertechnik ist der Aufruf von Unterprogrammen. Unterprogramme stellen im Sinne des Quelltextes eines Programmes üblicherweise Prozeduren, Methoden oder Funktionen dar. Mit der Programmanweisung des Aufrufs kann der Programmablauf zum entsprechenden Unterprogramm verzweigt werden. Hierbei können in der Regel auch Parameter übergeben werden, um zwischen dem aufrufenden Programmteil und dem Unterprogramm Daten austauschen zu können. Ist das Unterprogramm vollständig abgearbeitet worden, wird der Programmablauf hinter der Stelle des Unterprogrammaufrufs fortgesetzt.

Unterprogramme können mehrfach und von allen Stellen des Programcodes aufgerufen werden, in dem das Unterprogramm sichtbar ist. Die Unterscheidung zwischen Prozeduren und Methoden ist nicht einheitlich. Etliche Programmiersprachen verwenden kategorisch nur einen der beiden Begriffe. Hierbei kann zwischen traditionellen (statischen) Prozeduren und objektorientierten (typengebunden oder dynamischen) Prozeduren unterschieden werden. Letztere werden als Methoden einer Klasse oder aber auch als typengebundene Prozeduren eines Programmoduls bezeichnet.

Rücksprunganweisungen

Bearbeiten

Nach Ablauf des Unterprogramms kann ein Rückgabewert an das aufrufende Programm zurückgegeben werden, der im aufrufenden Programmteil dann zur Verfügung steht und als Ausdruck zum Beispiel an eine Variable zugewiesen werden kann. In diesem Fall wird ein Unterprogramm auch Funktion genannt, da als Ergebnis des Unterprogrammaufrufs ein Funktionswert berechnet wurde und dann zurückgegeben wird.

Jedes Unterprogramm hat daher exakt eine Rücksprunganweisung (oft mit dem Schlüsselwort return gekennzeichnet), die logischerweise die letzte Anweisung sein muss, damit alle Anweisungen vorher durchgeführt werden können. Der Rücksprung erfolgt immer zur Stelle des Unterprogrammaufrufs, wo die Programmausführung anschließend fortgeführt wird.

Hat das Unterprogramm keinen Rückgabewert, der an den aufrufenden Programmteil zurückgegeben werden muss, wird in vielen Programmiersprachen auf eine explizite Rücksprunganweisung verzichtet; in diesem Fall wird sie also implizit ausgeführt. Ist das Hauptprogramm vollständig durchlaufen, wird das Programm nach dessen Rücksprunganweisung beendet, und die Kontrolle an das Laufzeitsystem oder das Betriebssystem zurückgegeben, von wo aus das Hauptprogramm aufgerufen worden war.

Mehrfache und insbesondere vorzeitige Rücksprunganweisungen in einem Unterprogramm sind unstrukturiert und daher zu unterlassen, auch wenn die Programmiersprache dies nicht zwingend fordert. Vorzeitige Unterprogrammabbrüche (Break-Anweisungen) verhindern, dass nachfolgende Programmsequenzen ausgeführt werden können, obwohl sie bei einer Ausführung das Ergebnis für den Rückgabewert beeinflussen würden. Dies kann zur Verwirrung führen, weil das Unterprogramm bei einer Überprüfung oder einer Analyse immer vollständig auf potentielle vorzeitige Unterprogrammabbrüche durchsucht werden muss.

Der folgende unstrukturierte Java-Code, der den in der Variablen ergebnis gespeicherten Wert vor dessen Rückgabe als Text ausgeben soll, verdeutlicht dies:

private static double unterprogramm (double parameter)
{
   double ergebnis = parameter;
   boolean ganzzahlig = (parameter % 1 == 0);
   if (ganzzahlig) return ergebnis;
   ergebnis = ergebnis + 1;
   java.lang.System.out.println ("Ergebnis = " + ergebnis);
   return ergebnis;
}

Die Erhöhung des Wertes der Variablen ergebnis sowie die Textausgabe mit dem Aufruf der Methode println unmittelbar vor der Rücksprunganweisung erfolgen wegen der beiden vorhandenen Rücksprunganweisungen nur, wenn der Wert des Parameters parameter nicht ganzzahlig ist. Demzufolge erzeugen die folgenden beiden Unterprogrammaufrufe

   unterprogramm (0);
   unterprogramm (0.5);

die Textausgabe:

Ergebnis = 1.5

Dieses formal korrekte, aber unerwünschte Verhalten wird nur nachvollziehbar, wenn der gesamte Code des Unterprogramms analysiert wird, was bei komplexeren Unterprogrammen und beim Vorhandensein mehrerer Rücksprunganweisungen sehr aufwendig werden kann..

Das folgende Unterprogramm implementiert den eigentlich gewünschten Algorithmus in strukturierter Form mit einer einzigen Rücksprunganweisung am Ende des Unterprogramms:

private static double unterprogramm (double parameter)
{
   double ergebnis = parameter;
   boolean ganzzahlig = (parameter % 1 == 0);
   if (! ganzzahlig)
   {
      ergebnis = ergebnis + 1;
   }
   java.lang.System.out.println ("Ergebnis = " + ergebnis);
   return ergebnis;
}

Die Textausgabe bei den oben angegebenen Aufrufen des Unterprogramms erfolgt nun wie gewünscht:

Ergebnis = 0.0
Ergebnis = 1.5

Es empfiehlt sich grundsätzlich ebenfalls immer, innerhalb von Rücksprunganweisungen keine komplexen Ausdrücke, Kontrollstrukturen oder Unterprogrammaufrufe zu verwenden, um einfache und eindeutige Rückgabewerte zu erhalten sowie diese gegebenenfalls mit einer Textausgabe oder einem Debugger kontrollieren zu können. Im Idealfall wird in der Rücksprunganweisung nur der Wert einer zuvor berechneten lokalen Variable mit einem sprechenden Bezeichner zurückgegeben:

   ergebnis ← f (a, b, c);
   return ergebnis;

In der Regel ergeben sich durch die zusätzliche explizite Zuweisung an die lokale Variable ergebnis keine Laufzeiteinbußen, da im übersetzten Maschinencode implizit für den Rückgabewert sowieso eine Zuweisung ausgeführt werden muss. Moderne Übersetzer berücksichtigen diesen Kontext automatisch, so dass in beiden Fällen derselbe Maschinencode erzeugt wird.

Fallunterscheidungen

Bearbeiten

Die einfachste Fallunterscheidung ist die bedingte Anweisung. Verzweigungen enthalten mindestens zwei alternative Programmpfade.

Bedingte Anweisung

Bearbeiten
 
Struktogramm einer bedingten Anweisung.

Im folgenden Beispiel mit einer bedingten Anweisung (zum Beispiel if - then - end) wird die Dekrement-Anweisung a-- (der Wert der numerischen Variablen a soll um eins erniedrigt werden) nur dann ausgeführt, falls der boolesche Ausdruck a > b wahr ist, die entsprechende Bedingung also erfüllt ist:

falls a > b dann a-- ende

Hier wird der Wert der Variablen a also nur dann dekrementiert, wenn der Wert der Variablen a größer ist als der Wert der Variable b. Ansonsten wird der Programmablauf sofort hinter der ende-Marke fortgeführt.

Einfache Verzweigung

Bearbeiten
 
Struktogramm einer einfachen Verzweigung.

Die einfachste Verzweigung (zum Beispiel if - then - else - end) enthält genau zwei alternative Pfade, von denen in Abhängigkeit eines booleschen Ausdrucks nur einer ausgeführt wird, wie in diesem Beispiel:

falls a > b dann a-- ansonsten b-- ende

Je nachdem die entsprechende Bedingung erfüllt ist oder nicht, wird die eine oder die andere Anweisung ausgeführt. Im obigen Beispiel wird der Wert der Variablen a nur dann dekrementiert, falls der Wert der Variablen a größer ist als der Wert der Variable b, ansonsten wird hier im Vergleich zur bedingten Anweisung allerdings der Wert der Variablen b um eins erniedrigt. In beiden Fällen wird das Programm anschließend hinter der ende-Marke fortgeführt.

Mehrfache Verzweigung

Bearbeiten
 
Struktogramm einer mehrfachen Verzweigung.

Eine mehrfache Verzweigung (zum Beispiel switch - case - else - end) enthält mehr als zwei alternative Programmpfade, die meist, wie auch im folgenden Beispiel, von ganzzahligen Ausdrücken gesteuert werden:

verzweige mit dem Wert von a
   falls 1 : unterprogramm_A ()
   falls 2 : unterprogramm_B ()
   falls 3 : unterprogramm_C ()
   ansonsten unterprogramm_D ()
ende

In Abhängigkeit des in der ganzzahligen Variablen a gespeicherten Zahlenwertes wird genau eines der vier angegebenen Unterprogramme aufgerufen; beim Wert 1 unterprogramm_A, beim Wert 2 unterprogramm_B, beim Wert 3 unterprogramm_C und ansonsten unterprogramm_D. Danach wird der Programmablauf hinter den ende-Marke fortgeführt.

In manchen weniger stakt strukturierten Programmiersprachen wie C sind Vorsicht und Aufmerksamkeit geboten, weil beispielsweise dort die verschiedenen Fälle der entsprechenden switch-Anweisung nur optional mit einer break-Anweisung und nicht immer und obligatorisch abgeschlossen werden. Dies nutzen einige Programmierer, um in bestimmten Situationen mehrere Fälle hintereinander abarbeiten zu lassen. Dieses Vorgehen ist jedoch hochgradig unstrukturiert und führt sehr schnell und unübersichtlichem Programmcode und somit sehr leicht zu Programmierfehlern. Dies kann vermieden werden, wenn in diesen Programmiersprachen hinter jedem unterschiedenem Fall kategorisch die break-Anweisung implementiert wird, auch wenn die Programmiersprache oder der Übersetzer dies nicht fordern.

 
Struktogramm mit verschachtelten einfachen Verzweigungen, um eine mehrfache Verzweigung zu implementieren.

Mehrfache Verzweigungen mit aufeinanderfolgenden numerischen oder aufzählbaren Werten, wie im obigen Beispiel 1, 2 und 3, können rechnerintern unter Umständen effizient genutzt werden, weil die Sprungadressen arithmetisch berechnet werden können. Dies ist bei modernen Laufzeitsystemen in der Regel aber nicht mehr so relevant, und diese mehrfachen Verzweigungen können auch immer durch mehrfache Fallunterscheidungen programmiert werden. Durch eine eigene Anweisung für die mehrfache Verzweigung kann die Übersichtlichkeit des Quelltextes allerdings oft gesteigert werden. Auf der anderen Seite können die Übersichtlichkeit und die Nachvollziehbarkeit in der Regel auch hier mit entsprechenden Unterprogrammaufrufen gesteigert werden.

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 grundlegende 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 nur zum Zwischenspeichern der Abbruchbedingung dient und 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 verändert.

Kopfgesteuerte Schleifen

Bearbeiten
 
Struktogramm einer kopfgesteuerten Schleife.

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
 
Struktogramm einer fußgesteuerten Schleife.

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 Setzen der Laufvariable auf eine boolesche Endbedingung nicht erfolgt, weil dies in der verwendeten Programmiersprache nicht obligatorisch ist beziehungsweise vom Programmierer vergessen wurde, oder weil dies wegen eines zwangsläufig unstrukturierten Abbruchs innerhalb der Schleife (zum Beispiel mit einer Break-Anweisung) gar nicht erfolgen kann.

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)

beziehungsweise

do
{
   ...
}
while (true)

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 = i + 1
}

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 somit auch nicht verwendet werden kann:

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

Die äquivalente Schreibweise mit einer while-Schleife sieht wie folgt aus, wobei die äußere Blockanweisung dafür sorgt, dass die Zählvariable "i" innerhalb der Blockanweisung deklariert ist und nur in Verbindung mit der Schleife sichtbar respektive verwendbar ist:

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

Bei Algorithmen, die aus mehreren Kontrollstrukturen bestehen, ist es im Sinne der besseren Strukturierung vorzuziehen, alle Schleifen in eigene Methoden auszulagern, die aufgerufen werden und deren Schnittstellen über ihre Parameter eindeutig festgelegt sind. Hierbei können die Schleifen verschachtelt sein (innere und äußere Schleife) oder hintereinander ausgeführt werden. In jedem Fall sind alle Parameter und Variablen (also auch die jeweiligen Zählvariablen) innerhalb der entsprechenden Methoden lokal verfügbar.

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, wie zum Beispiel mit der symbolischen Konstante "Pi" für die Kreiszahl oder die symbolische Konstante "Title" für den Text "Programmierung":

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 mit der ganzen Zahl 3:

int inputNumber ← 3
...
if (inputNumber = 3) ...

Sondern besser mit der symbolischen Konstante "Exit" mit dem unveränderlichen, ganzzahligen Wert 3:

const int Exit ← 3

int inputNumber ← Exit
...
if (inputNumber = Exit) ...

Oder nicht zweimal mit derselben ganzen Zahl 3:

if (a > 3) and (b > 3) ...

Sondern besser mit zwei verschiedenen symbolischen Konstanten "Limit_a" und "Limit_b":

const int Limit_a ← 3
const int Limit_b ← 3

if (a > Limit_a) and (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 müssen:

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

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

Die beiden konstanten Zahlensymbole "12" haben nichts außer ihrem Zahlenwert gemeinsam, und daher ist der folgende Code erheblich besser nachvollziehbar:

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.")
}

Aufruf von Unterprogrammen

Bearbeiten
 
Struktogramm eines Unterprogrammaufrufs.

Unterprogramme, die in vielen Programmiersprachen auch Methoden, 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. Dieses Unterprogramm kann dann irgendwo im Programmcode aufgerufen werden. Dies gewinnt besonders dann Bedeutung, wenn das Unterprogramm an verschiedenen Stellen aufgerufen werden soll, so dass dann diese Programmanweisungen nicht mehrfach programmiert oder kopiert werden müssen.

Insbesondere wenn die ursprünglichen, an mehreren Stellen auftauchenden Programmanweisungen einen Fehler enthalten, muss dieser nach dem Entdecken des Fehlers - also möglicherweise zu einem viel späteren Zeitpunkt - zur Fehlerbehebung zwangsweise an mehreren Stellen im Programmcode korrigiert werden. Das ist nicht nur mühsam, sondern einzelne relevante Stellen können leicht übersehen werden, so dass der entdeckte Fehler gar nicht vollständig ausgemerzt wird.

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

const double Pi ← 3.141592654

double perimeter1 ← radius1 * 2 * Pi
double perimeter2 ← radius2 * 2 * Pi
double perimeter3 ← radius3 * 2 * Pi

Mithilfe des im Unterprogramm "perimeter" nur einmal implementierten Algorithmus' zur Ermittlung des Kreisumfangs und dessen dreimaligem Aufruf kann die Wiederholung der Implementierung der Rechenvorschrift leicht vermieden werden:

/* "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
}

double perimeter1 ← perimeter (radius1)
double perimeter2 ← perimeter (radius2)
double perimeter3 ← perimeter (radius3)

Zuweisungskompatibilität

Bearbeiten

Zuweisungskompatibilität liegt vor, wenn Ausdrücke und Variablen aufgrund hinreichend kompatibler Datentypen einander zugewiesen, miteinander verglichen oder eindeutig miteinander verknüpft werden können.

Es kann bereits im Quelltext überprüft werden, ob eine hinreichende Zuweisungskompatibilität vorliegt. Liegt diese nicht vor, handelt es sich um eine Typverletzung, und es muss eine explizite Typumwandlung programmiert werden. In diesem Fall kann es sehr leicht zu Programmfehlern kommen.

Programmiersprachen, die für die maschinennahe Programmierung konzipiert wurden, wie zum Beispiel Assemblersprachen oder die Programmiersprache C, haben oft gar keine oder nur eine sehr schwache Typprüfung, was sehr leicht zu Programmfehlern führen kann.

In manchen Programmiersprachen, wie zum Beispiel C, ist es sogar erlaubt, beliebige Zeiger einer Zeigervariablen zuzuweisen, ohne dass geprüft wird oder überhaupt geprüft werden kann, ob die Datentypen der referenzierten Daten kompatibel sind.

Zeichenketten

Bearbeiten

Ein weiteres schwerwiegendes Problem kann sich bei der Verwendung von Zeichenketten (englisch: string) ergeben, die in der Regel zum Speichern von Texten Verwendung finden und die aus einer Sequenz von einzelnen Zeichen respektive Buchstaben (englisch: character) bestehen.

Eine strukturierte Programmiersprache, die Zeichenkettenverarbeitung und einen entsprechenden Datentyp zur Verfügung stellt, sollte fordern, dass jede Zeichenkette mit einem Zeichen abgeschlossen wird, das das Ende der Zeichenkette markiert (englisch: string terminator). Hierfür wird allgemein das Zeichen mit dem numerischen Wert null verwendet, das keinen Buchstaben repräsentiert und in Quelltexten häufig mit den Symbolen NUL, 0X oder \0 kodiert wird.

Manche unstrukturierte Programmiersprachen fordern nicht, dass eine solche Kennzeichnung des Endes der Zeichenkette verwendet werden muss. Bei der Implementation von Zeichenkettenfunktionen in Programmbibliotheken, insbesondere wenn die dazugehörige Programmiersprache gar keinen Datentyp für Zeichenketten zur Verfügung stellt, ist auch bei strukturierten Programmiersprachen nicht unbedingt gewährleistet, dass eine entsprechende Kennzeichnung des Zeichenkettenendes obligatorisch ist. Bei der Implementierung von Vergleichsfunktionen oder Zeichenkettenmanipulationen in unstrukturierten Programmiersprachen oder Programmbibliotheken muss die Tatsache, ob das Nullzeichen vorhanden ist oder nicht, regelmäßig untersucht und berücksichtigt werden. Einfacher und sicherer ist es, mit einer strukturierten Programmiersprache oder einer entsprechenden Programmbibliothek zu arbeiten, bei der immer gewährleistet ist und vorausgesetzt werden kann, dass alle Zeichenketten mit einem Endezeichen abgeschlossen sind.

Strenge Zuweisungskompatibilität

Bearbeiten

Zuweisungen sind uneingeschränkt zulässig, wenn eine strenge Zuweisungskompatibilität gegeben ist. Dazu müssen die Datentypen eines zuzuweisenden Ausdrucks und einer Variable exakt übereinstimmen, wie in folgendem Beispiel mit dem Datentyp "Mann" und den beiden Instanzen "otto" und "emil":

TYPE Mann = Verbund von alter und groesse

VARIABLE otto, emil: Mann

otto.alter   ← 50
otto.groesse ← 1.80

emil ← otto

Alle Attribute von „otto“, nämlich „alter“ und „groesse“, können „emil“ eindeutig zugewiesen werden.

Zwei Instanzen sind streng zuweisungskompatibel, wenn sie derselben Klasse angehören, wie in diesem Beispiel die beiden Objekte "fenster1" und "fenster2" aus der Klasse "Rechteck":

TYPE Rechteck = Klasse mit breite, hoehe und mit Methode flaechenberechnung ()

VARIABLE fenster1, fenster2: Rechteck

fenster1.breite ← 200
fenster1.hoehe  ← 100
fenster1.flaechenberechnung () (Flächenberechnung für „fenster1“ ausführen)

fenster2 ← fenster1
fenster2.flaechenberechnung () (Flächenberechnung für „fenster2“ ausführen)

Die Zuweisung in der vorletzten Programmzeile ist möglich, da beide Instanzvariablen "fenster1" und "fenster2" derselben Klasse "Rechteck" angehören, und daher liefert auch der Methodenaufruf in der letzten Programmzeile ein korrektes Ergebnis.

Logische Kompatibilität

Bearbeiten

Zwei übereinstimmende Definitionen von zwei Datentypen sind nicht zuweisungskompatibel. Die Daten können zwar eindeutig überführt werden (technische Kompatibilität), es liegen zwei formal zwar identische, aber dennoch verschiedene Definitionen vor, so dass diese keine logische Kompatibilität aufweisen. Folgendes Beispiel wäre demzufolge formal korrekt, aber nicht logisch:

TYPE Mann = Verbund von alter und groesse
TYPE Frau = Verbund von alter und groesse

VARIABLE otto: Mann
VARIABLE anna: Frau

otto.alter   ← 50
otto.groesse ← 1.80

anna ← otto

Die Zuweisung in der letzten Programmzeile ist technisch zwar ohne Probleme möglich, aber logisch nicht korrekt, und sie birgt daher die Gefahr der Entstehung von Programmierfehlern. Um solche Fehler zu vermeiden, sind Zuweisungen mit impliziter Typumwandlung in einigen Programmiersprachen mit starker Typisierung nicht zulässig, und der Compiler verweigert die Übersetzung dieser Zuweisung.

In der objektorientierten Programmierung kann durch die Vererbung der gemeinsamen Eigenschaften von Datentypen leicht eine logische Kompatibilität hergestellt werden:

TYPE Mensch = Verbund von alter und groesse

TYPE Mann = Mensch
TYPE Frau = Mensch

VARIABLE otto: Mann
VARIABLE anna: Frau

otto.alter   ← 50
otto.groesse ← 1.80

anna ← otto

Die Objekteigenschaften "alter" und "groesse" sind hierbei Eigenschaften von Objekten des Datentyps "Mensch" und daher sowohl zuweisungskompatibel, als auch logisch korrekt; bei der Zuweisung können und werden nur die Attribute "alter" und "groesse" der gemeinsamen Basisklasse "Mensch" übertragen.

Wahrheitswerte

Bearbeiten

In einigen älteren Programmiersprachen, wie zum Beispiel C, gibt es keinen eigenen Datentyp für zweiwertige boolesche Variablen. Zur Behandlung und Verarbeitung entsprechender Information wird dann häufig der ganzzahlige Datentyp mit dem kleinsten Speicherbedarf verwendet, wobei der Zahlenwert null für den Wahrheitswert „Falsch“ und alle anderen Zahlenwerte für den Wahrheitswert „Wahr“ Verwendung finden. Auch hier ergeben sich logische Inkompatibilitäten und somit ein gefährliches Potential für Komplikationen, da mit binären Werten keine Arithmetik und mit Zahlen keine logischen Verknüpfungen oder logischen Operationen durchgeführt werden können. Im folgenden Beispiel wird dieser Missbrauch verdeutlicht:

VARIABLE schlechteWahrheit1, schlechteWahrheit2, ergebnisWahrheit: INTEGER

schlechteWahrheit1 ← 0
schlechteWahrheit2 ← 1

ergebnisWahrheit ← (schlechteWahrheit1 + schlechteWahrheit2)

ergebnisWahrheit ← (schlechteWahrheit2 + schlechteWahrheit2)

Das Ergebnis der ersten arithmetischen Addition ist 1, was fälschlich als der Wahrheitswert „Wahr“ missinterpretiert werden könnte, der nicht dem Ergebnis der logischen Und-Verknüpfung entspricht. Noch offensichtlicher ist das Problem in der zweiten arithmetischen Addition, wo das Ergebnis 2 erzielt wird. Somit existieren ohne Not mehr als zwei Zustände für die binären (also zweiwertigen) Variablen, was schnell zu Missverständnissen und Programmierfehlern führen kann.

Eine eindeutige und korrekte Implementierung wird erreicht, wenn die Programmiersprache oder eine dazugehörige Programmbibliothek einen zweiwertigen Datentyp, wie zum Beispiel „BOOLEAN“ oder „bool“, zwei entsprechende Ausprägungen, wie zum Beispiel "false" und "true", und die dazugehörigen eindeutigen booleschen Operatoren und Funktionen (beispielsweise "and", "or" oder "not") anbietet.

VARIABLE richtigeWahrheit1, richtigeWahrheit2, ergebnisWahrheit: BOOLEAN

richtigeWahrheit1 ← falsch
richtigeWahrheit2 ← wahr

ergebnisWahrheit ← (richtigeWahrheit1 und richtigeWahrheit2)

ergebnisWahrheit ← (richtigeWahrheit2 und richtigeWahrheit2)

Sinngemäß gilt das Gleiche für die Verknüpfungen von Mengen. Wenn hier bei den Datentypen und zulässigen Operatoren nicht zwischen Bitmengen (englisch: (bit) sets) und Zahlen unterschieden wird, kommt es wie zum Beispiel bei der Bestimmung von Vereinigungs- oder Differenzmengen zu Interpretationsproblemen. Eine eindeutige und korrekte Implementierung verwendet Datentypen, die für Mengen und Mengenoperationen definiert sind. In einigen Programmiersprachen werden solche Datentypen im Sprachumfang implizit angeboten, in anderen gibt es dafür standardisierte Programmbibliotheken, auf die über Unterprogrammaufrufe zugegriffen werden kann.

Zuweisungskompatibilität ohne Informationsverlust

Bearbeiten

In einigen Fällen kann die Information, die mit einem Datentyp dargestellt werden kann, eindeutig und ohne Informationsverlust in einen anderen Datentyp überführt werden. Typische Beispiele sind ganze Zahlen mit unterschiedlicher Speichergröße. So kann ein Integer mit 16 Bit Speichergröße eindeutig in einer vorzeichenbehafteten Integer-Variablen mit 32 Bit Speichergröße abgelegt werden, ohne dass die ursprünglich nur mit 16 Bit gespeicherte Zahl verändert wird. Umgekehrt ist dies jedoch nicht allgemein möglich, insbesondere unter der Beachtung von Vorzeichen und großen Zahlen. Der folgende Programmierabschnitt zeigt ein Beispiel ohne Zuweisungskompatibilität, da der Datentyp „BYTE“ nur 8 Bit Speichertiefe hat und nur Werte zwischen -128 bis +127 und somit nicht die Zahl 555 repräsentieren kann, wohingegen der Datentyp „SHORTINT“ eine Speichertiefe von 16 Bit hat und ganze Zahlen von -32768 bis +32767 repräsentieren kann:

zahl1: BYTE
zahl2: SHORTINT

zahl2 ← 555
zahl1 ← zahl2

Die letzte Programmzeile stellt einen ungültigen Versuch der Zuweisung der ganzen Zahl 555 aus der Variablen „zahl2“ an die Variable „zahl1“ dar.

Bei einer solchen Programmanweisung kann bei typsicheren Programmiersprachen bereits der Compiler verhindern, dass ausführbarer Maschinencode erzeugt wird. Bei fehlender Überprüfung durch den Compiler kann unbemerkt Information verloren gehen, so dass bei nachfolgenden Berechnungen unter Umständen grobe Berechnungsfehler auftreten, die relativ schwierig zu analysieren sind.

Zuweisungskompatibilität mit geringem Informationsverlust

Bearbeiten

Ein Sonderfall ist die Zuweisung von ganzen Zahlen an Variablen, die Gleitkommazahlen repräsentieren. In der Regel kann ohne die Gefahr von Programmfehlern toleriert werden, große ganze Zahlen implizit in Gleitkommazahlen umzuwandeln, da der Rechenfehler (wenn überhaupt vorhanden) hierbei sehr klein ist. Auch dies kann an einem Beispiel verdeutlicht werden: ein „LONGINT“ mit 64 Bit Speichergröße kann die Zahl 9223372036854775807 mit 19 Dezimalstellen speichern. Der folgende Programmierabschnitt zeigt ein Beispiel mit Zuweisungskompatibilität mit einem in der Regel zu vernachlässigenden Informationsverlust, da der Datentyp „REAL“ nach IEEE 754 mit 64 Bit nur Zahlen mit einer Mantisse mit maximal 14 Nachkommastellen speichern kann:

zahl1: LONGINT
zahl2: REAL

zahl1 ← 9223372036854775807
zahl2 ← zahl1

Die letzte Anweisung stellt in fast allen Programmiersprachen einen gültigen Versuch der Zuweisung der ganzen Zahl   aus der Variablen „zahl1“ an die Variable „zahl2“ dar, da diese gerundeten Zahlenwert   enthält, und der Fehler durch das Abschneiden der letzten Nachkommastellen hier nur in einer Größenordnung von   liegt und daher für praktisch alle Anwendungen vernachlässigt werden kann.

Bei einer erneuten Datentypkonvertierung zurück zu einem geeigneten ganzzahligen Datentyp kommt es dann aber zu einer Abweichung zu der ursprünglichen ganzen Zahl. In solchen Fällen ist es daher besser, vorsichtshalber und mit Inkaufnahme etwas längerer Programmlaufzeiten ausschließlich mit Gleitkommazahlen zu operieren.

Zuweisungskompatibilität mit definiertem Informationsverlust

Bearbeiten

Zwei Instanzen sind mit definiertem Informationsverlust zuweisungskompatibel, wenn die zuzuweisende Klasse einer Klasse angehört, die von der zugewiesenen Klasse abgeleitet wurde. Alle Daten die in der zugewiesenen Klasse deklariert und somit erforderlich sind, können dann zugewiesen werden, jedoch werden die in der zuzuweisenden abgeleiteten Klasse möglicherweise hinzugefügten Attribute ignoriert, wie das folgende Beispiel verdeutlichen soll, in welchem der Datentyp „Mensch“ alle Eigenschaften vom Datentyp „Lebewesen“ erbt und zusätzlich das Attribut „intelligenzquotient“ bekommt:

TYPE Lebewesen = Verbund von alter und gewicht

TYPE Mensch = Lebewesen mit intelligenzquotient

VARIABLE     otto: Mensch
VARIABLE eukaryot: Lebewesen

otto.alter               ← 50
otto.gewicht             ← 75
otto.intelligenzquotient ← 100

eukaryot ← otto

Die Zuweisung in der letzten Zeile ist korrekt, das Attribut „intelligenzquotient“ der Variable „otto“ vom Datentyp „Mensch“ wird jedoch nicht an die Variable „eukaryot“ zugewiesen, da es beim Datentyp „Lebewesen“ der Basisklasse nicht deklariert ist.

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 kleine, überschaubare 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) ...

In dieser bedingten Anweisung (if) ist nicht klar, in welcher Reihenfolge der Zuweisungsoperator (←), der Differenzoperator (-) und der Vergleichsoperator (=) ausgeführt werden (sollen).

Es ist erheblich besser, die Anweisungen klar zu trennen:

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

Die folgenden beiden Beispiele mit dem Zuweisungsoperator "=" und dem Vergleichsoperator "==" zeigen Programmsequenzen in der Programmiersprache C, die zu sehr leicht zu übersehenden Programmierfehlern führen können:

 /* Programmiersprache C */
 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 Rückgabe-Anweisung ("return") in der Programmiersprache Java ist nicht nur verwirrend, sondern sinnfrei:

/* Programmiersprache Java */
 long x = 2;
 long y = 1;
 return y + x--;

Der Dekrement-Operator "- -" wird in vielen Programmiersprachen gar nicht ausgeführt, weil er hierarchisch erst nach einer Zuweisung ausgeführt wird oder nach einer die Code-Sequenz beendende Return-Anweisung noch ausgeführt werden müsste, aber de facto gar nicht mehr ausgeführt wird. Deswegen ist die folgende Anweisungsfolge nicht nur weniger komplex, gut strukturiert und korrekt, sondern auch sinnvoll und leicht sowie eindeutig nachvollziehbar:

/* Programmiersprache Java */
 long x = 2;
 long y = 1;
 x--;
 long summe = y + x;
 return summe;

Beeindruckend sinnlos, verwirrend und komplex sind Monster-Ausdrücke, die in einigen Programmiersprachen wie zum Beispiel C erlaubt sind, wie zum Beispiel bei der Kombination einer Rücksprunganweisung ("return") mit einer Zuweisung ("="), zwei verschiedenen Inkrement-Operatoren ("++") und einem Additionsoperator ("+"). Es ist sehr schwierig durchschaubar, in welcher Reihenfolge diese fünf Anweisungen ausgeführt werden und ob diese überhaupt ausgeführt werden. Selbst wenn die Zuweisung oder die nachrangige Inkrementierung ausgeführt würden, wären sie völlig sinnlos, da auf die lokale Variable i nach der Return-Anweisung gar nicht mehr zugegriffen werden kann:

/* Programmiersprache C */
int i = 0;
return i = ++i+i++;

Es ist ebenfalls nicht leicht zu durchschauen, dass das Ergebnis dieser Anweisungsfolge in Java für die Variable y den Wert 5 ergibt, weil die Variable x während der Auswertung des arithmetischen Ausdrucks verändert wird:

/* Programmiersprache Java */
 long x = 1;
 y = ++x + ++x;

Noch gefährlicher wird es, wenn die Reihenfolge der Auswertung von arithmetischen Ausdrücken mit gleichwertigen Operanden für den Compiler oder Interpreter nicht definiert ist:

/* Programmiersprache C */
 int x = 1;
 y = ++x * --x;

Wird die Multiplikation von links nach rechts ausgewertet, ergibt sich für die Variable y der Wert 2, wird die Multiplikation von rechts nach links ausgewertet, ergibt sich für die Variable y der Wert 0. Derselbe Quelltext kann auf zwei verschiedenen Systemen also völlig andere Rechenergebnisse hervorrufen.

In anderen Programmiersprachen werden die Inkremente und Dekremente von ganzzahligen Variablen daher mit Prozeduraufrufen bewerkstelligt (beispielsweise INC() und DEC()), die fester Bestandteil der Programmiersprache sind, wie zum Beispiel in Pascal, wo der Zuweisungsoperator aus zwei verschiedenen Zeichen besteht (":="), damit es keine Verwechslungen mit einem Identitätsoperator oder Vergleichsoperator geben kann:

(* Programmiersprache Pascal *)
 VAR
    x, y, produkt: integer;
 BEGIN
    x := 1;
    INC (x);
    y := x;
    DEC (x);
    produkt := y * x;
 END;

Die Aufrufe der Inkrement- beziehungsweise Dekrementprozedur dürfen und können - genauso wie Zuweisungen - in der Programmiersprache Pascal also gar nicht Bestandteil eines arithmetischen Ausdrucks sein, so dass der Zeitpunkt der Ausführung immer eindeutig aus der Reihenfolge der Anweisungen hervorgeht.

Blockanweisungen

Bearbeiten

Blockanweisungen sind ein elegantes Mittel, um Programmcode zu strukturieren sowie die Sichtbarkeit von lokalen Variablen zu begrenzen. In vielen Programmiersprachen werden eindeutige Symbole für die Kennzeichnung von Programmblöcken verwendet, wie zum Beispiel geschweifte Klammern:

{
   ...
}

Die drei Punkte stehen hierbei für beliebige Anweisungsfolgen.

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

BEGIN
   ...
END

Der Blockinhalt mit Anweisungen - in beiden obenstehenden Beispielen durch die drei aufeinanderfolgenden Punkte symbolisiert -, wird in der Regel eingerückt, um die Lesbarkeit des Quelltextes für die Programmierer zu erleichtern.

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 unsinnig, unstrukturiert sowie überflüssig und daher in den meisten Programmiersprachen nicht zulässig:

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

In der Regel werden Klassen- und Methodenrümpfe sowie Kontrollstrukturen (also Fallunterscheidungen und Schleifen) mit Blockanweisungen implementiert.

Blockanweisungen bei Kontrollstrukturen

Bearbeiten
 
Struktogramm einer Anweisungsfolge.

Auch wenn die Programmiersprache die Verwendung von Blockanweisungen für Anweisungsfolgen in einer Kontrollstruktur nicht vorschreibt, ist es sehr ratsam, die Blockanweisung kategorisch einzusetzen, um Programmierfehler zu vermeiden. Wenn zum Beispiel eine if-Anweisung so wie in den Programmiersprachen C und Java so strukturiert ist, dass genau eine Folgeanweisung ausgeführt wird, wenn die Bedingung wahr ist, kann es ohne Blockanweisungen bei der Programmentwicklung oder -wartung leicht zu übersehenden Programmierfehlern kommen. Im folgenden korrekt formulierten Java-Programmbeispiel wird die Variable v1 auf den Wert null zurückgesetzt, falls sie den gleichen Zahlenwert wie max hat:

/* Programmiersprache Java */
 if (v1 == max)
    v1 = 0;

Soll zudem auch noch eine Textausgabe erfolgen, kann diese zusätzlich programmiert werden:

/* Programmiersprache Java */
 if (v1 == max)
    v1 = 0;
    java.lang.System.out.println ("Der Maximalwert wurde erreicht.");

Die unterste Programmierzeile ist zwar eingerückt, was suggeriert, dass sie nur ausgeführt wird, wenn die darüberstehende Bedingung erfüllt ist. Ein Java-Interpreter führt jedoch nur eine einzige unmittelbar nach der Bedingung aufgeführte Anweisung aus, wenn die Bedingung wahr ist. Mit anderen Worten: die Textausgabe erfolgt im obigen Programmbeispiel immer, also insbesondere auch wenn die Variablen v1 und max nicht den gleichen Zahlenwert haben. Im Quelltext sollte das daher besser folgendermaßen formuliert werden:

/* Programmiersprache Java */
 if (v1 == max)
    v1 = 0;
 java.lang.System.out.println ("Der Maximalwert wurde erreicht.");

Ähnlich tückisch ist die Tatsache, dass in manchen weniger streng strukturierten Programmiersprachen, auf den Wahrheitausdruck der if-Anweisung eine beliebige Anweisung folgen darf, die nicht notwendigerweise eine Blockanweisung sein muss. Dies führt zu leicht zu übersehenden Programmierfehlern, wie im folgenden Beispiel:

/* Programmiersprache Java */
 if (v1 == max);
 {
    v1 = 0;
    java.lang.System.out.println ("Der Maximalwert wurde erreicht.");
 }

Die Blockanweisung wird immer ausgeführt, obwohl ihre Darstellung mit korrekter Einrückung suggeriert, dass sie nur dann ausgeführt wird, wenn die boolesche Bedingung (v1 == max) erfüllt ist. Dies ist allerdings nicht der Fall, da direkt hinter den runden Klammern der if-Anweisung ein Semikolon steht, welches eine leere Anweisung implementiert.

All diese Missverständnisse können leicht vermieden werden, indem bei der Programmierung von Kontrollstrukturen kategorisch Blockanweisungen verwendet werden, selbst wenn die Programmiersprache dies nicht fordert:

/* Programmiersprache Java */
 if (v1 == max)
 {
    v1 = 0;
 }

Beziehungsweise:

/* Programmiersprache Java */
 if (v1 == max)
 {
    v1 = 0;
    java.lang.System.out.println ("Der Maximalwert wurde erreicht.");
 }
 else
 {
    java.lang.System.out.println ("Der Maximalwert wurde nicht erreicht.");
 }

Verschachtelung

Bearbeiten

In noch stärkerem Maße leidet die Verständlichkeit von Quellcode, wenn mehrere Kontrollstrukturen verschachtelt werden:

/* Programmiersprache Java */
 long v1, v2, v3;
 v1 = 3;
 v2 = 3;
 v3 = 7;
 if ((v1 > 0) && (v2 > 0))
    if (v1 > v2)
       v3 = v1 - v2;
 else
    v3 = v2 - v1;
 java.lang.System.out.println ("v3 = " + v3);

Beim Lesen des Quellcodes mit einer solchen "baumelnden" else-Anweisung (englisch: dangling else) kann schnell der Eindruck entstehen, dass sie zur ersten if-Anweisung gehört und das Ergebnis für die Variable v3 7 bleibt, da v1 und v2 positive Zahlen sind. Tatsächlich wird der Quellcode jedoch so ausgeführt, so dass die Variable v3 den Wert 0 erhält.

Um solche Missverständnisse zu vermeiden, ist es - wie oben bereits erwähnt - dringend geboten, Blockanweisungen kategorisch einzusetzen, auch wenn sie durch die Definition der Programmiersprache nicht sowieso vorgeschrieben sind:

/* Programmiersprache Java */
 long v1, v2, v3;
 v1 = 3;
 v2 = 3;
 v3 = 7;
 if ((v1 > 0) && (v2 > 0))
 {
    if (v1 > v2)
    {
       v3 = v1 - v2;
    }
    else
    {
       v3 = v2 - v1;
    }
 }
 java.lang.System.out.println ("v3 = " + v3);

Wertebereiche

Bearbeiten

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 mit dem Vergleichsoperator "<>" verhindert werden, der nur bei Ungleichheit der beiden Operanden den Ergebniswert "wahr" erzeugt:

if (divisor <> 0)
{
   quotient ← dividend / divisor
}
else
{
   /* Ausnahmebehandlung / Fehlermeldung */
}

Wertebereichsprüfung

Bearbeiten

Bei Parametern mit eingeschränktem zulässigen Wertebereich kann eine allgemeine und an allen entsprechenden Stellen verwendbare Funktion programmiert werden, die den gültigen Wertebereich überprüft und einen entsprechenden (häufig zweiwertigen respektive booleschen) Funktionswert zurückgibt, wie zum Beispiel die folgende Funktion "waterIsLiquid ", die überprüft, ob die Wassertemperatur zwischen 0° und 100° Celsius liegt, bevor das spezifische Gewicht des Wassers berechnet werden darf:

boolean waterIsLiquid (double temperature) /* temperature in degrees Celsius */
{
   boolean waterIsLiquid ← (temperature > 0) and (temperature < 100);
   return waterIsLiquid;
}

...

double temperature, density;

...

if waterIsLiquid (temperature)
{
   /* function computes an approximation of the density of air-free liquid water in kilograms per cubic metre */
   density ←
      (    999.83952
         + (16.945176 * temperature)
         - (0.0079870401 * temperature * temperature)
         - (0.000046170461 * temperature * temperature * temperature)
         + (0.00000010556302 * temperature * temperature * temperature * temperature)
         - (0.00000000028054253 * temperature * temperature * temperature * temperature * temperature)
      ) / ((0.01689785 * temperature) + 1);
}
else
{
   /* exception handling, because water is not liquid */
}

Parameterkombinationen

Bearbeiten

Es ist wichtig, dass immer alle auftretenden Parameterkombinationen berücksichtigt und vom Programmcode verarbeitet werden, wenn aus diesen Parametern valide berechnete Werte abgeleitet werden sollen.

Um zum Beispiel das Argument (also den Phasenwinkel zwischen -180° und +180°) einer komplexwertigen Zahl mit den reellwertigen Komponenten x und y über den Arcustangens ("arctan") zu berechnen, muss geprüft werden, ob der Parameter x gleich null ist, und welche Vorzeichen die Parameter x und y haben:

double x
double y

...

double argument

if (x = 0)
{
   if (y > 0)
   {
      argument ← 90
   }
   elseif (y < 0)
   {
      argument ← -90
   }
   else /* y is equal to 0, too */
   {
      stop /* argument is not defined */
   }
}
else /* x is not equal to 0 */
{
   argument ← arctan (y / x)
   if (x < 0)
   {
      if (y >= 0)
      {
         argument ← argument + 180
      }
      else /* both, x and y are less than 0 */
      {
         argument ← argument - 180
      }
   }
}

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 mit einfachen Bezeichnern:

StatisticsMyEvaluation
StatisticsMyAssessment

Diese Bezeichner sind in der Regel unmittelbar mit den Programmdateien im Dateisystem korreliert. Hierbei sind also keine qualifizierten Bezeichner auf verschiedene Konstrukte erforderlich, die hier im Beispiel aus mehreren Bezeichnern mit zwischengestellten Punkten zusammengesetzt sind. Links vom Punkt steht der Bezeichner der Programmbibliothek (Modulsammlung, Paket), und rechts vom Punkt steht der Bezeichner für ein Programmbaustein (Modul, Klasse):

package statistics

statistics.MyEvaluation
statistics.MyAssessment

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 mit qualifiziertem Bezeichner:

...
MyModule.drawLine ()
...

Mit diesen qualifizierten Bezeichnern ist es dann auch einfach und eindeutig möglich, gleichnamige Bezeichner, wie zum Beispiel für die Methode 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 gewisse 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 durch Aufruf ("call") des Unterprogramms ("procedure") sum aus der Klasse B in das Unterprogramm add der Klasse A sowie Aufruf des Unterprogramms add aus der Klasse A in das Unterprogramm sum der Klasse B.

Zyklische Importe beziehungsweise Zirkelbezüge sind nicht nur unübersichtlich, sondern auch unstrukturiert und können zu Speicherüberläufen führen, da sich Programmteile immer wieder gegenseitig aufrufen, ohne beendet zu werden. Ferner kann die Funktion des übersetzten Programms bei einer Optimierung des Codes von der Reihenfolge der Übersetzung der Quelltexte abhängen. Die Schnittstellen der Klassen und Module können im Allgemeinen weder unabhängig voneinander noch eindeutig überprüft werden.

Beispiel: Die beiden Funktionen Funktionen add aus der Klasse A und sum aus der Klasse B rufen sich endlos gegenseitig auf, um die Summe zweier Zahlenwerte zu berechnen, bis der Speicher überlaufen würde und das Laufzeitsystem die Ausführung deswegen abbricht oder der Speicher überläuft, das Programm unkontrolliert und ohne (nachvollziehbare) Fehlermeldung abstürzt.

public class A
{
   public int procedure add (int a, int b)
   {
      int result ← B.sum (a, b);
      return result;
   }
}
public class B
{
   public int procedure sum (int x, int y)
   {
      int result ← A.add (x, y);
      return result;
   }
}
 
Variante von Abhängigkeiten zwischen Modulen oder Klassen, in denen die zyklischen Bezüge weniger offensichtlich sind, wenn nur der nächste Nachbar betrachtet wird. Der dunkelrote Pfeil oben rechts zeigt nach unten auf ein bereits vorher definiertes Element, das sich rechts in der Mitte befindet. Die Definition dieses Elements darf bei einem strukturierten Aufbau der Programmteile allerdings nicht von dem Element ober rechts abhängig sein.

Solche zyklischen Abhängigkeiten können durch Verzweigungen und indirekte Aufrufe wesentlich weniger offensichtlich sein, und sind dann nur sehr schwierig zu erkennen und zu beheben. Sichere Programmiersprachen überprüfen solche zyklischen Zusammenhänge daher und lassen sie nicht zu.

In der Regel ist es bei der Anwendung von rekursiven Programmiertechniken mit wohldefinierten Abbruchbedingungen möglich, ohne zyklische Modulabhängigkeiten auszukommen. Ein Übersetzer kann in den Metadaten von Programm-Modulen Zeitstempel verwenden, um bei der Interpretation eines Programmteils herausfinden zu können, ob alle anderen importierten Programmteile bereits vorher gültig übersetzt wurden.

Nebeneffekte

Bearbeiten

Nebeneffekte treten auf, wenn der Programmierer von naheliegenden, jedoch falschen Annahmen ausgeht, die die Programmiersprache betreffen. Solche Nebeneffekte sind unerwünscht und können durch ein strukturiertes Vorgehen oft leicht vermieden werden.

Durch Rundung

Bearbeiten

Manchmal ist es schwierig zu erkennen, dass das Ergebnis einer Operation nicht dem exakten Ergebnis entspricht, das mathematisch zu erwarten wäre, weil es Rundungsfehler gibt. Gleitkommazahlen können nicht mit beliebig hoher Präzision gespeichert werden, und daher können sich dadurch solche Rundungsfehler auch mit einer völlig unerwarteten Wirkung ergeben. Hier ein Beispiel in der Programmiersprache Java für ein System mit einer Speichertiefe von 64 Bit:

 double a = 4.4;
 double b = 3.3;
 java.lang.System.out.println (a - b);

Die Ausgabe lautet nicht "1.1" wie zu erwarten wäre, sondern:

1.1000000000000005

Noch schwieriger ist es, wenn das Kommutativ-, das Distributiv- oder das Assoziativgesetz nicht zu gelten scheinen, wie in diesem Beispiel, bei dem die Variable "b" einmal zur Variable "a" und einmal zur Variable "c" assoziiert ist:

 double a = 4.4;
 double b = 3.3;
 double c = 1.1;
 java.lang.System.out.println ((a - b) - c);
 java.lang.System.out.println (a - (b + c));

Mathematisch kommt in beiden Fällen exakt der Wert null heraus, die Ausgabe lautet jedoch:

4.440892098500626E-16
0.0

Die Wirkung von derartigen Nebeneffekten sind nur sehr schwierig zu beherrschen, und daher sollte beim Vergleichen von Gleitkommawerten die Präzision respektive die Maschinengenauigkeit der gespeicherten Werte berücksichtigt werden. Manche Programmiersprachen stellen hierfür einen Wert für die kleineste relative Genauigkeit von Gleitkommazahlen   (epsilon) zur Verfügung. Das folgende Beispiel für den Datentyp double mit 64 Bit Speichertiefe nach dem Standard IEEE 754 in der Programmiersprache Java mit einem Wert für   nach der Formel:

 
 double a = 4.4;
 double b = 3.3;
 double differenzBerechnet = a - b;
 double differenzErwartet = 1.1;

 boolean gleichheit1 = (differenzBerechnet == differenzErwartet);
 java.lang.System.out.println (gleichheit1);

 double epsilon = 1.0E-15;
 boolean gleichheit2 = java.lang.Math.abs ((differenzBerechnet - differenzErwartet) / differenzErwartet) < epsilon;
 java.lang.System.out.println (gleichheit2);

Die Ausgabe lautet hier:

false
true

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 (lokale) 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.

Ferner ist es denkbar, dass durch den ersten Funktionsaufruf globale Variablen oder Instanzen verändert und beim zweiten Funktionsaufruf verwendet werden.

Die erwünschte Reihenfolge von Funktionsaufrufen kann leicht durch entsprechende Code-Sequenzen mit sequentiellen Anweisungen erzwungen werden:

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 gespeichert und somit abgefragt werden können. Diese sind nach einem Programmabbruch dann auch mit einem Post-Mortem-Debugger analysierbar.

Durch Kombination von Operatoren

Bearbeiten

Manche Programmiersprachen - insbesondere in der C-Sprachfamilie - erlauben die Kombination von Zuweisungsoperatoren und arithmetischen Operatoren.

Zuweisungsoperator: =
Arithmetische Operatoren: + - * /
Kombinierte Operatoren: += -= *= /=

Die kombinierten Operatoren sollen für die scheinbar äquivalenten Formulierungen mit getrenntem Zuweisungsoperator und arithmetischem Operator stehen:

a += 1; steht für a = a + 1;
a -= 1; steht für a = a - 1;
a *= 1; steht für a = a * 1;
a /= 1; steht für a = a / 1;

Diese Schreibweisen sollen wohl vor allem ein wenig Schreibarbeit bei der Programmierung ersparen, können aber zu schwer zu identifizierenden Programmierfehlern führen, wie das folgende Java-Beispiel verdeutlichen soll:

	long a = 1;
	double b = 1.5;
	a *= b;
	java.lang.System.out.println (a);
	a += b;
	java.lang.System.out.println (a);
	a -= b;
	java.lang.System.out.println (a);

Dieser Code erzeugt die Ausgabe:

1
2
0

Die nur scheinbar äquivalenten Formulierungen für die kombinierten Operatoren

	a = a * b;
	a = a + b;
	a = a - b;

werden in Java wegen der mangelnden Zuweisungskompatibilität der arithmetischen Ausdrücke hinter dem Zuweisungsoperator vom Datentyp "double" zum Datentyp "long" der Variable "a" gar nicht übersetzt.

Die tatsächlichen äquivalenten Formulierungen für die kombinierten Operatoren lauten nämlich wie folgt:

	a = (long) (a * b);
	a = (long) (a + b);
	a = (long) (a - b);

Durch die impliziten Datentypumwandlungen erklären sich auch die falschen numerischen und gegebenenfalls nicht erwarteten ganzzahligen Ergebnisse. Wenn bei der Programmierung diese Tatsachen nicht bewusst sind oder übersehen werden, ergeben sich numerische Fehler in den arithmetischen Berechnungen. Dies kann einfach vermieden werden, indem kombinierte Operatoren zugunsten der expliziten sowie transparenten Formulierungen mit separaten Operatoren nicht verwendet werden.

Durch Überladen

Bearbeiten

Eine Überladung liegt vor, wenn eine Operator oder ein Bezeichner mehrfach in verschiedenen Bedeutungen auftritt, die leicht zu Verwechslungen führen können. Streng strukturierte Programmiersprachen erlauben das polymorphe Überladen nicht, wenn es dadurch zu Programmierfehlern kommen kann.

Das Überladen muss in der objektorientierten Programmierung vom Überschreiben unterschieden werden, wobei auch überschriebene Methoden in weniger strukturierten Programmiersprachen überladen werden dürfen, was ebenfalls zu unübersichtlichem Programmcode und schnell zu übersehenden Programmierfehlern führen kann. Siehe hierzu unten unter Überladung.

Überladung von Divisionsoperatoren

Bearbeiten

Die Divisionsoperatoren sind in vielen Programmiersprachen leider überladen, wenn nämlich formal keine Unterscheidung zwischen Division mit ganzen Zahlen (Datentyp zum Beispiel "long" oder "int") und Gleitkommazahlen (Datentyp zum Beispiel "real" oder "double") gemacht wird. In diesen Fällen muss der Divisionsoperator sehr aufmerksam verwendet werden:

int i ← 2;
int j ← 1;
real k ← j / i; /* Ueberladener Divisionsoperator mit zwei ganzzahligen Operanden */

Verwendet die Programmiersprache im arithmetischen Ausdruck die ganzzahlige Division, hat dies zur Folge, dass die Variable k den Wert 0 erhält. Verwendet die Programmiersprache stattdessen die reelwertige Division, bekommt die Variable k den Wert 0,5 zugewiesen.

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

(* Programmiersprache Pascal *)
 i, j : integer;
 k : real;
 i := 2;
 j := 1;
 k := j / i; (* Gleitkommazahliger Divisionsoperator mit zwei ganzzahligen Operanden *)

In der letzten Anweisung wird der Variablen "k" der Zahlenwert der reellwertigen Division 0,5 zugewiesen.

(* Programmiersprache Pascal *)
 i, j, k : integer;
 i := 2;
 j := 1;
 k := j div i; (* Ganzzahliger Divisionsoperator mit zwei ganzzahligen Operanden *)

In der letzten Anweisung wird der Variablen "k" der Zahlenwert der ganzzahligen Division 0 zugewiesen.

Bei Programmiersprachen, die die Unterscheidung der Divisionsoperatoren nicht unterstützen, ist die Verwendung der expliziten und zuweisungskompatiblen Datentypumwandlung (englisch: type cast) nicht nur sinnvoll, sondern sogar zwingend erforderlich:

/* Programmiersprache Java */
 long i = 2;
 long j = 1;
 double k = ((double) j) / ((double) i) /* Ueberladener Divisionsoperator mit zwei gleitkommazahligen Operanden */

In der Regel ist es für eine Gleitkommadivision hierbei ausreichend, wenn nur einer der beiden Operanden, also nur der Nenner (Dividend) oder der nur Zähler (Divisor) der Division, eine Gleitkommazahl darstellt.

Entsprechende Überlegungen gelten auch für alle Modulo-Operatoren (wie zum Beispiel "%", "mod" oder "MOD").

Überladung von Variablen

Bearbeiten

Oft ist es in einer Programmiersprache erlaubt, dieselben Bezeichner für Variablen mit verschiedenen Sichtbarkeitsbereichen zu verwenden. Dies kann sehr einfach zur Verwechslung dieser Variablen führen, wie im folgenden Java-Beispiel verdeutlicht wird, wo es sowohl eine globale Klassenvariable (Sichtbarkeit in der Klasse "OverloadedVariables") als auch eine lokale Variable (Sichtbarkeit in der Methode "main") mit dem Namen "bezeichner" gibt:

public class OverloadedVariables
{
	// globale Klassenvariable "bezeichner"
	private static long bezeichner = 1;

	// Hauptprogramm (Methode "main")
	public static void main (java.lang.String [] argumente) 
	{
		// lokale Variable "bezeichner"
		long bezeichner = 2; 
		// Ausgabe der globalen Klassenvariable "bezeichner"
		java.lang.System.out.println ("Wert der globalen Variable = " + OverloadedVariables.bezeichner);
		// Ausgabe der lokalen Variable aus der Methode "main"
		java.lang.System.out.println ("Wert der  lokalen Variable = " + bezeichner);
	}
}

Falls die Klassenvariable referenziert werden soll, muss sie in Java qualifiziert bezeichnet werden, indem der Name der Klasse vorangestellt wird.

Überladung von Methoden

Bearbeiten

Viele Programmiersprachen erlauben die Deklaration von mehreren Methoden mit gleichem Bezeichner, die sich in der Anzahl oder den Datentypen ihrer Parameter unterscheiden. Das folgende Java-Beispiel mit zwei Methoden demselben Namens, von denen die mit der passenden Datentyp des Parameters "zahl" aufgerufen wird, verdeutlicht dies:

	private static long kehrwert (long zahl)
	{
		long kehrwert = 1 / zahl;
		return kehrwert;
	}

	private static double kehrwert (double zahl)
	{
		double kehrwert = 1 / zahl;
		return kehrwert;
	}

	public static void main (java.lang.String [] argumente)
	{
		double kehrwert1 = kehrwert (2);
		double kehrwert2 = kehrwert (2.0);
		java.lang.System.out.println ("Kehrwert 1 = " + kehrwert1);
		java.lang.System.out.println ("Kehrwert 2 = " + kehrwert2);
	}

Die Ausgabe ergibt zwei verschiedene Ergebnisse für den Kehrwert der Zahl Zwei:

Kehrwert 1 = 0.0
Kehrwert 2 = 0.5

Durch die kategorische Verwendung verschiedener Bezeichner für verschiedene Methoden kann die Verwechslungsgefahr leicht und ohne Probleme verhindert werden, und die Erzeugung der beiden verschiedenen Ergebnisse wird transparent:

private static long kehrwertLong (long zahl)
	{
		long kehrwert = 1 / zahl;
		return kehrwert;
	}

	private static double kehrwertDouble (double zahl)
	{
		double kehrwert = 1 / zahl;
		return kehrwert;
	}

	public static void main (java.lang.String [] argumente)
	{
		double kehrwert1 = kehrwertLong (2);
		double kehrwert2 = kehrwertDouble (2.0);
		java.lang.System.out.println ("Kehrwert 1 = " + kehrwert1);
		java.lang.System.out.println ("Kehrwert 2 = " + kehrwert2);
	}

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:

/* Programmiersprache C */

struct DataType { int a }; // Definition des Datentyps ''DataType'' mit einem ganzzahligen Datenfeld ''a''

// Deklaration der Funktion ''function'' mit einem Zeiger auf eine Variable vom Datentyp ''DataType'' als Speicheradresse für den Rückgabewert
DataType* function ()
{
  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'', die nach der Beendigung des Funktionsaufrufs gar nicht mehr gültig ist.
}

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.

Alternativ kann der Datentyp DataType nicht direkt als Verbund, sondern als Zeiger auf einen entsprechenden Verbund deklariert werden. In diesem Fall muss in der Funktion zunächst eine Instanz erzeugt werden (beispielsweise mit dem Kommando new oder allocate). Diese Instanz ist dann nicht mehr im lokalen Stapelspeicher (Stack) der Funktion gespeichert, sondern es kann im dynamischen Speicherbereich (Heap) global - also auch außerhalb der Funktion und nach Beendigung des Funktionsaufrufs - darauf zugegriffen werden.

Durch Verwechslung von Speicherinhalt und Speicheradresse

Bearbeiten

Die Werte von Variablen werden unter einer bestimmten Speicheradresse eines Computers gespeichert, wo vom Laufzeitsystem die für den entsprechenden Datentyp erforderliche Datenmenge der entsprechende Speicherplatz reserviert und bereitgehalten wird. Diese Speicheradresse wird in modernen Systemen in der Regel automatisch verwaltet, so dass sie im Allgemeinen gar nicht bekannt ist und auch gar nicht bekannt sein muss.

Daraus ergeben sich unter Umständen jedoch wichtige Implikationen. In manchen Programmiersprachen, wie zum Beispiel Java, ist nämlich nicht unmittelbar erkennbar, ob bei bestimmten Operationen der Speicherinhalt oder die Speicheradresse einer Variablen verwendet wird. So werden bei bei logischen Vergleichen mit Operanden, die aus Variablen mit einfachen Datentypen bestehen (etwa boolean, long oder double), die unter der Speicheradresse gespeicherten Werte verglichen, also die Inhalte. Bei Variablen mit komplexen Datentypen (beispielsweise eine abzählbare Liste von Daten eines Datentyps (array), ein Verbund (record / struct), der sich aus verschiedenen Datentypen zusammensetzen kann, oder allgemein in der objektorientierten Programmierung die Instanz eines Objekts) werden jedoch gar nicht unbedingt die gespeicherten Inhalte, sondern lediglich die Speicheradressen der beiden Operanden verglichen. Hier wird also beim Gleichheitsoperator nur geprüft, ob es sich um dasselbe Speicherobjekt (dieselbe Instanz) handelt, und nicht, ob zwei verschiedene Speicherobjekte den gleichen Inhalt haben. Bei strenger Strukturierung wird (hoffentlich schon vor der Ausführung bereits im Quelltext) zusätzlich geprüft, ob die zu vergleichenden komplexen Datentypen überhaupt zuweisungskompatibel und somit sinnvoll vergleichbar sind.

Unter welchen Umständen welche Speicheradressen für gleiche Speicherinhalte verwendet werden, ist insbesondere für unerfahrene Programmierer keineswegs immer naheliegend oder leicht nachzuvollziehen. Dies wird im Folgenden anhand des logischen Vergleichs auf Gleichheit von Zeichenketten (Java-Klasse java.lang.String) in der Programmiersprache Java verdeutlicht. Die Wirkungsweise des Gleichheitsoperators == wird der Wirkungsweise des Funktionsaufrufs der Methode java.lang.String.equals gegenübergestellt, die einen booleschen Rückgabewert hat.

	boolean vergleich;

	// Die symbolische Konstante für die Zeichenkette "abc" wird in einer Variablen mit dem Bezeichner text verwaltet
	// Die Zeichenkette "abc" wird von Java unter der Speicheradresse #MEM1 abgelegt
	// Die Variable text und die symbolische konstante Zeichenkette "abc" haben dieselbe Speicheradresse #MEM1
	java.lang.String text = "abc";

	// Vergleich der Speicheradresse #MEM1 mit der Speicheradresse #MEM1
	vergleich = ("abc" == "abc");
	java.lang.System.out.println ("1. Vergleich \"abc\" == \"abc\": " + vergleich);

	// Vergleich der Speicheradresse #MEM1 mit der Speicheradresse #MEM1 !!!
	vergleich = (text == "abc");
	java.lang.System.out.println ("2. Vergleich text == \"abc\": " + vergleich);

	// Vergleich des Inhalts bei der Speicheradresse #MEM1 mit dem Inhalt bei der Speicheradresse #MEM1
	vergleich = text.equals ("abc");
	java.lang.System.out.println ("3. Vergleich text.equals (\"abc\"): " + vergleich);

	// Neue Instanz fuer die bereits oben deklarierte Zeichenkette text 
	// Die neue Instanz wird mit dem new-Operator unter der Speicheradresse #MEM2 erzeugt
	// Der Speicherinhalt wird mit dem Konstruktor java.lang.String und dem Wert "abc" initialisiert
	// Die Variable text bekommt durch die Zuweisung die Speicheradresse #MEM2
	text = new java.lang.String ("abc");

	// Vergleich der Speicheradresse #MEM2 mit der Speicheradresse #MEM1 !!!
	vergleich = (text == "abc");
	java.lang.System.out.println ("4. Vergleich text == \"abc\": " + vergleich);

	// Vergleich des Inhalts bei der Speicheradresse #MEM2 mit dem Inhalt bei der Speicheradresse #MEM1
	vergleich = text.equals ("abc");
	java.lang.System.out.println ("5. Vergleich text.equals (\"abc\"): " + vergleich);

Die Textausgabe dieses Programms sieht wie folgt aus:

1. Vergleich "abc" == "abc": true
2. Vergleich text == "abc": true
3. Vergleich text.equals ("abc"): true
4. Vergleich text == "abc": false
5. Vergleich text.equals ("abc"): true

Symbolisch konstante Zeichenketten, wie zum Beispiel der Ausdruck "abc", werden unter einer verdeckten Speicheradresse abgelegt und von Java für gleichlautende Ausdrücke automatisch wiederverwendet. Wird jedoch mit dem new-Operator eine Instanz eines Objekts erzeugt, so bekommt diese unabhängig davon, welcher Inhalt dort gespeichert wird, stets eine andere neue Speicheradresse zugeordnet. Für den Vergleich des Inhalts von Zeichenketten auf Gleichheit ist in Java also immer die generische typengebundene Methode "equals zu verwenden. Diese typengebundene Methode "equals" gibt es auch in vielen anderen Java-Klassen, um den Inhalt der entsprechenden Objektinstanzen auf Gleichheit vergleichen zu können.

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 als Datentyp 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: ein Kreis oder ein Dreieck hat eine Farbe, eine Strickstärke sowie eine Methode zum Zeichnen), 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 (respektive typengebundenen Prozeduren) von GraphischesObjekt (dies kann eindeutig durch Ist-Beziehungen ausgedrückt werden: ein Kreis ist ein GraphischesObjekt, und ein Dreieck ist ein GraphischesObjekt) 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 auch bei objektorientierter Programmierung überflüssig, erschwert die Nachvollziehbarkeit vom Quellcode und birgt die Gefahr von Programmierfehlern, die unter Umständen erst lange nach der Entwicklung der Software bei deren Wartung entstehen. Das folgende Beispiel verdeutlicht einen leicht zu übersehenden Programmierfehler durch die Veränderung bei den überladenen Funktionen während der Programmentwicklung oder Programmwartung:

double quotient (double a, double b)
{
 return a / b; /* Gleitkommazahlige Division */
}

long i ← 1;
long j ← 2;
double q ← quotient (i, j); /* q ist 0,5 da die gleitkommazahlige Definition der Funktion 'quotient' verwendet wird */

Wird die Funktion 'quotient' später mit einer ganzzahligen Variante überladen, ergibt sich beim bestehenden Aufruf der Funktion unbeabsichtigt ein anderes Ergebnis für die Variable 'q':

double quotient (long a, long b)
{
 return a DIV b; /* Ganzzahlige Division */
}

double quotient (double a, double b)
{
 return a / b; /* Gleitkommazahlige Division */
}

long i ← 1;
long j ← 2;
double q ← quotient (i, j); /* q ist 0 da die ganzzahlige Definition der Funktion 'quotient' verwendet wird */

Noch unübersichtlicher wird die Lage, wenn zusätzlich auch noch Überladungen mit gemischten Datentypen für die Funktionsparameter definiert werden:

double quotient (long a, long b)

double quotient (double a, long b)

double quotient (long a, double b)

double quotient (double a, double b)

Deswegen werden Methoden oder Attribute besser nicht überladen, auch nicht, wenn die Programmiersprache dies zulässt. Auch jede Klasse bekommt daher maximal einen einzigen Konstruktor, der alle erforderlichen Parameter zur Initialisierung der Instanzvariablen enthält. Als günstige Nebeneffekte stellen sich kürzere Übersetzungszeiten ein.

Wenn die ursprünglichen Deklarationen in der Basisklasse oder einer der von ihr erbenden Klassen überladen werden, indem zum Beispiel weitere gleichnamige Methoden mit abweichenden Parametern definiert werden, dann kann es zu verändertem Verhalten von Software kommen. Ohne dass die Anwendung selbst geändert wurde, kann es allein durch die Aktualisierung einer verwendeten Klasse zu völlig anderen Rechenergebnissen kommen, weil automatisch eine andere, neu überladene Methode aufgerufen wird, ohne dass dies im Quelltext des Anwendungsprogramms sichtbar wird. Die Folge können schwerwiegende Programmierfehler sein, die schwierig zu analysieren sind.

Als ein Beispiel diene hier die Methode java.lang.Math.ulp zur Bestimmung der "units in the last place" ("Einheiten in der letzten Stelle"), die in der Klasse java.lang.Math aus historischen Gründen mit zwei Parametern deklariert und somit überladen ist:

/* Programmiersprache Java */
 public class Math;
 {
    public static double ulp (double d)
    public static float  ulp (float f)
 }

Der folgende Java-Code

/* Programmiersprache Java */
 double zahl;
 zahl = java.lang.Math.ulp (1L); // Datentyp long
 java.lang.System.out.println (zahl);
 zahl = java.lang.Math.ulp (1F); // Datentyp float
 java.lang.System.out.println (zahl);
 zahl = java.lang.Math.ulp (1D); // Datentyp double
 java.lang.System.out.println (zahl);

erzeugt folgende Ausgabe, da die ganze Zahl Eins mit dem Datentyp long (64 Bit) in der Programmiersprache Java implizit offensichtlich nicht in den Datentyp double (64 Bit), sondern in den Datentyp float (32 Bit) umgewandelt wird, und somit die mit dem Parameter des Datentyps float deklarierte Methode aufgerufen wird:

/* Programmiersprache Java */
 1.1920928955078125E-7
 1.1920928955078125E-7
 2.220446049250313E-16

Falls bei einer neueren Version der Klasse java.lang.Math die Methode ulp mit einem Parameter des Datentyps long überladen würde, wäre das Ergebnis mit dem ganzzahligen Parameter des Werts "1L" (long) nicht mehr vorhersagbar, obwohl der oben angegebene Methodenaufruf sich formal gar nicht geändert hätte.

/* Programmiersprache Java */
 public class Math;
 {
    public static double ulp (double d)
    public static float  ulp (float f)
    public static double ulp (long l)
 }

Das Ergebnis des Aufrufs

/* Programmiersprache Java */
 double zahl = java.lang.Math.ulp (1L);
 java.lang.System.out.println (zahl);

würde dann allein von der tatsächlichen Implementierung der neuen überladenen Methode mit dem Parameter des Datentyps long abhängen, die mit den alten beiden ulp-Methoden nichts mehr zu tun hat, außer, dass sie den gleichen Namen hat.

Überschreibung

Bearbeiten

Das Überschreiben von geerbten Methoden oder Konstruktoren ist etwas völlig anderes als das Überladen und kann sehr sinnvoll sein.

Beim Überschreiben muss die Signatur der Methode (oder des Konstruktors) unter strikter Beachtung der Zuweisungskompatibilität, der Anzahl und der Reihenfolge aller Parameter sowie der Rückgabewerte berücksichtigt werden. Wenn die Programmiersprache dies nicht automatisch unterstützt, sind wenigstens entsprechend aufwendige Maßnahmen im Quelltext sicherzustellen, wie zum Beispiel explizite Typenprüfungen oder hinreichend ausführliche Hinweise in Kommentaren.

Wenn Methoden oder Konstruktoren einer Basisklasse von der überschreibenden Klasse aufgerufen werden (englisch super call) kann es zum Fragile Base Class Problem (zu Deutsch Problem der anfälligen Basisklasse) kommen, da bei der Implementierung der Basisklasse die möglichen Auswirkungen in den später implementierten, überschreibenden Klassen nicht berücksichtigt werden konnten. Zur Abwendung dieser Gefahr sind ein besonders sorgfältiger und strukturierter Programmierstil sowie eine lückenlose Dokumentation des Quelltextes sehr hilfreich.

Das fehlerfreie und robuste Überschreiben von Klassen beziehungsweise die Vererbung von implementierten Klassen erfordern eine hohe Fähigkeit zum abstrakten Denken und eine umfangreiche Programmiererfahrung.

Mehrfachvererbung

Bearbeiten

Durch Mehrfachvererbung, also das Erben von Methoden und Instanzvariablen aus mehreren Basisklassen, führt zu komplexen, und schwierig zu durchschauenden Abhängigkeiten, die im Rahmen des Diamond-Problems sogar zu unerwünschten Mehrdeutigkeiten führen kann. Die Vererbung aus zwei Basisklassen kann bei Bedarf ohne weiteres durch die Verwendung von Zwillingsklassen vermieden werden, was den Programmieraufwand ein wenig erhöht, aber dafür solche Mehrdeutigkeiten verhindert.

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 auf beliebige ä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 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 in seinem Programmier-Team in jeder Programmiersprache gelingen, in kürzerer Entwicklungszeit besser strukturierte und funktionierende Programme zu schreiben.

Vergleich

Bearbeiten

In der folgenden Tabelle werde einige imperative, objektorientierte 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 bei Basistypen nein ja ja ja
Datentypsicherheit bei komplexen Datentypen nein ja nein 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
Bearbeiten

Einzelnachweise

Bearbeiten
  1. Niklaus Wirth: Program Development by Stepwise Refinement, Communications of the Association for Computing Machinery, Band 14, Nummer 4, April 1971, Seiten 221 bis 227

Zusammenfassung des Projekts

Bearbeiten

  „Strukturierte Programmierung“ ist nach Einschätzung seiner Autoren zu 100 % fertig

  • Zielgruppe: Programmierer, Software-Entwickler, Informatik-Lehrende
  • Lernziele: Vermeidung von Fehlern, die leicht und unbemerkt zur unstrukturierten Programmierung führen können. Schnelle und sichere Erstellung leicht zu wartender Software.
  • Buchpatenschaft/Ansprechperson: Benutzer:Bautsch
  • Sind Co-Autoren gegenwärtig erwünscht? Ja, sehr gerne. Korrekturen von offensichtlichen Fehlern direkt im Text; Inhaltliches bitte per Diskussion.
  • Richtlinien für Co-Autoren: Wikimedia-like.