Übersicht über Programmiersprachen
Anfangs wurde die Entwicklung von Programmiersprachen als Abfolge von Generationen aufgefasst (binäre Maschinensprache, Assembler, prozedurale Programmiersprache). Irgendwann wurde dies zu unübersichtlich, so dass man besser von Programmier-Paradigmen spricht, was die unterschiedliche Herangehensweise an ein zu lösendes Problem ausdrücken soll. Die wichtigsten Paradigmen sind: imperative Programmierung, deklarative Programmierung, funktionale Programmierung, objekt-orientierte Programmierung. Näher erläutert werden hier die strukturierte Programmierung (als Weiterentwicklung der imperativen Programmierung) und die objekt-orientierte Programmierung.
- Einordnung des Artikels
- Generationen von Programmiersprachen und Programmier-Paradigmen
- Erste Generation: Binäre Maschinensprache
- Zweite Generation: Assembler-Sprachen
- Dritte Generation: Prozedurale Programmiersprachen
- Programmier-Paradigmen
- Imperative Programmiersprachen
- Imperative Programmiersprache (prozedurale Programmiersprache)
- Prozeduren und Funktionen
- Strukturierte Programmierung
- Vermeidung von Sprungbefehlen
- Verwendung von Unterprogrammen
- Objekt-orientierte Programmiersprachen
Einordnung des Artikels
- Einführung in die Informatik
- Einführung in die Programmierung
- Übersicht über Programmiersprachen
- Einführung in die Programmierung
Generationen von Programmiersprachen und Programmier-Paradigmen
Anfangs wurden Programmiersprachen in Generationen eingeteilt. Bis zur dritten Generation war eine klare Abgrenzung der Programmiersprachen gegeneinander sehr gut möglich; ab der 4. Generation wurde diese Einteilung allerdings zu unübersichtlich, da die Grenzen zwischen den Programmiersprachen immer unschärfer wurden. Stattdessen spricht man von Programmier-Paradigmen und versucht damit auszudrücken, wie eine Programmiersprache an die Lösung eines Problems herangeht.
Die zeitliche Abfolge der Generationen von Programmiersprachen zeigt die folgende Abbildung:
Erste Generation: Binäre Maschinensprache
Wie früher ausführlich diskutiert wurde (siehe Unterabschnitt über Mikroprogramme im Kapitel über Die Komponenten des von Neumann Rechners), ruft jeder Maschinensprache-Befehl ein Mikroprogramm auf und spricht meist ein oder zwei Register-Adressen an. Daher besteht ein Maschinensprache-Befehl nicht aus einer Anweisung, die als Text gespeichert wird (wie ADD 1A F6
), sondern die Befehle werden durchnumeriert, um eindeutig auf das zuständige Mikroprogramm verweisen zu können. Ein Maschinensprache-Befehl besteht dann nur noch aus Zahlen (Nummer des Befehls, Register-Adressen).
Werden Programme auf dieser Ebene vom Menschen entwickelt, verwendet man Hexadezimalzahlen, zur Ausführung des Programms werden sie dann in Dualzahlen verwandelt.
Zweite Generation: Assembler-Sprachen
Maschinensprache-Programme auf der Ebene von Hexadezimalzahlen zu schreiben, ist mühsam und fehleranfällig. Es war ein großer Fortschritt als man — wie oben angedeutet — Befehle als Text wiedergeben konnte, wie oben der Additionsbefehl:
ADD 1A F6
Eine weitere Vereinfachung ergab sich dadurch, dass man die Speicherzellen nicht direkt durch ihre Adresse ansprechen musste, sondern Bezeichner (identifier) einsetzen konnte. Die Bezeichner sind eine Vorstufe zu den Variablen und stehen nur für den Inhalt einer Speicherzelle; Variablen — die es auf der Ebene der Assembler-Sprachen noch nicht gibt — haben einen gewissen Datentyp, der dann bestimmt wieviel Speicherplatz zum Abspeichern des Wertes einer Variable benötigt wird.
Mit Hilfe von Bezeichnern kann obiger Additionsbefehl etwa lauten:
ADD PREIS MWST
Der sogenannte Assembler übersetzt ein jetzt in textueller Form geschriebenes Programm in die Maschinensprache.
Dritte Generation: Prozedurale Programmiersprachen
Die Assembler-Sprachen haben immer noch gravierende Nachteile:
- Die Maschinensprache ist abhängig von der Maschine, auf der das Programm ausgeführt werden soll. Dies sieht man insbesondere dann, wenn zum Beispiel Ein- und Ausgabegeräte in einem Programm angesprochen werden müssen. Die Realisierung dieser Zugriffe ist immer abhängig vom Betriebssystem. Als Programmierer wünscht man sich dagegen eine Sprache, die für alle Maschinen identische Befehle anbietet. Erst die Übersetzung der Befehle in die Maschinensprache sollte die Eigenheiten der jeweiligen Maschine berücksichtigen.
- Algorithmen auf die Ebene der Maschinensprache herunterzubrechen, entspricht nicht der Art wie Menschen Probleme lösen. Als Programmierer würde man die Befehle lieber auf einer abstrakteren Ebene formulieren. Anders gesagt: Zwischen der Programmiersprache und der Maschinensprache soll eine möglichst große semantische Lücke liegen.
Um diese Nachteile zu vermeiden, wurden in den Programmiersprachen der dritten Generation Befehle angeboten, die sich in Gruppen von Maschinensprache-Befehlen übersetzen lassen. Das Übersetzen ist jetzt eine deutlich komplexere Aufgabe als bei den Assembler-Sprachen; die Übersetzer wurden Compiler genannt. Wörtlich heißt to compile etwa zusammentragen, zusammenstellen oder aufhäufen. Dieser Begriff rührt daher, dass die Unterprogramme zuerst zusammengetragen und dann in den Maschinencode übertragen werden müssen.
Die Regeln der Programmiersprache können maschinenunabhängig formuliert werden — je nach Maschine, auf dem das Programm ausgeführt werden soll, muss ein geeigneter Compiler eingesetzt werden.
Die bekanntesten frühen Programmiersprachen der dritten Generation sind FORTRAN (Formula Translator) und COBOL (Commom Business-Oriented Language).
Gerade die Möglichkeit, Befehle auf einer abstrakteren Ebene als in Maschinensprache zu formulieren, hat zu einer Vielzahl von Programmiersprachen geführt, so dass es nach der dritten Generation von Programmiersprachen schwierig wird, weitere Generationen anzugeben. Man unterteilt daher die späteren Programmiersprachen besser nach den zugrunde liegenden Paradigmen.
Programmier-Paradigmen
Die vier wichtigsten Programmier-Paradigmen sind:
- Imperative Programmierung
- Deklarative Programmierung
- Funktionale Programmierung
- Objekt-orientierte Programmierung
Nur die imperative und die objekt-orientierte Programmierung sollen hier näher vorgestellt werden (siehe folgende Abschnitte), vorerst die wichtigsten Eigenschaften:
1. Imperative Programmierung
Sie ist die konsequente Umsetzung der Ideen Turings und von Neumanns: Komplexe Probleme werden in elementare Einzelschritte zerlegt, die in einer bestimmten Reihenfolge abgearbeitet werden müssen (Algorithmus). Der von Neumann-Computer ist eine universelle Maschine zum Abarbeiten beliebiger Algorithmen. Dazu ist der Prozessor so aufgebaut, dass er einen elementaren Befehl nach dem anderen abarbeitet. In einer imperativen Programmiersprache wird ein Algorithmus als Abfolge von Befehlen realisiert. Sämtliche Programmiersprachen der ersten drei Generationen sind eindeutig dem imperativen Programmier-Paradigma zuzuordnen.
2. Deklarative Programmierung
Eine spezielle Form der deklarativen Programmierung ist die Logik-Programmierung. Bei der deklarativen Programmierung werden abstrakte Algorithmen vorgegeben (etwa die Regeln des logischen Schließens) und der Programmier hat die Aufgabe, sein Problem so zu formulieren (deklarieren), dass ein Lösungs-Algorithmus angewendet werden kann. Die bekannteste deklarative Programmiersprache ist PROLOG (Programming in Logic).
3. Funktionale Programmierung
Die funktionale Programmierung beruht auf dem Funktionsbegriff der Mathematik (einem Eingabewert wird ein Ausgabewert zugeordnet) und der Verkettung von Funktionen. Soll ein Algorithmus aus bestimmten Eingabewerten eindeutig definierte Ausgabewerte berechnen, so wird dies als eine Verkettung von Funktionen dargestellt.
4. Objekt-orientierte Programmierung
Anwendungsentwicklung wie sie heute betrieben wird, wäre mit imperativen Programmiersprachen nicht möglich: Die Quelltexte werden bei großen Projekten so komplex, dass sie nicht mehr beherrschbar sind. Durch die Fokussierung auf Objekte (und weniger auf Algorithmen) ermöglicht die objekt-orientierte Programmierung:
- Unterteilung einer Anwendung in klar voneinander abgegrenzte Module (Modularisierung),
- einfache Aufteilung der Arbeit in einem Team von Entwicklern,
- Wiederverwendbarkeit des Quellcodes,
- einfache Erweiterbarkeit einer Anwendung.
Imperative Programmiersprachen
Wie oben gesagt, sind imperative Programmiersprachen die konsequente Fortführung der Maschinensprache, wenn man einerseits einen möglichst großen Abstraktionsgrad der eingesetzten Befehle erreichen möchte und andererseits die Maschinen-Abhängigkeit der Programmiersprache in den Compiler verlegen möchte. Letzteres betrifft den Compiler-Bau und soll hier nicht weiterverfolgt werden. Dagegen soll näher erläutert werden, wie der im Vergleich zur Maschinensprache höhere Abstraktionsgrad mit Hilfe von Prozeduren und Funktionen erreicht wird.
Imperative Programmiersprache (prozedurale Programmiersprache)
Zunächst zur Erklärung der Begriffe imperative und prozedurale Programmiersprache: Ersteres soll betonen, dass der zentrale Bestandteil eines Programmes Befehle sind; im Gegensatz dazu spielen Deklarationen eine untergeordnete Rolle. In vielen imperativen Programmiersprachen sind Deklarationen ganz oder nahezu überflüssig, da die Datentypen automatisch an den Zuweisungen erkannt und später die Typen bei Bedarf automatisch konvertiert werden. Dies ist aber sehr fehleranfällig, da dann zum Beispiel
x = 5; y = 5.0; b1 = x / 2; // b1 == 2 b2 = y / 2; // b2 == 2.5
aus der Zuordnung für x ein ganzzahliger Datentyp und für y ein Gleitkommazahl-Datentyp abgeleitet wird. Und entsprechend wird die Division nach anderen Regeln ausgeführt.
Dagegen soll der Begriff prozedurale Programmierung ausdrücken, dass sich der Programmierer auf den Ablauf des Algorithmus konzentrieren muss und diesen durch Zusammenfassen mehrerer Befehle zu Prozeduren (oder Funktionen) vereinfachen kann.
Insgesamt stehen die Begriffe imperative Programmierung und prozedurale Programmierung für dasselbe Programmier-Paradigma.
Prozeduren und Funktionen
Die zentrale Frage der imperativen Programmierung ist: Wie kann man einen höheren Abstraktionsgrad als in der Maschinensprache erreichen?
Dazu hilft ein Rückblick auf die Schaltungstechnik: Dort wurde darauf verwiesen, dass man aus den elementaren logischen Operationen AND, OR, und NOT jede beliebige logische Funktion bilden kann. Dies wird zum Beispiel dazu verwendet, um programmierbare Logikbausteine anzubieten, die aus diesen drei Grundfunktionen aufgebaut sind und in der Lage sind jedes Schaltnetz (mit gegebener Anzahl von Ein- und Ausgabewerten) zu realisieren. Weiter wurden zusätzlich zu den Ein- und Ausgabewerten sogenannte Steuersignale betrachtet, die helfen Prozeduren und Funktionen besser zu verstehen.
Beispiel: Der Ein-Bit-Volladdierer
Als Paradebeispiel eines Schaltnetzes werde der Ein-Bit-Volladdierer betrachtet. Er besitzt:
- zwei Eingabewerte (die zu addierenden Bits x0 und x1),
- den Übertrag cin der letzten Addition, der eigentlich ein Eingabewert ist, aber treffender als Steuersignal für die durchzuführende Addition aufzufassen ist,
- den Ausgabewert y0,
- den Übertrag cout, der eigentlich ein Ausgabewert ist, aber wieder als Steuersignal für die nächste Addition aufzufassen ist.
Abbildung 2 zeigt (rechts) das Schaltbild für den Ein-Bit-Volladdierer und (links) die Tabelle, aus der zu ersehen ist, welche Eingabewerten zu welchen Ausgabewerten führen.
An diesem Beispiel kann man gut zeigen, wie Abstraktion in einer Programmiersprache entsteht: Die Berechnung, die der Ein-Bit-Volladdierer ausführt, stimmt mit keiner der drei elementaren logischen Funktionen AND, OR, und NOT überein. Aber es ist möglich, eine Schaltung anzugeben, die aus den elementaren Schaltungen aufgebaut ist und die die logische Tabelle des Ein-Bit-Volladdierers erzeugt. In diesem Sinne kann man stets auf die Funktion
(x0, x1, cin) → (y0, cout)
zurückgreifen; wie diese Funktion aus elementaren Funktionen realisiert wird, muss man sich nur einmal überlegen.
Ähnlich lassen sich in einer Programmiersprache elementare Befehle zusammenfassen und für diesen Block wird ein Name vereinbart, unter dem er anzusprechen ist. In der Sprache des Pseudocode war dies der Unterprogramm-Aufruf (siehe Pseudocode im Abschnitt Pseudocode und formale Sprachen). Zur besseren Unterscheidung zwischen den Aufgaben, die ein Unterprogramm erfüllen kann, spricht man von Prozeduren und Funktionen. Um diese Unterscheidung zu verdeutlichen, wurde das Beispiel des Ein-Bit-Volladdierers gewählt:
- er steuert die nächste Addition (durch den Übertrag cout)
- und er berechnet das niederigste Bit der Addition x0 + x1 + cin, das als y0 weitergegeben wird.
Aufgabe einer Prozedur ist es Aktionen auszuführen, Aufgabe einer Funktion ist es Werte zu berechnen und das Ergebnis der Einheit zurückzuliefern, die die Funktion aufgerufen hat. Der berechnete Wert wird daher als der Rückgabewert der Funktion bezeichnet. Eine Prozedur besitzt keinen Rückgabewert.
Um einige Beispiele anzuführen:
1. Die Prozedur greeting() könnte im Pseudocode folgendermaßen aussehen:
procedure greeting() { Ausgabe: "Herzlich Willkommen" }
Der Aufruf der Prozedur greeting() gibt aus:
Herzlich Willkommen
2. Einer Prozedur kann auch ein Eingabewert übergeben werden, hier eine Zeichenkette (string):
procedure greeting(string name) { Ausgabe: "Herzlich Willkommen" + name }
Der Name der übergebenen Zeichenkette (hier name) ist willkürlich: unter diesem Namen wird der Wert der string-Variable im Unterprogramm angesprochen. (Durch einen treffenden Namen, wird das Programm leichter lesbar.)
Jetzt würde der Aufruf
greeting("Mr. X")
die Ausgabe erzeugen:
Herzlich Willkommen Mr. X
Man beachte: beide Prozeduren greeting() besitzen keinen Rückgabewert — sie führen Aktionen aus.
3. Die Funktion getPrice() erhalte als Eingabewerte den Preis und den Rabatt (in Prozent) und soll als Rückgabewert den neuen Preis berechnen; alle Werte sind vom Datentyp double (Gleitkommazahlen). Die Funktion könnte dann wie folgt aussehen:
double getPrice(double price, double discount)
{
double newPrice = price * (1 - discount / 100);
return newPrice;
}
Der Aufruf
getPrice(1000, 5);
gibt den Wert 950 an die aufrufende Einheit zurück.
Zur Erklärung:
1. Vor dem Namen der Funktion (hier getPrice()) steht der Datentyp des Rückgabewertes (hier double), siehe Zeile 1.
2. In den runden Klammern nach dem Funktionsnamen stehen die Eingabewerte; und zwar immer mit ihrem Datentyp (hier zweimal double) und einem Namen, unter dem der Eingabewert bei den Berechnungen angesprochen werden kann (hier price und discount). Die Eingabewerte werden oft auch als die Parameter einer Funktion bezeichnet.
3. In den geschweiften Klammern (Zeile 2 und 5) stehen die Operationen, die zur Funktion getPrice() zusammengefasst werden sollen. Diese Operationen können bestehen aus:
- elementaren Befehlen oder
- weiteren Funktionsaufrufen.
4. Die letzte Zeile in den geschweiften Klammern muss das sogenannte return-statement sein (Zeile 4), das den Rückgabewert angibt; dieser muss von dem Datentyp sein, der in Zeile 1 vor dem Namen der Funktion steht.
5. Die verwendeten Namen für die Variablen sollen das Lesen des Programmes erleichtern: man stelle sich dazu vor, obige Definition der Funktion getPrice() werde durch die folgende ersetzt:
double getPrice(double a, double b)
{
double c = a * (1 - b / 100);
return c;
}
Inhaltlich hat sich nichts geändert — der Quelltext ist aber deutlich schwerer zu verstehen, da sich die Bedeutung von a und b nur schwer erschließen lässt.
Die scharfe Trennung zwischen Prozedur und Funktion wurde in vielen Programmiersprachen aufgegeben:
- Warum soll nicht auch eine Funktion Aktionen ausführen können? (Schon beim Ein-Bit-Volladdierer war es schwer, scharf zwischen einem Ausgabewert und einem Steuersignal zu unterscheiden.)
- Wenn kein Wert zurückgegeben wird, kann man dies auch als leeren Datentyp (void) auffassen.
Wenn heute der Begriff Funktion verwendet wird, ist meist Prozedur oder Funktion oder eine Mischung aus beidem gemeint.
Um zwei Beispiele anzuführen:
1. Die Funktion getPrice() von oben wird durch Aktionen angereichert (hier nur als Pseudocode):
double getPrice(double price, double discount) { double newPrice = price * (1 - discount / 100); Ausgabe: "Alter Preis = " + price; Ausgabe: "Rabatt = " + discount; Ausgabe: "neuer Preis = " + newPrice; return newPrice; }
2. Eine Prozedur würde man jetzt mit void als Datentyp des Rückgabewertes deklarieren:
void greeting(string name) { Ausgabe: "Herzlich Willkommen" + name; }
Strukturierte Programmierung
Die strukturierte Programmierung ist kein eigenes Programmier-Paradigma, sie gehört zur imperativen Programmierung. Bezeichnet man eine Sprache als strukturierte Programmiersprache, so möchte man damit ausdrücken, dass sie sich in zwei Aspekten von den meisten imperativen Programmiersprachen abhebt:
- Keine Verwendung von Sprungbefehlen.
- Konsequente Verwendung von Unterprogrammen mit den Zielen:
- bessere Strukturierung des Quelltextes,
- Wiederverwendbarkeit der Quelltexte.
Diese beiden Punkt sollen nun etwas detaillierter beschrieben werden.
Vermeidung von Sprungbefehlen
Maschinensprachen bieten stets einen Sprungbefehl wie jump not zero
an, mit dem zu einer Marke (LABEL
) im Programm gesprungen werden kann:
jnz LABEL;
Viele höhere Programmiersprachen haben dieses Konstrukt übernommen und realisieren den Sprungbefehl dadurch, dass die Befehlszeilen durchnumeriert werden und man eine Zeile mit dem GOTO-Befehl ansprechen kann.
10 doSomething(i); 20 i = i + 1; ... 50 GOTO 20;
Kontrollstrukturen (wie Schleife, Bedingung, Alternative, Aufruf von Unterprogrammen) bieten dann die Möglichkeiten, einen Algorithmus nicht sequentiell sonden mit Sprüngen versehen ablaufen zu lassen.
Man kann sich aber leicht vorstellen, dass Programme, die sehr oft Sprungbefehle einsetzen, schwer zu beherrschen sind. Insbesondere wenn semantische Fehler gesucht werden müssen und diese nur bei bestimmten Datenkonstellationen auftreten, ist es schwer nachzuvollziehen, wie der Algorithmus durchlaufen wird. Zur Fehlersuche in Programmen (wie in dem Schnipsel oben mit dem GOTO-Befehl) möchte man oft wissen, welcher Programm-Teil vor einer Anweisung ausgeführt wurde: so ist schwer zu entscheiden, ob vor der Zeile 20 die Zeile 10 oder die Zeile 50 ausgeführt wurde.
Es gab in den 60er Jahren ein wichtiges theoretisches Ergebnis: Man kann jedes Programm ohne Sprungbefehle realisieren. Genauer: Jeder GOTO-Befehl kann ersetzt werden durch die Sprünge, die bei einem Schleifendurchlauf oder bei einem Unterprogramm-Aufruf stattfinden.
Nach diesem Ergebnis hat man Programmiersprachen meist so formuliert, dass kein Sprungbefehl angeboten wird und der Programmierer somit gezwungen ist, die Strukturelemente Schleife und Unterprogramm-Aufruf einzusetzen.
Solche Programmiersprachen nennt man dann strukturierte Programmiersprachen, obwohl sie immer noch dem Paradigma der imperativen (oder prozeduralen) Programmierung angehören. Ein Paradebeispiel für eine strukturierte Programmiersprache ist Pascal; sie wurde 1971 entwickelt. Pascal wurde meist als Lehrsprache eingesetzt, um die Konzepte der strukturierten Programmierung zu demonstrieren. Für den praktischen Einsatz war Turbo-Pascal besser geeignet. In der Praxis werden heute weder Pascal noch Turbo-Pascal eingesetzt, aber viele Konzepte dieser Sprachen sind in aktuellen Programmiersprachen enthalten.
Verwendung von Unterprogrammen
Dass man Sprungbefehle nicht mit GOTO-Befehlen realisieren kann, lässt sich durch die Grammatik einer Programmiersprache erzwingen; dagegen sind die folgenden Richtlinien, wie Unterprogramme eingesetzt werden sollen, nicht zwingend von der Grammatik vorgegeben — es sind eben nur Richtlinien, die beim Einsatz einer strukturierten Programmiersprache empfohlen werden. Wenn hier von Unterprogrammen die Rede ist, sind stets Funktionen im allgemeinen Sinn — wie oben diskutiert — gemeint. Diese Richtlinien, wie Unterprogramme in einer strukturierten Programmiersprache eingesetzt werden sollen, sind im Folgenden beschrieben.
1. Der Einsatz von Unterprogrammen ist kein Selbstzweck und führt nicht von alleine zu besser strukturierten Programmen. Vielmehr muss ihr Einsatz wohlüberlegt sein. Als Faustregel kann man angeben:
Der Einsatz von Unterprogrammen muss dazu führen, dass man Programme wie Pseudocode lesen kann.
2. Jedes Unterprogramm sollte eine klar umrissene Aufgabe erfüllen.
Die Aufgabe eines Unterprogrammes sollte als kurzer Kommentar im Quelltext vermerkt werden.
Ist es nicht möglich, eine Aufgabe des Unterprogrammes anzugeben oder lässt sich ihre Aufgabe nicht in einem Satz beschreiben, sollte das Unterprogramm aufgeteilt werden.
3. Für jedes Unterprogramm sollte man einen treffenden Namen angeben; nichtssagende Namen sollte man vermeiden (wie in der Mathematik, wo eine Funktion meist f(x) oder g(x,y) heißt). Ein treffender Name ersetzt oft einen Großteil der Erklärung im Kommentar.
4. Das Hauptprogramm soll möglichst wenig elementare Befehle und stattdessen Unterprogramm-Aufrufe besitzen; durch ihre treffenden Namen liest es sich wie Pseudocode.
5. Unterprogramme sollen stets so definiert werden, dass sie wiederverwendbar sind, und zwar
- entweder im aktuellen Programm mehrfach eingesetzt werden können
- oder in andere Programme übertragen werden können.
- Die Wiederverwendbarkeit kann oft dadurch erreicht werden, dass man den Abstraktionsgrad eines Unterprogrammes erhöht. Was damit gemeint ist, soll besser an einem Beispiel erklärt werden.
Beispiel: Öffnen einer Datei
Zum Öffnen einer Datei wird man eine Funktion openFile(string fileName)
anbieten, die als Eingabewert den Namen fileName der Datei (als string, also Zeichenkette) erhält.
Dies kann aber zu Komplikationen führen, da man mehrere Fälle unterscheiden muss:
- Die Datei befindet sich im aktuellen Verzeichnis (in dem gerade das Programm ausgeführt wird) und kann somit direkt über den Dateinamen angesprochen werden.
- Die Datei befindet sich in einem anderen Ordner und muss über den Pfad zu diesem Ordner und den Dateinamen angesprochen werden (auf die Komplikation, ob es sich um einen relativen oder einen absoluten Pfad handelt, soll hier nicht eingegangen werden).
Man könnte jetzt zwei Unterprogramme anbieten, die für beide Fälle zuständig sind:
// öffnet ein File in diesem Verzeichnis mit Namen fileName openFile(string fileName); // öffnet ein File mit Namen fileName, das sich in dem unter path angegebenen Ordner befindet openFile(string path, string fileName);
Die elementaren Befehle, die bei den beiden Unterprogrammen auszuführen sind, stimmen zum Großteil überein — das heißt der Quelltext wiederholt sich und genau das möchte man in der strukturierten Programmierung vermeiden.
Es gibt eine Lösung, die beide Unterprogramme anbietet, aber die Wiederholung des Quelltextes vermeidet:
// öffnet ein File in diesem Verzeichnis mit Namen fileName openFile(string fileName); // öffnet ein File mit Namen fileName, das sich in dem unter path angegebenen Ordner befindet openFile(string path, string fileName) { Falls (path == NULL) DANN openFile(fileName) SONST { ... } }
Die allgemeinenere Funktion openFile(string path, string fileName)
ruft die speziellere Funktion openFile(string fileName)
auf;
der Quelltext muss nicht wiederholt werden.
In der objekt-orientierten Programmierung kann man dann sogar dafür sorgen, dass die spezielle Funktion openFile(string fileName)
dem Programmierer verborgen bleibt (man nennt dies Geheimnisprinzip) und er zum Öffnen einer Datei im aktuellen Verzeichnis aufrufen muss:
openFile (NULL, "myFile.txt");
Unverständlich ist dabei noch NULL: dies ist (ähnlich wie void ein leerer Datentyp ist) ein Objekt ohne Inhalt, das universell anstelle des Wertes einer beliebigen Variable eingsetzt werden kann. Ein derartiges Objekt wird von vielen Programmiersprachen angeboten (und heißt jeweils anders).
Das folgende Beispiel soll einige der als Richtlinien der strukturierten Programmierung bezeichneten Punkte illustrieren; es werden mehrere Unterprogramme als Service-Funktionen angeboten und im Hauptprogramm main() aufgerufen. Obwohl keine Implementierungen angegeben werden, sollte das Beispiel verständlich sein.
Beispiel: File-Operationen
int main()
{
openFile("header.html");
string header = readFile("header.html");
closeFile("header.html");
openFile("footer.html");
string footer = readFile("footer.html");
closeFile("footer.html");
openFile("index.html");
writeFile("index.html", header);
string table = createHtmlTable(numbers, squares);
writeFile("index.html", table);
writeFile("index.html", footer);
closeFile("index.html");
return 0;
}
// öffnet ein File in diesem Verzeichnis mit Namen fileName
// falls es nicht existiert, wird es zuerst erzeugt
openFile(string fileName);
// schreibt den Inhalt des Files fileName in einen string; das File muss geöffnet sein
string readFile(string fileName);
// schließt das File mit Namen fileName
void closeFile(string fileName);
//schreibt den string content am Ende in das File mit Namen fileName
void writeFile(fileName, content);
// erzeugt den HTML-Quelltext für eine zweispaltige Tabelle und gibt ihn als string zurück
string createHtmlTable (List list1, List list2);
Wie man die Unterprogramme definiert, ist keineswegs durch obige Richtlinien eindeutig vorgegeben. Denkbar wäre etwa auch die drei Befehle aus Zeile 3 bis 5 zu einem Unterprogramm zusammenzufassen und readFile() wie folgt zu definieren:
// öffnet das File fileName, schreibt den Inhalt in einen string, schließt das File string readFile(string fileName);
Der Nachteil ist, dass dieses neue Unterprogramm readFile() mehrere Aufgaben erfüllt. Im Falle eines Fehlers ist dann zum Beispiel schwer festzustellen, wo der Fehler aufgetreten ist (beim Öffnen, beim Lesen oder beim Schließen des Files).
Objekt-orientierte Programmiersprachen
Aus den bisherigen Ausführungen sollte klar sein: Eine strukturierte Programmiersprache ist einer rein prozeduralen Programmiersprache, die Unterprogramme nicht nutzt, deutlich überlegen. Da Quelltext-Wiederholungen (auch bei kleinen Variationen) durch den Einsatz der Unterprogramme vermieden werden können, sind auch komplexere Projekte beherrschbar. Der Entwickler kann seine Anwendung entweder sehr grob beschreiben — nämlich auf der Ebene der Unterprogramme, die komplexere Einheiten darstellen. Oder er kann — unabhängig davon — einzelne Unterprogramme in die elementaren Befehle aufschlüsseln, also die Anwendung sehr fein beschreiben.
Aber auch die strukturierte Programmierung stößt an ihre Grenzen: Es ist schwer die Entwicklung einer Anwendung auf ein großes Team von Entwicklern aufzuteilen, da eigentlich jeder Entwickler den Überblick über das Gesamtsystem behalten muss. Anwendungen wie sie heute üblich sind, lassen sich so nur schwer bewältigen.
Die objekt-orientierte Programmierung nutzt alle Vorteile der strukturierten Programmierung, wählt aber einen völlig anderen Ansatz zur Entwicklung einer Anwendung. Die wichtigsten Eigenschaften dieses objekt-orientierten Paradigmas sollen hier vorgestellt werden; auf Beispiele mit Pseudocode wird verzichtet.
1. Bei der objekt-orientierten Programmierung konzentriert man sich zunächst auf Objekte und weniger auf Algorithmen (wie in der prozeduralen Programmierung). Das soll heißen, man versucht in der zu entwickelnden Anwendung Objekte zu identifizieren, durch deren Wechselwirkung (oder Kommunikation) die Anwendung ihre Tätigkeiten verrichtet, zum Beispiel:
- Buttons nehmen die Nutzer-Eingaben entgegen.
- Ein Controller sorgt dafür, dass die Eingabe an den richtigen Empfänger weitergeleitet wird und löst dort eine Aktion aus (Speichern der Eingabe oder Berechnung eines neuen Wertes).
- Neu berechnete Werte werden durch den Controller an die graphische Oberfläche (GUI = graphical user interface) geschickt und dort angezeigt, und so weiter.
2. Wenn von Objekten die Rede ist, muss zwischen Klassen und Objekten unterschieden werden:
- Eine Klasse ist das Allgemeine,
- ein Objekt ist eine spezielle Ausprägung einer Klasse.
Die Klasse Button beschreibt allgemein das Verhalten jedes Buttons (zum Beispiel, dass er eine Farbe und eine Beschriftung hat, dass er eine Aktion auslösen kann), in einer graphischen Oberfläche sind mehrere spezielle Buttons enthalten (mit anderen Eigenschaften wie Farbe, Beschriftung, ausgelöste Aktion und so weiter).
3. Eine Klasse beschreibt die Eigenschaften (Datenelemente) und die Tätigkeiten (Methoden oder Elementfunktionen), die das Objekt später besitzen kann.
- Die Datenelemente sind Variablen entweder mit elementaren Datentypen oder wiederum Klassen.
- Die Methoden sind (kleine) Algorithmen, die im Sinne der strukturierten Programmierung implementiert werden müssen.
4. So wie jede Methode eine klar umrissene Aufgabe erfüllen soll, soll man für jede Klasse eine Verantwortlichkeit festlegen. So ist etwa:
- Ein Button dafür verantwortlich, dass der Nutzer eine Eingabe machen kann und er muss durch seinen Zustand anzeigen, ob er geklickt werden kann.
- Der Controller (siehe oben) ist dafür verantwortlich, dass die Nutzer-Eingaben an den richtigen Empfänger weitergeleitet werden; deren Weiterverarbeitung liegt schon außerhalb seiner Verantwortlichkeit.
5. Gelingt es, für die Methoden klar umrissene Aufgaben zu formulieren, können sie schnell und fehlerfrei implementiert werden. Anders als bei der strukturierten Programmierung ist es nicht nötig, sehr große Algorithmen zu überblicken.
6. Durch Vererbung können die Datenelemente und Methoden einer Klasse an andere Klassen weitergegeben werden. Neu implementiert werden müssen dann nur zusätzliche Datenelemente und Methoden. Stehen geeignete Programm-Bibliotheken bereit, muss man nur die für die eigene Anwendung spezifischen Eigenschaften implementieren.
7. Nach welchen Mustern die Objekte miteinander wechselwirken oder kommunizieren, ist bei vielen Anwendungen ähnlich; im Lauf der Zeit haben sich sogenannte Design-Pattern herauskristallisiert, die wiederverwendet werden können (oder deren Quelltext-Grundgerüst von Entwicklungsumgebungen erzeugt wird). Bekanntestes Beispiel ist das Design-Pattern Model-View-Controller, das heute in jedem Programm mit graphischer Oberfläche implementiert ist.
8. Gelingt es zudem, mehrere Klassen vom Rest der Anwendung abzugrenzen und zu einem Modul zusammenzufassen, so kann ein derartiges Modul wiederverwendet werden. Bei der Neu-Entwicklung einer Anwendung wird man zuerst prüfen, welche bestehenden Module eingebaut werden können.
9. Diese Modularisierung erlaubt es, die Anwendungsentwicklung in Teamarbeit durchzuführen. Da jeder Entwickler nur das Modul zu überblicken hat, an dem er gerade arbeitet, und nicht die gesamte Anwendung.
10. Erleichtert wird dies auch durch die Datenkapselung (Geheimnisprinzip): jeder Programmierer sieht nur die für ihn relavanten Informationen (Klassen, Datenelemente, Methoden) als Schnittstellen und nicht irgendwelche Details der Implementierung.
11. Bei einem guten objekt-orientierter Entwurf kann eine Anwendung später leicht erweitert werden: die Schnittstellen definieren eindeutig, wo die Erweiterungen anzusetzen haben (bei der prozeduralen Programmierung muss man meist das gesamte Programm absuchen).
Viele dieser Punkte wirken jetzt noch abstrakt oder sogar nichtssagend; später wird an der Programmiersprache C++ gezeigt, wie eine Anwendung objekt-orientiert entwickelt wird. Die jetzt noch unverständlichen Eigenschaften und Design-Prinzipien einer objekt-orientierten Programmiersprache werden dann ausführlich erklärt.