functional programming in python

Werbung
Nichtprozedurale Programmierung
in Python und anderen Sprachen
Thomas Letschert
FH Giessen–Friedberg
Version 1.0 vom 25. August 2005
Inhaltsverzeichnis
1
Funktionale Programme
1
1.1
Prozedurale und Nicht–Prozedurale Programmierung . . . . . . . . . . . . . . . . . . . . . . . .
5
1.1.1
Prozedurale Programmierung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
5
1.1.2
Deklarative Programmierung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
7
1.1.3
Typen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
11
1.1.4
Compilierte und Interpretierte Sprachen . . . . . . . . . . . . . . . . . . . . . . . . . . .
12
Ausdrücke und ihre Auswertung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
16
1.2.1
Definitionen und Ausdrücke . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
16
1.2.2
Statische und Dynamische Bindung . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
18
1.2.3
Statische und Dynamische Typisierung . . . . . . . . . . . . . . . . . . . . . . . . . . .
22
1.2.4
Kopiersemantik . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
26
1.2.5
Funktionen und ihre Definition . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
27
1.2.6
Auswertung von Funktionen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
31
1.2.7
Weitere Notationen und Eigenschaften . . . . . . . . . . . . . . . . . . . . . . . . . . . .
35
Funktionales Programmieren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
37
1.3.1
Algorithmenschemata . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
37
1.3.2
Numerisches . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
39
1.3.3
Listen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
40
1.3.4
Daten und Datenstrukturen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
45
1.3.5
Datenabstraktion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
49
1.3.6
Verwaltung von Zustandsvariablen im funktionalen Stil . . . . . . . . . . . . . . . . . . .
51
Funktionen in OO–Sprachen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
55
1.4.1
Funktionen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
55
1.4.2
Funktionen als Parameter . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
56
1.4.3
Funktionale Objekte . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
57
1.4.4
Dynamisch erzeugte Funktionen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
60
1.4.5
Konstruktion Funktionaler Objekte . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
63
1.4.6
Closures, Funktionale Objekte und Bindungen . . . . . . . . . . . . . . . . . . . . . . .
67
Rekursion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
71
1.5.1
Rekursion, Induktion, Iteration . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
71
1.5.2
Rekursive Daten und rekursive Funktionen . . . . . . . . . . . . . . . . . . . . . . . . .
73
1.2
1.3
1.4
1.5
2
Th Letschert, FH Giessen–Friedberg
1.6
1.7
2
1.5.3
Klassifikation der Rekursion . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
74
1.5.4
Fortsetzungsfunktionen und die systematische Sequentialisierung . . . . . . . . . . . . .
81
1.5.5
Entwicklung Rekursiver Programme . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
86
Iteratoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
91
1.6.1
Iteratoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
91
1.6.2
Iteratoren in Python . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
94
1.6.3
Bäume und Baum–Besucher . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
95
1.6.4
Bäume und Baum–Iteratoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
98
Generatoren und Datenströme . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 102
1.7.1
Generatoren . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 102
1.7.2
Generator–Beispiele . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 105
1.7.3
Implementierung von Generatoren durch Threads . . . . . . . . . . . . . . . . . . . . . . 107
1.7.4
Datenströme . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 114
Relationale Programme
2.1
2.2
2.3
2.4
3
118
Sprachen mit Mustererkennung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 119
2.1.1
Reguläre Ausdrücke: Reguläre Textstrukturen erkennen . . . . . . . . . . . . . . . . . . . 119
2.1.2
XML . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 124
Suche, Nichtderminismus, Relationen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 131
2.2.1
Mustererkennung als Suchproblem . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 131
2.2.2
Nichtdeterministische Programme . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 139
2.2.3
Relationale Programme . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 142
Logik–Programme . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 144
2.3.1
Prolog . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 144
2.3.2
Unifikation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 150
2.3.3
Prolog–Interpreter . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 155
Constraint Programmierung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 159
2.4.1
Programmieren mit Beschränkungen . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 159
2.4.2
Lösungssuche . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 159
Kapitel 1
Funktionale Programme
1
2
Nichtprozedurale Programmierung
“Sie sind doch geschickt darin, Wörter zu erklären, Herr Goggelmoggel”,
sagte Alice. “Können Sie mir freundlicherweise sagen,
was das Gedicht Der Zipferlake bedeutet?”
“Nur heraus damit”, sagte Goggelmoggel.
“Ich kann alle Gedichte erklären, die jemals gedacht worden sind –
und noch eine ganze Menge, bei denen das Erdenken erst noch kommt.”
Lewis Carroll, Alice hinter den Spiegeln
Vorbemerkung
Die Entwicklung der Programmiersprachen wurde in der Vergangenheit stets aus zwei Richtungen beeinflusst
und vorangetrieben. Zum einen von der Seite der Hardware, zum anderen von der Seite der Theorie mit ihrem
Nachdenken darüber, was denn ein Programm eigentlich ist und was es ausdrücken soll. In den frühen Jahren der
Informatik war der Einfluss von Seiten der Hardware absolut dominant. Die ersten Programmiersprachen waren
nichts anderes, als das Instruktionsrepertoir bestimmter Maschinen und sie sollten nichts anderes zum Ausdruck
bringen können, als deren elementare Operationen; und die waren und sind prinzipiell recht einfach. Die Maschinen hatten bestimmte prinzipielle Charakteristika, an denen sich im Laufe der Jahre wenig änderte: im Regelfall
hat man es mit Hardware zu tun, die heute, so wie vor 50 Jahren, der sogenannten “Von–Neumann–Architektur”
entspricht, und deren Funktionsweise sich über viele Abstraktionsstufen in den Programmiersprachen und Programmen wiederfindet.
Vektorrechner, Parallelrechner, verteilte Systeme und Ähnliches an Hardwarestrukturen drängt allerdings zunehmend nach vorn und es ist zumindest zweifelhaft, ob der von–Neumann–Rechner, als Grundmuster eines Rechners
und der Programmierung, für alle Zeiten ohne ernsthafte Alternativen bleiben sollte. Schon heute folgt also die
Hardware gar nicht mehr so recht dem klassischen Vorbild und es kommt vor, dass die Programmierer den Parallelismus einer Problemstellung beim Verfassen eines sequenziellen Programms mühsam eliminieren und dieses
Programm dann aufwändig vom Compiler in ein Maschinenprogramm übersetzt wird, das den Parallelismus der
Hardware auszunutzen versucht. Da wäre es vielleicht doch angemessen die parallele Problemlösung direkt auf
die parallele Hardware zu bringen.
Ein Rechner der klassischen Bauart besteht, sehr abstrakt gesehen, aus einer Menge von Speicherplätzen, deren Inhalt durch Befehle modifiziert, hin– und herkopiert sowie von externen Quellen eingelesen und dorthin ausgegeben
werden kann. Die Modifikation ist dabei bei einer “Von–Neumann–Architektur” auf Register beschränkt, also auf
sehr wenige schnelle Speicherstellen innerhalb der CPU. Sie werden von einem Maschinenprogramm zyklisch mit
Werten aus den anderen Speicherstellen im Hauptspeicher “geladen”, ihr Inhalt wird verarbeitet und das Ergebnis
wieder gespeichert.
Während die Techniker mit Programmen als Sequenzen von Lade–, Verarbeite– und Speicherbefehlen zufrieden
waren, wollten die Anwender sich von der Maschine lösen und Notationen nutzen, die der Problemwelt angemessener sind, d.h. sie wollten in “höheren Sprachen” programmieren. Das älteste Hilfsmittel der höheren Programmierung ist die Prozedur. Bereits in den 40–er Jahren entwickelten Computerpioniere wie Turing und Zuse
unabhängig von einander Programmiertechniken, bei denen größre Aufgaben mit Hilfe von speziellen oder standardisierten Unterroutinen gelöst wurden. Eine Unterroutine, eine Prozedur, war dabei nichts anderes, als eine
Sequenz von Maschinenanweisungen. Auch wenn die Programmierung mit Hilfe von Prozeduren erheblich vereinfacht wurde, so blieben die Programme doch weiterhin bis weit in die 50–er (jetzt strukturierte) Sequenzen
von Maschinen–Anweisungen. Es waren und blieben rein prozedurale Programme: Programme deren Inhalt die
Beschreibung einer Folge von Speicherplatzmanipulationen darstellt.
Nach und neben den Prozeduren wünschten sich die Autoren der frühen Programme, dass sie mathematische
Berechnungen in der gewohnten Art durch Formeln ausdrücken können. FORTRAN FORmula TRANslator bot,
als erste erfolgreiche Programmiersprache, die Möglichkeit arithmetische Ausdrücke in mathematischer Notation zu verwenden. Mit diesen “Formeln” enthielten FORTRAN–Programme Elemente die definitiv nicht von der
Hardware und ihren Konzepten hergeleitet waren, sondern aus dem Problembereich kamen. Die erste “höhere”
Programmiersprache war geboren und sie enthielt mit den arithmetischen Ausdrücken, den “Formeln”, ein nicht-
Th Letschert, FH Giessen–Friedberg
3
prozedurales Element.
Gleichzeitig mit der Entwicklung von FORTRAN, also noch in den 50–ern, konzipierte der Visionär McCarthy
eine Programmiersprache namens LISP (LISt Processor) die den Weg, weg von der Maschine, hin zu einer problemorientierten Formulierung von Programmen, weit radikaler ging. LISP–Programmierer wollten sich nicht nur
dem damals üblichen Geschäft des Lösens schnöder Rechenaufgaben widmen. Ihr Anspruch war weit umfassender: “Wissen” sollte dargestellt und verarbeitet werden, die Maschinen sollten “intelligent” werden. Die “wissenden” und “denkenden” Programme waren zunächst nichts anderes als Programme, die sich nicht wie damals auf
die Manipulation numerischer Werte beschränkten, sondern allgemeine Datenstrukturen mit alphanumersichen
Werten verarbeiteten. Die grundlegende Datenstruktur in LISP war und ist immer noch der, “Liste” genannte,
Binärbaum. Eine gleichzeitig mächtige wie allgemeine und einfache Datenstruktur. Aus heutiger Sicht ist die Idee,
mit Datenstrukturen und Zeichenketten zu arbeiten, eher trivial und die Verknüpfung dieser Idee mit Intelligenz
und Wissen klingt nach Hybris und Größenwahn. Man sollte jedoch bedenken, dass die Idee einer Datenstruktur damals völlig neu war und erst die Perspektive eröffnete, Computer zu anderem als zum Rechnen oder dem
Verwalten von Inventarlisten auf Magnetbändern zu verwenden.
Neben der Innovation der Liste, als Datenstruktur ohne direke Entsprechung in der Hardware, demonstrierte McCarthy, dass sich auch die Kontrollstruktur der Programme von den Vorgaben der Maschine lösen kann. In Lisp
geben die von der Mathematik inspirierten Funktionen den Takt der Programme an. Variablen, Anweisungen,
Schleifen, Sprünge sind bestenfalls noch als unterprivilegierte Kellerkinder geduldet. Wegen dieser Bedeutung der
Funktionen werden LISP und seine Verwandten funktional genannt. Sie waren von Anfang an in engem Kontakt
zur künstlichen Intelligenz und blieben es über die Jahre.
In der Folge kam es dann zu einem permanenten Wettstreit zwischen den Verfechtern des prozeduralen Stils
mit ihrem von “der Maschine” abgeleiteten Konzept eines Programms und den Verfechtern alternativer, nicht–
prozeduraler Programmierstile, die dafür plädieren, dass Programme in einer “natürlichen” und dem “Problem
angemessenen” Form formuliert werden sollten. Mit den intellektuellen Großwetterlagen schwankte der Zeitgeist
zwischen den Lagern. Als in den 80–ern die damals dominierende Technologie–Macht Japan die Entwicklung von
Computern der “fünften Generation” ankündigte, die ungeahnte Dinge mit Konzepten der künstlichen Intelligenz
bewerkstelligen sollten, hatte auch die funktionale Programmierung ein Jahrhundert–Hoch. Künstliche Intelligenz
und funktionale Sprachen entstammen schließlich dem gleichen intellektuellen Umfeld. Die Vorstellung geheimnisvoller asiatischer Supercomputer und Roboter ließ amerikanische und europäische Politiker Unmengen von
Fördergeldern in die aride1 Landschaft der KI-Forscher streuen.
Eine etwas bescheidenere Blüte hatten die funktionalen Sprachen kurz vorher, Ende der 70–er. Es ging dabei nicht,
wie bei der KI, um grandios–schaurige Visionen, sondern um tröge Programm–Korrektheit. Nach einer, damals
schon seit Jahren anhaltenden “Software–Krise”, mit Kuren wie Programmverifikation, systematischem Programmieren, abstrakten Datentypen, Spezifikationssprachen, etc. wurden immer noch schlechte und fehlerhafte Programme geschrieben. Nun, so meinten viele, war es Zeit für eine Radikalkur: Die alten schlechten Programmiermethoden können nur dann mit Stumpf und Stiel ausgerissen werden, wenn die Quelle allen Übels, der prozedurale
Stil, eliminiert wird. Programme haben dann die Reinheit und Klarheit mathematischer Formeln. Von diesen weiß
jeder, der niemals ernsthaft Mathematik betrieben hat, dass sie von allen kompetenten Wissenschaftlern schnell
und sicher in richtig und falsch geschieden werden können. Wenn Programme wie mathematische Funktionen
geschrieben werden, dann kann man endlich jeden Fehler mehr oder weniger sofort entdecken. In diesem Argument steckt ein wahrer Kern, aber zu glauben, dass die Abschaffung der Zuweisung letztlich alle Probleme des
Software–Engineering löst, ist schlicht lächerlich – wenn auch nicht wesentlich lächerlicher als der Gedanke, dass
die allgemeine Verwendung von UML die ultimative Lösung aller Probleme darstellt.
Mit den Kursen japanischer Aktien schmolz die Begeisterung für KI und damit für funktionale Programmierung
dann dahin. Im trockenen Wind von Prädikatenlogik, Lambda–Kalkül und Gödelschen Beweistechniken verdorrten die Förderoasen der KI nach dem Ausbleiben der Subventionswässer. Die Software-Techniker fanden mit der
Objekt–Orientierung auch schnell ein Betätigungsfeld mit höherem Sexappeal als Korrektheitsargumente bieten
können. Die funktionale Sicht der Welt, mit Programmen als Folgen von Eingabe, Verarbeitung und Ausgabe, war
auch jetzt einfach von altmodischer Beschränktheit im Vergleich zu den moderneren Konzepten der Verteiltheit
oder der reaktiven Programmierung. In der Softwaretechnik wurden die mit dem funktionalen Stil verbundenen und
kurz zuvor noch hochgelobten “strukturierten Techniken” von objektorientierter Analyse und Entwurf abgelöst und
1
arid = “trocken”, aus dem Lateinischen, botanische Bezeichnung für den Charakter von Wüsten und Halbwüsten.
4
Nichtprozedurale Programmierung
zu Weisheiten graubärtiger alter Männer. Der für die Informatik so typischen maßlosen Überschätzung von damals
stand plötzlich und bis heute andauernd eine ebenso typische übertriebene Ignoranz gegenüber. Alle “abweichenden” Konzepte werden von der aktuellen Inkarnation des prozeduralen Stils, der prozeduralen Programmierung,
der Objektorientierung, plattgewalzt. Konzepte, die zwar nicht die ultimative Lösung aller Probleme bringen, aber
einen wichtigen Bestandteil des Wissens– und Erfahrungsschatzes der Informatik darstellen, werden weitgehend
ignoriert.
FORTRAN, die erste erfolgreiche höhere Programmiersprache umfasste interessanterweise gleichzeitig funktionale und prozedurale Ausdrucksstile. Es gab Ausdrücke, die uns heute so selbstverständlich als Programmelemente
sind, aber damals einen revolutionären Bruch im Programmierstil darstellten. Ebenso gab es die damals gewohnten
Anweisungen: Zuweisungen, Sprünge und bedingten Anweisungen. Lisp und seine Verwandten und Nachfolger
mit rein funktionalem Charakter führen und führten schon immer ein Randdasein. Umgekehrt ist die Vorstellung,
Anwendungen in rein prozeduralen Sprachen zu schreiben, also beispielsweise ohne Ausdrücke, völlig absurd. Radikale Positionen sind im Streit der Programmierstile so unsinnig wie auch sonst im Leben. Die Objektorientierung
ist heute zweifellos das dominierende Konzept. Funktionale Programmiertechniken sind keine Konkurrenz zur Objektorientierung. Die Konzepte sind orthogonal.2 Beide Stile liefern komplementäre Denkmuster von bleibendem
Wert.
Der Autor dankt allen kritischen Lesern des Skripts und Teilnehmern der Veranstaltung, für die von
ihnen aufgedeckten Unrichtigkeiten, Nachlässigkeiten und Missverständlichkeiten und, noch mehr,
für ihren nüchtern skeptischen Enthusisasmus mit dem sie zu Inhalt und Qualität der Veranstaltung
beigetragen haben.
2 “Orthogonal” bedeutet senkrecht zueinander. Im übertragenen Sinn sind orthogonale Konzepte solche, die sich nicht gegenseitig in die
Quere kommen.
Th Letschert, FH Giessen–Friedberg
1.1
5
Prozedurale und Nicht–Prozedurale Programmierung
Computer science is no more about computers
than astronomy is about telescopes.
E.W. Dijkstra
1.1.1
Prozedurale Programmierung
Variablen und Werte
Die Welt besteht aus Dingen, die sich im Laufe der Zeit verändern können. Auf dieser Grundüberzeugung beruht
die prozedurale Programmierung. In einem einfachen prozeduralen Programm sind die veränderlichen Dinge die
Variablen. Sie werden von Anweisungen verändert. Wer eine prozedurale Sprache erlernt, muss als erstes dieses
Konzept der Variablen und Anweisungen verstehen. Die meisten von uns haben nach Jahren der Programmiererfahrung vergessen, dass dies mit einer gewissen Anstrengung verbunden war. Je erfolgreicher der Mathematikunterricht war, umso fester hat sich im Bewußtsein des Programmierlehrlings festgesetzt, dass eine Variable einen
Wert bezeichnet. Im Programmierunterricht muss dann wieder umgelernt werden: Das mathematische Konzept der
Variablen muss ersetzt werden durch Variablen, deren Wert vom Zustand des Programms abhängt.
2.0
a
mathematische Variable
2.0
a
Variable in einem prozeduralen Programm
Abbildung 1.1: Variablen mit ein- und zweistufiger Wertzuordnung
Dies wird meist mit dem “Behälterkonzept” der Variablen erläutert (siehe Abbildung 1.1).
• In der Mathematik sind Variablen Namen für Werte.
• In (prozeduralen) Programmen sind Variablen die Namen von Behältern (Speicherplätzen) die mit Werten
belegt sind.
Damit wird die Beziehung von Namen zu Werten zweistufig. Ein Name bezeichnet einen Speicherplatz und dieser
ist mit einem Wert belegt (siehe Abbildung 1.2). Die Beziehung von Namen zu Speicherplätzen ist relativ fest. Die
von Speicherplätzen zu Werten ändern sich häufig und schnell.
2.0
a
Name
Wert
mathematische Variable
2.0
a
Name
Behälter
Wert
Variable in einem prozeduralen Programm
Abbildung 1.2: Ein– und zweistufige Zuordnung von Werten
6
Nichtprozedurale Programmierung
Deklarationen ändern die Bedeutung von Namen. Anweisungen ändern die Belegung von Speicherplätzen. Die
aktuelle Belegung der Speicherplätze nennt man oft (Programm–) Zustand. Anweisungen transformieren damit
den Zustand und ein Programm ist eine Beschreibung von möglichen Zustandsänderungen (siehe Abbildung 1.3).
a −> 1
b −> 3
if (a>0)a=a+b; b=a/2;
a −> 4
b −> 2
prozedurales Programm
Zustand
Zustand
Abbildung 1.3: Das Grundkonzept eines prozeduralen Programms
Prozeduren
Komplexe Zustandsänderungen, also komplexe Programme, werden als Sequenzen elementarer Zustandsänderungen, den Anweisungen, aufgebaut. Dabei können beliebige Teilsequenzen zu Prozeduren zusammengefasst werden, die dann selbst wieder als Bestandteile komplexerer Prozeduren dienen können. Dieses Prinzip der prozeduralen Abstraktion bildet die Basis aller klassischen prozeduralen Sprachen. Ein Programm beschreibt das Verhalten
einer Maschine als Übergang von einem Ausgangs– zu einem Endzustand. Ein Programm gliedert sich dabei in
Unterprogramme (Prozeduren), Unter–Unterprogramme und so weiter. Das Gesamtprogramm kann man dann als
hypothetische Maschine verstehen, deren Ausgangszustand sich abhängig von der Eingabe in einen Endzustand
transformiert (siehe Abbildung 1.4). Das Gesamtprogramm entspricht einer hypothetischen Maschine, deren Verhalten von der realen Maschine auf Basis ihrer Beschreibung – des Programms – simuliert wird.
a −> 0
b −> 0
cin >> a >> b;
if (a>0)a=a+b; b=a/2;
cout >> b;
Programm im Ausgangszustand
Programmlauf mit Eingabe 1 und 3
a −> 4
b −> 2
cin >> a >> b;
if (a>0)a=a+b; b=a/2;
cout >> b;
Programm im Endzustand
Abbildung 1.4: Prozedurale Programme als hypothetische Maschinen
Ein prozedurales Programm definiert ein Objekt
Das Gesamtprogramm definiert damit ein Objekt: Etwas mit einem Zustand und einem Verhalten. Der Zustand
ist die aktuelle Variablenbelegung. Das Verhalten ist die Veränderung des Zustands bei Eingabe eines Wertes.
Das Objekt “Gesamtprogramm” ist in der klassischen prozeduralen Programmierung aus Prozeduren aufgebaut.
Prozeduren sind als “Verhaltensmuster” zu verstehen.
Die prozedurale Programmierung hat den großen Vorteil, sehr nahe an der realen Maschine zu sein. Jeder Rechner
ist ein physikalisches Objekt, das sich bei Programmstart in einem bestimmten Zustand befindet, der sich im Laufe
der “Rechenarbeit” verändert. Das Konzept einer Maschine, die sich in wechselnden Zuständen befindet, ist sehr
intuitiv. Der Mensch bewegt sich seit Jahrtausenden in einem Umfeld von Objekten mit Zustand und Verhalten.
Th Letschert, FH Giessen–Friedberg
7
Objektorientierte Programme: Objekte mit Unterobjekten
In der klassischen Programmierung ist nur das Gesamtprogramm eine Maschinenbeschreibung, also ein Objekt.
Seine Komponenten, die Prozeduren, sind dagegen Verhaltensbeschreibungen. In der objektorientierten Programmierung wird die Intuition der Maschine noch weiter getrieben. Nicht nur das Gesamtprogramm ist jetzt eine
Maschine, auch die Teile sind Maschinen. Das Programm ist ein Objekt: eine “Maschine” mit einem bestimmten
Verhalten – die sich selbst wieder aus Objekten, den “Untermaschinen” zusammensetzt. Die Objektorientierung
ist damit der ultimative Höhepunkt der prozeduralen Programmierung: Programme sind Objekte, Dinge mit einem
Zustand und einem Verhalten das sich beim Einfluss äußerer Einwirkungen als Änderung dieses Verhaltens zeigt.
Das Programm und all seine Objekte können selbst wieder aus Objekten zusammengesetzt sein.
1.1.2
Deklarative Programmierung
Funktionale Programmierung
Die prozedurale Programmierung hat das Konzept der Maschine verwendet und, in sukzessiver Ablösung und Entfernung von der realen Grundlage der Hardware, weiterentwickelt zum Paradigma eines Programms und seiner
Komponenten als hypothetischen Maschinen (Objekten). Die Entwicklung der nicht–prozeduralen Sprachen hat
dagegen gleich in den luftigen Höhen der Abstraktion mit der Überzeugung begonnen, dass eine höhere Programmiersprache vollständig frei sein sollte von allen Bezügen zu einem realen Rechner und seinem Maschinencode.
Es war naheliegend die gesuchte universelle “maschinen–ferne” Notation bei den reichhaltigen und teilweise jahrhundertelang erprobten Ausdrucksmöglichkeiten der Mathematik zu suchen. Als erstes wurden arithmetische Formeln wie etwa 2 + a ∗ (b − c) als elegante, übersichtliche und bewährte Art entdeckt, Rechenvorschriften zu
formulieren. Das Problem, derartige Formeln in elementare Maschinenbefehle zu übersetzen, war bereits Anfang
der 50–er gelöst und bald erschienen die ersten höheren Programmiersprachen, in denen arithmetische Ausdrücke
in Zuweisungen verwendet werden konnten. Sprachen deren Programme also den direkten Bezug zum Maschinencode verloren hatten und nur nach einer komplexeren Übersetzung oder durch einen Interpreter ausführbar waren.
Prominentester Vertreter dieser Sprachen ist FORTRAN mit den ersten Implementierungen ab Mitte der 50–er.
Arithmetische Ausdrücke lassen sich nahtlos in prozedurale Programme einbetten. Sie wurden darum anstandslos akzeptiert. Funktionen als primäres Ausdrucksmittel einer Programmiersprache taten sich etwas schwerer.
Das praktisch gleichzeitig mit FORTRAN konzipierte LISP hatte Funktionen und Ausdrücke als einzige Ausdrucksmittel und Paare (Binärbäume) als einzige Datenstruktur.3 Es ist erstaunlich, dass dieses minimalistische
Sprachkonzept ausreicht, komplexeste Programme zu schreiben. Weniger erstaunlich ist, dass sich nicht jeder für
diesen Minimalismus begeistern kann. Die Abwesenheit von Schleifen beispielsweise erzwingt den Einsatz von
Rekursion als Ersatz in einem Ausmaß, das die Produktivität nicht jedes Programmierers steigert.
Ein Beispiel für ein funktionales Programm in Lisp ist:
(define (quadrat x) (* x x))
-- Def. Funktion quadrat
(define (quadratsumme x y) (+ (quadrat x) (quadrat y))) -- Def. Funktion quadratsumme
(quadratsumme 3 4)
-- Funktionsaufruf
Dieses Programm liefert den Wert 25 (25 = 3 ∗ 3 + 4 ∗ 4 = quadrat(3) + quadrat(4) = quadratsumme(3, 4))
Zuerst wird die Funktion quadrat mit Parameter x und quadratsumme mit den Parametern x und y definiert,
dann wird die zweite Funktion auf 3 und 4 angewandt. Lisp verwendet eine irritierende Fülle an Klammern und
eine nicht wesentlich weniger irritierende Prefix–Notation bei Operatoren und Funktionsaufrufen. So hätte man
statt
(+ (quadrat x) (quadrat y))
(Prefix–Notation)
in anderen Sprachen sicher
quadrat(x) + quadrat(y)
(übliche Aufruf– und Infix–Notation)
geschrieben. Diese Extravaganzen sind nicht essentiell für das funktionale Programmieren. Sie haben vielmehr mit
3
LISP war allerdings die erste Sprache die überhaupt eine Datenstruktur zu bieten hat.
8
Nichtprozedurale Programmierung
der Philosophie von Lisp zu tun, alle Daten, auch die Programme selbst, als Listen darstellen zu wollen, sowie der
Priorität der Einfachheit der Analyse gegenüber der Lesbarkeit.
Sieht man von Dogmatikern der Objektorientierung ab, dann ist das Konzept der (freien) Funktionen allgemein
bekannt und wird oft eingesetzt. Funktionale Sprachen werden darum allgemein nicht als etwas angesehen, das
neue Ausdrucksmöglichkeiten bietet, sondern als Beschränkung, als etwas Mangelhaftes, dem es an bekannten und
guten Ausdrucksmitteln fehlt. Variablen sind wie in der Mathematik Bezeichner für Werte und nicht wie üblich
“Behälter” für Werte. Als Konsequenz gibt es keine Zuweisung. Der Begriff eines sich ändernden Zustands des
Gesamtprogramms oder seiner Teil–Objekte ist den mathematisch orientierten funktionalen Sprachen fremd. Das
schlägt sich nieder als Fehlen von Anweisungssequenzen, speziell als Fehlen von Schleifen. Man kann versuchen
diesen Mangel als Vorteil zu verkaufen. Das Fehlen des Zustandskonzepts macht die Analyse von Programmen
einfacher und erhöht so die Chance korrekte Programme zu schreiben. Wirklich überzeugend ist dieses Argument
aber nicht.
Relationale Programmierung: Programmieren mit Prädikation und Relationen
Die Definition und Verwendung von Funktionen als Mittel zur Formulierung von Algorithmen ist intuitiv einsichtig, wenn auch etwas gewöhnungsbedürftig. Vor allem, wenn sie das einzige Ausdrucksmittel ist und dazu noch,
wie in Lisp, alles konsequent vollständig geklammert und in Prefix–Notation zu verfassen ist. Bei der logischen
Programmierung wird die Sache etwas exotischer. Statt Funktionen werden Ausdrücke der Prädikatenlogik aus
der Mathematik übernommen und als algorithmische Notation verwendet. Nehmen wir als Beispiel den Satz des
Pythagoras:
a2 + b2 = c2
hier wird eine Beziehung (Relation) zwischen a, b und c definiert. Diese Beziehung kann als Prädikat pythagoras
aufgefasst werden:
pythagoras(a, b, c) gdw. a2 + b2 = c2
So, wie eine Funktion in einer funktionalen Sprache (aber nicht nur dort) benutzt werden kann, um einen Wert zu
berechnen, z.B. den Wert in Lisp:
(quadratsumme 3 4) -> 25
so kann ein Prädikat dazu benutzt werden, um den Wahrheitsgehalt einer Aussage zu testen, z.B. in einer logischen
Sprache wie PROLOG (PROgramming in LOGic):
phytagoras(3,4,5) -> true
Das Prädikat wird hierbei wie eine Funktion mit Booleschem Ergebnis verwendet. Der Aufruf wird etwas interessanter, wenn eines der Argumente eine Variable ist. Das System sucht dann eine Belegung der Variablen, für die
die Aussage wahr wird. Z.B:
phytagoras(3,4,c) -> c = 5
aber auch:
phytagoras(3,b,5) -> b = 4
und sogar
phytagoras(a,b,5) -> a = 3, b = 4
Bei logischen Programmen sucht das System also nicht, wie bei einer Funktion, ausgehend von den Argumenten “vorwärts” nach dem Wert, sondern versucht “rückwärts” vom Ergebnis Variablenbelegungen zu finden, für
die die Aussage wahr ist. Die logische Programmierung ist somit in gewisser Weise eine Verallgemeinerung der
funktionalen Programmierung.
Bei der funktionalen und der logischen Programmierung werden Algorithmen nicht direkt angegeben. Man deklariert statt dessen gewisse Sachverhalte, eine Funktion oder ein Prädikat. Logische und funktionale Sprachen
nennt man darum auch deklarative Sprachen. Sie gelten oft als “höherwertig”, weil in ihnen nicht das Wie einer
Berechnung, sondern das Was angegeben wird.4 Im Gegensatz dazu werden bei der prozeduralen Programmierung
4
Die Grenzen zwischen Was und Wie sind letztlich natürlich fließend.
Th Letschert, FH Giessen–Friedberg
9
Algorithmen in Form zustandverändernder Anweisungen angegeben. Prozedurale Sprachen heißen darum auch
imperativ (= befehlend).
Programmiersprachen
prozedurale Sprachen
(imperative Sprachen)
nichtprozedurale Sprachen
(deklarative Sprachen)
. . . andere deklarative Stile . . .
klassisch prozedural
objektorientiert
funktional
logisch
z.B. C
z.B. C++
z.B. Lisp
z.B. Prolog
Abbildung 1.5: Klassifikation der Programmiersprachen
SQL: Programme beschreiben Relationen
Eine deklarative Programmiersprache, die in der Bedeutung sowohl Lisp als auch Prolog bei weitem übertrifft ist
SQL. In SQL werden Relationen durch Selektion (WHERE), Projektion (SELECT), Vereinigung (JOIN) aus anderen Relationen gebildet. Die Konstruktion von Relationen (Tabellen) wird dabei nicht algorithmisch in Schleifen
und Zuweisungen ausprogrammiert, sondern in Form von SQL–“Anweisungen” deklariert. Mit einem Ausdruck
wie etwa
SELECT StudentId, Note
FROM KlausurErgebnisse
WHERE Kurs="NPP"
wird eine Relation auf eine andere abgebildet. Man deklariert das gewünschte Ergebnis ohne die zu dessen Berechnung notwendige Schleife über komplexen Datenstrukturen explizit (prozedural) anzugeben. Das Konzept der
relationalen Datenbanken und SQL sind sicherlich bekannt. Wir gehen darum nicht weiter darauf ein.
SQL basiert auf der Theorie relationaler Datenbanken. Beides, SQL und die relationalen Datenbanken, sind innerhalb der Informatik mit die wichtigsten Belege dafür, dass nichts so praktisch ist wie eine gute Theorie. Im
praktischen und alltäglichen Umgang mit Datenbank–Tabellen vergisst man es leicht, aber relationale Datenbanken
basieren auf dem klaren, mathematisch orientierten Konzept der Relationen–Algebra, das vor der Implementierung
und dem praktischen Einsatz entwickelt wurde.5
XSLT: Programme definieren Baum-Muster und ihre Transformationen
Neben Lisp, Prolog und SQL wurden und werden weiterhin eine Vielzahl deklarativer Sprachen definiert. In letzter
Zeit machen speziell XML–Technologien von sich reden. Ein XML–Dokument wie etwa folgendes
<?xml version="1.0" encoding="UTF-8"?>
<gedicht>
<autor>
<name>
Busch
</name>
<vorname> Wilhelm </vorname>
</autor>
<titel> Langeweile </titel>
<strophe>
<zeile> Guten Tag Frau Eule!
</zeile>
<zeile> Habt Ihr Langeweile? - </zeile>
5 Eine Vorgehensweise, die trotz (wegen ?) der inflationären Vermehrung wissenschaftlich ausgebildeter Informatiker leider vollkommen
aus der Mode gekommen ist.
10
Nichtprozedurale Programmierung
<zeile> Ja eben jetzt,
<zeile> solang Ihr schwaetzt.
</strophe>
</gedicht>
</zeile>
</zeile>
stellt einen Baum dar (siehe Abbildung 1.6).
Gedicht
autor
titel
Name
vorname
Text
Text
Text
strophe
zeile
zeile
zeile
zeile
Text
Text
Text
Text
Abbildung 1.6: XML Dokument als Baum
Im Fall von SQL haben wir es mit Ausdrücken zu tun, die Relationen verarbeiten und zu neuen Relationen verknüpfen. XML–Dokumente sind hierarchisch strukturierte Informationen – kurz Bäume.6 Die Verarbeitung von
Bäumen, Baumtransformationen also, kann man ebenso wie die von Relationen deklarativ beschreiben. Ein Programm in der deklarativen Sprache XSLT, das eine XHTML–Version des Gedichts von oben erzeugt ist:
<?xml version="1.0" encoding="UTF-8"?>
<xsl:stylesheet
version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<xsl::output method="html">
<xsl:template match="/">
<html>
<head><title>Gedicht</title></head>
<body>
<xsl:apply-templates select="gedicht"/>
</body>
</html>
</xsl:template>
<xsl:template match="gedicht">
<p>
<h1><xsl:apply-templates select="titel"/></h1>
<xsl:apply-templates select="strophe"/>
</p>
</xsl:template>
.... etc ....
<xsl:template match="zeile">
<li><xsl:value-of select="."/></li>
</xsl:template>
</xsl:stylesheet
6 In der irrealen Welt außerhalb der Informatik versteht man unter “Baum” nicht etwa hierarchisch strukturierte Information, sondern ein
unklar definiertes Konzept der Botanik: eine Unterklasse der Klasse Flora, deren Exemplare aus unerfindlichen Gründen stets auf dem Kopf
stehen. Dies ist für uns Informatiker jedoch ohne jede Bedeutung und ohne jeden Bezug zu richtigen Bäumen.
Th Letschert, FH Giessen–Friedberg
11
Ohne zu sehr ins Detail gehen zu wollen, sehen wir hier eine Folge von Baum-Mustern. Das letzte Muster ist
<xsl:template match="zeile">
<li><xsl:value-of select="."/></li>
</xsl:template>
Es passt zu allen Zeilen–Knoten und definiert eine Aktion auf diesen Knoten, hier die Ausgabe von
<li>Inhalt des Zeilen–Knotens</li>
Das XSLT–Programm insgesamt beschreibt ein Durchwandern des XML–Baums mit einer Aktivierung dieser
Muster. Es ist an dieser Stelle nicht notwendig diesen Mechanismus im Detail zu verstehen. Man sieht aber, dass
ein XSLT–Programm eine komplexe Datenstruktur durchwandert. Dies wird nicht mit prozeduralen Elementen,
Schleifen, Variablen, Zeigern, etc. programmiert, sondern durch einem System von Baummustern mit zugehörigen
Aktionen. Für jeden Teilbaum, auf den ein angegebenes Muster passt, wird die entsprechende Aktion aktiviert.
Fassen wir zusammen. In Programmen in deklarativem Stil wird das gewünschte Ziel definiert, ohne anzugeben,
wie das Ziel zu erreichen ist. Das kann sehr unterschiedliche Formen annehmen und in ganz unterschiedlichen
Anwendungsbereichen auftreten. Einige Beispiele wurden hier genannt. Darunter waren solche wie Prolog die
nur völlig vergeistigte Informatiker anlocken und solche für Anwender, die Programmen normalerweise nur im
Schutzanzug und mit Schweißerbrille gegenüber treten. Man nennt deklarative Programmierstile auch nichtprozedural. Allen nichtprozeduralen Sprachen ist gemeinsam, dass sie von der Anwendung her kommen und prozedurale Konzepte wie Variablen als Namen von Speicherplätzen oder Anweisungen gar nicht, oder nur widerwillig
unterstützen.
1.1.3
Typen
Typen von Werten, Ausdrücken und Variablen
Typen sind Kategorien von Dingen. Jede ganze Zahl gehört zur Kategorie der ganzen Zahlen und hat folglich den
Typ ganze Zahl. Ein Typ ist zunächst einmal etwas, das einem Wert zukommt. Ein Wert, die Zahl eins beispielsweise, hat einen Typ. Typen geben Operationen einen Sinn. So ist die Operation + in x+y davon abhänig, ob x und
y Zeichenketten, Listen, oder Integerwerte bezeichnen. Viele Operationen sind nicht möglich, weil die Typen der
Operanden nicht passen, man braucht die Werte gar nicht anzusehen, schon ihr Typ sagt, dass die Operation nicht
gelingen kann. Allen Varianten von Programmiersprachen kennen diese Art von Typen als Attribute von Werten.
Typ des Ausdrucks
Typ der Variable
Typ des Wertes
2.0
a
Ausdruck (hier Name)
Behälter
Wert
Abbildung 1.7: Typkonzepte
Sehr oft hat man dieses rein semantischen Konzept von Typ erweitert und auf die syntaktische Ebene der Programmkonstrukte übertragen: Ausdrücke wie “1” oder “2*f(12,4)+3” wird dort ebenfalls ein Typ zugewiesen.
Das ist eine abgeleitete Konstruktion, die aber in vielen Programmiersprachen – vor allem in prozeduralen aber
auch in manchen nicht–prozeduralen – definiert ist und die der Korrektheit und der Effizienz der Programme dient:
• Korrektheit Stellt sich bei der Analyse des Programms durch einen Compiler heraus, dass einem Ausdruck
kein Typ zugewiesen werden kann, dann gilt das Programm als falsch: Der Fehler wurde frühzeitig entdeckt.
• Effizienz Ist der Typ jeden Ausdrucks bekannt, dann können viele Verarbeitungsprozesse schon zur Übersetzungszeit stattfinden. Ist beispielseise schon von x und y und damit von x+y als Ausdruck der Typ Integer
12
Nichtprozedurale Programmierung
bekannt, dann muss nicht zur Laufzeit, jedes Mal wenn x+y ausgeführt wird, aufwändig analysiert werden,
welche Art von Addition auszuführen ist.
Beides hat seinen Preis. Der Compiler geht auf Nummer sicher und lehnt alles ab, was nicht garantiert korrekt
ist – unabhängig davon, ob zur Laufzeit ein entsprechender Typfehler tatsächlich auftreten würde oder nicht. Die
Programmiererin hat dafür zu sorgen, dass alle Ausdrücke die richtigen Typen haben und muss dazu oft intellektuell
anspruchsvolle Typsysteme für die Programme erfinden.
Wir wollen hier nicht in die komplexe Welt der Typen einsteigen, aber zumindest klar unterscheiden, was da einen
Typ hat:
• Typ eines Werts: natürliches Konzept.
• Typ eines Ausdrucks: (allgemeinster) Typ aller Werte, die der Ausdruck jemals im Laufe des Programms
haben kann.
• Typ einer Variablen: (allgemeinster) Typ aller Werte, die jemals im Laufe des Programms die Variable belegen können.
In der exakten Beschreibung einer Programmiersprache wird meist nur Ausdrücken ein Typ zugewiesen. Bezeichnern (Namen), als elementaren Ausdrücken, wird in prozeduralen Sprachen in der Regel ein fester Typ zuordnet.
Aus den Typen der Bezeichner wird dann der Typ der komplexeren Ausdrücke abgeleitet. Hinter dem formalen
Typ von Bezeichnern steckt die Intuition, dass Variablen einen Typ haben: den Typ der Werte die sie aufnehmen
können oder dürfen.
In nicht–prozeduralen Sprachen wird meist auf den “abgeleiteten” Begriff von Typ verzichtet. Werte haben auch
dort einen Typ, Ausdrücke, inklusive Bezeichner (Namen), dagegen nicht.
1.1.4
Compilierte und Interpretierte Sprachen
Compilierte und Interpretierte Sprachen
Programme in klassischen Programmiersprachen wie C, C++, Pascal, Java, etc. werden in Dateien abgespeichert,
von einem Compiler intensiv untersucht, in Zwischen– und/oder Maschinencode übersetzt und dieser wird dann
schließlich von einer realen oder virtuellen Maschine ausgeführt. Interpreter führen dagegen dagegen das Quellprogramm direkt aus. Ohne sich lange mit irgendwelchen Analysen des Quellprogramms aufzuhalten, werden dessen
Anweisungen oder Ausdrücke sofort ausgeführt. Sprachen, deren Programme vor der Ausführung typischerweise
von Compilern übersetzt werden, nennt man compilierte Sprachen, die anderen sind die interpretierten Sprachen.
Im Prinzip lassen sich die Programme jeder Programmiersprache in beiden Varianten bearbeiten. Der Charakter
einer Sprache und die Art der Verarbeitung hängen aber so eng zusammen, dass im Regelfall nur entweder die
Übersetzung oder die Interpretation sinnvoll ist.
Klassische Compilierte Sprachen
Die klassische Art, ein Programm auszuführen, ist, es in Maschinencode zu übersetzen und diesen dann von einer
realen Maschine interpretieren zu lassen. Reale Maschinen, richtige Hardware also, die ein Programm einer höheren Programmiersprache direkt ausführen können, mögen theoretisch denkbar sein, technologische und ökonomische Gründe sprechen aber dagegen, dies wirklich zu versuchen. Die Programme müssen also übersetzt werden.
Dabei versucht man in jedem Fall so viel Arbeit wie nur irgend möglich von der Ausführungszeit in Übersetzungszeit zu verlagern. Compilierte Sprachen sind auch stets so konzipiert, dass tatsächlich viel zur Übersetzungszeit
passieren kann. Dieses Ziel beeinflusst den Charakter einer Sprache ganz wesentlich. Bei einer Zuweisung wie
x = y
die möglicherweise sehr oft ausgeführt wird, muss beispielsweise jedesmal festgestellt werden, wie viele Bytes
ab wecher Adresse zu welcher Adresse zu bewegen sind und dann müssen sie bewegt werden. Das tatsächliche
Bewegen der Bytes kann nur bei jeder Ausführung der Anweisung erfolgen. Bei einer compilierten Sprache wird
Th Letschert, FH Giessen–Friedberg
13
Quellcode
Compiler: Analyse,
Optimierung,
Transformation
reale Maschine (CPU)
(Interpreter des
Cods): Ausfuehren
Maschinen−Code
Abbildung 1.8: Compilierte Sprache der klassischen Art
aber bestrebt sein, dass die Quell– und Zieladressen und die Anzahl der Bytes schon bei der Übersetzung festliegen. Derartige statischen Berechnungen7 sind aber möglich, wenn die Sprache in ihren Ausdruckmöglichkeiten
eingeschränkt wird.
Bei einer interpretierten Sprache nutzt es nichts oder nur wenig, wenn bei jeder Ausführung einer bestimmten
Zuweisung immer die gleiche Zahl von Bytes von der gleichen Quell– zur gleichen Zieladresse bewegt werden.
Interpretierte Sprachen haben es dagegen nicht nötig, den Charakter ihrer Programme so zu beschränken, dass viel
Arbeit zur Übersetzungszeit getan werden kann – es gibt ja keine Übersetzungszeit.
Die Entscheidung zwischen Flexiblität auf der einen und Effizienz und Sicherheit auf der anderen Seite ist immer
eine Gradwanderung. Klassische compilierte Sprachen bieten ihren Programmierern die Möglichkeit mit Konversionen, Zeigern, Polymorphismus und Ähnlichen aus ihrem engen Gehäuse auszubrechen.
Quellcode
Compiler: Analyse,
Optimierung,
Transformation
Daten
Zwischen−Code
reale Maschine (CPU)
(Interpreter des
Cods): Ausfuehren
virtuelle Maschine /
Common Language Runtime (CLR)
Anweisungen
Madchinen−Code
Abbildung 1.9: Compilierte Sprache der modernen Art
7
Als statisch bezeichnen die Compilerleute alles, was vor der Laufzeit des Programm festliegt, bzw. festgestellt (berechnet) werden kann.
14
Nichtprozedurale Programmierung
Compilierte Sprachen der modernen Art
Heute gibt es mit Java und C# compilierte Sprachen, die interpretiert werden. Von ihrem Grundcharakter her
sind beide immer noch compilierte Sprachen. Der Compiler versucht viel an Prüfung und Vorausberechnung zu
übernehmen und die Sprachen sind so konzipiert, dass dies auch möglich ist. Der erzeugte Zwischencode ist ein
abstrakter Maschinencode, der eine Unabhängigkeit von der realen Hardware und der Betriebsumgebung liefern
soll und der dadurch auch relativ leicht “im Fluge” (oder just in time) in echten Maschinencode umgewandelt
werden kann.
Compiler und Typen
Das Typsystem des Programms spielt eine wichtige Rolle beim Zurechtstutzen einer Sprache auf Compilationstauglichkeit. Allen Variablen, formalen Parameter und Funktionsergebnissen müssen eindeutige Typen zugewiesen
werden. Die Typen sind nicht nur eine enorme Hilfe bei der Konzeption der Programme im Kopf der Programmierer, sie ermöglichen es dem Compiler auch, eine Vielzahl von Programmierfehlern zu entdecken und für die
Konstrukte effiziente Übersetzungen in Maschinen– (oder Zwischen–) Code zu finden.
Die Typen der Ausdrücke sind dabei letztlich nichts anderes, als die – zur Übersetzungszeit verfügbaren – kondensierten Informationen über die – zur Laufzeit – möglichen Werte der Ausdrücke. Diese Informationen können
zu Prüf– und Optimierungzwecken eingesetzt werden. In ernsthaften Programmen sind die Typen ihrer Konstrukte
aber keineswegs trivial, im Gegenteil, die Kunst des Programmierens in einer compilierten Sprache besteht ganz
wesentlich darin, ein geeignetes Typsystem zu erfinden, in dessen Rahmen jedem Ausdruck ein eindeutiger Typ
zukommt.
Das Einzwängen in ein Typsystem bringt immer eine Beschränkung der Ausdrucksmöglichkeiten mit sich. Je
flexibler und fostgeschrittener das System ist, um so geringer sind diese Beschränkungen. Man kann dann so etwas
wie
...
x = new Viereck;
x = new Dreieck;
sagen, erkauft sich das aber damit, dass man einen Typ GeometrischesObjekt für x erfinden und eine Programmiersprache beherrschen muss, in der dies möglich ist.
1. x = new Viereck;
2. x = new Dreieck;
1
0. GeoObjekt = ????
1. GeoObjekt x;
X
2
2. x = new Viereck;
3. x = new Dreieck;
X
2
1
GeoObjekt
3
Abbildung 1.10: Nicht-typisierte und typisierte Programmierung
Interpretierte Sprachen
Compilierte Programmiersprachen spielen eine dominierende Rolle in der Ausbildung – und das zu Recht. Die
hohe Kunst der Informatiker, die Konstruktion großer und effizienter Programmsysteme, braucht Sprachen mit
deren Eigenschaften.
Nun sind aber nicht alle nützlichen Programme groß. Effizienz ist auch nicht immer ein Thema und Nicht–
Informatiker sind oft von den Typ–Konzepten moderner Hochsprachen überfordert. Für diese Anwendungsfälle
und Anwender gibt es eine Vielzahl an alternativen Sprach–Konzepten. Diese kleinen, leichten Sprachen für ad–
hoc Anwendungen und ad–hoc Programmierer werden meist von einem Interpretierer verarbeitet. Das hat zwei
wichtige Gründe: das Verhältnis von Aufwand und Ertrag bei der Übersetzung und die Natur der kleinen Sprachen.
Th Letschert, FH Giessen–Friedberg
15
Zunächst einmal will man einen kleinen Auftrag eingeben und sofort seine Ausführung sehen, statt mühsam einen
Compiler zu aktivieren und dann das erzeugte Programm zu starten. Selbst wenn die sofortige Ausführung – die
Interpretation – durch einen Interpretierer um den Faktor 1000 langsamer sein sollte, als die Ausführung von äquivivalentem Maschinencode, bei winzigen Programmen lohnt sich der Aufwand der Übersetzung trotzdem nicht.
In weitverbreiteten und unter Nicht–Informatikern oft und gerne benutzten Systemen, wie etwa MATLAB, gibt
man einen mathematischen Ausdruck ein und das System berechnet ihn sofort, bzw. erzeugt sofort eine graphische
Darstellung. Der Gedanke, diesen Ausdruck in ein zu übersetzendes Programm einbetten zu müssen, ist für die Anwender solcher Systeme völlig absurd. Unter Informatikern bekanntere Skript–Sprachen wie Perl, TCL, Python,
AWK, etc. liegen auf der gleichen Linie. Die Shell eines Unix–artigen Systems ist ebenfalls eine Programmiersprache, die eingegebene Kommandos sofort verarbeitet.
Die Verarbeitung der genannten Sprachen durch einen Interpreter hat nicht nur ergonomische Gründe. Die Sprachen selbst machen die Verarbeitung durch einen Compiler schwierig bis sinnlos.8 Klassischen Programmiersprachen liegt das “Behälter–Konzept” von Variablen zugrunde. Behälter haben einen Typ. Das schränkt die Varianz
der in ihnen speicherbaren Werte ein. Der Compiler kann die Typinformationen zur Übersetzungszeit verwenden,
um korrekten und effizienten Code zu erzeugen. Informatikern mag das Behälterkonzept in Fleisch und Blut übergegangen sein, für “normale Menschen” ist aber eine Variable ein Name für einen Wert. Der Typ eines Behälters
macht Sinn, nicht aber der Typ eines Namens für Werte. Wenn Variablen aber Namen für Werte sind, dann ist es
unsinnig zu verlangen, dass es beim Wechsel der Bedeutung einer Variablen eine Typbeschränkung geben sollte.
Warum soll nicht etwa a einen Integer–Wert bezeichnen, dann eine dreidimensionale Matrix und schließlich eine
Funktion.
Prozedural und Compiliert, Deklarativ und Interpretiert
Funktionale und andere deklarative Sprachen sind typischerweise interpretierte Sprachen. Die strenge Typisierung ist ein Konzept das ursprünglich von der Maschine kam, vom Ziel einen Compiler schreiben zu können,
der effizienten Code erzeugt. Deklarative Sprachen kommen dagegen von der Anwendungsseite. Ihnen liegt das
mathematische Konzept einer Variablen zugrunde. Variablen sind Namen für Werte. Eine Variable kann dann
konsequenterweise jeden beliebigen Wert bezeichnen, Typbeschränkungen gibt es nicht. Die Übersetzung wird
unter diesen Bedingungen schwierig und sie bringt auch nicht so viel an Effizienzgewinn. Die Werte selbst haben
natürlich weiterhin Typen, aber wenn Variablen kein Typ zugeordnet wird, dann kann zur Übersetzungszeit nicht
mehr vorausgesehen werden, welchen Typ der Wert einer Variablen zur Laufzeit haben wird – die entsprechende
Information muss dynamisch verwaltet werden.
Kurz und gut, dekarative Sprachen sind meist nicht typisiert, d.h. ihre Ausdrücke haben keine fixen im Voraus
bestimmbaren Typen und sie werden meist interpretiert. Beides ist aber kein unbedingtes Muss. Deklarative Sprachen können und sind gelegentlich streng typisiert und compiliert und Typen kommen zwar von den Compiler–
Techniken in die Programmiersprachen, Typen sind aber weit mehr als nur Hilfsmittel für Compiler.
8
Aber nicht unmöglich: alles was interpretiert werden kann, kann auch übersetzt werden.
16
Nichtprozedurale Programmierung
1.2
Ausdrücke und ihre Auswertung
Abstrahieren heißt die Luft melken.
F. Hebbel
1.2.1
Definitionen und Ausdrücke
Ausdrücke
Ein funktionales Programm ist im einfachsten Fall ein Ausdruck. Die Ausführung des Programms besteht schlichtweg darin, dass dieser Ausdruck ausgewertet wird. So ist beispielsweise
2+3∗4
ein triviales funktionales Programm das zu 14 ausgewertet wird. Wenn auch nicht jede funktionale Sprache interpretiert wird und nicht jede interpretierte Sprache funktional ist, so ist es doch ein typisches Charakteristikum
funktionaler Sprachen, dass sie einen Interpretierer zur Verfügung stellen. Dieser erlaubt es, solche Ausdrücke
interaktiv auszuführen. Ihre Programme werden entweder interaktiv eingegeben oder in einer Datei gespeichert. In
ein Datei gespeichert, nennt man sie gelegentlich “Skripte” und die entsprechenden Sprachen “Skript–Sprachen”.
Manche Sprachen bestehen auf einer besonderen Notation für ihre Ausdrücke. Lisp beispielsweise will alle Ausdrücke vollständig geklammert und in Präfix-Notation sehen. Statt 2 + 3 ∗ 4 übergibt man also dem Interpreter
>>> (+ 2 (* 3 4))
zur Auswertung ein und erhält die Antwort
14
Die vollständig geklammerte Präfix–Notation in Lisp hat ihre besonderen Gründe, die aus ganz speziellen Eigenschaften und der Historie dieser Sprache folgen und nicht auf einem allgemeinen Prinzip funktionaler Sprachen beruhen. Python ist eine prozedurale Skript-Sprache mit den wesentlichen Möglichkeiten einer funktionalen
Sprache, ohne auf das “Funktionale” beschränkt zu sein. Sie erlaubt Ausdrücke in normaler Infix–Notation mit
Präzedenz–Regeln:
> python
Python 2.2.2 ...
>>> 2+3*4
14
>>>
Definitionen
Ausdrücke allein sind etwas langweilig als Programme. Sie werden etwas interessanter, wenn ihnen Definitionen
vorausgehen. Beispielsweise werden in folgendem Pythoncodestück zwei Namen definiert x und f, die dann im
darauf folgenden Ausdruck verwendet werden:
def f(n):
if n==0:
return 1
else:
return n*f(n-1)
x=5
f(x)+f(x-1)
Th Letschert, FH Giessen–Friedberg
17
Freie und gebundene Variablen
Definitionen sind dazu da, um freien Variablen eine Bedeutung zu geben. In
f(x)+f(2*x)
sind sowohl x als auch f frei. Nur mit diesem Ausdruck allein haben sie keinen Wert und damit ist der ganze
Ausdruck undefiniert. Klar, wir müssen die Definitionen hinzunehmen, um dem Ausdruck insgesamt einen Wert
zuweisen zu können.
Frei oder gebunden sind relative Begriffe. In f(x)+f(x-1) sind die Variabelen f und x. Im gesamten Programm
dagegen nicht. Die Definitionen vorher binden sie an eine Bedeutung. Alle Variablen sind damit gebunden.
Ähnlich verhält es sich in der Funktionsdefinition. In
n==0
ist n frei. In der gesamten Funktionsdefinition ist es an die Bedeutung “Funktionsparameter” gebunden.
Ein– und Zweistufige Wertzuordnung
Definitionen ordnen Namen eine Bedeutung (einen Wert) zu. Beispielsweise wird in folgendem C–Beispiel dem
Namen x ein Speicherplatz als Wert/Bedeutung zugeordnet:
int x;
In einem zweiten Schritt kann dann der Speicherplatz mit einem Wert belegt werden:
x = 5;
Vom Namen zum Wert 5 gelangt man dann in zwei Schritten:
x → Variable/Speicherplatz → 5
Um die beiden “Werte” von x zu unterscheiden spricht man in C und C++ vom l-Wert und vom r–Wert. Der l–Wert
ist der Speicherplatz und der r–Wert die 5. Der l-Wert eines Namens ist relativ stabil, der r–Wert kann dagegen mit
jeder Zuweisung wechseln. Aber Achtung, nicht nur Namen haben l-Werte, auch ein Ausdruck kann einen l-Wert
haben. In
a[x+i] = 5;
hat a[x+i] einen l–Wert der höchst dynamisch sein kann.
In typfreien Sprachen wie Python wird nicht zwischen l– und r–Wert unterschieden. Einem Namen wird eine
einzige Bedeutung zugeordnet.
x = 5
Vom Namen zum Wert gelangt man dann in einem Schritt:
x→5
Umgebung
Die von einer oder mehreren Definitionen erzeugte Abbildung von Namen auf ihre Bedeutung nennt man Umgebung. Die Umgebung enthält die Bedeutung (Definition) der freien Variablen eines Ausdrucks. In
f(x)+f(2*x)
sind f und x frei. Der Ausdruck enthält damit freie Variablen und kann darum nur in einer Umgebung ausgewertet
werden, die x und f definiert. In der von den Definitionen
def f(n):
if n==0:
return 1
else:
return n*f(n-1)
18
Nichtprozedurale Programmierung
x=5
erzeugten Umgebung u mit
u = [f → 5, f → F akultätsf unktuion]
hat f(x)+f(2*x) den Wert 144.
Andere Definitionen erzeugen andere Umgebungen, die zu anderen Werten des gleichen Ausdrucks führen.
Zustand
In Sprachen mit zweistufigem Variablenkonzept gibt es zwei Abbildungen. Die erste ordnet einem Namen einen
Speicherplatz zu, die zweite dem Speicherplatz einen Wert. Die erste wird Umgebung genannt. Die zweite ist der
aktuelle Maschinen–Zustand oder kurz Zustand. In C muss man, um den Wert eines Ausdrucks wie
2*x
zu bestimmen, wissen welche Speicherstelle (l–Wert) mit x gemeint ist – das ist die Umgebung. Kennt man dann
noch die aktuelle Belegung (r–Wert) dieser Speicherstelle – das ist der Zustand – dann lässt sich der Wert berechnen. Also: in einer Umgebung, in der x sich auf die Speicherstelle 0x4711 bezieht und einem aktuellen
Mascheinzustand, in dem 0x4711 den Wert 5 hat, wird 2*x zu 10 ausgewertet.
1.2.2
Statische und Dynamische Bindung
Statische Bindung
In allen relevanten Programmiersprachen können Prozeduren bzw. Funktion definiert werden und in der Regel
dürfen sie freie Variablen (freie Bezeichner) enthalten. In der C–Funktion
inf f(int x) { return x+y; }
ist beispielsweise y frei. Generell stellt sich die Frage, was ein freier Bezeichner zu bedeuten hat, oder an welche
Definition seine Verwendung, jeweils gebunden ist?
In unserem kleinen C–Programm oben, sind in return x+y; sowohl x und y frei – es sind Verwendungsstellen
der Bezeichner x und y. Die Bindungsregeln von C sagen, dass x in return x+y; an die Parameterdefinition
im Kopf der Funktion gebunden wird. Einfach ausgedrück, mit x ist der Parameter gemeint. y hat keine Bindung
innerhalb der Funktionsdefinition. Es ist nicht nur, wie x, im Funktionskörper, sondern in der gesamten Funktionsdefinition frei. Jeder C–Programmierer weiß, und die anderen ahnen, wie zu diesem freien y eine Bindung zu
suchen ist: Es kann nur eine globale Variable sein und man kann sie ausfindig machen, indem man den Programmtext analysiert. Wir brauchen dazu auch gar nicht Bedracht zu ziehen, wo wann oder ob die Funktion f jemals
aktiviert wird.
In C und ähnlichen Sprachen ist die Bindung eines Bezeichners immer klar durch den Programmtext bestimmt.
Ein freier Bezeichner in einer Funktion bezieht sich immer auf das, was an deren Definitionsstelle gültig ist. Im
C–Beispiel oben ist mit y der Speicherplatz gemeint auf den sich y an der Definitionsstelle von f bezieht, egal in
welchem Kontext f aufgerufen wird und welche Definition von y dort zuletzt ausgeführt worden sein mag. Die
“Bindung” von y erfolgt an der Definitionsstelle. Sie ist unveränderlich und kann zur Übersetzungszeit bestimmt
werden: sie ist “statisch”.
C, C++, Java, und viel andere Sprachen definieren eine statische Bindung der freien Bezeichner. Folgendes C++–
Programm gibt darum den Wert 10 (und nicht -7) aus:
#include <iostream>
using namespace std;
Th Letschert, FH Giessen–Friedberg
19
int x = 10;
void f(){
cout<<x<<endl;
}
void g(){
int x = -7;
f();
}
int main() {
g();
}
Statische Bindung ist dadurch charakterisiert, dass die Umgebung jeden Ausdrucks statisch, d.h. zur Übersetzungszeit rein aus Betrachtung des Quellcodes und seiner textuellen (Gültigkeits–) Bereiche, bestimmt werden
kann. Statische Bindung macht Programme “berechenbarer”und einfacher zu verstehen und im Allgemeinen auch
einfacher und effizienter zu übersetzen. Sie wird bei praktisch allen aktuellen Programmiersprachen verwendet.
Dynamische Bindung
Bei dynamischer Bindung bestimmt die aktuelle Situation während des Programmlaufs die Umgebung in der nach
der Bedeutung (Bindung) freier Variablen gesucht wird. Im Beispiel oben ist x in der Funktion f frei. f wird
von g aus aufgerufen. g hat ein lokales x. Es enthält also Umgebung in der x definiert ist. Dieser Umgebung
würde bei dynamischer Bindung der Vorzug vor der globalen Umgebung Umgebung gegeben. Zum Zeitpunkt der
Auswertung von f() ist die Umgebung von g die “aktuellste”, die ein x enthält. Würde C++ mit dynamischer
Bindung arbeiten, dann würde obiges Beispielprogramm also -7 ausgeben.
Fassen wir die Unterschiede der beiden Bindungsarten noch einmal kurz zusammen:
• Statische Bindung: Die Bindungsstelle (Definition) eines Bezeichners kann aus dem Text des Programms
(statisch) bestimmt werden. Typischerweise ist ein Bezeichner an die textuelle nächstgelegene äussere Definition gebunden.
• Dynamische Bindung: Die Bindungsstelle (Definition) eines Bezeichners kann erst zur Laufzeit des Programms (dynamisch) bestimmt werden. Typischerweise ist ein Bezeichner an die zuletzt ausgeführte Definition gebunden.
Bestimmt also der Programmtext, die Prgrammstatik, oder die Programmausführung, die Prgrammdynamik, welche Bindung gültig ist.
Die Bindungsart von Python
Um zu testen, welche Bindungsart Python verwendet, muss obiges Testprogramm nur entsprechend umgesetzt
werden:
x = 10
def f():
print x
def g():
x = -7
f()
g()
Dieses Testprogramm liefert uns mit der Wert 10 die beruhigende Erkenntnis, dass auch Python statische Bindung
verwendet. Das x in f ist immer das globale x, von wo und wann auch immer f auch aktiviert werden mag.
20
Nichtprozedurale Programmierung
Wir dürfen uns nicht davon irritieren lassen, dass jede Modifikation des gloablen x sich auf f auswirkt. Das
Programm
x = 10
def f():
print x
def g():
x = -7
f()
x=0
g()
würde selbstverständlich 0 ausgeben – aber im C++–Beispiel gilt das genauso.
In Python definiert also jede Funktion ihren eigenen Gültigkeitsbereich und erzeugt somit ihre eigene Umgebung.
Ein Name kann darum in mehreren unterschiedlichen Umgebungen definiert sein. Freie Variablen werden in der
Umgebung gesucht (= gebunden), die ihrer Verwendungstelle statisch (textuell) am nächsten ist.
Lokal und Global in Python
In C++ werden Definitionen und Zuweisungen unterschieden. Wenn in einer Funktion ein globales x mit einem
Wert belegen werden soll, dann schreibt man
...
void f(){
x = 5;
...
}
...
Soll dagegen ein lokales x erzeugt und belegt werden, dann schreibt man
void f(){
int x = 5;
...
}
In Python gibt es den Unterschied zwischen Definition und Zuweisung nicht. Jede erste Zuweisung in einem
Gültikeitsbereich wird als Definition interpretiert.
x = 0
x = 5
def f():
x = 5
x = 6
}
# Definion globales x
# Zuweisung an globales x
# Definition eines lokalen x
# Zuweisung an lokales x
Will man auf eine nicht–lokale Variable schreiben zugreifen, dann muss explizit gesagt werden, dass es sich nicht
um eine Definition handelt. Der Zugriff auf einen globalen Namen muss explizit gekennzeichnet werden. Mit
def f():
x = 5
...
wird ein lokales x erzeugt und gleichzeitig mit einem Wert versehen. Will man das nicht, dann muss man es sagen.
Mit
Th Letschert, FH Giessen–Friedberg
21
def f():
global x
x = 5
...
bezieht sich jede Verwendung von x in f auf das x der globalen Umgebung, es wird keine lokale Variable erzeugt.
Verschachtelte Gültigkeitsbereiche in Python
Gültigkeitsbereiche können üblicherweise verschachtelt werden. In
int x = 3;
void f () {
int z = 2;
for ( int z=1; z<10; ++z ) {
x = y + z;
}
}
haben wir beispielsweise drei verschachtelte Güligkeitsbereiche (global, Funktion, for–Block) mit jeweils einer
Definition. C und verwandte Sprachen erlauben es Gültigkeitsbereiche beliebig tief zu verschachteln. Allerdings
ist es bei C und all seinen nahen und fernen Verwandten nicht erlaubt Funktionen innerhalb anderer Funktionen zu
definieren.
void f (int x) {
// Funktion in Funktion
int g(int y) { // VERBOTEN
return x + y;
}
...
}
In Python gibt es diese Beschränkung nicht. In Funktionen können ohne weiteres andere Funktionen definiert
werden. So gibt
def f(x):
def g(y):
return x+y
return g(x)
print f(2)
ohne jede Beschwerde den Wert 4 aus. Das Spiel kann weiter getrieben werden. So kann in g noch ein h definiert
werden
def f(x):
def g(y):
def h(z):
return x+y+z
return h(y)
return g(x)
und so weiter.
Python ist statisch gebunden und es gilt die üblichen Bindungsregel, nach der die Bindung eines freien Bezeichners
stets von innen nach aussen in umfassenden Gültigkeitsbereichen gesucht wird. Dabei gibt es allerdings eine subtile Beschränkung. Auf eine Variable eines umfassenden Bereichs kann nur dann schreibend zugegriffen werden,
wenn der umdassende Bereich der globale Bereich ist, denn global ist die einzige mögliche Kennzeichnung mit
der etwas als nicht lokal deklariert werden kann. Damit ist ein schreibender Zugriff auf Variablen in “Zwischenbereichen” nicht möglich. Entweder man verwendet global und bezieht sich auf eine globale Variable
22
Nichtprozedurale Programmierung
x = 5
# globales x
def f():
x = 6
# f::x, das x von f -- in g nicht schreibend zugreifbar
def g(y):
global x
x = 0
# das globale x wird 0
...
oder man lässt global weg und bezieht sich auf eine neue lokale Variable:
x = 5
def f():
x = 6
def g(y):
x = 0
...
# globales x
# f::x, das x von f -- in g nicht schreibend zugreifbar
# ein neues lokales g::x wird 0
Diese Beschränkung ist nicht zufällig, sie hat ihren Sinn auf den wir an anderer Stelle noch einmal zu sprechen
kommen werden.
1.2.3
Statische und Dynamische Typisierung
Typisierung: Statisch oder Dynamisch
Unter Typisierung versteht man die Zuordnung eines Typs zu einem Ausdruck. Auch hier hat man die beiden
grundsätzlichen Optionen einer statischen oder einer dynamischen Typisierung. Während bei Frage der Bindung
allgemeiner Konsens herrscht, dass statische Bindung vernünftig ist, werden darüber, wann und wie die Typisierung
eines Programms zu erfolgen hat, heftige Debatten geführt. Die Positionen sind dabei oft genug dogmatisch fix
und werden mit religösen Eifer vertreten. Dieser Eifer hat durchaus seine Berechtigung. Die Art der Typisierung
ist von fundamentaler Bedeutung für den Charakter der Sprache und damit für den Programmierstil.
Auf der einen Seite gibt es die Mehrheitsfraktion der statischen Typisierer. Sie sind der Meinung, dass Programme
statisch, zur Übersetzungzeit, typisiert werden können und müssen: Jedem syntaktischen Konstrukt, d.h. jedem
Ausdruck und jedem Teilausdruck in einem Programm, muss ein fester Typ zugeordnet werden können, der sich
während des Programmlaufs niemals ändert. Der Compiler stellt die Typen fest, prüft die korrekte Zusammenstellung des Programms in Bezug auf die Typen und benutzt die Typ–Information um effiziente Maschinenprogramme
zu generieren.
Bei der dynamischen Typisierung werden Typinformationen zur Laufzeit verarbeitet. Entsprechende Sprachen
Sprachen werden oft auch (nicht ganz korrekt) typfrei genannt. Ausdrücke haben auch hier Werte und diese haben einen Typ. Im Gegensatz zu Sprachen mit statischer Typisierung kann der Typ dieser Werte aber zur Laufzeit
beliebig wechseln und dazu noch bei manchen Programmläufen korrekt sein und bei anderen fehlerhaft.
Kommt in einem Programm mit statischer Typisierung beispielsweise irgendwo so etwas wie
a[i]+i
vor, dann kann ich und der Compiler, allein aus der Betrachtung des Programms, schlı̈eßen, welchen Typ a[i] hat
und ob a[i]+i insgesamt ein korrekter Ausdruck ist: Irgendwo sind a und i definiert und aus diesen Definitionen
kann dann geschlossen werden, ob a[i]+i auswertbar ist oder nicht.
Bei Sprachen mit dynamischer Typisierung gilt das nicht. Der Typ von a[i] ist der Typ seines Wertes und welcher
das ist, ist nicht vorhersehbar. In Python etwa ist folgendes Programmstück völlig legal:
a = {’hoho’:’hallo’, 1:2, 2:’welt’}
i = input()
print a[i]+i
Für manche Eingaben von i ist es korrekt, für andere bricht es mit einem Typfehler ab.
Hinter den beiden Vorstellungen zur Typisierung stecken folgende Sprachkonzepte:
Th Letschert, FH Giessen–Friedberg
23
• Statische Typisierer sind der Überzeugung, dass jeder Ausdruck und jeder Teilausdruck in einem Programm
einen ganz bestimmten festen Typ hat, und jeder Wert, den dieser Ausdruck oder Teilausdruck in einem
Programmlauf annehmen kann, hat ebenfalls diesen Typ.9
• Dynamische Typisierer sind dagegen der Meinung, dass “Typ eines Ausdrucks” ein unsinniges Konzept ist.
Ausdrücke haben keine Typen. Sie haben wechselnde Werte und nur diese Werte haben einen Typ.
Bei Programmen mit statischer Typisierung kann der Compiler viele Programmierfehler aufdecken und deutlich
effizientere Programme erzeugen. Dafür muss der Programmierer ein Typsystem definieren, bei dem die Typen der
Ausdrücke und die ihrer Werte in der richtigen Beziehung stehen. Programme in Sprachen mit dynamischer Typisierung sind meist wesentlich kürzer und einfacher zu schreiben. Die Konstruktion eines passenden Typsystems
für die Ausdrücke entfällt. Dafür sind sie aber langsamer und fehlerträchtiger.
Der schlimmste Makel von Sprachen mit dynamischer Typisierung ist vielleicht, dass sie als Sprachen von Hobby–
Programmierern gelten. Typen sind schwierige Konstrukte. Jeder Ausdruck muss in ein Typsystem passen. Speziell
in modernen Sprachen mit statischer Typisierung ist dazu ein Typsystem von gewisser Komplexität notwendig, das
dazu auch noch vom Programmierer selbst definiert werden muss. Wer diese Definition durch die Verwendung
einer Sprache mit dynamischer Typisierung vermeidet, gerät leicht in Ruf, zu ihr nicht fähig gewesen zu sein.
Python ist eine dynamisch typisierte Sprache
Interpretierte (“Skript”–) Sprachen arbeiten normalerweise ohne explizite Typangaben in ihren Programmen (Ausdrücken). Das heißt nicht, dass es keine Typen gibt, sondern dass man darauf verzichtet syntaktischen Konstrukten
im Quelltext explizit einen Typ zuzuweisen. Es gibt trotzdem Typen, alle Werte haben einen Typ und auch Ausdrücke haben einen Typ. Der Typ der Ausdrücke ist aber nicht statisch im Quelltext festgelegt, er wechselt mit dem
Typ den der Wert des Ausdrucks zur Laufzeit annimmt.
Im folgenden Beispiel wird der Python–Interpretierer mit Ausdrücken unterschiedlichen Typs konfrontiert. Die Typen der Teilausdrücke bestimmen die Typen des Gesamtausdrucks und die Art der Operationen. Typfehler werden
dabei festgestellt und zurückgewiesen:
>>> 2 + 3
5
>>> ’hallo’ + ’Welt’
’halloWelt’
>>> 3 * 4
12
>>> 3 * ’hallo’
’hallohallohallo’
>>> 3 * 3.1415
9.4245000000000001
>>> 3 * ’hallo’ + 4
Traceback (most recent call last):
File "<stdin>", line 1, in ?
TypeError: cannot concatenate ’str’ and ’int’ objects
Ohne irgendwelche Deklarationen und Definitionen interpretiert das System die Operatoren hier jeweils in der
richtigen Weise als Operation auf Int–, Float, oder Stringwerten. Der Typfehler im letzten Ausdruck wird auch
nicht bei einer Übersetzung oder sonstigen Analyse entdeckt, sondern bei dessen Ausführung.
Der Typ eines Werts kann zwar nicht festgelegt, aber mit Hilfe der type–Funktion explizit abgefragt werden:
>>> type(3 * ’hallo’)
<type ’str’>
9 Das “hat diesen Typ” kann auch eine komplexe Vererbungsbeziehung oder eine Instanzierung eines Templates, oder sonst eine Relation
in einem beliebig komplexen Typsystem sein.
24
Nichtprozedurale Programmierung
Basistypen
Wie jede andere Sprache hat auch Python eine Grundmenge an einfachen und zusammengesetzten Datentypen mit
den jeweils zugehörigen Operatoren und Operationen. Die Datentypen sind:
• Zahlen
– Ganze Zahlen
mit Literalen in C/C++–Notation, 1, 02 (oktal), 0x13 (hexadezimal) und Werten im Bereich der Int–
Werte von C.
– Lange ganze Zahlen
sind ganze Zahlen, die keine Größenbeschränkung haben. Sie werden durch ein angehängtes L gekennzeichnet, Z.B. 9999999999999999L.
– Fließkomma–Zahlen
ebenfalls mit Literalen im C–Stil (z.B. 1.0 oder 12.3E-10) und Werten die double–Werten in C
entsprechen.
– Komplexe Zahlen
√
mit Fließkomma–Literalen denen ein “j” als Zeichen für −1 angehängt wird. Z.B.: 2.5j, oder 0j,
sie werden durch ein Paar von Fließkomma–Werten dargestellt werden.
>>> 0.5j + 2
(2+0.5j)
– Boolesche Werte
Werden wie in C als Int–Werte behandelt. Ab Version 2.3 gibt einen eigenständigen Typ für boolesche
Werte mit den Literalen True und False.
• Sequenzen
– Zeichenketten (strings)
sind in einfache oder doppelte Hochkommas eingeschlossene Folgen von Zeichen, z.B. ’hallo’
– Tupel
sind unveränderliche Folgen fester Länge, z.B. (1, 2.0, ’hallo’). Ein Tupel kann Elemente
von unterschiedlichem Typ enthalten.
– Listen
sind veränderliche Folgen variabler Länge, z.B. [1, 2.0, ’hallo’]. So wie Tupel können Listen
Elemente unterschiedlichen Typs enthalten.
• Abbildungen (Dictionaries)
sind Zuordnungen von Werten (beliebigen Typs) zu Schlüsseln fast beliebigen Typs,
z.B.: {1:’hallo’, ’hallo’:’hugo’} Abbildungen sind veränderlich. Auch bei ihnen ist ein Typmix
erlaubt.
In Python sind Listen und Abbildungen Objekte, d.h. veränderliche Dinge. Ein Konzept, das der “reinen” funktionalen Programmierung zuwider läuft. Dort gibt es nur richtige Werte, die im mathematischen Sinn ewig und
unveränderlich sind. Alles Veränderliche ist ein Objekt, etwas aus der Welt der prozeduralen Programmierung.
Selbstverständlich können Listen und Abbildungen auch in rein funktionalen Programmen verwendet werden,
folgt doch aus der Tatsache, dass Listen und Abbildungen verändert werden können, nicht, dass sie verändert
werden müssen.
Details und weitere Beispiele entnehme man der Literatur oder einem Python–Tutorium.
Vordefinierte Operatoren und Funktionen
Auf den numerischen Werten sind die üblichen arithmetischen und relationalen Operatoren definiert, z.B.:
Th Letschert, FH Giessen–Friedberg
25
>>> 5.0/2 > 5/2
1
>>> 5.0/2 < 5/2
0
An diesem Beispiel erkennen wir, dass es eine ganzzahlige und eine Fließkomma–Version der Division gibt, und
dass boolesche Werte als Integerwerte bestimmt werden. Beides entspricht, so wie die weiteren Operatoren in
Python, dem Vorbild C.
Auf Listen– und Tupel–Elemente wird mit der Index–Notation zugegriffen:
>>> [1, 2, 3][1]
2
>>> (2, 3.14, ’hallo’)[2]
’hallo’
in gleicher Weise wird der Wert zu einem Schlüssel in einer Abbildung selektiert:
>>> {1:’hallo’, ’hallo’:’hugo’, 2.0:1}[’hallo’]
’hugo’
Listen und Tupel können mit + zusammengefügt werden:
>>> [1, 2] + [’hugo’, ’egon’]
[1, 2, ’hugo’, ’egon’]
>>> (’hugo’, ’egon’) + (’emilie’, ’karla’)
(’hugo’, ’egon’, ’emilie’, ’karla’)
Tupeln und Listen dürfen dabei nicht vermischt werden.
Selbstverständlich gibt es auch in Python vordefinierte Funktionen. Mathematische Funktionen werden im Modul
math definiert. Z.B.:
>>> import math
>>> math.cos(3.1415926) + math.sqrt(3.1415926)
0.77245383578811577
oder
>>> from math import sin
>>> sin(3.1415)
9.2653589660490244e-05
Mächtige Ausdrücke
Bei der ersten Begegung mit Sprachen wie Python fällt auf, dass dort mit sehr mächtigen Ausdrücken gearbeitet
werden kann. Man schreibt einfach
m = {1:[’hugo’, ’emil’], 2:[’karla’, ’charlotte’], 3:[]}
und hat schon gleich eine komplexe Datenstruktur erzeugt. Natürlich liese sich auch in C++ oder Java eine entsprechende Datenstruktur aufbauen, aber dort gibt es keinen Ausdruck, dessen Wert direkt eine solche beliebig
komplexe Struktur ist. Stattdessen müsste man Konstruktoren und diverse Einfüg–Operationen bemühen. Warum
ist das so?
Ausdrücke gehören zum Kern einer Sprache. Python und vergleichbare Sprachen sind von Anfang an mit komplexen Datenstrukturen wie Listen und Abbildungen ausgestattet. Ausdrücke mit Listen und Abbildungen als Wert
sind darum selbstverständlich. Programmierprobleme wird man in einer solchen Sprache auch normalerweise mit
der Grundausstattung an Typen lösen. Eigene Typdefinitionen sind, wenn überhaupt möglich, dann doch unüblich.
26
Nichtprozedurale Programmierung
C++ und Verwandte folgen einem völlig anderen Paradigma. Der Sprachkern ist vergleichsweise sparsam mit
Typen, bietet aber ein reichhaltiges Instrumentarium zu deren Definition. Es ist zwar bei diesen Sprachen heute
auch üblich mit gut ausgestatten Klassenbibliotheken zu arbeiten, aber diese gehören nicht zur Sprache, es sind
immer Zusätze. Ein typisches Programm dieser Sprachen besteht darum im wesentlichen aus Typdefinitionen.
1.2.4
Kopiersemantik
Die Kopiersemantik einer Sprache beeinflusst deren Charakter ganz wesentlich. Programmierer mit mathematischem Hintergrund und ohne formelle Informatik-Ausbildung sind oft etwas überrascht, und manchmal wundern
sich sogar Informatiker, wenn sie folgende Konversation mit ihrem Python–Interpreter führen:
>>> l1=[1,2,3]
>>> l2=l1
>>> l2
[1,2,3]
-- OK
>>> l1[0]=100
>>> l1
[100, 2, 3]
-- OK
>>> l2
[100, 2, 3]
-- UUPS
Jeder Mensch hat wohl ein eigenes intuitives Verständnis von Werten und Objekten und dem was bei einer Zuweisung passiert. Im nicht–prozeduralen Verständnis liest man
>>> l1=[1,2,3]
>>> l2=l1
-- l1 hat den Wert "Liste 1, 2, 3"
-- l2 hat den gleichen Wert wie l1
(n Python FALSCHES Verstaendnis)
(in Python FALSCHES Verstaendnis)
In diesem Verständnis wird l2 mit einer Kopie (einem “Klon”, einer tiefen Kopie) von l1 belegt. Nach der
Veränderung von l1 mit
>>> l1[0]=100
erwartet man darum nicht, dass l2 mit verändert wird. Man erwartet referentielle Transparenz: l2 wurde nicht
angefasst und sollte darum das Gleiche sein wie vorher.
Tatsächlich ist Python aber eine prozedurale Sprache bei der die beiden Zuweisungen folgendermaßen zu verstehen
sind:
>>> l1=[1,2,3]
>>> l2=l1
-- l1 bezeichnet das Objekt "Liste 1, 2, 3"
-- l2 bezeichnet das gleiche Objekt wie l1
(OK)
(OK)
l1 und l2 sind also Namen für das gleiche Objekt. Die zweite Zuweisung führt nur einen neuen Bezug, eine neue
Referenz, auf das Objekt ein. In der Implementierung wird lediglich ein Zeiger kopiert (flache Kopie).
Keine der beiden Sichten auf die Zuweisung ist “besser” oder “natürlicher” als die andere. Beide haben ihre Berechtigung und beim Blick auf ein Programm sollte man als Informatiker immer fragen, was genau a = b bedeuten soll. Nur Naive halten diese Frage für naiv. In Python ist besondere Vorsicht angebracht, da verschiedene
Philosophien des Kompierens angewendet werden. Man beachte:
>>> l1 = [1,2,3]
>>> l2 = l1
>>> l1 = l1+l1
>>> l2[0] = 100
>>> l2
[100, 2, 3]
>>> l1
[1, 2, 3, 1, 2, 3]
-- + kopiert tief
Die Zuweisung kopiert also flach und der +–Operator tief.
Th Letschert, FH Giessen–Friedberg
1.2.5
27
Funktionen und ihre Definition
Funktion, Algorithmus, Programm
Funktionen sind das Herzstück der funktionalen Programmierung. Ein funktionales Programm besteht typischerweise aus einer Serie von Funktionedefinitionen und einem Ausdruck, der mit diesen ausgewertet wird. Funktionen
haben natürlich nicht nur in der funktionalen Programmierung ihren Platz. Es gibt sie in allen Arten von Programmen und sogar in der Mathematik.
In der Mathematik werden Funktionen üblicherweise nach folgendem Muster definiert:
• Eine Menge ist ...
• Das kartesische Produkt A × B zweier Mengen A und B ist ...
• Eine Relation R zwischen zwei Mengen A und B ist eine Teilmenge des Kreuzprodukts A × B.
• Eine Funktion f : A → B von einer Menge A nach einer Menge B ist eine rechts-eindeutige Relation aus
A × B. D.h. zu jedem aA gibt es genau ein (höchstens ein - bei partiellen Funktionen) Element bB mit
< a, b > f ; dieses b wird mit f (a) bezeichnet.
Eine mathematische Funktion ist also eine Menge von Paaren aus A × B mit der Eigenschaft, dass jedem Element
aus A genau (höchstens) ein Element aus B zugeordnet wird. Es ist also eine Zuordnung von Werten zu Werten.
Für die Informatik ist dieser Standpunkt zu abstrakt, denn er lässt zwei für sie wesentliche Gesichtspunkte außer
Acht:
• Wie wird die Zuordnung vollzogen: Auf welche Art wird der Ergebniswert aus dem Ausgangswert bestimmt
(berechnet)?
• Wie wird die, im Allgemeinen ja unendliche, Funktion beschrieben?
Natürlich wird auch in der Mathematik (gelegentlich) gerechnet und es werden auch Funktionen beschrieben,
aber das wesentliche Grundkonzept ist die abstrakte, als Paarmenge definierte Zuordnung. In der Informatik geht
es aber um den konkreten Mechanismus, der die Zuordnung ausführt. Algorithmen mit ihrer Spezifikation und
Implementierung sind die entsprechenden Konzepte.
Vom “funktionalen Standpunkt” aus, hat ein Informatiker Algorithmen zu implementieren, die funktionale Zusammenhänge realisieren. Diese Sicht gilt heute mit guten Grund als veraltet, da sich weder interaktive, noch verteilte
Systeme (ohne heftige Verrenkungen) als Funktionen deuten lassen. Wenn der funktionale Standpunkt auch nicht
(mehr) die Gesamtheit der Software beschreiben kann, wenn also nicht alles eine Funktion ist, so sind doch sehr
viele Probleme als Funktionen zu verstehen.
In diesem Kapitel gehen wir einfach davon aus, dass Programme Implementierungen von Algorithmen sind und
dass alle Algorithmen Verfahren zur Zuordnung von Ergebnis– zu Argumentwerten sind. Wir beschränken uns also
auf die funktionale Sicht, nach der jedes Programm eine Funktion implementiert.
Funktionen höherer Ordnung, Funktionale
√
Wir kennen eine Vielzahl von Bezeichnungen für Werte: 1, 2.0, π, ... und solche für Funktionen: +, , ....
Funktionen werden auf Werte angewendet und man erhält neue Werte. Funktions– und Werte–Bezeichner können
kombiniert und somit zu Bezeichnungen für neue Werte verknüpft werden:
√
2, 2 ∗ π + r2 , ...
1 + 7,
Im Vergleich zu der Vielzahl an Möglichkeiten mit Hilfe von Funktionen aus Werten neuen Werte zu erzeugen,
ist die Zahl der Möglichkeiten, Funktionen zu neuen Funktionen zu verknüpfen, eher bescheiden. Ein bekanntes
Funktional – so nennt man Funktionen verarbeitende Funktionen oft – ist die Hintereinanderausführung. Sind f
und g Funktionen, so bezeichnet
f ◦g
28
Nichtprozedurale Programmierung
eine neue Funktion. Die Hintereinanderausführung, “◦”, ist dabei das Funktional, also die Funktion, die aus zwei
Funktionen eine neue Funktion macht. Funktionen, die nicht (nur) Werte, sondern auch Funktionen (oder auch
Funktionale) verarbeiten, nennt man Funktionen höherer Ordnung. Eine bekannte Funktion höherer Ordnung ist
map, die eine übergebene Funktion auf alle Elemente einer Kollektion, z.B. einer Liste, anwendet:
map(f, < x, y, z >) = < f (x), f (y), f (z) >
In Python ist map eine vordefinierte Funktion (genauer ein vordefiniertes Funktional) Beispiel:
>>> def f(x): return 2*x
...
>>> map(f, [1,2,3] )
[2, 4, 6]
-- Definition der Funktion f
-- f auf alle Listenelemente anwenden
Ableitung und Integration sind weitere bekannte Beispiele für Operationen auf Funktionen, also Funktionen höherer Ordnung, oder Funktionale.
Funktionsdefinition durch Abstraktion
Der heutige mathematische Begriff der Funktion ist in einem Prozess der Klärung und Reduktion in den letzten
200 Jahren entwickelt worden, also für mathematische Verhältnisse sehr jung. Für die Mathematiker des 18–ten
Jahrhunderts war – genau wie für uns normale Nicht–Mathematiker – eine Funktion nichts anderes, als eine durch
eine Formel ausgedrückte Abhängigkeit zwischen Werten.10 So bezeichnet der Ausdruck
x2 + 2x − 3
einen bestimmten Wert, wenn dem Symbol x ein Wert zugeordnet wird. Der Wert des Ausdrucks hängt vom Wert
von x ab. “Abstrahiert man von x”11 , so gelangt man zur charakteristischen Eigenschaft des neuen Ausdrucks: er
beschreibt ein Verfahren, um aus einem Wert einen anderen zu berechnen. Die übliche Notation für eine solche
Verfahrensvorschrift ist:
f (x) = (x2 + 2x − 3)
Hier wird
• eine Funktion durch Abstraktion definiert und gleichzeitig
• dieser Funktion ein Name gegeben.
Beide Prozesse trennend, hätte man schreiben können:
f = [x → (x2 + 2x − 3)]
um dam auszudrücken, dass f etwas ist, das einem beliebigen x den Wert (x2 + 2x − 3) zuordnet. In der Mathematik ist eine solche Trennung zwischen Definition und Benennung der Funktion weder üblich noch notwendig,
genauso in gängigen Programmiersprachen. Unklarheiten können zwar leicht auftreten: Was ist beispielsweise mit
der Gleichung
g(x) = h(x)12
gemeint? Sind hier zwei Werte (g(x) und h(x)) oder zwei Funktionen (g und h) gleich? Solche Fragen werden
in der Mathematik durch den Kontext oder durch mündliche oder schriftliche Erläuterungen geklärt. In gängigen
Programmiersprachen ist die Sache sowieso klar. Funktionen können nicht verglichen werden können und
g(x) = h(x)
ist darum entweder ein Vergleich von Werten, wenn “=” der Vergleichsoperator ist, oder eine Zuweisung.
Bei einer intensiveren Beschäftigung mit Funktionen – sei es als Mathematiker oder als funktionaler Programmierer – ist es nützlich, bei Funktionsdefinitionen zwischen Abstraktion und Namensgebung zu unterscheiden
10
Nach Courant ([7]) war Leibniz der erste der das Wort Funktion in diesem Sinne benutzte
Auch wenn Abstraktion ein so wichtiges Konzept der Informatik darstellt, so hätte man doch gelegentlich auch andere Begriffe verwenden
können.
12 Informale Alltagsnotation, kein C oder Python–Code. “=” soll hier “gleich” bedeuten!
11
Th Letschert, FH Giessen–Friedberg
29
und unter Umständen auch beides zu trennen. Eine aus der Mathematik stammende weitverbreitete Schreibweise
benutzt die sogenannte Lambda–Notation zur Darstellung von Abstraktionen13 . Wir schreiben statt
[x → (x2 + 2x − 3)]
in Lambda–Notation (λ–Notation):
λ x . x2 + 2x − 3
und definieren so unsere Funktion f folgendermaßen:
f = λ x . x2 + 2x − 3
f ist die Funktion, die einen Wert x nimmt und daraus den Wert x2 + 2x − 3 erzeugt. Der griechische Buchstabe
λ wird “Lambda” ausgesprochen. Er hat keine weitere Bedeutung, als dass er die Funktionsdefinition einleitet und
dem formalen Parameter vorangestellt wird.14 Der Ausdruck
λ x . x2 + 2x − 3
bedeutet nichts anderes, als
“Ich bin eine Funktion, leider habe ich keinen Namen, aber wenn man mich mit einem Argument x
aufruft, dann liefere ich x2 + 2x − 3.”
Der Begriff “Abstraktion” wird deutlich, wenn man sich klar macht, dass zwar
x2 + 2x − 3
y 2 + 2y − 3
und
normalerweise zwei sehr unterschiedliche Werte darstellen, aber
λ x . x2 + 2x − 3
und
λ y . y 2 + 2y − 3
völlig identisch sind. x und y spielen hier die Rolle von Platzhaltern, von Parametern. Man hätte auch jeden
beliebigen anderen Bezeichner nehmen können, es kommt nicht auf ihn an: er wurde “weg–abstrahiert”.
Im Gegensatz zu den meisten anderen Programmiersprachen erlaubt Python Lambda–Ausdrücke. Sie schreibt man
in Python den Ausdruck λ x . 2x − 3 einfach als
lambda x: 2*x - 3
Selbstverständlich erlauben auch funktionale Sprachen wie Lisp die Verwendung von Lambda–Ausdrücken. In
Lisp natürlich hübsch mit Klammern dekoriert und sortiert:
(LAMBDA (x) (- (* 2 x) 3))
Lambda–Abstraktionen: Funktionen als Werte von Ausdrücken
Jeder, der auch nur mit minimalster mathematischer Bildung ausgestattetet ist, kann Funktionen definieren, ohne das Konzept der Lambda–Abstraktion zu kennen. Alle halbwegs ernst zu nehmenden Programmiersprachen
erlauben die Definition von Funktionen. Nur einige wenige Exoten bieten dazu auch das Konzept der Lambda–
Abstraktion. Was bringt es also, wenn so viele ohne es auskommen? Was kann mit Lambda–Abstraktionen ausgrückt werden, das nicht auch mit “normalen” Funktionsdefinitionen gesagt werden kann.
Der entscheidende Unterschied zwischen einer Funktionsdefinition wie
def f (x):
return 2*x - 3
13 Wir unterscheiden die Lambda–Notation und den Lambda–Kalkül. Die Lambda–Notation ist einen Art Funktionsdefinitionen bequem zu
notieren. Der Lambda–Kalkül ist eine, in den 40–er Jahren des 20–ten Jahrhunderts entwickelte mathematische Theorie der Funktionen als
Regeln statt – wie sonst in der Mathematik – als Graphen (Tupel-Mengen). Er diente (auch) der Untersuchung von Fragen der Berechenbarkeit. (Alles was eine Turingmaschine kann, lässt sich mit λ–Ausdrücken beschreiben und umgekehrt.) Die Lambda–Notation wurde mit dem
Lambda–Kalkül entwickelt, ist aber nur eine triviale Notation innerhalb der Darstellung einer ausgefeilten mathematischen Theorie.
14 Die Tatsache, dass ein griechischer Buchstabe verwendet wird, macht die Sache nicht zu komplizierter Mathematik. Also keine Angst – es
ist alles ganz einfach. Auch normale Menschen können es verstehen.
30
Nichtprozedurale Programmierung
und der Verwendung eines Lambda–Ausdrucks wie in
f = lambda x: 2*x - 3
ist,
• dass im ersten Programmstück eine Funktion per Definition eingeführt wird
• und im zweiten Programmstück die gleiche Funktion der Wert eins Ausdrucks ist.
Definitionen verbinden einen Bezeichner statisch (d.h. zur Übersetzungszeit) mit einem Wert. Im ersten Beispiel
wird der Bezeichner f zur Übersetzungszeit mit der Funktion verbunden. Ein Ausdruck wird dagegen zur Laufzeit berechnet. Im zweiten Beispiel wird darum zur Laufzeit der Wert des Ausdrucks lambda x: 2*x - 3
berechnet und der Variablen f zugewiesen. Das Ergebnis beider Aktionen ist sehr ähnlich: f wird mit dem Wert
“λ x . 2∗x−3”15 verbunden. Einmal durch eine (statische) Definition, einmal durch eine (dynamische) Zuweisung.
In den meisten Sprachen können Funktionen nur definiert und nicht berechnet und zugewiesen werden. In diesen Sprachen ist der Umgang mit Funktionen darum beschränkt. 16 Prozedurale Sprachen akzeptieren diese Beschränkung. Die Verarbeitung der Programme im Rechner und im Geist der Programmierer wird durch diesen
Verzicht auf Ausdrucksmöglichkeiten vereinfacht. Die Kernidee des funktionalen Programmierens besteht aber
darin, sie aufzuheben und auch Funktionen als gleichberechtigte Werte zu behandeln und dann zu sehen, welche
Programme, Programmier– und Denkstile damit möglich werden. Der erste Schritt dazu ist, dass Funktionen als
dynamisch erzeugbare Werte behandelt werden und dazu braucht man Ausdrücke, deren Werte Funktionen sind.
Natürlich kann jeder implementierbare Algorithmus auch ohne die Verwendung von Lambda–Abstraktion implementiert werden. Aber genauso kann er auch ohne OO–Konzepte, ohne Funktionen, ohne Prozeduren, ohne Datentypen in reinem Maschinencode, wenn es sein muss in dem der Turingmaschine, implementiert werden. Welche
Konzepte ein Programmierer einsetzt ist eine Geschmacksfrage. Wieviele er kennt und einsetzen kann, bestimmt
allerdings ganz entscheidend die seine Produktivität.
Gebundene und freie Variable
Im Körper einer (Funktions–) Abstraktion, also in dem Ausdruck der abstrahiert wurde (das hinter dem Punkt), ist
der Parameter eine gebundene Variable. Nicht gebundene Variablen heißen frei (siehe Abbildung 1.11).
λ x . x + y − 3
Körper der Abstraktion
Abstraktion
freie Variable
gebundene Variable
Parameter
Abbildung 1.11:
Gebundene Variablen sind an eine Bedeutung, eine Aufgabe, gebunden. Die Variable x ist in
λx.x + y − 3
an ihre Aufgabe als Parameter gebunden. Dagegen ist y nicht gebunden, es hat keine festgelegte Bedeutung oder
Aufgabe in diesem Ausdruck. Es ist eine freie Variable: Der Kontext “ist frei” es in beliebiger Weise zu interpretieren. Fassen wir zusammen: Innerhalb eines Ausdrucks gibt es gebundene Variablen und freie Variablen:
15 Ja, “λ x . 2 ∗ x − 3” ist ein Wert! Kein einfacher Wert, aber ein Wert, ein Wert der eine Funktion ist. Ja das gibt’s. Wir müssen uns hier
daran gewöhnen, dass Funktionen als Werte behandelt werden!
16 Eine entsprechende Beschränkung hätte man bei Integerwerten, wenn sie nur in Konstantendefinitionen eingeführt werden könnten. Jeder
Integerwert der bei jedem Lauf jeden Programms jemals auftauchte, könnte im Voraus dem Quelltext des Programms entnommen werden.
Genau diese Situation haben wir bei Funktionen bei den meisten Programmen und Programmiersprachen.
Th Letschert, FH Giessen–Friedberg
31
• Gebundene Variablen sind an einen definierten Wert oder die Funktion als Parameter gebunden;
• alle nicht gebundenen Variablen sind frei.
1.2.6
Auswertung von Funktionen
Funktionsanwendung, Reduktion
Abstraktionen bezeichnen namenlose (anonyme) Funktionen. Man kann sie trotzdem auf Argumente anwenden:
(λ x . x2 + 2x − 3) (4) = 21
Häufig lässt man man auch die Klammern um das Argument weg:
(λ x . x2 + 2x − 3) 4 = 21
Die Klammerung der Abstraktion ist jedoch nicht überflüssig, denn verabredungsgemäß bindet der Punkt so weit
wie möglich nach rechts und damit wäre
λ x . x2 + 2x − 3 ∗ 4
keine Funktionsanwendung, sondern eine Abstraktion, d.h. eine Funktion.
Die Funktionsanwendung – die Auswertung eines Funktionsaufrufs –, besteht natürlich darin, dass der Parameter
im Körper der Funktion durch das Argument ersetzt wird. Der Ausdruck wird dabei vereinfacht, oder – wie man
auch sagt – reduziert. Für einen Schritt in der Auswertung / Reduktion eines Ausdrucks benutzt man oft einen
Pfeil:
(λ x . x2 + 2x − 3) (4) → 42 + 2 ∗ 4 − 3 → 16 + 8 − 3 → 24 − 3 → 21
Die Anwendung einer Funktion ist also Teil einer Ausdrucks-Auswertung, deren einzelne Schritte als Reduktionen
bezeichnet werden.
Funktionen höherer Ordnung
Die Lambda–Notation, mit ihrer Trennung von Definition und Namensgebung, ist besonders gut geeignet, um
Funktionen höherer Ordnung zu definieren. Beispielsweise ist der Ausdruck
λx.x + y − 3
eine Funktionsdefinition, die durch Angabe eines Wertes für die freie Variable y erst noch genauer bestimmt werden
muss. Abstrahieren wir die freie Variable y, so erhalten wir eine Funktionen erzeugende Funktion:
λ y . λ x . x + y − 3 = λ y . (λ x . x + y − 3)
Diese Funktion nimmt einer Wert und erzeugt damit eine neue Funktion. Beispielsweise:
(λ y . λ x . x + y − 3) (4) = λ x . x + 4 − 3; = λ x . x + 1
(λ y . λ x . x + y − 3) (3) = λ x . x + 3 − 3; = λ x . x
Funktionsabstraktionen (Lambda–Ausdrücke) können natürlich auch als Parameter übergeben werden:
((λ f. λ x. f (f (x))) (λ y . 2 ∗ y)) (4)
= (λ x .(λ y . 2 ∗ y) ((λ y . 2 ∗ y)(x)) (4))
= (λ y . 2 ∗ y) (8)
= 16
Selbstverständlich ist es nicht verboten, den durch Abstraktion definierten Funktionen und Funktionalen durch eine
Definition einen sinnvollen Namen zu geben. Damit wird dann das letzte Beispiel etwas übersichtlicher:
twice = λ f . λ x. f (f (x))
double = λ x . 2 ∗ x
(twice(double))(4) = 16
Funktionen höherer Ordnung können in Python recht einfach definiert werden. Beispielsweise die Hintereinanderausführung:
32
Nichtprozedurale Programmierung
def kringel(f, g):
return (lambda x: g(f(x)))
Ein Anwendungsbeispiel ist:
def kringel(f, g):
return (lambda x: g(f(x)))
def m2(x):
return 2*x
def m3(x):
return 3*x
ff = kringel(m2, m3)
print ff(2)
Natürlich geht das Gleiche auch komplett mit anonymen Funktionen, also mit Lambda–Ausdrücken:
print (lambda f,g:(lambda x: g(f(x))))(lambda x:2*x, lambda x:3*x)(2)
Oder, umgekehrt, auch komplett mit benannten Funktionen. Die Funktion kringel kann ja auch als
def kringel(f, g):
def gf(x):
return g(f(x))
return gf
definiert werden.
Variablenkollisionen
Bei der Funktionsanwendung durch Ersetzen von Parametern durch die ihnen entsprechenden Argumente können
Variablenkollisionen auftreten. Beispiel:
(λ y . (λ x . x + y))(x + 1)
? =? λ x . x + (x + 1) Fehler!
= λx.2 ∗ x + 1
Hier wird das freie x des Arguments irrtümlich gebunden. Der Irrtum besteht darin, dass es zufällig den gleichen
Namen hat wie der Parameter der inneren Funktion. Das Parameternamen irrelevant sind, benennen wir ihn einfach
um:
(λ y . (λ x . x + y))(x + 1)
= (λ y . (λ z . z + y))(x + 1)
= λ z . z + (x + 1)
In allen Herleitungen, ob manuell oder maschinell, müssen Variablenkollisionen durch geeignetes Umbenennen
der gebundenen Variablen vermieden werden.
Auswertungsreihenfolge: strikt oder nicht–strikt
Bei fast allen Programmiersprachen werden die Argumente einer Funktion ausgewertet, bevor sie an die Funktion
übergeben werden. Bei der Auswertung der Lambda–Ausdrücke gehen wir meist genauso vor:
(λ x . 2 ∗ x + 1)((λ y . y ∗ y) (4))
= (λ x . 2 ∗ x + 1)(4 ∗ 4)
= (λ x . 2 ∗ x + 1)(16)
Th Letschert, FH Giessen–Friedberg
33
= 2 ∗ 16 + 1
= 33
Diese Auswertungsreihenfolge, bei der die ausgewerteten Argumente an die Funktion übergeben werden, nennt
man strikt, oder auch call–by–value, oder “normal”. Statt in strikter Reihenfolge, kann man Ausdrücke auch in
nicht–strikter Reihenfolge auswerten:
(λ x . 2 ∗ x + 1)((λ y . y ∗ y) (4))
= (2 ∗ ((λ y . y ∗ y) (4)) + 1)
= (2 ∗ (4 ∗ 4)) + 1)
= 2 ∗ 16 + 1
= 33
Normalerweise ist die Auswertungsreihenfolge ohne Belang. Die strikte Auswertung ist in der Regel effizienter
und fast immer enden Auswertungen nach beiden Strategien mit dem gleichen Wert. Allerdings gibt es auch Fälle,
bei denen die Reihenfolge nicht egal ist. Ist beispielsweise das Argument nur sehr aufwändig oder gar nicht berechenbar, wird aber in der Funktion nicht benutzt, dann ist die nicht–strikte Auswertung von Vorteil, bzw. führt, im
Gegensatz zur strikten Auswertung zu einem Ergebnis.
(λ x . 1)((λ y . y ∗ y ∗ y ∗ y ∗ y ∗ y ∗ y ∗ y ∗ y) (3.1415926))
= 1
Hier benutzt die Funktion ihr Argument nicht, es lohnt sich darum nicht es auszuwerten und die nicht–strikte
Auswertung ist von Vorteil. Selbstverständlich hätte eine strikte Auswertung in diesem Beispiel zum gleichen
Ergebnis geführt. Hier wird klar, warum die nicht–strikte–Auswertung auch verzögert oder auch faul (delayed
oder lazy evaluation) genannt wird. Man ist zu faul das Argument sofort auszuwerten und verschiebt (verzögert)
diese Aufgabe so lange wie möglich.
Puristen unterscheiden noch feiner das Verhalten auf Funktions– und auf Sprachebene:
• Strikte / nicht strikte Funktion: Da Sprachen denkbar sind, bei den der Programmierer festlegen kann, ob
eine einzelne Funktion ihre Argumente vor dem Betreten des Rumpfes auswertet oder nicht, macht es Sinn
von strikten und und nicht strikten Funktionen zu reden.
• Sprache mit normaler / appliktiver Auswertungsstrategie: Liegt das Verhalten dagegen auf Sprachebene
fest, dann spricht man gelegentlich von Sprachen mit einer normalen (normal order) oder einer applikativen
(applicative oder) Auswertungstrategie. In den Sprachen mit applikativer Auswertungsstartegie sind alle
Funktionen strikt und umgekehrt.
Nicht strikte Auswertung eröffnet die Möglichkeit mit unendlichen Strukturen zu arbeiten. Doch davon später.
Currying
Eine Funktion mit zwei Argumenten kann nach allgemeinem Verständnis nicht auf nur ein Argument angewendet
werden:
(λ x, y . x + y)(2) = ?
Versteht man aber eine Funktion mit zwei Argumenten als Funktionen erzeugenden Funktion
λ x, y . x + y = λ x . λ y . x + y
dann kann einem Aufruf mit zuwenig Argumenten ein Ergebnis zugeordnet werden:
(λ x, y . x + y)(2) = (λ x . λ y . x + y)(2) = λ y . 2 + y
Diese Anwendung einer Funktion auf weniger Argumente als verlangt, mit einer neuen Funktion als Ergebnis,
nennt man Currying17 Currying gibt es weder in Python, noch in einer anderen gängigen Programmiersprache. In
gewisser Weise kann die partielle Instanzierung von Templates in C++ als Currying verstanden werden.
17 Nach einem Mathematiker namens Curry, der wesentliche Beiträge zum Lambda–Kalkül lieferte. (Es ist dem Autor nicht bekannt, in
welcher Beziehung er zu der gleichnamigen Wurst steht. Die Frage, ob eine Curry–Wurst durch Currying aus einer Bratwurst erzeugt wird,
muss ebenfalls noch erforscht werden.)
34
Nichtprozedurale Programmierung
Umgebung, Closure
Ein Ausdruck mit freien Variablen kann nur dann sinnvoll interpretiert werden, wenn die Bedeutung, der Wert, der
freien Variablen bekannt ist. Beispielsweise kann man mit dem Ausdruck
f (3, x)
nichts anfangen, wenn man den Wert der beiden freien Variablen f und x nicht kennt. Eine Festlegung der Bedeutung freier Variablen in einem Ausdruck nennt man Umgebung (engl. environment) des Ausdrucks. In der
Umgebung
[f → (λ x, y . x + y), x → 5]
hat f (3, 5) den Wert 8. In einer anderen Umgebung hat er in der Regel einen völlig anderen Wert. Man beachte
dass in diesem Beispiel x als freie Variable (in f (3, 5)) und als gebundene Variable (in λ x, y . x + y) vorkommt.
Die Umgebung bestimmt natürlich nur die freien Vorkommen einer Variablen.
Die Kombination aus einem Ausdruck und seiner Umgebung bezeichnet man oft als Closure. Beispielsweise ist
< f (x, 3), [f → (λ x, y . x + y), x → 5] >
ein Closure. In Python kann man leicht ein Closure als Funktion in einer fixierten Umgebung erzeugen:
def f(x):
return (lambda y: x+y)
foo1 = f(1)
foo2 = f(2)
print foo1(1)
print foo2(1)
In Sprachen wie etwa Pascal, in denen Funktionen innerhalb von Funktionen definiert werden können, kann ein
ähnlicher Effekt erreicht werden: Die innere Funktion kann freie Variablen enthalten, die in der äußeren gebunden
sind.
In C++ kann ein Closure nur über die Definition einer Klasse erzeugt werden, etwa äquivalent zum PythonBeispiel:
#include <iostream>
using namespace std;
class F {
public:
F (int p_x) : x(p_x) {}
int operator() (int y) { return x+y; }
private:
int x;
};
int main() {
F foo1(1);
F foo2(2);
cout << foo1(1) << endl
<< foo2(1) << endl;
}
Eine Funktionsanwendung kann man als Erweiterung der Umgebung verstehen:
< (λ x, y . x + y)(3, 4) [ ] > = < x + y, [x → 3, y → 4] >
Ein Funktionsaufruf in einer leeren Umgebung wird hier zu einem einfachen Ausdruck in einer erweiterten Umgebung.
Th Letschert, FH Giessen–Friedberg
1.2.7
35
Weitere Notationen und Eigenschaften
let–Notation
Will man einem Ausdruck informal eine Umgebung zuordnen, dann benutzt man oft die let–Notation:
let f = λ x, y . x + y,
in f (3, x)
x = 5
Dies ist natürlich nichts anderes als übersichtlichere notationelle Variante der Funktionsanwendung:
(λ f, x . (f (x, 3)))(λ x, y . x + y , 5)
Rekursive Funktionen definieren wir explizit mit dem Schlüsselwort letrec. Beispielsweise die Fakultätsfunktion:
letrec f = λ x . if x = 0 then 1 else f (x − 1) ∗ x
Rekursive Definitionen dieser Art sind nicht einfach eine andere Schreibweise für eine Funktionsanwendung. Die
Bedeutung der Notation ist jedoch sicher klar und mit Feinheiten aus der wunderbaren Welt der Rekursion werden
wir uns später etwas intensiver beschäftigen.
Funktionsdefinition durch Parameter–Muster
Einen weiteren Gewinn an Verständlichkeit und Übersichtlichkeit der Ausdrücke erhält man, wenn Funktionen
über Parametermuster definiert werden. Statt wie oben kann die Fakultätsfunktion mit Parametermustern wie folgt
definiert werden:
f (0) = 1
f (n + 1) = f (n) ∗ (n + 1)
Derartige Definitionen sind gut zu lesen, aber in einer Programmiersprache leider nur schwer zu implementieren.
Da sie auch nicht zu der Ausdrucksfähigkeit einer Sprache beitragen, werden sie fast nur informal benutzt. Einzig
in Prolog sind Elemente von Funktionsdefinitionen durch Parametermuster vorhanden.
Typen
Die Betrachtung von komplizierteren Ausdrücken wird durch Typ–Bestimmungen wesentlich erleichtert. Wenn
der Operator + auf reellen Zahlen R definiert ist, dann hat x + x den Typ R oder kurz
x+x : R
Weiter gilt
λx.x + x : R → R
Das heißt λ x . x + x ist eine Funktion von reellen Zahlen in reell Zahlen. Damit f (λ x . x + x) etwas Sinnvolles
bezeichnet, muss f eine (höhere) Funktion sein, die Funktionen vom Typ R → R verarbeitet und ein Ergebnis
von einem Typ T liefert:
f
f (λ x . x + x)
λ f . f (λ x . x + x)
:
(R → R) → T
: T
: ((R → R) → T ) → T
Funktionen werden in Lambda–Notation in der Regel ohne explizite Typen angegeben. Sie sind damit nicht typfrei,
sondern haben “viele Typen”. Beispielsweise hat
λx.x
den Typ T → T für jeden Typ T und
λx, y . x + y
hat den Typ (T × T ) → T für jeden Typ T auf dem die Addition definiert ist.
36
Nichtprozedurale Programmierung
Hat ein Ausdruck einen Typ, der sich mit Hilfe von Typ–Parametern beschreiben lässt, dann spricht man oft
von parametrischem Polymorphismus. Der Ausdruck hat keinen festen Typ, er ist polymorph, was so viel wie
“vielgestaltig” bedeutet. Seine Vielgestaltigkeit ist aber regelmäßig und kann mit einem oder mehreren Parametern
beschrieben werden. Folgende Ausdrücke sind beispielsweise parametrisch polymorph:
λx.x
λf.λx.f (f (x))
Polymorph, aber keineswegs parametrisch polymorph ist dagegen
λx, y . x + y
denn der Typ aller Paare, auf denen die Addition definiert ist, kann nur durch eine Aufzählung und nicht durch
einen Typausdruck mit beliebig ersetzbarem Typ–Parametern definiert werden.
Th Letschert, FH Giessen–Friedberg
1.3
37
Funktionales Programmieren
Wandelt sich rasch auch die Welt
Wie Wolkengestalten,
Alles Vollendete fällt
Heim zum Uralten.
R. M. Rilke
1.3.1
Algorithmenschemata
Zerlegen von Algorithmen
Algorithmen lassen sich oft in Klassen einteilen, deren Mitglieder Varianten eines bestimmten Grundschemas
sind. Ein Beispiel ist die Berechnung der einfachen Summe einer Zahlenfolge und die Berechnung der Summe der
Quadarte:
#einfache Summe
def sum(von, bis):
if von > bis:
return 0
else:
return von+sum(von+1,bis)
#Quadratsumme
def sumsq(von, bis):
if von > bis:
return 0
else:
return von*von+sumsq(von+1, bis)
Hier sind sehr deutlich die Gemeinsamkeiten und das Trennende zu erkennen und es gelingt leicht das Tennende,
als Funktion auf den einzelnen Elementen der Folge, zu isolieren:
def sumg(von, bis, f):
if von > bis:
return 0
else:
return f(von)+sumg(von+1, bis,f)
Die beiden ursprünglichen Definitionen werden damit zu:
#einfache Summe
#
def sumsq(von, bis):
def f(x): return x
return sumg(von, bis, f)
#Quadratsumme
#
def sumsq(von, bis):
def f(x): return x*x
return sumg(von, bis, f)
Oder etwas knapper:
#einfache Summe
#
def sum(von, bis):
return sumg(von, bis, lambda x: x)
#Quadratsumme
#
def sumsq(von, bis):
return sumg(von, bis, lambda x: x*x)
Lokale Funktion
Die allgemeine Summations-Funktion sumg lässt sich auch noch etwas anders formulieren. In ihr ist bekannt,
welche Verarbeitungsfunktion f angewendet werden soll und so können wir eine entsprechende lokale Traversierungsfunktion18 konstruieren, die dann die Arbeit macht:
18
Traversieren = Durchlaufen
38
Nichtprozedurale Programmierung
# Summation mit lokaler Hilfsfunktion
#
def sumg(f, von, bis):
def sumf(von, bis):
if von > bis:
return 0
else:
return f(von)+sumf(von+1, bis)
return sumf(von, bis)
print sumg(lambda x: x, 1,3)
print sumg(lambda x: x*x, 1,3)
#einfache Summe
#Quadratsumme
Der Trick bei einer lokalen Funktion19 ist, dass in ihr der oder die Parameter der “Mutterfunktion” sumg auf einen
bestimmten Wert fixiert sind. Sie wird in der Umgebung der Mutterfunktion ausgewertet. Die freien Variablen der
Kindfunktion sind in der Mutterfunktion gebunden.
Funktionale Ergebnisse
Aus einer Funktion, die drei Parameter hat, lässt sich eine Funktion machen, die einen Parameter annimmt und
deren Ergebnis dann auf den zweiten und dritten Parameter wartet (Currying der Funktion). In unserem Beispiel
machen wir aus einer Funktion mit drei Parametern (f, von, bis) eine die einen (f) nimmt und ein Ergebnis
erzeugt, das auf die zwei weiteren (von, bis) wartet. Wir geben dazu einfach die lokale Funktion als Funktionsergebnis zurück:
#ge-curry’te allgemeine Summation
#
def sumg(f):
def sumf(von, bis):
if von > bis:
return 0
else:
return f(von)+sumf(von+1, bis)
return sumf
In Lambda–Notation ist das nichts anderes als:
λ f . letrec
sumf = λ von, bis . if von > bis then 0 else f (von) + sumf (von + 1, bis)
in sumf
sumf will nicht mehr
nur zuhause arbeiten
und verlässt das Mutterhaus
sumg
f
sumf
Abbildung 1.12: Funktionsergebnisse als befreite lokale Funktionen
19 die
es in C, C++ und Java nicht gibt
Th Letschert, FH Giessen–Friedberg
39
Funktionale Ergebnisse liefern eine weitere Entkopplung
Welchen Sinn macht das? Hier wird nichts wirklich Neues eingeführt. Die Funktion sumf wird weiterhin wie oben
in ihrer Mutterfunktion sumg erzeugt, dann aber kann sie sich von ihr lösen. Sie verlässt ihre Mutterhaus (nimmt
dabei f mit) und kann unabhängig von ihr an jedem anderen Platz verwendet verwendet werden (siehe Abbildung
1.12). Wir können jetzt beispielsweise schreiben:
sumsq = sumg(lambda x: x*x)
# Erzeugen der Kindfunktion
print sum(1,3)
# Kindfunktion wird verwendet
print sumg(lambda x: x*x)(1,3)
Aber der Verwendung sind keine Grenzen gesetzt. sumg(h), wobei h eine beliebige Funktion ist, kann an jeder
Stelle verwendet werden, an der eine Funktion mit zwei Parametern auftauchen kann. Wir können mit Hilfe dieser
Entkopplung keine Algorithmen formlieren, die Probleme lösen die auf andere Art unlösbar sind. Wir können sie
aber eventuell einsetzen, um elegantere, kürzere und übersichtlichere Programme zu schreiben oder um schneller
und sicherer zu einer Lösung zu kommen.
Funktionen höherer Ordnung bieten also die Möglichkeit einer Zerlegung von Algorithmen und der Entkopplung
ihrer Bestandteile. Sie sind wie etwa die Objektorientierung eine Möglichkeit, Abstraktionen zu bilden und damit letztlich eine Programmiertechnik, die, je nach Geschmack, Geschick und Begabung produduktiv eingesetzt
werden kann – oder eben nicht.
1.3.2
Numerisches
Fixpunkt
Ein Fixpunkt einer Funktion f ist ein Wert x der von f nicht transformiert wird, für den also gilt f (x) = x. Der
Fixpunkt einer Funktion f kann mit folgender Funktion, von einer ersten Näherung firstguess aus, angenähert
werden:
def fixedpoint (f, firstguess):
tolerance = 0.0001
def closeenough (v1, v2):
return (abs(v1- v2) < tolerance)
def tr(guess):
next = f(guess)
if closeenough(guess,next):
return next
else:
return tr(next)
return tr(firstguess)
Die Funktion fixedpoint hat zwei lokale Hilfsfunktionen. closeenough testet ob die gefundene Annäherung
am dicht genug am wahren Fixpunkt ist und tr produziert die nächste Annäherung.
Liefert fixedpoint einen Wert oder eine Funktion, die dann den Fixpinkt sucht? Nun, fixedpoint ist recht
bescheiden, mit einer Funktion und einer ersten Annäherung als Argument liefert es den Fixpunkt – natürlich
nur, wenn es einen solchen tatsächlich gibt. f (x) = 12 x + 1 hat den Fixpunkt 2. Wir testen wie dicht unsere
fixedpoint–Funktion daran heran kommt:
fix = fixedpoint(lambda x: 0.5*x+1, 1.0)
print fix, " = ", (lambda x: 0.5*x+1)(fix)
Wurzel ziehen
Die Fixpunkt–Funktion
√ kann eingesetzt werden um Wurzeln zu ziehen. Wir beobachten dass
Funktion y = x2 ist. ( 2 = √22 ). Also müsste
√
2 ein Fixpunkt der
40
Nichtprozedurale Programmierung
fixedpoint(lambda x: 2/x, 1.0)
die Wurzel aus 2 berechnen. Leider konvergiert die Berechnungg nicht: Sie läuft in eine Endlosrekursion bei der
in fixedpoint der Wert von next zwischen 1 und 0 hin und her springt. Dies kann verhindert werden, wenn
die Funktion y = x2 zu
y = 21 (x + x2 )
“gedämpft” wird. Wie man leicht ist ist ja jeder Fixpunkt von y = f (x) auch Fixpunkt von y = 12 (x + f (x)) und
umgekehrt. Wie ziehen also die Wurzel aus 2 mit folgendem Verfahren:
sqrt2 = fixedpoint(lambda x: 0.5 * (x + 2/x), 1.0)
Wir haben hier Suche nach die Wurzel aus 2 als Suche nach einem Fixpunkt formuliert. Das kann auch verallgemeinert werden. Für jedes z ist der Fixpunkt von y = xz gleich der Wurzel von z. Wir beachten den notwenigen
Dämpfungsfaktor und erhalten einen funktionalen Algorithmus zur Wurzelberechnung:
def sqrt(z):
return fixedpoint(
lambda x: 0.5 * (x + z/x), 1.0)
Dieser Algorithmus berechnet den Fixpunkt der zu y = 21 (x + xz ) gedämpften Funktion y = xz . Diese Dämpfung der Funktion ist nur eine von verschiedenen Möglichkeiten der Dämpfung. Im Code ist aber leider nicht
mehr erkennbar, was die Funktion und was ihre Dämpfung ist. In Sinne einer weitestgehenden Entkopplung aller
Bestandteile des Algorithmus können wir den “versteckten” Übergang von
f : y = f (x) = 21 (x + xz )
zur gedämpften Version
D(f ) : y = 21 (x + f (x)) = 12 (x + xz )
leicht explizit machen:
def sqrt(z):
f = lambda x: z/x
D = lambda f : lambda x : 0.5*(x + f(x))
return fixedpoint(D(f), 1.0)
Jetzt kann die Dämpfung und die Funktion jederzeit leicht ausgetauscht werden. Wollen wir etwa die dritte Wurzel
ziehen dann brauchen wir nur die Funktion auszutauschen. Die dritte Wurzel aus z ist ein Fixpunkt von y = xz2 :
def sqrt3(z):
f = lambda x: z/x*x
D = lambda f : lambda x : 0.5*(x + f(x))
return fixedpoint(D(f), 1.0)
Der Übergang zur n–ten Wurzel ist jetzt auch kein Problem mehr.
Wir sehen, dass der funktionale Stil gut geeignet ist, die Entkopplung von Software zu fördern. Wenn es um
komplexere Algorithmen geht, liefert die Objektorientierung in der Regel kaum Hinweise zu einer Strukturierung,
hier kann aber der funktionale Stil gute Dienste leisten. Funktionales Programmieren ist also ein zwar kleines, aber
durchaus ernst zu nehmendes Kapitel der Softwaretechnik.
1.3.3
Listen
Abbildungen von Listen: Map
Im letzten Abschnitt haben wir gezeigt, wie der funktionale Stil bei der Organisation von Software helfen kann,
speziell dann, wenn die Software anspruchsvollere algorithmische Verfahren beinhaltet. Das legt eine Anwendung
mathematisch–numerische Problemstellungen nahe, ist aber keineswegs auf sie beschränkt.
Th Letschert, FH Giessen–Friedberg
41
Bei der Arbeit mit Datenstrukturen hat man es häufig mit komplexeren Algorithmen zu tun, bei denen eine Strukturierung nützlich ist. Naheliegend ist dabei eine Strukturierung, bei der die zwei prinzipiellen Komponenten von
Algorithmen auf Datenstrukturen frei gelegt werden:
• die Verarbeitung der einzelnen Elemente, sowie
• das Durchlaufen der Datenstruktur um diese Elemente aufzufinden.
In der objektorientierten Variante wird eine entsprechende Entkopplung durch das Iterator–Muster unterstützt.
Das Äquivalent des Iterators in der “funktionalen Softwaretechnik” ist die map–Funktion. Eine Map–Funktion
durchläuft eine Liste, wendet eine übergebene Funktion auf jedes Listenelement an und liefert die daraus gebildete
Liste als Gesamtergebnis:
def Map(f, l):
if l == []:
return []
else:
return [f(l[0])]+Map(f, l[1:])
Hier ist l[0] das erste Listenelement, l[1:] ist der Rest der Liste und + verbindet zwei Listen zu einer Gesamtliste.
Wie oft auch in rein funktionalen Sprachen üblich, ist in Python die Map–Funktion als map vordefiniert. So liefert
map(lambda x: x+1, [1,2,3,4])
genau wie unsere Map–Funktion den Wert
[2, 3, 4, 5]
Das vordefinierte map von Python kann auch zwei Listen verarbeiten. Wird es entsprechend aufgerufen, dann wird
die Funktion paarweise auf die Listenelemente an gleicher Position in beiden Listen angewendet:
map(lambda x,y: x*y, [1,2,3,4], [’a’,’b’,’c’,’d’])
liefert darum dann den Wert
[’a’, ’bb’, ’ccc’, ’dddd’]
Die Map–Funktion von oben kann, genauso wenig wie die vordefinierte, auf verschachtelte Listen angewendet
werden. Folgender Aufruf wird darum eine Fehlermeldung quitiert:
map(lambda x: x+1, [1,[2,3],4]) Geht nicht, Fehler!
Reduktion von Listen: Reduce
Die Map–Funktion erzeugt aus einer Liste eine neue Liste. Gelegentlich will man die Elemente einer Liste zu
einem skalaren Gesamtergebnis zusammenfügen. Beispielsweise können alle Elemente einer Liste mit folgender
Funktion aufaddiert werden (Der besseren Übersicht haben wir die elementaren Listenoperationen in Form von
Hilfsfunktionen definiert) :
def leer(l):
return len(l) == 0
def kopf(l):
return l[0]
def rest(l):
return l[1:]
# ist die Liste leer ?
# das erste Element einer Liste
# der Rest, alle ausser dem ersten
def addAll(l):
if leer(l): return 0
else:
return kopf(l) + addAll(rest(l))
Auch hier ist die konkrete Operation des Addierens ein Sonderfall, der aus dem Gesamtalgorithmus “heraus abstrahiert” werden kann:
42
Nichtprozedurale Programmierung
def Reduziere(f, l, s):
if leer(l): return s
else:
return f(kopf(l), Reduziere(f, rest(l), s))
Die Folge der Aktionen startet mit einem Anfangswert s und wendet die Funktion f sukzessive auf das bisher
gebildete Zwischenergebnis und ein Listenelement an. So liefert
Reduziere(lambda x,y: x+y, [1,2,3], 0)
den Wert 6 und mit dem Wert 24 endet
Reduziere(lambda x,y: x*y, [1,2,3, 4], 1)
Liebhaber iterativer Algorithmen können sich die Funktion f als Körper einer Schleife vorstellen, die über die
Liste läuft. Reduziere ist dann so etwas wie:
def ReduziereIter(f, l, s):
v = s
for i in l:
v = f(i, v)
return v
Allerdings mit einem feinen Unterschied. Während ReduziereIter die Liste von vorn durchläuft, arbeitet sich
die rekursive Variante von hinten nach vorn vor. Bei Funktionen f, die, wie unsere Beispiele, kommutativ sind,
macht das keinen Unterschied. Das + auf Zeichenketten ist nicht kommutativ und so beobachten wir:
>> ReduziereIter(lambda x,y: x+y, [’a’,’b’,’c’,’d’], ’’)
’dcba’
>>> Reduziere(lambda x,y: x+y, [’a’,’b’,’c’,’d’], ’’)
’abcd’
Python enthält mit der vordefinierten Funktion reduce auch eine Implementierung der Reduce–Funktion. Es
bleibt dem Leser überlassen heraus zu finden , welcher unserer Varianten der Reduce–Funktion die vordefinierte
entspricht.
Eine tiefe Reduktion
Map und Reduce sind Vefahren die auf Listen operieren. Auf verschachtelte Listen (= Bäume) können sie nicht
angewendet werden. So sind die beiden folgenden Ausdrücke nicht auswertbar:
map (lambda x:x+1, [1, [2, 3], 4])
reduce (lambda x,y:x+y, [1, [2, 3], 4], 1)
Geht nicht, FEHLER!
Geht nicht, FEHLER!
Wollen wir alle Blätter eines Baums aufaddieren, dann muss eine neue Funktion her:
def addBlatt(l):
if leer(l):
return 0
if atom(kopf(l)):
return kopf(l) + addBlatt(rest(l))
else:
return addBlatt(kopf(l)) + addBlatt(rest(l))
Wir verwenden der Übersichtlichkeit halber wieder Hilfsfunktionen:
def leer(l):
return len(l) == 0
def atom(l):
try:
x=len(l)
return 0
# Liste leer
# ist l atomar, d.h. kein strukturiertes Objekt
Th Letschert, FH Giessen–Friedberg
except:
return 1
def kopf(l):
return l[0]
43
# Listenkopf: erstes Element
Die Funktion atom testet, durch versuchsweises Anwenden der Längenfunktion, ob ihr Argument eine Liste oder
eine unstrukturiertes “Atom” ist.
Bei addBlatt können wir wieder den Traversierungs– und den Verarbeitungsalgorithmus trennen, um zu einer
verallgemeinerten Reduce–Funktion ReduziereTief zu kommen. Die Verarbeitung ist wieder eine Addition.
Extrahieren wir also eine Addition aus addBlatt, derart, dass beispielsweise
ReduziereTief(lambda x,y:x+y, [1, [2, 3], 4], 0)
genau wie
addBlatt([1, [2, 3], 4])
den Wert 10 liefert. Der gesuchte Algorithmus ist eine Variante des Reduktionsalgorithmus, bei dem wir beachten,
dass ein Listenelement selbst eine Liste sein kann, die mit dem gleichen Verfahren bearbeitet werden muss:
def ReduziereTief(f, l, s):
if leer(l):
return s
if atom(kopf(l)):
return f(kopf(l),
ReduziereTief(f, rest(l), s))
else:
return f(ReduziereTief(f, kopf(l), s),
ReduziereTief(f, rest(l), s))
Kombinationen von Map und Reduce
Hätten wir konsequenter in den Abstraktionen gedacht, die Map und Reduce liefern, wären wir eventuell schneller
ans Ziel gelangt. Der Sinn von Abstraktionen liegt darin, dass sie ein Konzept verkörpern, das man anwenden kann,
ohne die Innereien und Details zu beachten und dass sie damit zu selbständigen Bausteinen des Denkens werden
können.
Map verarbeitet alles in einer Liste zu einer neuen Liste. Reduce reduziert alle Listenelemente zu einem Wert.
Die tiefe Reduktion ist eine Kombination von beidem. Mit Map können die Sublisten bearbeitet werden und ein
anschließendes Reduzieren komprimiert dann die Ergebnisliste:
def ReduziereTief(f, l, s):
def h(x):
if atom(x):
return x
else:
return ReduziereTief(f,x,s)
return reduce(f, map(h,l), s)
So liefert der Aufruf
ReduziereTief( lambda x,y:x+y,
[1, [2, 3], 4, [1, 2], [], [1]],
0 )
erwartungsgemäß den Wert 14.
Das Konzept Funktion als Wert liefert eine noch etwas übersichtlicher Variante von ReduziereTief
def ReduziereTief(f,s):
return lambda l: (
44
Nichtprozedurale Programmierung
(atom(l) and [l])
or (leer(l) and [s])
or [reduce(f, map( ReduziereTief(f,s), l), s)])[0]
Eine Anwendung dieser Funktion ist
addiereAlle = ReduziereTief(lambda x,y:x+y, 0)
print addiereAlle([1, [2, 3], 4, [1, 2], [3,[1]], [1]])
In Lambda–Notation wird die Definition etwas übersichtlicher:
letrec ReduziereT ief = λ f, s .
λ l . if atom(l)
then l
elseif leer(l)
then s
else
reduce(f, map(ReduziereT ief (f, s), l), s)
Insgesamt haben wir gesehen, wie Funktionsparameter und Funktionen als Ergebnisse oft überraschend neue und
klare und/oder kompakte Strukturierungen von Algorithmen möglich machen. Mit der Verwendung von Funktionen höhrer Ordnung, wie beispielsweise map und reduce, eröffnet sich darüber hinaus ein Raum in dem mächtige
neue Konzepte (Bausteine, Komponenten) entwickelt und zu weiteren mächtigen neuen Konzepten aufgebaut werden können (siehe Abbildung 1.13).
reduziereTief
map
Abstraktionsebene 2
reduce
elementare Operationen auf Listen
Abstraktionsebene 1
Abstraktionsebene 0
Abbildung 1.13: Ebenen funktionaler Abstraktionen
Th Letschert, FH Giessen–Friedberg
1.3.4
45
Daten und Datenstrukturen
Daten: einfach oder strukturiert, endlich oder unendlich, Elemente endlicher oder unendlicher Mengen
Daten können einfach oder zusammengesetzt sein. Sie können Elemente einer endlichen oder unendlichen Menge sein. Boolesche Werte sind einfach und Elemente einer endlichen Menge. Natürliche Zahlen sind einfach und
Elemente einer unendlichen Menge. Paare sind zusammengesetzt und je nach dem Typ ihrer Komponenten Elemente einer endlichen oder einer unendlichen Menge. Listen sind zusammengesetzt und unabhängig vom Typ ihrer
Komponenten selbst stets Elemente unendlicher Mengen.
• true {true, f alse}
• 3 {0, 1, 2, · · ·}
• < 1, 2 > {<>, < 0 >, < 1 >, · · · , < 0, 0 >, < 0, 1 >, · · ·}
Daten selbst, nicht nur die Mengen denen sie entstammen, können ebenfalls unendlich sein. Die Liste aller natürlichen Zahlen ist ein unendliches Datum, das Element einer “noch unendlicheren” Menge ist, die von den Listen
beliebiger, auch unendlicher Länge gebildet wird.20
Objekte von unendlicher Größe wollen wir an dieser Stelle nicht weiter betrachten. Praktisch gesehen spielen in
der Informatik nicht einmal unendliche Mengen möglicher Objekte eine Rolle. Da der Speicherplatz begrenzt ist,
ist die Zahl der Bitmuster, die im Speicher einer realen Maschine ablegbar sind, immer endlich. Also wird auch
jede gespeicherte Zahl nur eine eine maximale Größe oder Genauigkeit haben und jede Liste nur eine maximale
Länge. Nicht nur das einzelne Element ist also begrenzt, die Zahl der Möglichkeiten solche Elemente zu bilden,
ist es auch.
Diese Grenzen hängen jedoch von der konkreten Maschine, ihrer Konfiguration und ihrem aktuellen Zustand ab.
Es ist darum üblich, und erleichtert zudem die Argumentation wesentlich, von diesen Beschränkungen abzusehen
und unendliche Mengen möglicher Objekte zu betrachten. Listen werden also üblicherweise als beliebig lange
Listen betrachtet und nicht als Listen, die höchstens so lang sind, dass sie in den aktuell zur Verfügung stehenden
Speicher passen.
Induktive Mengen
Einfache Funktionsdefinitionen wie die von mult operieren auf induktiven Mengen und folgen in ihrer Definition der Struktur der Elemente dieser Mengen. Induktive Mengen sind Mengen wie die natürlichen Zahlen, deren
Elemente entweder zu einer endlichen Menge an Basiselementen gehören oder in eindeutiger Weise aus den Basiselementen mit Hilfe einer Konstruktionsfunktion erzeugt werden können. Bei den natürlichen Zahlen ist die
Menge {0} die Basismenge und λx.x + 1 ist die Konstruktionsfunktion. Die rekursive Definition der Multiplikation folgt dem “Aufbau” einer natürlichen Zahl aus der Null und einer Serie von Additionen von Eins, indem sie
diesen Aufbau rückwärts wieder abbaut.
Induktive Mengen nennt man auch rekursive Daten. Sie spielen eine außerordentlich wichtige Rolle in der Informatik. Nicht bei allen21 , aber bei sehr viele Datentypen mit unendlich vielen Elementen handelt es sich um induktive
Daten. Nehmen wir als Beispiel die Menge der Sequenzen (Listen) von natürlichen Zahlen S. Eine Sequenz ist
entweder leer, oder wird aus einer Sequenz durch Vorsetzen eines weiteren Elementes gebildet. Wir können diese
Menge mit einer rekursiven Definition beschreiben:
<> S
s S, n N ⇒ < n, s > S
Die Basismenge ist einelementig und besteht aus der leeren Sequenz. Wie bei den natürlichen Zahlen gibt es auch
nur eine einzige Konstruktionsfunktion, nennen wir sie einfach sKonstr, Sequenzen–Konstruktionsfunktion:
20 Die Menge der natürlichen Zahlen ist abzählbar, die Menge der Listen endlicher Länge von natürlicher Zahlen ebenfalls. Beide Mengen sind also “gleich unendlich”. Lässt man jedoch auch beliebige Listen unendlicher Länge zu, dann wird die Menge überabzählbar, also
“unendlicher” als einfach “unendlich”.
21 Die reellen Zahlen sind nicht induktiv
46
Nichtprozedurale Programmierung
sKonstr = λn, s. < n, s >
Sie nimmt eine Zahl und eine Sequenz und bildet daraus eine um ein Element längere Sequenz.
Eine wesentliche Eigenschaft induktiver Mengen ist die Tatsache, dass ihre Elemente in eindeutiger Weise aus
einfacheren aufgebaut sind. Das bedeutet beispielsweise, dass zu jeder Sequenz s, die nicht die leere Sequenz ist,
genau eine einfachere Sequenz s0 und genau eine natürliche Zahl n existieren, aus der s erzeugt wurde:
s = sKonstr(n, s0 ) = < n, s0 >
Wenn nun die Bestandteile n und s0 von s eindeutig sind, dann können wir Funktionen definieren, die sie bestimmen. Die Selektionsfunktionen head und tail bestimmen die Komponenten einer Sequenz:
s = sKonstr(n, s0 ) ⇒ head(s) = n, tail(s) = s0
oder einfacher:
head(< n, s0 >) = head(sKonstr(n, s0 )) = n
tail(< n, s0 >) = tail(sKonstr(n, s0 )) = s0
Diese Konstruktions– und Selektionsfunktionen dürfen keineswegs mit Konstruktoren und Destruktoren in C++
verwechselt werden. Ein Konstruktor initialisiert ein bereits existierendes Objekt. Eine Konstruktionsfunktion erzeugt dagegen wirklich einen Wert. Ein Destruktor räumt vor der Vernichtung auf. In der funktionalen Welt gibt
es keine Vernichtung von Werten und Aufräumaktionen gibt es nur hinter den Kulissen als Bestandteil der Implementierung, wenn der Garbage Collector aktiv wird.
Sequenzen, Tupel, Listen
Sequenzen sind Folgen von Elementen. In einem typisierten Umfeld haben diese Elemente den gleichen Typ. Die
Bedeutung von “gleicher Typ” kann dabei recht weit gespannt sein. In Sprachen ohne Objektorientierung und
Polymorphismus ist der Typbegriff eher eng. In OO–Sprachen ohne eine allgemeine Basisklasse, wie etwa C++,
ist er schon etwas weiter und in Sprachen wie Java, in denen jedes Objekt von einer Basisklasse abgeleitet ist und
grundsätzlich mit Zeigern gearbeitet wird, sind wir nicht mehr sehr weit von dynamischer Typisierung (Typfreiheit)
entfernt.
In jedem Fall soll der Begriff “Sequenz” zum Ausdruck bringen, dass man es mit einer Folge von gleichartigen
Elementen zu tun hat und dass diese Elemente entweder keine weitere Unterstruktur haben, oder dass diese Unterstruktur nicht weiter relevant ist. So verstandene Sequenzen sind induktive Daten. Man beginnt mit der leeren
Sequenz und erzeugt durch durch Anhängen bzw. Vorsetzen eines weiteren Elements eine neue Sequenz.
Der Begriff “Tupel” ist weitgehend synonym zu dem der “Sequenz”. Bei einem Tupel denkt man aber, eher als bei
einer Sequenz, an eine definierte fixe Länge. Paare und Tripel sind Sonderformen von Tupeln. In Python versteht
man “Tupel” als unveränderliche Listen/Sequenzen – also als wertorientierte Datenabstraktion.
In unserer informalen Notation benutzen wir spitze Klammern um Sequenzen darzustellen. So ist < 1, 1, 2, 3, 5 >
eine Sequenz in informaler (mathematischer) Notation. Deren Realisation wird in unterschiedlichen Programmiersprachen unterschiedlich aussehen. In Python stehen uns die vordefinierten (Python–) Listen und (Python–)
Tupel zur Verfügung. Java und C++ kennen (Java/C++–) Vektoren und Listen. Daneben kann man sich in
der Regel noch seine eigenen Typen oder Klassen zur Realisation von Sequenzen zusammen basteln.
Statt von Sequenzen spricht man oft auch von Listen. Der Begriff “Liste” wird meist etwas etwas allgemeiner als
der der “Sequenz” aufgefasst. Die Einheitlichkeit der Elemente wird bei Listen etwas lockerer gesehen als bei
Sequenzen. Auf Listen sind zudem weitere Operationen erlaubt. So erwartet man von einer Liste im Allgemeinen,
dass Elemente nicht nur am Anfang oder Ende, sondern mitten in ihrem Inneren eingefügt und entnommen werden
können. Für die Implementierung von Listen in diversen Programmiersprachen gilt das Gleiche wie für Sequenzen.
Die Sprachen bieten meist geeignete Konstrukte mit unterschiedlichen Namen (list, vektor, etc.) und erlauben
bei Bedarf die Konstruktion eigener Typen.
Th Letschert, FH Giessen–Friedberg
47
Listen und Paare in Lisp
In der funktionalen Welt spielen Listen eine besondere Rolle. Das liegt auch an Lisp und des mit dieser Sprache so
eng verbundenen Konzepts der (Lisp–) Liste. Von Anfang an bis heute sind Listen der einzige strukturierte Datentyp
von Lisp. Eine Liste besteht aus einer Folge von beliebigen Elementen. Listen können also alles enthalten, auch
wieder Listen, oder Listen die selbst auch wieder Listen enthalten. Eine Liste wird mit der Funktion list erzeugt.
Beispielsweise erzeugt man mit dem Ausdruck
(list 1 2 3)
die Liste (123). Lisp–Ausrücke sind vollständig geklammert und werden in Prefix–Notation geschrieben. D.h. jeder Ausdruck beginnt mit dem Operator. Statt 2 + 3 oder f (x) schreibt man also in Lisp:
(+ 2 3) und
(f x)
In (list 1 2 3) sehen wir den Aufruf der Funktion list mit den Argumenten 1, 2 und 3.
Zur internen Speicherung aller Datenstrukturen – auch der Listen – verwendet Lisp eine einzige elementare Struktur, das Paar. Ein Paar ist ein Knoten mit zwei Zeigern, die jeweils auf ein Element eines Paars zeigen. Listen
werden auf Paare zurück geführt. Die leere Liste ist der Null–Zeiger. Eine Liste mit einem Element ist das Paar
aus diesem Element und der leeren Liste, etc. Beispielsweise hat die Liste (X(Y Z)U ) drei Elemente, und wird so
als Folge von drei Paaren gespeichert. Das zweite Element ist selbst eine Liste und wird intern als Folge von zwei
Paaren dargestellt.
X
Y
U
0
Z
0
Abbildung 1.14: Speicherung der Liste (X (Y Z) U) in Lisp
Diese Strukturen sehen aus wie Listen, es liegt nahe sie “Listen” zu nennen. Im Folgenden werden wir dies auch
ohne weitere Skrupel tun. Tatsächlich sind sie natürlich keine Listen sondern Binär–Bäume. (siehe Abbildung
1.14).
Die Konstruktionsfunktion für Paare heißt cons. Ist a ein Atom oder eine Liste und l eine Liste, dann ist cons(a, l)
– oder in Lisp–Notation (cons a l) – die Liste, die durch das Vorsetzen von a vor l entsteht. Beispiele:
(cons 1 ())
= (1)
(cons 1 (cons 2 (cons 3 ())))
= (1, 2, 3)
(cons (cons 1 ()) (cons 2 (cons 3 ()))= ((1), 2, 3)
Die Funktion list basiert auf cons So ist
(list 1 2 3)
als
(cons 1 (cons 2 (cons 3 ())))
zu verstehen. Die Selektionsfunktionen nennen sich in Lisp car und cdr (sprich “Kudr”). car selektiert das erste
Element einer Liste und cdr den Listenrest. Beispiele:
(car (list 1, 2, 3) )
(cdr (list 1, 2, 3) )
=1
= (2, 3)
Als Bezeichnung der leeren Liste wird häufig nil oder null verwendet. Damit kommt man zu den Listen im Sinn
von Lisp und seinen Nachfolgern (A ist die Menge der “Atome”, z.B. der natürlichen Zahlen, L die Menge der
Listen):
48
Nichtprozedurale Programmierung
• nil L
• a A, l L ⇒ cons(a, l) L
• l1 l2 L ⇒ cons(l1 , l2 ) L
In Worten: die leere Liste ist eine Liste und aus einer Liste oder einem Atom und einer weiteren Liste kann man
mit cons wieder eine Liste bilden. Da Elemente einer allgemeinen Liste entweder Atome oder selbst wieder Listen
sein können, benötigt man noch eine Funktion die testet, ob es sich bei einem Objekt um eine Liste oder ein Atom
handelt:
(atom l) = f alse für jede Liste l
Diese Definition der Listen folgt [22]. Sie ist stark von deren Implementierung über verkette Knoten inspiriert. Die
leere Liste N IL wird durch einen Null–Zeiger implementiert und cons entspricht einem Listen–Knoten der das
erste Element enthält und mit dem Rest der Liste verbindet. Die etwas seltsamen Namen der Konstruktions– und
Selektionsfunktionen stammen aus der Implementierung der Listen in Form verketteter Knoten. 22
Rekursive Funktionen auf Sequenzen und Listen
Listen, ob in ihrer allgemeinen Form, oder in Form von Sequenzen, sind induktive Mengen, auf denen Funktionen
entsprechend ihrer Struktur rekursiv definiert werden können. Als erstes Beispiel definieren wir eine Funktion
length, die die Länge einer Liste berechnet. Wir gehen dazu von der etwas anstrengenden Lisp–Notation zurück
zu einer normalen Schreibweise:
length(nil) = 0
length(cons(a, l)) = length(l) + 1
Die Funktion append, die zwei Listen zu einer verknüpft, ist:
append(nil, l) = l
append(cons(a, l), l0 ) = cons(a, append(l, l0 ))
Die Funktion reverse, die eine Liste umkehrt ist:
reverse(nil) = N IL
reverse(cons(a, l)) = append(reverse(l), cons(a, N IL))
Listen in Python
In Python sind Listen ein elementarer Datentyp. Es ist erlaubt beliebige Objekte zu Listen zu gruppieren. Listen
werden als Literale des Datentyps Liste geschrieben werden:
[1 2 3]
[1, 2, ’Hugo’]
[’charlotte’, 0.01, [1, 2, ’Hugo’], 17]
Die klassischen (Lisp–) Listenoperationen können in Python wie folgt definiert werden:
def car(l):
return l[0]
def cdr(l):
return l[1: ]
def cons(x, l):
return [x] + l
22 “cons” bedeutet construct. “car” und “cdr” sind die beiden Teile eines Speicherworts, in dem Listenknoten in der ersten Lisp–
Implementierung zu Beginn der 60–er gespeichert wurden. “car” steht für contents of address part of register und “cdr” für contents of decrement part of register. Die Maschine mit dieser ersten Lisp–Implementierung, auf der Registerworte in einen Adress– und Dekrement–Teil
zerlegt wurden, ist längst den Weg allen Vergänglichens gegangen, “car” und “cdr” leben immer noch.
Th Letschert, FH Giessen–Friedberg
49
l[0] ist die Art von Python auf das erste Element einer Liste zuzugreifen. Allgemein liefert l[i] das i–te
Element einer Liste l. Bei cdr benutzen wir das sogenannte Slicing. l[1: ] ist die Liste l vom zweiten Element
(Index 1) bis zum Ende. l[i:j] ist die Teilliste (“slice”) von ab und inklusive l[i] bis und exklusive l[j].
Lässt man i oder j weg, dann ist der der Anfang (Index 0) bzw. das Listenende (Index des letzten Elements)
gemeint.
Die Funktion reverse mit der Hilfsfunktion rev kann in Python wie folgt definiert werden:
def rev(l, r):
if l == []:
return r
else:
return rev(cdr(l), cons(car(l), r))
def reverse(l):
return rev(l, [])
l = input()
print reverse(l)
cons, car und cdr sind dabei die oben definierten Hilfsfunktionen mit Lisp–Gefühl.
Die Möglichkeiten der Listenverarbeitung in Python gehen deutlich über die von Lisp hinaus. Mit “+” ist beispielsweise die Listenverknüpfung vordefiniert. Daneben gibt es auch noch eine append–Methode. Listen in Python und
auch in Lisp sind Objekte, die verändert werden können. Darauf wollen wir hier allerdings nicht weiter eingehen.
1.3.5
Datenabstraktion
Binärbäume im funktionalen Stil
Datentypen sind entweder vordefiniert oder vom Programm/Programmierer selbst entworfen und als sogenannte
abstrakte Datentypen oder Datenabstraktionen realisiert. Objektorientierte Sprachen bieten stets einen gut gefüllten Werkzeugkasten mit dem sich solche Datenabstraktionen realisieren lassen. Funktionale/deklarative Sprachen
glänzen dagegen mit sehr mächtigen vordefinierten Typen. In Lisp ist alles eine Liste. Python bietet mit Listen, Tupeln und Abbildungen noch mehr. Die Entwicklung eines Programms in einer objektorientierten statisch typisierten
Sprache besteht im Wesentlichen aus dem Entwurf und der Implementierung eines Typkonzepts. In funktionalen
dynamisch typisierten Sprachen werden dagegen wenige oder keine Typen definiert. Das vorgegebene Repertoire
reicht meist völlig aus.
Wie kann das sein? Ob ein Problem in Java oder Python gelöst wird, so dramatisch können die Unterschiede ja
nicht sein. Sind sie auch nicht. Auch Python–Programmierer arbeiten mit Datenabstraktionen. Sie können dazu auf
die OO–Sprachmittel zurück greifen. Aber es gibt auch einen funktionalen Ansatz zu Datenabstraktion. Er sieht
nur etwas anders aus als gewohnt. Nehmen wir als Beispiel Binärbäume.
Vor die Aufgabe gestellt, Binärbäume zu definieren, entwerfen geschulte OO–Programmierer eine interne Repräsentation für Bäume, geben eine Schnittstelle als externe Darstellung der Funktionalität an und implementieren
diese dann als Operationen auf der internen Darstellung. In C++ sieht das Ergebnis dieser Überlegungen dann etwa
wie folgt aus:
template<typename T>
class BTree {
public:
BTree() : a(0) {}
void insert(T);
....
private:
struct Node{...};
Node * a;
};
50
Nichtprozedurale Programmierung
Python bietet Klassen und darum können auch dort Bäume im gleichen OO–Stil definiert werden. Bäume können
aber auch ohne Rückgriff auf OO–Sprachmittel sauber und unter Beachtung des Geheimnisprinzips in einem, nennen wir es funktionalen OO–Stil definiert werden, indem wir Konstruktoren und Selektoren angeben. Konstruktoren
sind Funktionen die Bäume erzeugen und Selektoren zerlegen sie in ihre Bestandteile:
# Baeume im funktionalen Stil
# Konstruktoren:
#
def mKnoten(v, tl, tr): return [v, tl, tr]
def mBlatt(v):
return [v]
# Baum mit Unterbaeumen
# Baum als Blatt
# Selektoren:
#
def istBlatt(b):
def sLinks(b):
def sRechts(b):
def sWert(b):
#
#
#
#
return
return
return
return
len(b) == 1
b[1]
b[2]
b[0]
Test ob der Baum ein Blatt ist
linker Unterbaum
rechter Unterbaum
Knotenwert
Das Geheimnisprinzip
Das Geheimnisprinzip (Information Hidding Principle) wurde zu Beginn der 70–er von Parnas (damals zeitweise
TU Darmstadt) als elementares Prinzip der Softwaretechnik formuliert und populär gemacht. Wie alle wichtigen
Prinzipien ist es einfach und – wenn man es kennt – auch offensichtlich. Parnas postulierte, dass Software in Module zu gliedern ist, mit jeweils zwei strikt zu trennenden und klar dokumentierten Komponenten: der öffentlichen
Schnittstelle und internen Implementierung.
Die Schnittstelle sollte dabei möglichst klein sein und nur die Informationen des Moduls preisgeben, die zu seiner
Benutzung unbedingt bekannt sein müssen. Der Rest wird Implementierung genannt und sollte das Geheimnis des
Moduls bleiben. Spätere Sprachdefinitionen haben den Begriff “Geheimnis” dann wörtlich genommen und mit
public und private dem Modulschreiber Mechanismen an die Hand gegeben, die Unterscheidung nicht nur
zu dokumentieren, sondern auch ihre Einhaltung durch den Modulbenutzer vom Compiler überwachen zu lassen.
Die Durchsetzung softwaretechnischer Prinzipien wird in unserem Beispiel nicht von der Sprache unterstützt. Sie
liegt allein in der Verantwortung der Programmierer, aber auch ohne public und private werden wir wohl
genügend Disziplin aufbringen um Bäume ausschließlich über Konstruktor– und Selektorfunktionen zu manipulieren.
OO technisch: Verwaltung von Zuständen
Bei der OO–Programmierung unterscheidet man23 wert– und zustandsorientierte Klassen. Objekte wertorientierter
Klassen repräsentieren ewige unveränderliche Werte. Objekte zustandsorientierter Klassen stellen dagegen Dinge
dar, die sich verändern können. Sie besitzen darum Zustandsvariablen mit veränderlichen Werten. Ein nicht unbeträchtlicher Teil der Softwaretechnik beschäftigt sich damit, wie solche Klassen/Objekte zu implementieren sind,
insbesondere wie die Zustandsvariablen zu verwalten sind.
Die Einführung programmiersprachlicher Konstrukte, mit denen zustandsorientierte Klassen direkt als Elemente
eines Programms definiert werden können, kann als ein wesentlicher Fortschritt der praktischen Informatik betrachtet werden.24 Ohne diese modernen programmiersprachlichen Möglichkeiten ist man auf die Verwendung
von globalen Variablen angewiesen, wenn Zustandsinformationen von (Methoden–/Funktions–) Aufruf zu Aufruf
aufbewahrt werden sollen. In C boten statische Variablen einer Funktion oder einer Übersetzungseinheit schon vor
den OO–Zeiten die Möglichkeit Zustandsvariablen mit begrenzter Sichtbarkeit zu definieren. So ist in
int iter() {
23
Wenn auch nach Meinung des Autors nicht häufig genug.
Es waren immerhin etwa 20 Jahre (und das Entfachen eines gigantischen Hypes) nötig damit sich dieser, letztlich doch recht simple
Gedanke allgemein durchsetzen konnte.
24
Th Letschert, FH Giessen–Friedberg
51
int = 0;
/* statische Variable einer C-Funktion */
return ++i;
}
Die Sichtbarkeit von i auf f begrenzt, ihre Lebensdauer dagegen von f völlig unabhängig. Man kann dies als
Implementierung einer Zählerklasse Z mit den Mitteln von C auffassen.
class Z {
int i;
Z():i(0){}
int iter() {return ++i;}
}
Variablen in einem C–Programm, die mit dem Attribut static ausserhalb von Funktionen definiert werden, sind
in ihrer Sichtbarkeit auf die jeweilige Übersetzungseinheit begrenzt. Auch dies stellt eine rudimentäre Kontrolle
der Sichtbarkeit dar. Beide Mechanismen sind damit frühe Formen einer “OO–Unterstützung” durch die Sprache.
Der Rest war allein in die Hände der Programmierer gelegt.
1.3.6
Verwaltung von Zustandsvariablen im funktionalen Stil
OO-Technik: funktional realisiert
Bemerkenswerterweise hatten die Verfechter funktionaler Sprachen schon sehr früh ein elegantes Mittel zur
Verfügung, um Datenkapelungen zu realisiern. In Scheme kann die Zähler–Klasse ohne Rückgriff auf OO–
Sprachmittel oder globale Variablen folgendermaßen definiert werden:
(define (mkZ)
# OO im funktionalen Stil
(define i 0)
# in Scheme
(define (iter)
(begin
(set! i (+ i 1))
i
)
)
iter
)
Hier wird mit mkZ eine Konstruktor–Funktion für Zähler definiert. Beispielsweise werden mit
(define z1 (mkZ))
(define z2 (mkZ))
(z1)
(z2)
#
#
#
#
z1: Z-Objekt
z2: Z-Objekt
˜ z1.iter() -> 1
˜ z2.iter() -> 1
zwei Zählerobjekte erzeugt, die beide über ihr eigenes i verfügen, das jeweils unabhängig vom anderen verändert
wird und so als Zustandsvariable der Objekte dienen kann. In Python übersetzt, sieht das ganze vielleicht etwas
lesbarer aus, wenn sich dabei leider kein korrekter Python–Code ergibt:
def mkZ():
i = 0
def iter():
i = i+1
return i
return iter
z1 = mkZ()
z2 = mkZ()
print z1()
print z2()
# Achtung kein korrekter
# Python-Code
52
Nichtprozedurale Programmierung
Python: keine Modifikation von Variablen in mittleren Gültigkeitsbereichen
Versucht man die Python-Variante des Scheme–Zählers auszuführen, dann wird man mit einer Fehlermeldung
überrascht: “local variable ’i’ referenced before assignment”. Klar in iter finden wir eine Zuweisung an i. Jede erste Zuweisung an eine Variable in einem Gültigkeitsbereich wird gleichzeitig als Definition dieser Variablen
interpretiert. In iter ist i eine neue lokale Variable und nicht etwa das i des umfassenden Gültigkeitsbereichs.
Scheme unterscheidet klar zwischen Zuweisungen und Definition. Man sich darum explizit auf das i des umfassenden Gültigkeitsbereichs beziehen.
Will ein Python-Programmierer verhindern, dass eine Zuweisung als Definition interpretiert wird, dann kann er
die betreffende Variable als global kennzeichnen. Sie wird damit als Element des globalen Gültigkeitsbereichs
identifiziert:
i = 0
def mkZ():
i = 0
def iter():
global i
i = i+1
return i
return iter
z1 = mkZ()
z2 = mkZ()
print z1()
print z2()
# korrekter Python-Code
# aber nicht das Gewollte
# Ausgabe 1
# Ausgabe 2
Das ist natürlich nicht das, was wir wollten und brauchten. Durch den Aufruf von mkZ sollte ja gerade eine
jeweils neue Zustandsvariable i erzeugt werden, die zusammen mit iter als “Methode” ein Objekt ausmacht. In
Scheme geht das, in Python dagegen nicht. Die Sprachregeln von Python lassen den (schreibenden) Zugriff nur
auf Variablen des aktuellen und des globalen Gültigkeitsbereichs zu. Alles was dazwischen liegt ist tabu (siehe
Abbildung 1.15).
i
i
i
.. i ...
Abbildung 1.15: Schreibende Zugriffe auf Zwischen–Gültigkeitsbereiche sind tabu
Ein rein lesender Zugriff auf solche Zwischenbereiche ist möglich. So wird
def mkZ():
i = 0
def iter():
return i+1
return iter
# lesender Zugriff auf mkZ::i
klaglos akzeptiert. Mit einem rein lesenden Zugriff können wir allerdings keine zustandsorientierten Objekte realisieren.
Th Letschert, FH Giessen–Friedberg
53
Python ist Stackbasiert
Lesender Zugriff auf nicht–lokale Variablen ist ohne Beschränkungen möglich, beim schreibenden Zugriff ist man
aber auf den aktuellen lokalen und den globalen Gültigkeitsbereich beschränkt. Man fragt sich, warum. Ist es rein
zufällig so, etwa aus einer Nachlässigkeit der Entwickler, die so etwas wie Scope–Operatoren vergessen haben,
mit denen man sich explizit auf einen Bereich beziehen könnte:
def mkZ():
# Achtung Python-Pseudo-Code
i = 0
# mit (nicht verfuegbarem) Scope-Operator ::
def iter():
mkZ::i = mkZ::i+1 # ich meine das i aus mkZ, nicht ein
return mkZ::i
# globales i und auch keins aus iter
return iter
Tatsächlich ist es wohl kein Versehen. Der unbeschränkte Zugriff auf Variablen in beliebigen äußeren Verschachtelungstiefen hätte einen drastischen Einfluss auf die Implementierung des Python–Interpreters zur Folge.
Python wird in gängigen Implementierung stackbasiert ausgewertet: Für jede aktive Pythonfunktion wird eine
Instanz auf dem Stack angelegt und beim Verlassen der Funktion wieder vernichtet. Jede Funktion kann ausser den
globalen Variablen nur auf die Variablen zugreifen, die sich auf dem Stack befinden. Damit ist ein Zugriff nur auf
Variablen aktiver Funktionsinstanzen möglich. In unserem Beispiel will aber iter auf das i einer mkZ–Instanz
zugreifen, nachdem diese längst beendet wurde.
Durch die Kombination von Funktionen als Funktionswerte und der Möglichkeit auf Zwischenbereiche zugreifen
zu können, wird ein Zugriff auf verlassene Gültigkeitsbereiche möglich. Damit ist eine stackbasierte Auswertung
nicht mehr möglich. Klassische compilierte Programmiersprachen wie C verbieten Funktionsergebnisse. Python
erlaubt sie, muss aber dann im Gegenzug den Zugriff auf Variablen in Bereichen zwischen aktuell und global
verbieten.
In Scheme ist ist beides möglich. Die Auswertung ist nicht stackbasiert. Funktionsinstanzen werden nicht stapel–
sondern heap–artig verwaltet. Sie bleiben bestehen, so lange, wie noch eine Referenz auf sie besteht. Da die
stapelartige Verwaltung einfacher und effizienter ist als ein Heap (mit obligatorischem Garbage Collector) hat man
sich in Python für ein Stapel–Regime der Auswertung entschieden und die damit verbundenen Beschränkungen in
Kauf genommen.
Man kann hier einwerfen, dass Python für den lesenden Zugriff auf Zwischen–Gültigkeitsbereiche (Lambda–
Ausdrücke!) sowieso schon Closure–Objekte erzeugen muss und auch, auch von Natur aus, nicht ohne Garbage
Collector auskommt; dass also die paar Stack–Frames ohne weiteres als “Heap–Frames” realisiert werden könnten. Aber eine solche Kritik sollte dann mit einem Implementierungsvorschlag kommen.25
Java ist Stackbasiert
Auch in Java begegnet man gelegentlich seltsamen Beschränkungen die sich aus der Stack–basiertheit dieser Sprache ableiten. So können beispielsweise innerhalb von Methoden anonyme geschachtelte Klassen definiert werden.
Syntaktisch hat das die Form:
new SuperClassName(args) { ... }
Dies wird häufig in GUI–Klassen eingesetzt, um einem Widget einen Handler zuzuordnen. Beispiel:
private class ChangeButtonListener implements ActionListener {
public void actionPerformed(ActionEvent event) {
infoLabel.setText("You clicked the button!");
}
}
In diesen geschachtelten Klassen können Variablen aus dem lokalen Kontext verwendet werden. Allerdings gibt
es dabei Beschränkungen: sie müssen final (nicht mehr veränderbar) sein. So muss im folgenden Beispiel ein
zusätzliches ii eingeführt werden, da die Laufvariable i verändert wird und so nicht final sein kann:
25
Tatsächlich gibt es von Christian Tismer mit seinem Stackless Python einen solchen Vorschlag.
54
Nichtprozedurale Programmierung
class GUI extends JFrame implements Observer {
....
private JButton[] button_Z = new JButton[10];
// Tasten
....
private Controller controller;
// das C in MVC
....
private void callbackInit() {
...
for ( int i=0; i<10; i=i+1) {
final int ii = i;
//<<----!!!!!
button_Z[i].addActionListener(
new ActionListener() { // Taste gedrueckt: controller.pZ aktivieren
public void actionPerformed(ActionEvent e) {
controller.pZ(ii);
}});
}
...
}
}
Wir haben also auch hier eine Beschränkung beim Zugriff auf einen Zwischen–Gültigkeitsbereich: Er ist nur in
lesender Form (final) erlaubt.
Ist stackbasiert noch zeitgemäß? Wann werden sich wohl Java und Python vom Stack befreien?
Th Letschert, FH Giessen–Friedberg
1.4
55
Funktionen in OO–Sprachen
Der erste Beweis, dass ein junger Mensch klüger geworden,
ist, wenn er anfängt, Dinge, die ihm immer ganz begreiflich und natürlich vorkamen,
nicht zu verstehen.
F. Grillparzer
1.4.1
Funktionen
Funktionen – freie Funktionen also – als eigenständige Einheiten eines Programms sind in den letzten Jahren etwas
aus dem Mode gekommen. C++ kennt sie noch, in Java wurden sie dem OO–Kult geopfert. Für die Entwickler
von UML – der definitiven Entwurfssprache um alle(!) Konzepte der Software–Technik auszudrücken – existieren
Funktionen nicht. Hat sich die Informatik in den ersten 20 Jahren ihrer Existenz nicht fast ausschließlich mit der
Berechnung von Funktionen beschäftigt? Haben nicht Gurus der Softwaretechnik alles Elend dieser Welt jahrelang
der Tatsache zugeschrieben, dass nicht ausschließlich mit reinen Funktionen gearbeitet wird?
Wie auch immer. Wir bleiben dabei, dass es Funktionen als ernstzunehmendes Software–technisches Konzept
gibt, und bestehen darauf, Programme zu schreiben, die Funktionen enthalten. In Sprachen, die, wie Python, die
funktionale Programmierung direkt unterstützen, ist die Verwendung auch von anspruchsvolleren funktionalen
Konzepten problemlos möglich. Funktionen können
• als Parameter übergeben,
• in Datenstrukturen gespeichert und sogar
• dynamisch erzeugt werden.
Das haben wir bereits im letzten Kapitel kennengelernt. Wir wenden uns hier vor allem der Frage zu, wie fortgeschrittenere funktionale Konzepte ohne direkte Unterstützung in einer OO–Sprache realisiert werden können.
Funktionen werden dabei zu funktionalen Objekten Das soll ein wenig Einblick in die Implementierung der funktionalen Konstrukte geben und außerdem zeigen, dass man zur funktionalen Programmierung nicht unbedingt eine
funktionale Sprache benötigt.
Funktionen in Python
Python enthält natürlich Funktionen. Sie werden typfrei definiert:
def f(n):
if n == 0 :
return 1
else :
return f(n-1)*n
print f(3)
Hier wird die Fakultätsfunktion definiert und aufgerufen. Das Besondere von Python besteht darin, dass Zeilenumbrüche und Einrückungen wesentlicher Bestandteil der Syntax sind.
Ein anderes Beispiel ist:
def add(x, y):
return x + y
print add (4, 5)
print add (’Hallo’, ’Welt’)
56
Nichtprozedurale Programmierung
Diese Funktion hat den Typ (T × T ) → T für jeden Typ T auf dem die Addition definiert ist. In Python kann man
numerische Werte und Zeichenketten addieren. Sie entspricht damit folgender Definition in Lambda-Notation:
letf = λx, y . x + y
Im Gegensatz zu Sprachen aus der C–Linie akzeptiert Python lokale Funktionsdefinitionen. Man kann also in einer
Funktion eine andere Funktion mit lokalem Gültigkeitsbereich definieren. Dies ist eine weitverbreitete Fähigkeit
von Programmiersprachen, aber heute vielen unbekannt, da C und seine Nachfolger aus Gründen der Effizienz–
und Einfachheit darauf verzichtet haben.
Eine weitere Besonderheit von Python ist, dass es Ausdrücke mit Funktionen als Wert gibt und, dass Funktionen
Funktionen als Wert zurück geben können.
Funktionen in C++
In C++ können wie in Python numerische Werte und Zeichenketten mit “+” addiert werden. Eine entsprechende
Funktion ist jedoch typgebunden. Der Funktionsname kann zwar überladen werden, trotzdem müssen aber mindestens zwei Funktionen definiert werden.
double add (double x, double y) { return x + y;
std::string add (std::string x, std::string y) {
}
return x + y;
}
int main () {
std::cout << add (4, 5) << std::endl;
std::cout << add ("Hallo", "Welt") << std::endl;
}
Hier werden zwei Funktionen definiert, die den gleichen Namen, aber unterschiedliche Typen haben:
add : double × double → double
add : string × string → string
Der Compiler entscheidet an der Aufrufstelle an Hand der Argumenttypen, welche der beiden Funktionen gemeint
ist und generiert einen Aufruf dieser Funktion. In Python haben wir es dagegen mit nur einer Funktion zu tun,
die Argumente unterschiedlichen Typs verarbeiten kann und bei der der Typ der Argumente zur Laufzeit über die
auszuführenden Aktionen entscheidet.
Um zu einer, der Additions–Funktion in Python etwas äquivalenteren Version, zu kommen, müssen in C++ Templates benutzt werden:
template<typename T>
T add (T x, T y) {
return x + y;
}
int main () {
std::cout << add (1, 2) << std::endl;
std::cout << add (std::string("Hallo"), std::string("Welt")) << std::endl;
}
Auch hier muss natürlich wieder der Compiler die Typ–Information verarbeiten. Nicht nur, wie oben, um herauszufinden, welche Funktion aufgerufen werden soll, sondern auch, um diese Funktion zu generieren.
1.4.2
Funktionen als Parameter
Funktionsparameter
Funktionale Parametrisierung ist ein sehr häufiges Gestaltungsprinzip in Softwaresystemen. Das klassische Beispiel ist die numerische Integration, deren Algorithmus unabhängig von der zu integrierenden Funktion ist und
Th Letschert, FH Giessen–Friedberg
57
darum die Funktion als Parameter haben kann. Ein typisches Beispiel für die Verwendung von Funktionsparametern ist eine Routine zur numerischen Integration beliebiger Funktionen nach der Trapez–Regel:
double integral (double f(double), double unten, double oben, int n) {
double sum = 0, h = (oben-unten)/double(n);
for ( double x = unten+h; x < oben; x = x+h )
sum = sum + f(x);
sum = 2*sum;
sum = sum + f(unten) + f(oben);
return sum*h/2.0;
}
Ein anderes Beispiel für den Einsatz von Funktionsparametern ist die Anwendung einer Funktion auf alle Elemente
einer Kollektion, etwa um aus einer Liste eine neue Liste zu konstruieren:
#include <string>
#include <iostream>
#include <list>
using namespace std;
template<typename T>
list<T> map (list<T> l, T f(T)) {
list<T> result;
for ( typename list<T>::iterator i = l.begin();
i != l.end();
++i )
result.push_back(f(*i));
return result;
}
list<int> l_1, l_2;
int f(int x) { return 2*x; }
int main () {
for ( int i = 0; i<10; ++i)
l_1.push_back(i);
l_2 = map(l_1, f);
while ( !l_2.empty() ) {
cout << l_2.front() << endl;
l_2.pop_front();
}
}
1.4.3
Funktionale Objekte
Funktionen als Objekte
Jede Funktion kann trivialerweise in ein Objekt verwandelt werden. Ein einfaches Beispiel ist:
template<typename T>
// Funktionsobjekt statt der Funktion
class FO {
// void f(T X) { cout << x << endl; }
public:
void operator() (T x) {
cout << x << endl;
58
Nichtprozedurale Programmierung
}
};
void map (list<int> l, FO<int> fo) { // Funktion
for ( list<int>::iterator i = l.begin();
i != l.end();
++i )
fo(*i);
}
int main() {
list<int> l;
for ( int i = 0; i<10; ++i)
l.push_back(i);
FO<int> fo;
map (l, fo);
}
Hier ist FO die Klasse der Funktionsobjekte. Sie ersetzen die Funktion f. Mit map natürlich immer noch eine freie
Funktion auf, aber auch sie kann leicht in ein Objekt transformiert werden.
Die Umwandlung von Funktionen in Objekte ist in den Sprachen notwendig, in denen, aus welchen Gründen auch
immer, freie Funktionen nicht unterstützt werden. In C++ sind freie Funktionen erlaubt. Es gilt aber gelegentlich
als unfein sie zu verwenden. Die Funktion wird dann aus rein ästhetischen Gründen in ein Objekt verwandelt.
Funktionen als Objekte speichern
Funktionen als solche können in C++ nicht in einer Datenstruktur gespeichert werden. Schon in C war es aber
schon möglich, mit Zeigern auf Funktionen zu hantieren und sie in einer Datenstruktur zu speichern und in C++
geht das weiterhin. Aus
def mal2(x):
return 2*x
f = mal2
print mal2(3)
wird bei Verwendung von Funktionszeigern das C++–Programm
int mal2 (int x) { return 2*x; }
int main() {
int (*f)(int) = mal2;
cout << f(3) << endl;
}
// Variable hat Funktion (--s-Zeiger) als Wert
Mit einem Funktionsobjekt werden wir etwas zeitgemäßer:
class Mal2 {
// Klasse der Funktionsobjekte
public:
int operator() (int x) { return 2*x; }
};
int main() {
Mal2 f;
cout << f(3) << endl;
}
// Variable hat Funktion (--s-Objekt) als Wert
Th Letschert, FH Giessen–Friedberg
59
Generische Funktionsobjekte
Funktionsparameter und Funktionszeiger bieten ein hohes Maß an Generizität.26 So akzeptiert
void g(void f(int), ...)
beispielsweise jede Funktion vom Typ Integer → Integer. Bei Funktionen, die als Objekte daherkommen, muss
diese Generizität über den Template– oder/und den Vererbungsmechnismus nachgebildet werden. So nimmt
void g(FuncObj fo, ...)
nur dann unterschiedliche Funktionsobjekte an, wenn FuncObj eine Basisklasse ist. Auch im Beispiel oben mit
der Klasse Mal2, als Ersatz einer Funktion, haben wir das Problem, dass die Klasse nur eine einzige Instanz
hat und deren Speicherung in einer Variable nur wenig Sinn macht. Mehr Flexibilität kann mit Polymorphismus
eingeführt werden, der dann aber (in C++) wieder mit Zeiger kombiniert werden muss, um wirksam zu sein:
class IntToInt {
public:
virtual int operator() (int) = 0;
};
class Mal2 : public IntToInt {
public:
int operator() (int x) { return 2*x; }
};
class Mal3 : public IntToInt {
public:
int operator() (int x) { return 3*x; }
};
int main() {
IntToInt * f = new Mal2;
cout << (*f)(3) << endl;
f = new Mal3;
cout << (*f)(3) << endl;
}
// Funktions als Wert einer Variable
// andere Funktion als Wert der Variable
Der Ableitungsmechanismus kann durchaus mit dem der Templates kombiniert werden. Eine “objekt–tifizierte”
Version der map–Funktion von weiter oben zeigt folgendes Beispiel:
#include <string>
#include <iostream>
#include <list>
using namespace std;
template<typename T> // Basisklasse: Typ der Funktionsobjekte
class FuncObj {
public:
virtual void operator() (T x) = 0;
};
template<typename T> // Funktion mit Funktionsobjekten als Parameter
void map (list<T> l, FuncObj<T> & f) {
for ( typename list<T>::iterator i = l.begin();
i != l.end();
++i )
f(*i);
26 Generisch: ein Geschlecht oder eine Gattung betreffend. Etwas ist generisch, wenn es auf alle Mitglieder einer Gattung, auf alles einer
bestimmten Art, angewendet werden kann. “Generisch” wird in der Informatik im Sinne von “flexibel” oder “auf vieles anwendbar” verwendet.
60
Nichtprozedurale Programmierung
}
template<typename T> // Instanz der Basisklasse: eine Art von Funktion
class Print : public FuncObj<T> {
public:
void operator() (T x) {
cout << x << endl;
}
};
Print<int> printInt;
int main() {
list<int> l;
for ( int i = 0; i<10; ++i)
l.push_back(i);
map (l, printInt);
}
Insgesamt wird damit wohl deutlich, dass einfache funktionale Konzepte, wie schon die Verwendung von Funktionsparametern, zu ausgedehnter objektorientierter Gymnastik führen können.
1.4.4
Dynamisch erzeugte Funktionen
Funktionsobjekte als Ergebnisse
Die Umwandlung von Funktionen in Objekte ist dann unumgänglich, wenn Funktionen als Parameter verwendet
werden sollen, aber die Programmiersprache keine freien Funktionen zuläßt. C++ enthält freie Funktionen. Die
direkte Verwendung von Funktionsparametern ist ebenfalls nichts Ungewöhnliches. Wahrscheinlich sind trotz aller
OO-Indoktrination27 funktionale Objekte eher ungewohnt.
Wenn es jedoch zu Funktionen als Ergebnis einer Berechnung kommt, dann ist auch C++ am Ende. Eine einfache
Funktion wie
let genAdd = λx.λy.x + y
ist zwar problemlos in Python, nicht aber ohne weiteres in C++ zu implementieren. Das Problem liegt nun einmal
darin, dass genAdd eine Funktion dynamisch erzeugt und dann zurück gibt. In Python verwendet man einen
Lambda–Ausdruck und schreibt einfach
def genAdd(x):
return lambda y : x + y
add2 = genAdd(2)
print add2(3)
Lambda–Ausdrücke sind nicht auf ihre Verwendung als Funktionsergebnisse beschränkt. Typischerweise werden
sie überall dort verwendet, wo eine kleine Funktion nur einmal verwendet wird und sich somit eine aufwändige
Funktionsdefinition nicht lohnt. Beispielsweise als Funktionsparameter für map und filter:
l = [1, 2, 3, 4, 5]
print map( lambda x : 2*x, l)
Dynamisch Erzeugte Additionsobjekte
Die dynamische Erzeugung von Funktionen ist in C++ nicht möglich. Sie kann aber mit Funktionsobjekten emuliert28 werden. So ist in
27
28
Indoktrination, die: massive Beeinflussung, meist mit ideologischen oder dogmatischem Hintergrund.
emulieren: nachbilden, nachahmen.
Th Letschert, FH Giessen–Friedberg
61
class Add { // emuliert Funktional lambda x : lambda y : x + y
public:
Add (int p_x) : x(p_x) {}
int operator() (int y) { return x+y; }
private:
int x;
};
Add genAdd (int x) { // Funktion die Funktion(sobjekt) erzeugt
return Add(x);
}
int main() {
Add add2 = genAdd(2);
cout << add2(3) << endl;
}
genAdd eine C++–Version der Python–Funktion
def genAdd(x) return lambda y : x + y
Wir sehen, die Erzeugung einer Funktion durch ein Funktional wird zu Instanzierung der Klasse. Der Funktionsparameter der Python–Funktion wird zum Parameter des Konstruktors. Er wird intern gespeichert und später in
der Instanz, der Emulation einer erzeugten Funktion, genutzt. Auf diese Art können natürlich auch komplexere Funktionale in C++ emuliert werden. Bevor wir uns jedoch an anspruchsvollere Funktionale in C++ wagen,
“template–tifizieren” wir das Additionsfunktional und erzeugen eine Liste von Additionsfunktionen:
template<typename T> // Typ: T x T -> T
class FunTtoT {
public:
virtual T operator() (T x) = 0;
};
class Mult : public FunTtoT<int> { // eine Funktion vom Typ: int x int -> int
public:
Mult (int x) : _x(x) {} // Parameter des Funktionals => Parameter des Konstruktors
int operator()(int y) { return _x*y; }
private:
int _x;
};
typedef FunTtoT<int> *
FintTOintP;
typedef list<FintTOintP> List;
int main () {
List lf;
for ( int i = 1; i < 25; ++i ) {
lf.push_back(new Mult(i));
}
for (List::iterator i = lf.begin();
i != lf.end();
++i ) {
cout << (*(*i))(2) << endl;
}
}
Man beachte, dass die Liste Zeiger auf funktionale Objekte enthält. Das ist notwendig, um den Polymorphismus
wirken zu lassen. Etwas professioneller hätte man die Zeiger in einer Umschlag-Klasse verpackt. Der Einfachheit
halber haben wir hier darauf verzichtet.
62
Nichtprozedurale Programmierung
Compose: Verknüpfung von Funktionsobjekten
Bei der Emulation von genAdd wird ein Integer–Wert als Parameter gespeichert. Das muss auch mit komplexeren
Daten gehen. Warum nicht mit Funktions–Objekten. Damit könnte
def compose(f, g) return lambda x : g(f(x))
emuliert werden. Die Sache ist ziemlich unabhängig von allen Typen. Schreiben wir darum gleich ein Template:
template<typename T>
class FunTtoT {
public:
virtual T operator() (T) = 0;
};
// Interface-Klasse, Typ: Int -> Int
// funktionaler Objekte
template<typename T>
// Lambda f, g . Lambda x . f(g(x))
class Compose : public FunTtoT<T> { // Funktions-Verknuepfung
public:
// ist ein funktionales Objekt
Compose (FunTtoT<T> * f,
FunTtoT<T> * g) : _f(f), _g(g) {}
T operator() (T x) { return (*_f)((*_g)(x)); }
private:
FunTtoT<T> * _f;
FunTtoT<T> * _g;
};
template<typename T>
FunTtoT<T> * compose (FunTtoT<T> * f, // Erzeugung einer Verknuepfung
FunTtoT<T> * g) {
return new Compose<T>(f, g);
}
Das Klassen–Template FunTtoT fungiert hierbei als Schnittstelle, es definiert den Typ der Funktionen mit Int–
Parameter und Int–Wert. Die Klasse Compose liefert Objekte von diesem Typ, also eine bestimmte Art von Int →
Int Funktionen. Die entsprechenden Objekte müssen immer indirekt angesprochen werden. Wir verwenden zu
diesem Zweck wieder Zeiger und verzichten der Einfachheit halber auch wieder auf Umschlag-Klassen. Auch hier
haben wir wieder gleiche Verfahren wie oben zur Konstruktion funktionaler Objekte angewendet: Parameter des
Funktionals werden zu Parametern des Konstruktors der funktionalen Objekte.
Die Klasse Compose ist eine Klasse funktionaler Objekte. Ihre Objekte implementieren Funktionen, die durch
Anwendung von
λf, g.λx.f (g(x))
erzeugt werden können. Die Parameter f und g dieses Funktionals werden zu Parametern des Konstruktors und x
wird zum Parameter der Operator–Methode. Die Funktion compose erzeugt einfach ein Compose–Objekt.
Eine einfache Anwendung ist:
... wie oben ...
template<typename T>
// Lambda x . Lambda y . x*y
class Mult : public FunTtoT<T> {
public:
Mult (T x) : _x(x) {}
T operator()(T y) { return _x*y; }
private:
T
_x;
};
int main () {
FunTtoT<int> * f = new Mult<int>(2);
Th Letschert, FH Giessen–Friedberg
63
FunTtoT<int> * g = new Mult<int>(3);
FunTtoT<int> * gf = compose(f,g);
cout << (*gf)(2) << endl;
}
1.4.5
Konstruktion Funktionaler Objekte
Von Funktionen zu Funtionalen Objekten
Wir haben gesehen, dass ein funktionaler Programmierstil in einer OO–Sprache möglich ist, indem Funktionen in
funktionale Objekte umgewandelt werden. In diesem Kapitel präsentieren wir die Strategie der Umwandlung noch
einmal zusammenfassend.
Funktionen können direkt in funktionale Objekte umgewandelt werden. Man definiert eine Klasse, deren einzige
Methode die Arbeit der Funktion übernimmt. In C++ wird dies durch den Anwendungsoperator (operator())
recht elegant. Nach dieser Methode wird
let add = λx, y.x + y
zu
template<class T>
class Add {
public:
T operator() (T x, T y) { return x + y; }
};
int main() {
Add<int> add;
int i = add(2, 3); // Aufruf der Methode operator()
}
Funktionstypen müssen als Basistypen definiert werden. Das Äquivalent zum C++–Funktionstyp IntToInt mit
typedef int (*IntToInt)(int);
ist die Klasse
class IntToInt {
public:
virtual int operator() (int) = 0;
};
Damit wird aus dem Programm
typedef int (*IntToInt)(int);
// Funktionstyp
int add2 (int x) { return x+2; }
int add3 (int x) { return x+3; }
// freie Funktionen
int main() {
IntToInt f;
f = add2;
cout << f(10) << endl;
f = add3;
cout << f(10) << endl;
}
im klassischen Stil die moderne Version:
// Funktionsvariable
// Funktionszuweisung
64
Nichtprozedurale Programmierung
class IntToInt {
// Funktionstyp
public:
virtual int operator() (int) = 0;
};
class Add2 : public IntToInt {
// Funktion (-sobjekt)
public:
int operator() (int x) { return x+2; }
};
class Add3 : public IntToInt {
public:
int operator() (int x) { return x+2; }
};
int main() {
IntToInt * f;
f = new Add2;
cout << (*f)(10) << endl;
f = new Add3;
cout << (*f)(10) << endl;
}
// Funktionsvariable
// Funktionszuweisung
Das Ganze kann dann noch bei Bedarf “template-tifiziert” werden.
Von Funktionalen zu Funktionalen Objekten
Funktionale, also Funktionen höherer Ordnung, nehmen ein Argument und erzeugen damit eine Funktion. Diese kann dann selbst wieder Argumente annehmen. Funktionale erster Ordnung nehmen Argumente und erzeugen
damit “normale” Funktionen. Sie werden also sozusagen zweimal mit Argumenten gefüttert. Diese doppelte Fütterung kann emuliert werden: Das erste Argument wird zum Parameter eines Konstruktors und das zweite zum
Argument des Anwendungsoperators. Aus
λx.λy.x + y
wird mit dieser Technik die Funktion genAdd
class IntToInt {
public:
virtual int operator() (int) = 0;
};
// Typ: Int -> Int
class Addx : public IntToInt {
// Funktion vom Typ Int -> Int
public:
Addx (int x) : _x(x) {}
int operator() (int y) { return _x+y; }
private:
int _x;
};
IntToInt * genAdd (int p_x) {
return new Addx(p_x);
}
// Erzeugung einer Funktion
Der exorzistische29 Ritus zur Austreibung von Funktionen kann dann noch auf die freie (igitt!) Funktion genAdd
angewendet werden:
// Typ: Int -> Int
//
29
exorzieren: Böse Geister durch magische Beschwörung vertreiben.
Th Letschert, FH Giessen–Friedberg
65
class IntToInt {
public:
virtual int operator() (int) = 0;
};
// Objekte dieser Klasse sind Funktionen
//
lambda y . x + y vom Typ Int -> Int
// Sie werden vom Konstrukter erzeugt
// Der Konstruktor ist eine Funktion
//
lambda x . lambda y . x + y
class Addx : public IntToInt {
public:
Addx (int x) : _x(x) {}
int operator() (int y) { return _x+y; }
private:
int _x;
};
// Typ: Int -> (Int -> Int)
class Int2IntToInt {
public:
virtual IntToInt * operator()(int) = 0;
};
// Funktion: VOID -> (lambda x . lambda y . x + y)
//
class Genadd : public Int2IntToInt {
public:
IntToInt * operator()(int p_x) { return new Addx(p_x); }
};
int main() {
IntToInt * f;
// Variable mit Funktion als Wert
Int2IntToInt * gf;
// Variable mit Funktional als Wert
gf = new Genadd;
f = (*gf)(2);
cout << (*f)(10) << endl;
f = (*gf)(3);
cout << (*f)(10) << endl;
}
Eine derartige “Objekt–tifizierung” befreit uns nicht nur von so harmlosen kleinen Geistern wie den freien Funktionen, sie öffnet auch die Tür zu Funktionalen höherer Ordnung. Diese erzeugen keine einfachen Funktionen,
sondern selbst wieder Funktionale. Die Klasse GenAdd oben hat keinen Konstruktor. Alle ihre Instanzen sind
darum gleich. Geben wir ihr einen Konstruktor mit Parameter, dann repräsentiert sie eine Funktion die Funktionale
erzeugt. Wir treiben damit die Sache eine funktionale Stufe höher:
#include <string>
#include <iostream>
#include <list>
using namespace std;
// Typ: IntToInt = Int -> Int
//
class IntToInt {
public:
virtual int operator() (int) = 0;
};
66
Nichtprozedurale Programmierung
// lambda x . lambda y . x+y
//
class Addx : public IntToInt {
public:
Addx (int x) : _x(x) {}
int operator() (int y) { return _x+y; }
private:
int _x;
};
// Typ: Int2IntToInt = Int -> (Int -> Int)
//
class Int2IntToInt {
public:
virtual IntToInt * operator()(int) = 0;
};
// lambda z . lambda x . lambda y . x*z + y
//
class Genadd : public Int2IntToInt {
public:
Genadd (int z) : _z(z) {}
IntToInt * operator()(int p_x) { return new Addx(p_x*_z); }
private:
int _z;
};
int main() {
IntToInt * f;
Int2IntToInt * gf;
// Variable mit Funktion als Wert
// Variable mit Funktional als Wert
// gf = (lambda z . lambda x . lambda y . x*z + y)(2)
//
= lambda x . lambda y . x*2 + y
//
gf = new Genadd(2);
// f = (lambda x . lambda y . x*2 + y)(2)
//
= lambda y . 2*2 + y
//
f = (*gf)(2);
// Ausgabe: f(10) = (lambda y . 2*2 + y)(10) = 14
//
cout << (*f)(10) << endl;
gf = new Genadd(3);
f = (*gf)(5);
cout << (*f)(20) << endl; // Ausgabe 35
}
Mit der zweiten Ausgabeanweisung in diesem Programm wird 35 ausgegeben, denn:
35
= f (20)
= gf (5)(20)
= Genadd(3)(5)(20)
= (λz.λx.λy.x ∗ z + y)(3)(5)(20)
Th Letschert, FH Giessen–Friedberg
=
(λx.λy.x ∗ 3 + y)(5)(20)
=
(λy.5 ∗ 3 + y)(20)
=
=
5 ∗ 3 + 20
35
67
So viel objektorientierter Rauch vertreibt nicht nur alle freien Funktionen, dem einen oder anderen Exorzist benebelt er eventuell auch den Kopf. Wem es aber noch nicht reicht, der kann noch Templates dazugeben und noch eine
funktionale Stufe höher steigen.
1.4.6
Closures, Funktionale Objekte und Bindungen
Closure
Weiter oben haben wir Closures als Ausdrücke in einer bestimmten Umgebung beschrieben. In Python etwa erzeugt
der Aufruf F(5) in
def F(x):
return (lambda y: x+y)
f = F(5)
die Funktion λy.x+y in der vpm Aufruf F(5) erzeugten Umgebung, d.h. einer Umgebung, in der x an 5 gebunden
ist.
Beim Übergang von Funktionen zu funktionalen Objekten machen wir nichts anderes, als eine solche implizite
Konstruktion des Closures explizit auszuprogrammieren. In
class F {
public:
F (int p_x) : x(p_x) {}
int operator() (int y) { return x+y; }
private:
int x;
};
F f(5);
wird mit der Definition von f ein Objekt erzeugt in dem die Umgebung als Wert der privaten Komponente f.x
gespeichert ist.
Die explizite Implementierung ist natürlich aufwändiger als der Automatismus, den eine Sprache wie Python
bietet. Sie hat aber den Vorteil, dass man die Sache vollständig unter Kontrolle hat. So ist beispielsweise die Art
der Bindung der Variablen x in der OO–Variante vollkommen klar. In dem Augenblick, indem das funktionale
Objekt erzeugt wird, erhält x seinen Wert. Damit entspricht f in der C++–Version dem Closure
< (λy.x + y), [ x → 5 ] >
Das kann hier direkt dem Anwendungscode entnommen werden. Glücklicherweise verhält sich Python – zumindest
ab Version 2.2 – vernünftig und erzeugt die Funktion in der gleichen Art.
Mit f = F(5) wird in der Python–Version das innere x durch die Parameterübergabe an 5 gebunden und so
entspricht f dem Closure von oben.
Bildung einer Closure
Aber Vorsicht bei Sprachen wie Python, die es erlauben (funktionale) Ausdrücke mit freien Variabelen im Programm herum zu bewegen. Ausdrücke werden zwar bei jeder Übergabe an eine Funktion ausgewertet, aber nicht
unbedingt in dem Augenblick, in dem sie in eine Closure eingehen. Wir betrachten dazu folgendes Beispiel:
68
Nichtprozedurale Programmierung
x = 0
F = lambda x : lambda y : x + y
f = F(5)
print f(3)
x = 700
print f(3)
<-<-<-<--
<-- globales x
<-- lokales x
das lokale x wird an 5 gebunden (Parameteruebergabe)
x in x+y ist 5, Ausgabe 8
globales x (nicht relevant)
Ausgabe 8
Dieses Programm gibt zweimal den gleichen Wert 8 aus, das Programm
x = 5
g = lambda y : x + y
print g(3)
x = 700
print g(3)
<-- ein globales x
<-- x ist frei in diesem Ausdruck
<-- es wird nicht(!) an den Wert 5 gebunden
<-- x ist hierbei 5
<-- x ist hierbei 700
liefert aber 8 und 703. Der Wert von g ist
lambda y : x + y
ein Ausdruck in dem x frei vorkommt. x wird folglich an das nächstgelegene x gebunden. Da Python eine Sprache
mit statischer Bindung ist, wird das freie x im Lambda–Ausdruck an das globale x gebunden. Es wird also eine
Closure erzeugt in der x an das globale x gebunden ist, und nicht nicht etwa an den aktuellen Wert, den dieses x
zu dem Zeitpunkt hat, an dem die Funktion erzeugt wurde.
g = < λx.x + y , [x → xglobal ] >
nicht etwa
g = < λx.x + y , [x → 5] > Nein, so nicht!
Wollen wir diesen Effekt vermeiden, also x anen einen feste Wert binden, dann muss die Auswertung mit einem
Funktionsaufruf erzwungen werden:
x = 5
g = (lambda x : lambda y : x + y)(x) <- Auswerung des globalen x zu 5 und
<- Bindung von 5 an eine neue
<- lokale Variable x
print g(3)
-> 8
x = 700
print g(3)
-> 8
Dieses Programm gibt wieder zweimal den Wert 8 aus.
Closure können freie Variablen mit gloabler Bindung enthalten
Also Vorsicht, eine freie Variable wird nicht einfach desshalb ausgewertet, weil sie in einem Lambda–Ausdruck
auftaucht. Sie bleibt frei. Wollen wir eine freie Variable in einem Lambda–Ausdruck auf einen Wert fixieren, dann
muss eine neue lokale Variable mit einer eigenen Bindung erzeugt werden. Am einfachsten so wie im Beispiel mit
einer Lambda–Abstraktion die sofort mit einer Anwendung wieder aufgehoben wird.
Werden Funktionsergebnisse in einer OO–Sprache mit funktionalen Objekten simuliert, dann kann natürlich der
gleiche Effekt auftreten:
#include <iostream>
int x = 0;
class PlusX {
public:
Th Letschert, FH Giessen–Friedberg
69
int operator()(int y) { return y+x; }
};
int main () {
PlusX plusX;
std::cout << plusX(2) << std::endl;
x = 700;
std::cout << plusX(2) << std::endl;
}
// Ausgabe
2
// Ausgebe 702
Hier wird genau wie in dem Python–Beispiel von oben, der Wert einer globalen Variablen sozusagen hinter dem
Rücken eines funktionalen Objekts verändert. Der Effekt ist der gleiche wie in Python. C++ und Python unterscheiden sich in dieser Hinsicht nicht. In Python ist allerdings mehr möglich und das gemeinsame Grundkonzept,
die statische Bindung, kann in kreativerer und gelegentlich überraschenderer Art genutzt werden.
Funktionen können in Python aus der Umgebung heraus transportiert werden, in der ihre freien Varibalen definiert
sind. So erzeugt
def f(x):
y = 2
return lambda z : x + y +z
eine Funktion in der x und y an lokale Definitionen des Erzeugers gebunden sind und exportiert sie dann. So etwas
ist in C++ nicht möglich.
Closure und freie Variablen in C++
Auch mit funktionalen Objekten ist es nicht möglich Ausdrücke aus ihrer definierden Umgebung heraus zu bewegen. Auch nicht mit Hilfe funktionaler Objekte. Klassendefinition die lokale Definitionen von Funktionen nutzen,
sind verboten! Wir können darum nicht schreiben:
class FunItoI {
public:
virtual int operator() (int) = 0;
};
FunItoI * f (int x) {
int y;
// Funktion
// lokale Variable
class Plus : public FunItoI {
//
public:
//
int operator()(int z) { return x+y+z; } //
};
//
return new Plus();
Lokale Klassendefinition: OK
Klasse mit freien Varibalen
die an lokale (auto) Variablen
gebunden sind: GEHT NICHT
}
Auch alle anderen Tricks helfen nicht. Sollte ein Compiler sie akzeptieren, dann ist der Compiler kaputt. Es ist ein
geheiligtes Prinzip von C++ (und seinen Verwanden), dass Ausdrücke mit freien Variablen nicht unbeschränkt im
Programm herum schwirren dürfen.30
Die Bildung von Closures ist in C++ (und verwandten Sprachen) also nur mit Eingeschränkungen möglich. Wirklich frei verschiebbar sind sie nur, wenn sie von uns explizit konstruiert werden. So wie in folgendem Beispiel, in
dem ein frei bewegbares funktionales Objekt erzeugt wird. Klassendefinition und Objekterzeugung finden lokal in
einer Funktion statt, aus der das erzeugte Objekt dann heraustransportiert wird:
30 Das ist zum Wohle der Compilerbauer und der Ausführungsgeschwindigkeit so. Mit den Beschränkungen kann deutlich einfacherer und
effizienterer Code generiert werden.
Die Beschränkungen führen nämlich dazu, dass eine Funktion oder Methode nur Funktionen oder Methoden aktivieren kann, die in der
Verschachtelungshierarchie nicht weiter innen liegen. Die Umgebung eines Ausdrucks ist darum grundsätzlich immer auf dem Stack zu finden
und muss niemals aufwändig im Heap verwaltet werden.
70
Nichtprozedurale Programmierung
#include <iostream>
int x = 0;
class FunItoI {
public:
virtual int operator() (int) = 0;
};
FunItoI * f (int x) {
int y;
class Plus : public FunItoI {
public:
Plus (int px, int py) : _x(px), _y(py) {}
int operator()(int z) { return _x+_y+z; }
private:
int _x, _y;
};
return new Plus(x, y);
}
int main ()
FunItoI *
std::cout
x = 700;
std::cout
}
{
plus = f (3);
<< (*plus)(2) << std::endl; // Ausgabe 5
<< (*plus)(2) << std::endl; // Ausgabe 5
Jetzt sind wir natürlich vor Überraschgungen sicher. Die Übergabe als Parameter an den Konstruktor entspricht
dem Übergang von
...x...
zu
(lambda x : ...x...)(x)
Sie erzwingt eine Auswertung und schützt uns davor, dass eine freie Varibale “hinter unserem Rücken” verändert
wird. Der Ausdruck enthält keine freien Variablen mehr.
Wir sehen, Freiheit ist der Preis der Sicherheit. In C++ können Kinder ihre Mutter nicht verlassen, wenn sie Ideen
haben, die nur von der Mutter kommen. Nur der oder die darf gehen, die innerlich vollkommen gefestigt ist, und
nur solche Ideen hat, die Allgemeingut sind. Das schützt vor Überraschungen und sorgt für Effizienz – ist aber
auch ein wenig langweilig.
Th Letschert, FH Giessen–Friedberg
1.5
71
Rekursion
Rekursion ist einfach,
man versteht sie sofort,
wenn man die Rekursion verstanden hat.
1.5.1
Rekursion, Induktion, Iteration
Rekursion: Essenz funktionaler Programme
Rekursiv definierte Funktionen spielen eine wichtige Rolle in der Mathematik und Informatik. In rein funktionalen
Sprachen kommt der Rekursion eine ganz besondere Rolle zu. Da sie keine Anweisungen kennen, gibt es auch
keine Schleifen. Jede Art der Wiederholung muss darum durch Rekursion ausgedrückt werden. Programme rein
funktionaler Sprachen enthalten darum praktisch immer rekursive Funktionsdefinitionen.
Die Notwendigkeit, beim Schreiben funktionaler Programme so oft auf Rekursion zurückgreifen zu wollen oder
zu müssen, prägt deren Charakter in starkem Maß und macht einen beträchtlichen Teil des “Fremdartigen” der
funktionalen Programmierung aus. Warum aber soll es so rekursiv sein?
Rekursion und der deklarative Stil
Ein offensiv vertretener Vorteil funktionaler Programmierung und funktionaler Sprachen ist ihr deklarativer Charakter. Man sagt nicht “Wie”, sondern nur “Was” berechnet werden soll. Konkret bedeutet das oft nicht viel mehr,
als dass es rufschädigend, verboten, oder gar unmöglich ist, Programme zu schreiben, in denen Schleifen vorkommen. Ein rekursiv definierter Algorithmus gilt als deklariert, einem iterativ formulierten Algorithmus haftet
dagegen der Ruch einer Implementation auf (zu) niedriger Ebene an. Einem rekursiv formulierten Algorithmus
sieht man nach dieser Sicht mehr oder weniger direkt an, welche Funktion er berechnet, bei einem iterativen Algorithmus muss explizit nachgewiesen werden, dass er tut was er tun soll.
Diese Sicht mag nicht jedem unmittelbar einleuchten, sie hat aber schon eine gewisse Berechtigung. Nehmen wir
die unvermeidliche Fakultätsfunktion
letrec f ak = λx. if x = 0 then 1 else f ak(x − 1) ∗ x
Selbst einem eher weniger begabten Programmierer wird unmittelbar einleuchten, dass ein Programm wie
def fak(x):
if x == 0: return 1
else: return fak(x-1)*x
diese Funktion implementiert. Bei einer iterativen Lösung wie etwa
def fakWhile(x):
r = 1
i = 1
while i <= x:
r = r*i
i = i+1
return r
hat man es dagegen nicht mehr nur mit einer mechanischen Umsetzung der Notation zu tun. Ein klein wenig
Überlegung ist schon notwendig, um einzusehen, dass f ak von fakWhile berechnet wird.
Rekursive Algorithmen als direkt umgesetzte rekursive Funktionsdefinitionen sind also quasi automatisch korrekt.
Es gibt ja de facto nichts zu implementieren. In bester deklarativer Manier ist die exakte Spezifikation des Problems
schon dessen Lösung.
72
Nichtprozedurale Programmierung
Rekursion und Programmkorrektheit
Kommt, wie bei mehr formal und mathematisch orientierten Menschen üblich, die Masse aller Problemstellungen
in Form rekursiver Funktionsdefinitionen daher, dann ist es nur natürlich, dass Schleifen in rein funktionalen Sprachen kaum vermißt werden. Aber auch wenn einmal der rekursive Algorithmus nicht direkt die zu berechnende
Funktion ausdrückt, hat die Rekursion doch auch Vorteile gegenüber der Iteration. Als triviales Beispiel betrachten
wir eine Funktion in rekursiver Form:
def Irec(x):
if x == 0: return 0
else: return Irec(x-1)+1
Irec berechnet die Identitätsfunktion
let I = λx.x
Das sieht man unmittelbar ein und wenn nicht, dann hilft ein einfacher Induktionsbeweis dabei, sich oder andere
davon überzeugen:
Irec(0) = 0 = (λx.x)(0)
Angenommen Irec(n) = n = (λx.x)(n) dann gilt:
Irec(n+1) = Irec(n) +1 = n + 1 = (λx.x)(n + 1)
und damit ist die Behauptung bewiesen, dass Irec die Identitätsfunktion berechnet.
Eine äquivalente iterative Implementierung von I ist:
def Iiter(x):
r,i = 0,x
while i > 0:
# INV: r=I(x-i)
r,i =r+1,i-1
return r
Um überzeugend argumentieren zu können, dass dieses Programm die Identitätsfunktion berechnet, muss eine
Schleifeninvariante gefunden werden, deren Gültigkeit bei Schleifen-Eintritt und am Ende jeden Durchlaufs muss
gezeigt werden, und schließlich müßte nachgewiesen werden, dass aus der Invariante und der invertierten Schleifenbedingung das gewünschte Ergebnis folgt. In unserem Fall ist dies alles trivial. Die Schleifeninvariante haben
wir gleich ins Programm geschrieben und den Rest überlassen wir dem Leser. Man sieht aber, dass Korrektheitsargumente bei iterativen Programmen in jedem Fall komplexer sind als bei äquivalenten rekursiven Lösungen.
Rekursion, Iteration, Induktion
Iteration ist die Wiederholung des Gleichen. Eine Iteration kann algorithmisch sein, beispielsweise, wenn in einer
Schleife die gleiche Folge von Anweisungen immer wieder durchlaufen wird. Eine Iteration kann sich aber auch
auf Datenstrukturen beziehen. Eine Liste ist eine iterative Datenstruktur; sie ist leer, oder hat ein Element und noch
eine Element und so weiter. Iterative Algorithmen und iterative Datenstrukturen kommen oft zusammen. Eine Liste
etwa wird natürlicherweise in einer Schleife durchlaufen.
Rekursion ist eine Verallgemeinerung des Konzepts der Iteration. Sie kann algorithmisch aufgefasst werden, dann
bildet ein Lösungsverfahren einen Teil seiner selbst. Ein Hochhaus verlässt man, indem man im Parterre zur
Haustür hinaus geht oder ansonsten einen Stock tiefer geht und dann das Hochhaus mit dem gleichen Verfahren verlässt. Rekursion gibt es auch in Form von Datenstrukturen: Ein Binärbaum beispielsweise ist leer oder er
besteht aus einem Knoten und zwei Binärbäumen. Rekursive Datenstrukturen und rekursive Algorithmen gehören
ebenso eng zusammen wie iterative Datenstrukturen und iterative Algorithmen.
Rekursive Algorithmen werden rekursiv, durch einen Bezug auf sich selbst, definiert. Eine rekursive Definition kann sinnvoll oder sinnlos sein. Beispielsweise ist die Definition “Ein Gluckbowitsch ist ein Gluckbowitsch”
ebenso sinnlos wie die der Python–Funktion
f = lambda x : f(x)
Th Letschert, FH Giessen–Friedberg
73
In beiden Fällen bezieht sich die Definition auf das zu Definierende. Dabei dreht sie sich hier in diesen beiden
Beispielen aber immer nur um sich selbst, ohne dabei jemals zu einem Ziel oder Ende zu kommen.
Es ist klar, dass Rekursion dann einen Sinn macht, wenn der Selbstbezug ein Rückbezug ist, ein Rückbezug auf
einfachere Variante seiner selbst, um so irgendwann zu einem Anfang und Ende zu kommen. Das gilt gleichermaßen für Algorithmen wie für Datenstrukturen. Die beiden Unterbäume, aus denen ein Binärbaum besteht, sind
einfacher (kleiner / kürzer) als der gesamte Baum. Die Berechnung der Fakultät f (x − 1) in
letrec f = λ x . if x = 0 then 1 else f (x − 1) ∗ x
ist einfacher als die von f (x).
Eine sinnvolle Rekursion hängt also mit einem Konzept von “einfach” und “komplex” bei den Daten zusammen.
Daten, die systematisch in zunehmender Komplexität aus einfacheren aufgebaut werden, nennt man oft induktive
Daten.31 Die natürlichen Zahlen sind induktiv. Man beginnt mit der sehr einfachen Null und kann durch einfache
Addition von Eins jede beliebige natürliche Zahl bilden. (siehe Abbildung 1.16) Nicht induktiv sind dagegen
beispielsweise die reellen Zahlen, oder die Menge der Steine auf dem Mond. Es gibt keine definitive Menge
einfachster Startelemente, aus denen mit einem einheitlichen Verfahren alle anderen systematisch erzeugt werden
könnten.
0
Basismenge
1
2
3
...
Konstruktionsfunktion
Abbildung 1.16: Die natürlichen Zahlen als induktive Menge
Mit rekursiven Algorithmen eng verwandt ist die mathematische Beweistechnik der vollständigen Induktion, bei
der gezeigt wird, dass eine Eigenschaft vollständig, d.h. für alle Elemente einer induktiven Menge, gilt. Man zeigt,
dass die Eigenschaft P für das oder die Anfangselement(e) {b1 , b2 , ..} gilt und bei jedem “Konstruktionsschritt”
K erhalten bleibt.
Wenn P (bi ) für alle bi gilt
und die Schlussfolgerung P (x) ⇒ P (K(x)) korrekt ist,
dann gilt P (e) für alle Elemente e.
Ein Beweis durch vollständige Induktion entspricht damit einer Berechnung durch einen rekursiven Algorithmus,
bei dem der Wert F (K(x)) für den komplexen Fall aus dem Wert F (x) für den einfacheren Falle konstruiert wird.
Wenn F (bi ) für alle bi einfach berechnet werden kann
und F (K(x)) aus F (x) berechnet werden kann
dann kann F (e) für alle Elemente e berechnet werden.
1.5.2
Rekursive Daten und rekursive Funktionen
Rekursion auf Daten und wilde Rekursion
Da es sich bei der Rekursion aber – neben einer Geschmacksfrage – auch um eine Frage der Gewohnheit handelt,
wollen wir uns jetzt mit einigen Beispielen beschäftigen. In informaler λ–Notation definieren wir eine rekursive
Multiplikations–Funktion wie folgt:
letrec f = λx. if x = 0 then 1 else f (x − 1) ∗ x
31
induktiv = auf Induktion beruhend. Induktion (lat.) = Herleitung
74
Nichtprozedurale Programmierung
Rekursive Funktionen lassen sich auch gut mit Parametermustern ausdrücken. Beispielsweise die Multiplikation
auf Basis der Addition:
mult(0, y) = 0
mult(x + 1, y) = mult(x, y) + y
Einer sehr einfache Funktion ist die Identität
letrec I = λx. if x = 0 then 0 else I(x − 1) + 1
Diese Funktionen sind einfach zu verstehen, da sie sich in ihrer Definition eng an die Struktur der natürlichen
Zahlen anlehnen. Sie gehen vom Komplexen, hier der größeren Zahl, systematisch zum Einfacheren. So lange, bis
sie unten angekommen sind. Die meisten sinnvollen rekursiven Funktionen sind von dieser Art.
Die die sich nicht in dieser Art direkt an irgendwelche Daten anlehnen, sind aber nicht unbedingt so völlig sinnlos
divergent wie f = λx.f (x). Eine etwas geheimnisvolles Beispiel einer nicht divergenten Funktion mit wilder
Rekursion ist f 91:
letrec f 91 = λx. if x > 100 then x − 10 else f 91(f 91(x + 11))
Ihre recht Wirkung ist nicht ganz einfach erkennbar: f 91(x) liefert den Wert x − 10 für x > 100 und ansonsten
91. Dieses Verhalten ist bisher nur eine Beobachtung, die noch niemand bewiesen hat.
Ein anderes Beispiel einer wilden Rekursion ist die Definition der Collatz–Folge auf positiven ganzen Zahlen x:
collatz(x) = collatz(x/2)
collatz(x) = collatz(3 ∗ x + 1)
falls x gerade
sonst
Eine unbewiesene Vermutung besagt, dass jede Collatz–Folge in dem Zykel 1 → 4 → 2 → 1 → 4 · · · endet.
Wir sehen, dass wilde Rekursionen nicht unbedingt gleich völlig sinnlos sind. Ihr Verhalten ist aber schwer
verständlich. Sie sind kaum zu beherrschen – wild eben. Wenden wir uns lieber wieder den zahmeren Exenplaren
zu.
Die Definition einer einfachen rekursiven Funktion wie mult folgt streng dem inneren Aufbau des ersten Arguments als natürlicher Zahl. Eine natürliche Zahl ist
• entweder 0, oder
• oder n + 1 für eine eindeutig bestimmte “einfachere” andere natürliche Zahl n.
Die Funktion mult orientiert sich an dieser Struktur. Dies sieht man besonders deutlich, wenn sie mit Parametermustern definiert wird:
mult(0, y) = 0
mult(x + 1, y) = mult(x, y) + y
Neben der dieser strikten Orientierung am Aufbau des ersten Arguments enthält mult noch eine weitere fundamentale “Einfachheit”: Der Funktionswert des einfachsten Arguments 0 kann direkt angegeben werden und der
Funktionswert komplexeren Arguments x + 1 kann einfach und direkt – ohne weitere rekursive Aufrufe – aus dem
Funktionswert von x berechnet werden.
Im Vergleich dazu orientieren sich f 91 oder collatz weder am Aufbau seines Arguments als natürliche Zahl, noch
ist der Funktionswert des komplexeren Arguments direkt aus dem Funktionswert des einfacheren zu berechnen.
Zur Berechnung von f 91(x) für x < 100 sind sogar zwei Rekursionsaufrufe benötigt werden.
1.5.3
Klassifikation der Rekursion
Unterschiedliche Arten der Rekursion
Rekrusiv definierte Funktionen kommen in unterschiedlicher Gestalt vor. Manche sind sehr einfach. Nehmen wir
eine Funktion find, die nachsehen soll, ob in einer Liste ein bestimmtes Element vorkommt:
def find(x, l):
Th Letschert, FH Giessen–Friedberg
75
if l == []:
return 0
else:
if l[0] == x :
return 1
else:
return find(x, l[1:])
Diese Funktion “frisst sich” einfach durch die Liste indem sie rekursiv einen immer kürzeren Listenrest bearbeitet.
Spätestens bei Erreichen der Listenendes ist die Arbeit erledigt.
Andere rekursive Funktionen müssen nicht nur beim “Hinweg in”, sondern auch auf dem “Rückweg aus” der
Rekursion arbeiten. Die rekursive Summuation aller Listenenlemente ist von dieser Struktur:
def sum(l):
if l == []:
return 0
else:
return sum(l[1:])+l[0]
Die Additionsoperationen werden hier beim Rückweg aus der Rekursion ausgeführt. Sie “hängen der Rekursion
nach”.
Rekursionen ohne nachhängende Operationen sind wie Schleifen: man tut das Gleiche so lange, bis man fertig ist.
Sie können darum sehr leicht in die effizientere Form einer Schleife transformiert werden. Leider sind sie aber
relativ selten.
In der praktischen Arbeit ergeben sich meist rekursive Funktionen mit nachhängenden Operationen. Diese können
zwar nicht immer, aber sehr oft mit eine zusätzlichen Parameter in eine Variante ohne nachhängende Operation
transformiert werden. So definieren wir für die Summuation der Listenelemente eine entsprechende Hilfsfunktion
sumR:
def sum(l):
def sumR(l, s):
if l == []:
return s
else:
return sumR(l[1:], s+l[0])
return sumR(l, 0)
Diese Form der Summation büßt etwas an Natürlichkeit und Übersichtlichkeit ein, ist aber dafür “schleifenartiger”.
Wir haben dies durch einen Parameter s erreicht, in dem die Zwischenergebnisse aufgesammelt (akkumuliert) und
durch die Rekursionsstufen weiter gereicht werden. Solche Parameter nennt man akkumulierende Parameter.
Rekursion mit akkumulierendem Parameter
Akkumulierende Parameter kann man also verwenden um nachhängende Operationen zu eliminieren und die Rekursion dadurch schleifenartiger zu machen. Man kann die Akkumulation aber auch als Programmiertechnik verwenden. Rekursive Funktionen werden oft einfacher, wenn man zusätzliche Parameter verwendet und der zusätzliche Parameter ist oft ein akkumulierender Parameter.
Ein (vollständig geklammerter) arithmetischer Ausdrück, der als Liste von Zeichen und Zahlen vorliegt, kann
beispielsweise nicht durch eine einfache Rekursion über die Struktur des Ausdrucks berechnet werden. Auf der
Liste
[ "(", "(", 2, "+", 3, ")", "*", 4, ")"]
kann man keine Wert-Berechnung von der Art:
Wert(l) = f(l[0], Wert(l[1:]))
76
Nichtprozedurale Programmierung
definieren, da die physische Struktur der Liste nicht der logischen Struktur eines Ausdrucks entspricht.32 Mit akkumulierenden Parametern wird die Sache aber ganz einfach. Wir können den Ausdruck in bewährter Stapeltechnik
abarbeiten:
def top(s):
return s[0]
def push(x,s):
return [x]+s
def pop(l):
return l[1:]
def fun(c):
if c == "+" : return lambda x,y:x+y
if c == "-" : return lambda x,y:x-y
if c == "*" : return lambda x,y:x*y
def wert(l):
def wertH(l, oS, wS):
if l == []:
return top(wS)
elif l[0] == "(":
return wertH(l[1:], oS, wS)
elif l[0] == ")":
o = fun(top(oS))
oS = pop(oS)
w2 = top(wS)
wS = pop(wS)
w1 = top(wS)
wS = pop(wS)
return wertH(l[1:], oS, push(o(w1,w2),wS) )
elif (l[0] == "+") or(l[0] == "-") or (l[0] == "*"):
return wertH(l[1:], push(l[0],oS), wS)
else:
return wertH(l[1:], oS, push(l[0],wS))
return wertH(l, [], [])
Kategorien rekursiver Funktionsdefinitionen
Rekursive Funktionsdefinitionen treten in unterschiedlichen Stufen der Komplexität auf. In ihrer einfachsten Form
können sie unmittelbar in ein einfaches iteratives Programm umgesetzt werden. Bei den komplexeren Formen
steigt der Aufwand der Umformung und die Komplexität des Ergebnisses. Wir unterscheiden im Folgenden drei
Kategorien rekursiver Funktionsdefinitionen:
• repetitive,
• linear rekursive und
• allgemein rekursive
Definitionen.
Repetitiv rekursive Funktionsdefinitionen
In ihrer einfachsten Variante hat eine rekursive Funktionsdefinition die Form:
f (x) = if p(x) then g(x) else f (h(x))
32
Siehe auch die entsprechende Diskussion im Skript zu Programmieren I.
Th Letschert, FH Giessen–Friedberg
77
Solche Funktionen, mit beliebigem – natürlich nicht rekursiven – Prädikat p und Hilfsfunktionen h und g nennt
man repetitiv rekursiv. Man spricht auch von End–Rekursion, oder engl. Tail–Recursion. Repetitiv rekursive sind
die einfachste Variante der rekursiven Funktionsdefinitionen. Ihr besonderes Merkmal ist die Tatsache, dass der
rekursive Aufruf die letzte Aktion der Funktion ist, die Rekursion also nicht mehr “zurückkehrt”. Das bekannteste
Beispiel einer repetitiv rekursiven Funktion ist die ggt–Funktion (in Python):
def ggt(x, y):
if x == y:
return x
else:
if x > y:
return ggt(x-y, y)
else:
return ggt(y-x, x)
Mit Hilfe von Maximum und Minimum–Funktion kann diese Definition in die oben angegebene allgemeine Form
gebracht werden. Ohne eine solche Umformung erkennt man aber auch sofort das Charakteristische der End–
Rekursion: das Ergebnis des rekursiven Aufrufs wird nicht weiter verarbeitet, es stellt direkt das Endergebnis dar.
Linear rekursive Funktionsdefinitionen
Repetitiv rekursive Funktionsdefinitionen sind einfach aber leider relativ selten. Nützliche rekursive Funktionen
enden meist nicht mit dem rekursiven Aufruf, sondern verarbeiten dessen Ergebnis noch in irgendeiner Art. Die
Fakultätsfunktion ruft sich beispielsweise selbst auf und multipliziert das Ergebnis dieses Aufrufs noch mit ihrem
Argument:
def fak(x):
if x == 0:
return 1
else:
return fak(x-1)*x
Derartige Funktionen nennt man linear rekursiv. Ihre allgemeine Form ist
f (x) = if p(x) then g(x) else ψ(f (h(x)), x)
Linear rekursive Funktionen kehren also aus der Rekursion mit einem Zwischenergebnis zurück und verarbeiten
dieses dann zusammen mit ihrem Argument. Zur Abwicklung derartiger Funktionen benötigt man einen Aufbewahrungsort für die Argumente jeden rekursiven Aufrufs. Dies ist üblicherweise der Stack und lineare Rekursion
wird darum oft auch Stack–Rekursion genannt.
Linear rekursive Funktionen treten sehr häufig auf. Folgende linear rekursive Funktion berechnet beispielsweise
die Summe aller Elemente einer Sequenz:
sum(l) = if l =<> then 0 else head(l) + sum(tail(l))
Durch die Zwischenspeicherung auf dem Stack und dessen Verwaltung sind linear rekursiv definierte Funktionen
weniger effizient als repetitive. Oft lassen sie sich durch die Technik der Akkumulation in repetitive umwandeln.
Weiter oben haben wir bereits Beispiele für diese Technik gesehen: Die Umkehrung einer Liste und die Summation
aller Listenelemente wurden entsprechend umgeformt.
Allgemein rekursive Funktionsdefinitionen
In ihrer allgemeinsten Form ruft eine rekursive Funktion sich selbst mehrfach auf. Sie nimmt damit folgende
allgemeine Form an:
f (x) = if p(x) then g(x) else ψ(f (h1 (x)), f (h2 (x), · · · f (hn (x)), x)
Ein bekanntes Beispiel für eine allgemein rekursiv definierte Funktion ist die Fibonacci–Funktion mit ihren zwei
rekursiven Aufrufen.
78
Nichtprozedurale Programmierung
Repetitiv rekursive Funktionen sind Schleifen
Jede repetitiv rekursive Funktion ist in ihrem “Innersten” eine Schleife. Als Beispiel nehmen wir die GGT–
Funktion. Wir nutzen dabei die Hilfsfunktionen max und min, um die Definition an das allgemeine Muster anzugleichen:
def ggt(x, y):
if x == y: return x
else: return ggt(max(x,y)-min(x,y), min(x,y))
Die Funktion endet, wenn x und y gleich sind. Ansonsten wird das Maximum, Minimum und die Differenz der
beiden bestimmt und es geht wieder von vorne los. Das ist eine Schleife:
def ggtIter(x, y):
while x != y:
(x,y) = (max(x,y)-min(x,y), min(x,y))
return x
In dieser iterativen Version der GGT–Berechnung haben wir wieder die in Python mögliche simultane Zuweisung
benutzt. Den Variablen x und y werden dabei mit einer Zuweisung neue Werte zugewiesen. Das erspart uns die
Verwendung von mindestens einer Hilfsvariablen.
Allgemein entspricht eine rekursive Funktion der Form
f (x) = if p(x) then g(x) else f (h(x))
einer iterativen Prozedur der Form
PROC f (x)
WHILE !p(x) DO
x = h(x);
RETURN g(x);
Mit der Einführung einer Hilfsvariablen r kann diese Prozedur Eingabe–erhaltend gestaltet werden.33 Damit wird
die Korrektheit und der Bezug zur rekursiven Version noch klarer:
PROC f (x)
VAR r = x
WHILE !p(r) DO //WHILE-INV f(x) = f(r)
r = h(r);
// f(x) = f(r) AND p(r)
RETURN g(r);
Akkumulation und Generalisierung: Iterative Versionen linear rekursiver Funktionen
Linear rekursive Funktionen können oft mit der Technik der Akkumulation in repetitiv rekursive Funktionen, also
in Schleifen, umgewandelt werden. Bei der Akkumulation werden die Zwischenergebnisse akkumuliert (aufgesammelt). Weiter oben hatten wir bereits als Beispiel eine Funktion, die die Summe aller Elemente einer Liste
aufaddiert. Wir wiederholen das Experiment noch einmal im Lisp–Stil mit den aus Lisp entlehnten Listenfunktionen car und cdr (Die Hilfsfunktionen car und cdr berechnen das erste Element und den Rest der Liste):
def car(l):
return l[0]
def cdr(l):
return l[1: ]
def sumLinear(l):
if (l==[]): return 0
else:
return car(l) + sumLinear(cdr(l))
33
Eine Eingabe–erhaltende Funktion modifiziert ihre Eingabeparameter nicht.
Th Letschert, FH Giessen–Friedberg
79
Die linear rekursive Funktion kann leicht in eine repetitiv rekursive umgewandelt werden bei der die Zwischenergebnisse sofort berechnet und an den rekursiven Aufruf weitergegeben werden. Die “nachhängende” Addition
kann damit entfallen:
def sumRep(l,s):
if (l==[]): return s
else:
return sumRep(cdr(l), s+car(l))
def sum(l):
return sumRep(l,0)
Die repetitiv rekursive Funktion sumRep kann jetzt direkt in eine iterative Form gebracht werden:
def sumIter(l):
s = 0
while l != []:
(l, s) = (cdr(l), s+car(l))
return s
Die Umformungstechnik der Akkumulation ist eine Generalisierung der Funktion. Das ursprüngliche Problem
der von sum(l), die Bestimmung der Summe aller Listenelemente, wird zur Berechnung von sumRep(l, s), der
Summe der Elemente von l plus s verallgemeinert (generalisiert). Das verallgemeinerte Problem kann in repetitiv
rekursive Form gebracht werden und ist damit effizienter zu lösen als das Ausgangsproblem.
Der funktionale Entwicklungsprozess und die Ehre der Schleifen
In funktionalen Kreisen gelten Schleifen und repetitiv rekursive Funktionen als vollkommen äquivalent. Eine
Schleife ist eine repetitive Rekursion. Von einem Compiler einer rein–funktionalen Sprache wird darum auch
mit Recht erwartet, dass er aus solchen Rekursionen Schleifen generiert um so effizienteren Code zu erzeugen.
Warum sind Schleifen denn dann so ehrlose Gesellen, wenn sie doch äquivalent zu einfachen aber doch ehrenvollen
Rekursionen sind? Nun, sie werden als zu nah an der Implementierung betrachtet. Für einen echten Funktionalen
riechen sie nach Hardware, Schmieröl und rohen Bits. Programmierer schreiben Schleifen nicht selbst, es ist die
Aufgabe des Compilers sie aus einem “höheren” Konstrukt zu generieren. Das entspricht den goto-s in der klassischen strukturierten Programmierung. Jede Schleife wird vom Compiler in eine Programmsequenz mit goto-s
umgesetzt. Das ist OK, selbst geschriebene goto-s sind dagegen unehrenhaft.
Im Sinn der funktionalen Programmierung beginnt die Entwicklung eines korrekten und gleichzeitig effizienten
Programms mit allgemein oder linear rekursiven Funktionen. Bei Effizienzproblemen werden diese dann systematisch in äquivalente repetitiv rekursive Funktionen umgesetzt und diese dann am Schluss (wenn möglich automatisch) in Schleifen umgesetzt (siehe Abbildung 1.17).
Linear oder allgemin rekursive Funktion
kreativer
Entwicklungsprozess
repetiv rekursive Funktion
mechanisches
Umsetzen
Schleife
Abbildung 1.17: Der funktionale Entwicklungsprozess
80
Nichtprozedurale Programmierung
Die kreative Arbeit, die in der Transformation einer allgemein rekursiven in eine repetitiv rekursive Funktion
steckt, ist im Wesentlichen äquivalent zum Finden einer Schleife (inklusive ihrer Invariante). Das relativiert die
überlegene Attitüde der funktionalen Fraktion durchaus ein wenig. Gute Programme sind gute Programme, egal
nach welchem Paradigma sie entwickelt wurden und ihre Entwicklung ist und bleibt harte Arbeit.34
Rekusionstheorie und die Klassifikation der rekursiven Definitionen
Die Informatik kennt auch eine Klassifikation von Funktionen in verschiedene Arten von rekursiven Funktionen
und solchen die nicht rekursiv sind. Dabei geht es um die Berechenbarkeit von Funktionen und die Definition von
Varianten der “Rekursion” ist eine etwas andere. Unsere Klassifikation der rekursiven Funktiondefinitionen hier
darf man also nicht mit der in dieser Rekursionstheorie verwechseln. Da sich gezeigt hat, dass alles Berechenbare
mit reukriven Funktionen berechnet werden kann, wird der Name “Rekursionstheorie” allgemein als Synonym zu
“Berechenbarkeitstheorie” verwendet. In dieser Theorie unterscheidet man
• primitiv–rekursive,
• µ–rekursive und
• rekursive
Funktionsdefinitionen und überlegt, welche Funktionen mit einer Definition in der jeweiligen Art definiert werden
können.
Primitiv rekursive Funktionen in der Teminologie der Rekursions– (= Berechenbarkeits–) Theorie sind alle Funktionen, die mit einer, in unserem Sinne, linear rekursiven Definition induktiv über den natürlichen Zahlen definiert
werden können. Solche Funktionen terminieren immer. Sie können mit Schleifen formuliert werden, bei denen die
Zahl der Durchläufe bei Eintritt in die Schleife festliegt, also mit “echten” For–Schleifen. (For–Schleifen in C sind
verklausulierte While–Schleifen.)
Die µ–rekursiven Funktionen verwenden auch Schleifen, die eventuell nicht terminieren. Es sind, in unserem
Sinne, linear rekursive Definitionen, die nicht unbedingt induktiv über irgendeinen Bereich definiert sind. Zum
Beispiel gehören die f 91–Funktion oder Berechnung der Collatz-Folge bis zu einem Zykel zu dieser Kategorie.
Die rekursiven Funktionen haben keinerlei Beschränkungen in ihrer Definitionsstruktur. Alle berechenbaren Funktionen können als rekursive Funktionen definiert werden. µ–rekursive Funktionen sind in ihrer Ausdruckskraft
äquivalent zu den rekursiven Funktionen. Nicht alle berechnbaren Funktionen können jedoch als primitiv rekusive
Funktionen definiert werden.
Die Rekursionstheorie klassifiziert also Funktionen in Bezug auf die in ihrer Definition verwendeten Ausdrucksmittel. Das Ziel dabei ist, berechenbare von nicht berechenbaren Funktionen zu unterscheiden. Das Ergebnis ist
grob gesagt folgendes:
• Alles was berechenbar ist, kann als rekursive Funktion definiert werden und umgekehrt. Rekursion entspricht
der Turing–Maschine bzw. dem Lambda–Kalkül.
• Alles was berechenbar ist, kann mit While–Schleifen (= repetitve Rekursion) berechnet werden (rekursiv
entspricht µ–rekursiv). Selbstverständlich benötigt man dazu eventuell mehr als nur eine While–Schleife.
• Es gibt Funktionen die nur mit Hilfe von Schleifen unbegrenzter Laufzeit berechnet werden können. For–
Schleifen reichen also nicht aus um alles Berechenbare zu berechnen. (Nicht jede rekursive Funktion ist
primitiv rekursiv.)
Interessierte Leser verweisen wir auf die Literatur zur theoretischen Informatik.
34
“Arbeit ist Arbeit und wird immer Arbeit bleiben.” (Klappentext eines Buchs über die aktuelle Manie der der social skills).
Th Letschert, FH Giessen–Friedberg
81
Zusammenfassung
Wir haben hier drei verschieden Arten von rekursiven Funktionsdefinitionen vorgestellt:
• Repetitiv rekursiv sind Funktionen, bei denen ein rekursiver Aufruf vorkommt und dieser den Rückgabewert
direkt bestimmt:
f (x) = if p(x) then g(x) else f (h(x))
• Linear rekursiv sind Funktionen, bei denen ein rekursiver Aufruf vorkommt, dieser aber das Ergebnis nicht
direkt verarbeitet, sondern lediglich ein Zwischenergebnis liefert:
f (x) = if p(x) then g(x) else ψ(f (h(x)), x)
• Allgemein rekursive Funktionen sind solche die nicht linear und schon gar nicht repetitiv rekursiv sind.
Allgemein rekursive Funktionen könnten noch weiter unterteilt werden. Beispielsweise danach, ob der rekursive
Aufruf, wie bei der Fibonacci–Funktion, nur mehrfach vorkommt, oder, wie in f 91, sogar geschachtelt ist.
Linear rekursive Funktionen treten regelmäßig bei der Verarbeitung induktiver Datenstrukturen auf. Sie sind so
häufig wie gutmütig, denn mit der Technik der Akkumulation und/oder der Generalisierung lassen sie sich oft
gut in repetitiv rekursive Funktionen umwandeln. Repetitiv rekursive Funktionen selbst sind nichts anderes als
Schleifen.
Man vergesse bei dieser Einteilung nicht, dass hier Funktions–Definitionen und damit Algorithmen kategorisiert
werden. Die Aufteilung in “repetitiv”, “linear” und “allgemein” hat keinen Bezug zu den Funktionen selbst als
mathematische Objekte. Die GGT–Funktion beispielsweise ist als solche weder linear noch repetitiv rekursiv. Es
gibt lediglich linear rekursive und repetitiv rekursive Definitionen (Berechnungs–Algorithmen) dieser Funktion.
1.5.4
Fortsetzungsfunktionen und die systematische Sequentialisierung
Rekursion ist Hardware–Hunger
Ein funktionales Programm F beschreibt eine Berechnung, die zu gegebenen Eingabewerten ganz bestimmte,
ihnen zugeordnete Ausgabewerte erzeugt. Dieser Prozess kann nur dadurch realisiert werden, dass eine reale physische Handlungsinstanz, also eine reale – biologische, mechanische oder elektronische – Maschine ihn tatsächlich
ausführt. Eine Möglichkeit dazu wäre, eine auf F spezialisierte Maschine tatsächlich zu bauen. Diese direkte Implementierung ist in der Regel natürlich viel zu aufwändig und man benutzt eine indirekte Methode. Das Programm
wird entweder durch ein anderes Programm interpretiert oder in Maschinensprache übersetzt. Die Interpretation
eines Programms durch ein anderes ist ein wenig gemogelt, denn letztlich muss irgendein Programm dann doch
tatsächlich auf einer realen Hardware ausgeführt werden. Die reale Hardware realisiert dabei ebenfalls ein Programm. Letztlich muss also dann doch ein Algorithmus in Hardware gegossen werden.
Dass und wie Funktionen über Booleschen Werten als Schaltnetze realisiert werden können, ist allgemein bekannt. Im Prinzip kann jede rekursionsfreie Funktion in ein entsprechendes “Schaltnetz” umgesetzt werden. Das
Schaltnetz realisiert dabei den Datenflussgraph der Funktion.
Derartige Datenflussgraphen können einfach und effizient in Hardware umgesetzt werden. Die Angelegenheit wird
jedoch problematisch, wenn rekursiv definierte Funktionen ins Spiel kommen. Das Schaltnetz zu einer rekursiven
Funktion ist unendlich groß (siehe Abbildung 1.19). Zur Berechnung eines bestimmten Funktionswerts wird zwar
kein unendlich großes Netz gebraucht, aber es muss zumindest bei Bedarf auf jede beliebige Größe annehmen
können und liefert dann sofort den gewünschten Wert.
Reale Hardware wächst nicht bei Bedarf. Zumindest heute noch nicht. In Zukunft wird es vielleicht biologische
Computer geben, die beliebig komplexe Dinge unmittelbar berechnen können, wenn man ausreichend viel Nährflüssigkeit in sie kippt. Vorerst wird die Hardware aber in ihren Ausmaßen beschränkt bleiben und statt eine komplexe Berechnung mit mehr Masse (mehr Schaltkreisen) zu erledigen, wird es weiterhin einfach länger dauern. Die
Komplexität wird also mit einem erhöhten Bedarf an Zeit bezahlt.
82
Nichtprozedurale Programmierung
+
*
x
+
y
z
Abbildung 1.18: λx, y, z.(x ∗ y) + (y + z) als Schaltnetz
Die Implementierung eines funktionalen Programms besteht letztlich darin, den in einer (rekursiven) Funktionsdefinition steckenden Anspruch auf einen (potentiell) beliebig großen Schaltkreis in einen Anspruch an (potentiell)
beliebig viel Zeit bei konstanter Größe umzuwandeln. Dies gelingt nicht in jedem Fall. Viele rekursiv definierte
Funktionen können nur realisiert werden, wenn man ihrer Implementierung nicht nur beliebig viel Zeit, sondern
auch beliebig viel Speicherplatz zur Verfügung stellt.
Sequenzialisierung Repetitiv Rekursiver Funktionen
Zur Abwicklung der Rekursion in einem Datenflussgraph (Schaltnetz) wäre ein zumindest potentiell unendlich
großer Graph notwendig. Aus offensichtlichen Gründen lässt sich so etwas nur schlecht verwirklichen. Die “generative Kraft”, die Alternative zur Rekursion, die uns reale Maschinen zur Verfügung stellen, ist die Wiederholung.
Wiederholungen (Iterationen) verbrauchen Zeit. Ein Umsetzung von Rekursion in Iteration ist eine Umsetzung von
“Schaltkreis-Bedarf” in Zeitbedarf und damit eine Anpassung an die Realität gegebener Maschinen.
Wie wir weiter oben schon gesehen haben, können repetitiv rekursive Funktionen unmittelbar und schematisch in
Schleifen umgewandelt werden. So hat
f = λx. if p(x) then g(x) else f (h(x))
das prozedurale Äquivalent:
def pf(x):
r = x
while !p(x): # INV f(r) = f(x)
r = h(r)
return r
Diese Umsetzung ist korrekt: Bei Eintritt in die Schleife gilt die Invariante offensichtlich. Ihre Gültigkeit wird
durch die Schleife nicht berührt, denn laut Definition von f gilt:
aus 6 p(x) folgt f (r) = f (h(r))
Schließlich gilt eventuell irgendwann:
p(r)(f (r) = f (x) und damit f (x) = f (r) = g(r)
Damit ist bewiesen dass pf die Funktion f korrekt berechnet.
Kreative Sequenzialisierung Linear Rekursiver Funktionen
Bedauerlicherweise treten rekursive Funktionen nur extrem selten in repetitiver Form auf. Schon das Standardbeispiel der Rekursion, die Fakultätsfunktion, ist linear rekursiv und damit keine Schleife. Nun wird jeder Informatiker
am Ende des ersten Semesters mit Recht einwenden, dass er wohl in der Lage ist die Fakultät in einer Schleife zu
berechnen:
Th Letschert, FH Giessen–Friedberg
83
T
?
=0
F
1
−1
T
1
?
=0
F
−1
*
*
Abbildung 1.19: f = λx. if x = 0 then 1 else f (x − 1) ∗ x als unendliches Schaltnetz
f(x) = fW(x)
def fW(x):
r,i = 1,x
while i > 0: INV r * f(i) = f(x)
r,i = r*i, i-1
return r
Übersetzt man diese prozedurale/iterative Variante zurück in eine repetitiv rekursive Form, dann sieht man, dass,
bei dieser Implementierung der Fakultät als Schleife, tatsächlich durch die Technik der Akkumulation eine äquivalente repetitiv rekursive Variante gefunden wurde:
f(x) = fWR(1,x)
def fWR(r,i):
if i == 0:
return r
else:
return fRW(r*i, i-1)
Bei einer solchen Umformung muss in der Regel ein gewisses Maß an Kreativität und programmiertechnischer
Begabung eingesetzt werden, zwei ständig knappe Güter. Es stellt sich die Frage, ob es nicht, so wie für repetitive,
84
Nichtprozedurale Programmierung
auch für linear–rekursive Funktionen eine “mechanische” Umsetzung in eine Schleife gibt.
Linear rekursive Funktionen unterscheiden sich von den repetitiven durch ihre “nachhängende Operation” im
rekursiven Zweig. Einige Beispiele legen die Vermutung nahe, dass die Umformung in repetitive Form immer
möglich ist, indem die nachhängenden Operationen in einem Akkumulationsparameter aufgesammelt werden. Die
linear rekursive Funktion reverse kann so in eine repetitive Form umgesetzt werden:
reverse(l) =
if l =< >
then < >
else append(reverse(cdr(l)), cons(car(l), <>))
ist äquivalent zu:
reverse(l) = revR(l, <>)
revR(l, r) =
if l =< >
then r
else revR(cdr(l), cons(car(l), r))
was wiederum eine Schleife ist.
Andere linear rekursive Funktionen widersetzen sich allerdings heftig dem Versuch, sie mit Hilfe eines akkumulierenden Parameters in repetitive Form zu transformieren.
Linear Rekursive Funktion in Fortsetzungs–Form
Ein besonders hartleibiger Vertreter der Klasse der linear rekursiven Funktionen ist die append–Funktion. Sie
setzt jedem Versuch sie in eine repetitive Form zu bringen Widerstand eintgegen.35 Greifen wir sie darum etwas
systematischer an, um festzustellen, ob ein prinzielles Problem vorliegt. In
append(l1 , l2 ) =
if l1 =< >
then l2
else cons(car(l1), append(cdr(l1), l2))
Die nachhängende Funktion ist hier cons(car(l1), ...). Sie wird angewendet auf das Zwischenergebnis
append(cdr(l1), l2). In Form einer Fortsetzungs–Funktion (Continuation) können wir die nachhängenden Operationen aufsammeln:
append(l1 , l2 ) = appendC(l1 , l2 , λl.l)
appendC(l1 , l2 , cont) =
if l1 =< >
then cont(l2 )
else append(cdr(l1 ), l2 , (λl.cont(cons(car(l1 ), l))))
Die Funktion appendC hat sicher eine repetitive Form. Mit der Fortsetzungs–Funktion ist das zwar noch nicht
ganz das, was wir erwartet haben. Übersetzen wir sie trotzdem in eine Schleife:
append (l1, l2):
return appendCW(l1, l2, lambda l : l)
def appendCW(l1, l2, cont):
x1,x2 = l1,l2
while x1 != []:
# INV cont( append(l1, l2)) = appendCW(x1, x2, cont)
x1,x2,cont = cdr(x1), x2, lambda l: cont(cons(car(Xx1),l))
return cont(x2)
35 Das gilt natürlich nur, wenn wir uns auf cons, car und cdr beschränken. Bei die Verwendung von Hilfsfunktionen, die auf beliebige
Positionen einer Liste zugreifen können, ist die Transformation in eine repetitive Form natürlich völlig trivial.
Th Letschert, FH Giessen–Friedberg
85
In Python wird die Schleife in appendCW so nicht funktionieren, da die intern, zur Repräsentation einer Funktion als berechnetem Wert, aufgebauten Closures nur einen Bezug zum gesamten Gültigkeitsbereich setzen und
nicht die aktuellen Werte der Variablen in diesem Gültigkeitsbereich festhalten.36 Mit künstlich erzeugten lokalen
Gültigkeitsbereichen kann dem abgeholfen werden:
def appendCW(l1, l2, cont):
x1,x2 = l1,l2
while x1 != []:
(x1,x2,cont) = (cdr(x1), x2, (lambda Xx1, Xcont :
(lambda l: Xcont(cons(car(Xx1),l)))
)(x1, cont) )
return cont(x2)
Diese spezielle Eigenschaft von Python soll hier aber nicht weiter beachtet werden. Ebenso wollen wir ignorieren,
dass die Zuweisungen x2 natürlich völlig sinnlos sind, die zweite Liste wird ja an keiner Stelle manipuliert.
Zwei Schleifen für eine lineare Rekursion
Eine Funktion, die Funktionen aufbaut, ist nicht unbedingt das, was wir als eine Sequenzialisierung angestrebt
haben. Statt einer Fortsetzungsfunktion wollen wir einen Wert akkumulieren, um ihn später dann als Ergebnis abzuliefern. Sammeln wir also die Informationen auf, mit denen die Fortsetzungsfunktion sukzessive aufgebaut wird.
Ein kurzer Blick auf die Funktion oben zeigt, dass die Fortsetzungsfunktionen nur mit Hilfe des Wertes car(x1)
gebildet werden. Sammeln wir diese Werte auf, dann erhalten wir eine “irdische” Darstellung der Fortsetzungsfunktion:
def appendCStack(l1, l2):
x1,c2,CR = l1,l2,[]
while x1 != []:
x1,x2,CR = cdr(x1), x2, cons(car(x1), CR)
return
?? Decodierung von CR zusammen mit x2 ??
Natürlich kann die in CR codierte Fortsetzungsfunktion nicht einfach so zurück gegeben werden. Wir müssen den
Aufruf der Fortsetzungsfunktion von oben (cont(x2)) noch als Decodierung ihrer Darstellung CR zusammen
mit x2 nachbilden:
def appendCR(l1, l2):
# CR aufbauen
#
x1,x2,CR = l1,l2,[]
while x1 != []:
x1,x2,CR = cdr(x1), x2, cons(car(x1), CR)
# CR Decodieren:
#
res = x2
while CR != []:
res, CR = cons(car(CR),res), cdr(CR)
return res
Wir verallgemeinern dies zu der Erkenntnis, dass die Transformation einer linear rekursiven Funktion generell
zu zwei Schleifen (zwei repetitiv rekursiven Funktionen) führt. In einer Schleife wird ein Zwischenwert CR als
Repräsentation der Fortsetzungsfunktion aufgebaut und in einer zweiten Schleife wird er dann abgebaut und dabei
decodiert.
Nennen wir CR Stapel (Stack) dann kommen wir zu dem was wir schon längst ahnten. Bei linear rekursiven
Funktionen muss im Allgemeinen erst ein Stapel aufgebaut (in die Rekursion kriechen) und dann wieder abgebaut
36
Dies haben wir in Aufgabe 4 von Blatt 3 bereits erforscht!
86
Nichtprozedurale Programmierung
und dabei decodiert (aus der Rekursion heraus kriechen) werden. Nur in einfachen Fällen, wie bei reverse, kann
eine Codierung CR (ein “Stapel”) gefunden werden, die ohne weitere Aktionen das Endergebnis darstellt.
Im Falle der append sehen wir als CR eine invertierte erste Liste. Auch das haben wir längst geahnt. append
kann nur mit Hilfe von reverse – also einer zweiten repetitiven Funktion – in repetitive Form gebracht werden.
In der ersten Schleife wird die erste Liste umgekehrt und in der zweiten Schleife dann Element für rückwärts
vor die zweite gesetzt. Die systematische Analyse des Problems hat mit viel Mühe zu einer Lösung geführt, die
intuitiv unmittelbar naheliegend ist. Das zeigt, dass im Allgemeinen zwei Schleifen benötigt werden um eine
linear rekursive Funktion zu sequentialisieren. Es zeigt aber auch, dass mit Intuition viel zu machen ist, wir also
zum Programmieren geboren sind.
Sequenzialisierung Allgemein Rekursiver Funktionen
Eine allgemein rekursive Funktion hat die Form
f (x) = if p(x) then g(x) else ψ(f (h1 (x)), f (h2 (x), · · · f (hn (x)), x)
Beschränkt man sich auf den Fall n = 2, dann wird daraus
f (x) = if p(x) then g(x) else ψ(f (h1 (x)), f (h2 (x)), x)
Auch diese Funktion kann unmittelbar in eine äquivalente repetitiv rekursive Form mit Fortsetzungsfunktion gebracht werden:
f c(x, c) =
if p(x) then c(g(x))
else f c(h1 (x), λv1 .f c(h2 (x), λv2 .c(ψ(x, v1 , v2 ))))
Für die Fortsetzungsfunktion kann man sich einen Repräsentanten ausdenken, der in einer ersten Schleife aufgebaut und in einer zweiten Schleife decodiert wird. Interessanterweise enthält die Fortsetzungsfunktion selbst
wieder rekursive Aufrufe von f c. Das bedeutet, dass in der Decodierschleife selbst wieder Codierungen aufgebaut
werden müssen. Zwischen Codierung und Decodierung des Repräsentanten wird also hin– und her-gewechselt.
Den Repräsentanten der Fortsetzungsfunktion kann man als Stapel auffassen. Das Hin– und Hergehen zwischen
Codierung und Decodierung heißt dann nichts anderes, als dass der Stapel nicht, wie linear rekursiven Funktionen,
zuerst vollständig auf- und dann vollständig abgebaut wird, sondern dass er wechselweise wächst und schrumpft.
Auch mit zwei Schleifen allein kann also eine allgemein rekursive Funktion nicht sequentialisiert werden! Wir
benötigen einen Stapel der nicht nur Zwischenwerte enthält, sondern auch noch Informationen darüber, in welchem Zustand die Berechnung, für einen rekursiven Aufruf, unterbrochen wurde. Die Sequentialisierung allgemein
rekursiver Funktionen kann mit einigem Aufwand allgemein behandelt werden, wir verzichten hier allerdings darauf, merken uns, dass dazu ein Stapel mit Werten und Fortsetzungsinformationen notwendig ist und verweisen den
Leser ansonsten im vollen Vertrauen auf seine Programmierintuition.
1.5.5
Entwicklung Rekursiver Programme
Induktion als Programmiertechnik
Im einfachsten Fall haben wir es mit einem Algorithmus zu tun, der auf einer induktiven Menge operiert und sich
dabei der Struktur der Elemente “entlang hangeln” kann. Oder, um es etwas gebildeter auszudrücken, das Leben
wird leicht, wenn wir einen Algorithmus induktiv über die Struktur einer induktiven Menge definieren können.
Listen und Bäume sind beliebte induktive Mengen und viele nützliche Funktionen sind können induktiv über deren
Struktur definiert werden. Zwei willkürliche Beispiele sind:
• Die Summe aller Listenelemente:
– Die Summe aller Listenelemente der leeren Liste ist Null.
– Kenne ich die Summe der Elemente einer Liste l dann kann ich daraus die Summe der Listenelemente
von cons(e, l) berechnen.
Th Letschert, FH Giessen–Friedberg
87
• Maximale Tiefe eines Baums (längster Weg von der Wurzel zu einem Blatt):
– Die maximale Tiefe eines Baums, der aus einem einzigen Knoten besteht, ist 0.
– Kenne ich die maximale Tiefe von n Bäumen, dann kann ich die maximale Tiefe des Baums berechnen,
der aus einem Knoten und diesen n Bäumen als Unterbäumen besteht.
Hier wird jeweils nur behauptet, dass es möglich ist, den Induktionsschritt zu gehen, d.h. also aus der Summen
des Listenrests und Tiefe der Unterbäume die Listensumme, bzw. die Baumtiefe zu berechnen. Der Beweis der
Behauptung, also die Formulierung des rekursiven Algorithmus’, können wir aber sicherlich dem Leser überlassen.
Das Prinzip der Programmentwicklung durch Induktion besteht darin, dass man nicht in Sequenzen von auszuführenden Operationen denkt, sondern zu zeigen versucht, dass
1. das Problem darin besteht, eine Funktion auf einer induktiven Menge zu berechnen und dass es
2.
(a) für Basiselemente gelöst werden kann, sowie
(b) dass die Lösung des Problems für komplexe Elemente aus einer Lösung für die Elemente berechnet
werden kann, aus denen das komplexe Element konstruiert wurde.
Nicht immer ist es derart offensichtlich wie in diesen Beispielen, dass Funktionen induktiv über die Struktur
einer induktiven Menge definiert werden können. Die Induktivität der Definitionsmenge ist dabei auch meist nicht
das Problem. Die Mengen, auf denen unsere Algorithmen operieren, sind in der Regel induktiv. Der induktive
Charakter der zu berechnenden Funktion ist aber nicht immer offensichtlich.
Permutationen
Die Permutationen einer Sequenz s =< s0 , s1 , · · · sn > ist die Menge aller Sequenzen, die die gleichen Elemente
in beliebiger Reihenfolge enthalten. Die Definitionsmenge der gesuchten Funktion ist induktiv: klar, es die Menge
der Sequenzen. Kurzes Nachdenken und Experimentieren mit einem Beispiel lässt vermuten, dass die Funktion
zur Berechnung der Permutationen induktiv formuliert werden kann. Ein Blick in unseren Knuth ([18]) bestätigt
diese Vermutung:
Basis Die leere Sequenz hat eine einzige Permutation: die leere Liste.
Induktionsschritt Angenommen wir haben alle Permutationen einer Sequenz s. Daraus können wir alle Permutationen der um ein Element längeren Sequenz s0 = cons(x, s) berechnen. Sei beispielsweise
s =< a, b >
und angenommen wir hätten
permut(s) =<< a, b >, < b, a >>
schon berechnet. Daraus können wir die Permutationen von s0 = cons(x, s) =< x, a, b > berechnen. Wir brauchen dazu lediglich das neue Element x an alle möglichen Positionen in den bereits gefundenen Permutationen zu
setzen:
permut(s0 ) =<< x, a, b >, < a, x, b >, < a, b, x >, < x, b, a >, < b, x, a >, < b, a, x >>
Damit ist ein rekursiver Algorithmus gefunden. Es fehlen lediglich noch ein paar Hilfsfunktionen. Die wichtigste
ist die Funktion allInsertions, die ein Element an alle möglichen Positionen in einer Liste setzt. Diese Funktion
erzeugt aus einem Element und einer Sequenz eine Sequenz von Sequenzen. Beispiel:
allInsertions(1, < 2, 3 >) =<< 1, 2, 3 >, < 2, 1, 3 >, < 2, 3, 1 >>
Auch diese Funktion kann induktiv definiert werden. Auch ohne Hilfe von Knuth finden wir:
Basis In die leere Sequenz kann ein Element nur auf eine einzige Art eingefügt werden:
allInsertions(x, <>) = << x >>
Induktionsschritt Angenommen wir wären in der Lage ein Element x an alle Positionen in eine Liste l einzufügen.
Könnten wir daraus alle Einfügungen in der um ein Element a erweiterten Liste l0 = cons(x, l) Konstruieren? –
Ein Experiment mit l =< b, c > zeigt:
88
Nichtprozedurale Programmierung
allInsertions(x, < b, c >) = << x, b, c >, < b, x, c >, < b, c, x >>
allInsertions(x, < a, b, c >) = << x, a, b, c >, < a, x, b, c >, < a, b, x, c >, < a, b, c, x >>
Ok also, wir erweitern alle schon gefundenen Einfügungen um das neue Element a. Dazu nehmen wir noch die
Sequenz cons(a, l).
Die Funktion allInsertions kann jetzt leicht mit Hilfe von map definiert werden:
letrec allInsertions =
λx, l . if l =<> then << x >>
else cons(cons(x, l),
map(λr.cons(car(l), r),
allInsertions(x, cdr(l))
)
)
append ist dabei die Funktion, die zwei Listen zusammenfügt.
Jetzt können wir auch die Funktion zur Generierung der Permutation definieren:
letrec allP erms = λ l.
if l =<> then <<>>
else join(map(λp.allInsertions(car(l), p),
allP erms(cdr(l))
)
)
Die Funktion join dient dazu, eine Liste von Listen zu einer Liste zu verschmelzen, z.B:
join(<< 1, 2 >, < 3 >>) =< 1, 2, 3 >.
In Python, mit Python–Listen als Sequenzen, wird das Programm zur Erzeugung von Permutationen nur unwesentlich länger:
def allInsertions(x, l):
if l == []:
return [[x]]
else:
return append ([cons(x,l)],
map( lambda r : cons(car(l),r),
allInsertions(x,cdr(l))
)
)
def allPerms(l):
if l == []:
return [[]]
else:
return join(
map( lambda p : allInsertions(car(l), p),
allPerms(cdr(l))
)
)
Die Implementierung der Hilfsfunktionen join, append, cons, car und cdr in Python überlassen wir dem Leser.
Th Letschert, FH Giessen–Friedberg
89
Teile und Herrsche
Algorithmen, die induktiv über die Struktur einer Menge definiert werden können, realisieren homomorphe37 Abbildungen. Nun sind leider nicht alle berechnenswerten Abbildungen homomorph. Ein in der Praxis sehr wichtiges
Gegenbeispiel ist das sogenannte Rucksack–Problem (engl. Knapsack Problem). Es geht dabei darum, eine Menge
Gegenständen mit unterschiedlichem Gewicht oder unterschiedlicher Größe so in einen Rucksack zu packen, dass
dessen Fassungsvermögen vollständig oder möglichst weit ausgenutzt wird. Der Rucksack kann natürlich auch ein
LKW, ein Schiff, eine Leiterplatte, eine Datenleitung etc. sein.
Das Rucksackproblem Wir formulieren das Rucksack–Problem als mathematisches Problem über natürlichen
Zahlen: Gegeben sei eine natürliche Zahl k und eine Sequenz s =< s0 , · · · sn−1 > von natürlichen Zahlen.
Gesucht ist KnapAll(s, k): Menge aller Folgen s0 von Elementen aus s, derart dass ihre Summe den Wert K hat.
Das Problem kann nicht durch eine einfache Induktion über die Struktur von s oder k gelöst werden. Weder
kann aus KnapAll(s, k) unmittelbar eine Lösung für KnapAll(s, k + 1) (Induktion über k) noch eine für
KnapAll(cons(x, s), k) (Induktion über s) konstruiert werden. Allerdings kann aus mehreren Lösungen für einfachere Probleme die Lösung für das Gesamtproblem konstruiert werden. Algorithmen mit dieser Eigenschaft nennt
man Teile und Herrsche bzw. englisch divide–and-conquer–Algorithmen.38
Die Idee zur Lösung des Rucksack–Problems ist folgende:
Basis Falls k = 0, dann ist der leere Rucksack die einzige Lösung. Falls s =<> und k 6= 0 dann gibt es keine
Lösung.
Induktionsschritt Wenn der Sack mit dem Rest der Sequenz s gefüllt werden kann, dann ist dies eine Lösung des
Problems, wir ignorieren dabei das erste Element. Ausserdem sind alle Lösungen für knapAll(cdr(s), k − car(s))
Lösungen des Problems, wenn ihnen noch car(s) hinzugefügt wird.
Basis und Induktion können in einer Funktion zusammengefügt werden. In Python, mit Python–Listen zur Darstellung von Sequenzen, sieht das dann so aus:
def knapAll(s,k):
if k <= 0 :
return [[]]
elif (k != 0) and (s == []) :
return []
return append (knapAll(cdr(s),k),
map(lambda x : cons(car(s),x),
knapAll(cdr(s),k-car(s))
)
)
Dieser Algorithmus ist ausserordentlich ineffizient. Die große Zahl der rekursiven Aufrufe liegt in der Natur der
Problemstellung und kann nicht prinzipiell beseitigt werden. Ähnlich wie bei der Fibonacci–Funktion ist die Zahl
der echten Teilprobleme aber eher gering und ein Großteil der rekursiven Aufrufe bezieht sich auf Teilprobleme, die schon einmal gelöst wurden, oder später noch einmal zu lösen sind. Durch die Technik der dynamischen
Programmierung39 , also eine systematische Tabellierung aller Zwischenergebnisse, kann die Effizienz des Algorithmus verbessert werden.
37 Homomorphe Abbildungen sind “strukturerhaltend”. Sie bilden ein zusammengesetztes Element der Definitionsmenge in ein “entsprechend” zusammengesetztes Element der Wertemenge ab. Für eine technische Definition konsultiere man seine Erinnerung an den Mathematikunterricht, oder ein Lehrbuch, beispielsweise [8].
38 Für eine ausführlichere Diskussion algorithmischer Strategien konsultiere die Leserin ihre, sicherlich stets griffbereite, Literatur zu Datenstrukturen und Algorithmen, z.B. [20].
39 Der Begriff “Programmierung” ist hier als “Tabellierung” zu verstehen (programmiert = systematisch). Die Technik stammt aus einer
Zeit, als es noch keine Computer und damit Programme und Programmierer im heutigen Sinne gab. Das zeigt die praktische Bedeutung der
Problemstellung und der Lösungstechnik.
90
Nichtprozedurale Programmierung
Backtracking
Problemstellungen, die mit der Technik des Teile–und–Herrsche oder besser noch der dynamischen Programmierung angegangen werden können, sind zeigen immer noch eine gewisse Gutmütigkeit. In manchen Fällen bleibt
aber kein anderer Weg zur Lösung eines Problems, als einfach alle möglichen Lösungen durchzuprobieren. Man
kann dazu alle potentiellen Lösungen durchprobieren und jede von ihnen auf Gültigkeit prüfen. Gelegentlich kann
dieser Prozess noch etwas optimiert werden. Erkennt man während der Konstruktion einer potentiellem Lösung,
dass man gerade dabei in eine Sackgasse zu laufen, dass also die potentielle Lösung, die gerade konstruiert keine gültige Lösung sein wird, dann wird der Konstruktionsprozess abgebrochen, man geht wieder zurück und die
nächste potentielle Lösung wird angegangen. Aus dem Abbrechen und Zurückgehen (backtrack) leitet sich der
Name Backtracking dieser Technik ab.
Das klassische Beispiel für einen Backtrack–Algorithmus ist das Problem der 8 Damen, bei dem 8 Damen so
auf einem Schachbrett zu positionieren sind, dass keine von ihnen irgendeine andere bedroht. Ein Bachtrack–
Algorithmus hat eine rekursive Struktur, die sich leicht an der Struktur der Lösung des Damen-Problems zeigen
lässt. Die queens(n) positioniert eine Dame in Zeile n so, dass sie nicht von den anderen geschlagen wird.
queens(n) :
falls n > 8 : dann sind wir fertig, das Brett ist voll.
sonst: setze eine Dame irgendwo in Zeile n
und löse das Problem für n + 1
falls das gelingt: Fertig
falls das nicht gelingt, versuche es mit einer anderen Setzung (Backtrack!)
falls es keine unversuchte Setzung mehr gibt: Gescheitert
Die Ausformulierung des entsprechenden Algorithmus sei dem Leser überlassen. Das Themenfeld der Datenstrukturen und Algorithmen bietet unzählige weitere Beispiele für Rekursion und Induktion. Die wenigen Hinweise hier
sollen lediglich noch einmal auf die fundamentale Bedeutung der Rekursion in Theorie und Praxis der Informatik
hinweisen.
Th Letschert, FH Giessen–Friedberg
1.6
91
Iteratoren
Manch einer auf der Wanderschaft
Kommt ans Tor auf dunklen Pfaden.
Golden blüht der Baum der Gnaden
Aus der Erde kühlem Saft.
G. Trakl
1.6.1
Iteratoren
Das Iterator–Konzept
Ein Iterator ist ein Objekt mit dem eine Kollektion von Werten systematisch durchlaufen werden kann. Iteratoren
sind damit eine verallgemeinerte und abstrakte moderne Alternative zu Indizes in Feldern und zu Zeigern. In der
alten konkreten Welt von C wird eine Kollektion von Werten durch ein Feld oder durch eine selbst implementierte
verzeigerte Struktur, eine verkettete Liste etwa, dargestellt. Ein Programmstück, in dem eine solche Kollektion
durchlaufen wird, sieht dann etwa so aus:
KollektionTyp kollektion;
// Anwendung fingert in den Innereien
...
// der Kollektion herum
for (KnotenTyp * p = kollektion.anker; p != 0; p = p->naechster)
tue_etwas_mit(p->wert);
Hier ist der Anwendungscode eng an die interne Definition des Typs KollektionTyp gebunden: obwohl ich
lediglich alle Elemente bearbeiten will, muss ich die interne Struktur des Kollektionstyps genau kennen: Knotentyp
und Aufbau, Art der Verkettung, Name des Ankers, des Weiter–Zeigers und die Art des Zugriffs auf einen Wert.
Eine derartige hohe Kopplung von unterschiedlichen Modulen – Verwendung und Implementierung der Kollektion
– widerspricht allen softwaretechnischen Prinzipien.
Mit einem Iterator können Kollektionen dagegen auf einheitliche Weise durchlaufen werden, ohne dass der Anwendungscode irgendwelche Informationen über deren innere Struktur haben muss. In C++ sieht das so aus:
KollektionTyp kollektion;
// Iteratoren in C++
...
for ( KollektionTypIterator i = kollektion.begin(); i != kollektion.end(); ++i )
tue_etwas_mit( *i );
Jede Kollektion, die Iteratoren unterstützt, bietet eine Methode begin(). Diese liefert einen Iterator, der auf das
erste Element positioniert ist. Mit dem *–Operator kommt man vom Iterator zum entsprechenden Element. Der
Iterator wird mit dem ++–Operator weiterbewegt und die Methode end dient dazu das Ende des Durchlaufs zu
testen.
Ein Typ unterstützt Iteratoren, wenn er einen “Partnertyp” von Iteratoren hat. In der Standardbibliothek von C++
hat jeder Kollektionstyp einen zugeordneten Iteratortyp. Zum Typ list<T> gehört beispielsweise der Iteratortyp
list<T>::iterator und zum Typ set<T> passt der Iteratortyp set<T>::iterator.
In Java gibt es dagegen einen Basistyp Iterator mit dem über jede Kollektion iteriert werden kann, die dies
unterstützt. Ansonsten fehlt hier natürlich die explizite Zeigersemantik (Keine *– oder ++–Operatoren):
KollektionTyp kollektion;
...
Iterator i = kollektion.iterator();
while ( i.hasNext() ) {
tue_etwas_mit( i.next() );
}
// Iteratoren in Java
Die Methode iterator liefert einen Iterator in der entsprechenden Kollektion und entspricht damit begin in
C++. Statt des Vergleichs mit end() haben wir in Java hasNext und die next über nimmt die Aufgabe des
++– und des *–Operators.
92
Nichtprozedurale Programmierung
Iteratoren in Python: implizit oder explizit
In Python gehören Iteratoren zur Grundausstattung der Sprache. Unser Beispiel wird damit besonders einfach:
for i in kollektion :
tue_etwas_mit( i )
So wie in der C++–STL und in Java haben auch in Python alle Kollektionstypen (Listen, Tupel und Strings) einen
zugeordneten Iteratortyp. Die entsprechenden Iterator–Objekte werden durch die for–Anweisung automatisch
erzeugt.
Wie viele andere Skript–Sprachen, bietet also auch Python implizite Iteratoren über allen Kollektionstypen. Iteratoren können damit verwendet werden, ohne dass das Wort “Iterator” getippt werden muss, ein Tätigkeit, die
erfahrungsgemäß bei manch zart fühlenden Programmierern Allergien auslösen kann. Ist etwa l eine Liste, ein
Tupel oder ein String dann wird in
for i in l:
anweisung(i)
# Liste mit IMpliziten Iterator durchlaufen
die Anweisung für jedes Element von l ausgeführt, wobei i den Wert des jeweiligen Elements annimmt.
Die for–Anweisung ist eine Kurzform zum Gebrauch von Iteratoren. Das Gleiche kann mit einem expliziten
Iterator wie folgt ausgedrückt werden:
i = iter(l)
# Liste mit EXpliziten Iterator durchlaufen
while True:
try:
x = i.next()
except StopIteration: break
anweisung(x)
Die Funktion iter liefert zu einer Kollektion den entsprechenden Iterator. next entspricht der next–Methode
in Java. Statt wie dort mit hasNext das Ende des Durchlaufs zu testen, wird in Python die von next bei Ende
des Durchlaufs ausgelöste Ausnahme abgefangen.
Iteratoren liefern Werte oder Referenzen auf Werte
Die Iteratoren in C++ oder Java und in Python – egal ob in expliziter oder impliziter Notation – unterscheiden
sich neben technischen Details in einem Punkt: In C++ und in Java ist ein Iterator eine Referenz auf ein Element
einer Kollektion. In Python nimmt der Iterator den Wert des jeweiligen Elements der Kollektion an. Das macht es
beispielsweise unmöglich eine Liste über einen Iterator zu verändern. So ist
for i in l:
i = f(i)
in Python ohne Wirkung auf l, während in C++ mit
for ( typename list<T>::iterator i = l.begin();
i != l.end();
++i )
i
=
(f(*i));
*
die Liste verändert wird.
Die for–Anweisung in Python unterstützt Mehrfachzuweisungen. Wenn also die Kollektion “zerlegbare” Elemente enthält, dann können sie mit einer Mehrfachzuweisung zerlegt werden. Beispiel:
for i,j in [(1,2),(3,4),(5,6)]:
print i*10+j
Th Letschert, FH Giessen–Friedberg
93
Eine Abbildung (engl. dictionary oder Map) ist auch eine Kollektion, die das Iterator–Protokoll40 unterstützt.
Beispiel:
telefonListe = { ’erkan’:4711, ’stefan’:5612 }
for i in telfonListe:
print i
In dieser Schleife werden aber nur die Schlüssel (’erkan’ und ’stefan’) ausgegeben. Mit geeigneten Methoden können aber auch die Werte, oder die Schlüssel–Wert–Paare durchlaufen werden. Beispielsweise:
for k,v in telefonListe.items() :
print ’Name: ’, k, ’\tTelefon: ’, v
Für weitere Details zu Abbildungen konsultiere man ein Python-Tutorium.
Listen-Umformung
In vielen Anwendungsfällen wird eine Liste in eine andere transformiert. Im klassisch–prozeduralen Stil sieht das
etwa folgendermaßen aus:
def f(x): return 2*x
listA = [1, 2, 3, 4, 5]
listB = []
for i in range(5):
listB.append(f(listA[i]))
Im funktionalen Stil und unter Verwendung allgemeiner Iteratoren schreibt man kürzer und übersichtlicher:
listB = map( lambda x : 2*x, listA)
Als weitere Abkürzung, sowie für Leute mit einer λ–Allergie, unterstützt Python die sogenannte list comprehension
(Listenunformung), mit der das Gleiche noch kompakter ausgedrückt werden kann:
listB = [ x * 2 for x in listA ]
Generell kann in dieser Form jedes iterierbare Objekt transformiert werden. Das Ergebnis ist selbst wieder iterierbar und kann mit einem Iterator durchlaufen werden:
personen = {’pan’:’peter’, ’gates’:’bill’, ’faust’:’gretchen’}
for i in [ j + ’ ’ + i
print i
for
i,j
in
personen.items()]:
oder selbst in gleicher Weise weiter transformiert werden.
In die Listen-Umformung kann auch noch die Filterfunktion einbezogen werden. So ist
[ x * 2 for x in l if x > 3 ]
äquivalent zu:
map (lambda x:x*2, filter(lambda x:x>3, l) )
Wir sehen, in Python kann das Durchlaufen einer Datenstruktur auf verschiedene Arten ausgedrückt werden, wobei
impliziten Iteratoren die kompaktesten und übersichtlichsten Formulierungen erlauben.
40
D.h. Abbildungen haben die Methoden iter und next mit dem üblichen Verhalten.
94
Nichtprozedurale Programmierung
1.6.2
Iteratoren in Python
Definition eines Iterators in Python
Jede Container–Klasse der STL von C++ hat eine zugeordnete Iterator–Klasse, jede Standard–Kollektion in Python
unterstützt die Methode iter, die einen passenden Iterator liefert. Daneben können in Python, so wie in C++,
Java oder anderen OO–Sprachen beliebige eigene Klassen mit Iteratoren ausgestattet werden. Eine Klasse ist ein
Iterator, wenn sie das Iterator–Protokoll unterstützt. Wie beginnen mit einem einfachen Beispiel in Python:
class MyRange :
def __init__(self, upTo):
self.limit = upTo
self.index = 0
def __iter__(self):
return self
def next(self):
if self.index == self.limit :
raise StopIteration
self.index = self.index + 1
return self.index-1
for i in MyRange(10) :
print i
Hier wird eine Klasse MyRange definiert. In der For–Scheife wird eine Instanz der Klasse benutzt um eine Zahlenfolge zu erzeugen, die von for dann durchlaufen wird. MyRnage soll sich in etwa wie das vordefinierte range
verhalten.
Die Notation ist wohl weitgehend selbst-erklärend.41 MyRange ist der Name der Klasse. Es folgt eine Serie von
Methoden-Definitionen. Eine Unterscheidung in public und private gibt es auch in Python42 und jede Methode
muss explizit mit dem Parameter self (= this) definiert werden. Methoden mit doppelten Unterstrichen am
Anfang und Ende ihres Namens spielen eine besondere Rolle, die darin besteht, dass sie in bestimmten Situationen
automatisch aktiviert werden. So wird die init–Methode bei Erzeugung eines Objekts aktiviert. Sie spielt damit
die Rolle eines Konstruktors. Die iter–Methode gehört zum Iterator–Protokoll. Sie wird automatisch aktiviert,
wenn ein Objekt dieser Klasse einen Iterator liefern soll. So verlangt im Beispiel oben die for–Schleife nach
einem Iterator–Objekt, das zu dem mit
MyRange(10)
erzeugten Objekt gehört. Im Beispiel oben wird also in
for i in MyRange(10)
zuerst die Methode init aufgerufen und dann, weil for nach einem Iterator verlangt, die Methode iter .
Die Iterator erzeugende Methode iter liefert in diesem Beispiel das Objekt selbst zurück. Das Objekt ist
damit sein eigener Iterator und muss folglich auch die Iterator–Methode next anbieten, mit der die “Kollektion” durchlaufen werden kann und die am Ende StopIteration auslöst. Die for–Schleife entspricht damit
folgender while–Schleife:
i = iter(MyRange(10))
while True :
try :
x = i.next()
except StopIteration : break
print x
41
Mit Klassendefinitionen in Python wollen wir uns hier nur so weit beschäftigen, wie dies zur Illustration von Konzepten unbedingt
notwendig ist.
42 Methoden, deren Namen die mit zwei Unterstrichen beginnt, sind privat.
Th Letschert, FH Giessen–Friedberg
95
Abhängige und unabhängige Iteratoren
Im Beispiel oben wird eine minimalistische Klasse erzeugt, die das Iterator–Protokoll implementiert. Der Iterator ist dabei nicht unabhängig von der zu durchlaufenden Kollektion, er ist vielmehr sogar mit ihr identisch:
Die iter–Methode liefert sich selbst. Eine solche Abhängigkeit führt dazu, dass eine Kollektion nicht mehrfach
durchlaufen werden kann. So liefert beispielsweise
r3 = MyRange(3)
for i in r3 :
for j in r3 :
print i,j
die Ausgabe:
0 1
0 2
In Python können Kollektionen mit vordefinierten (impliziten) Iteratoren unabhängig zu durchlaufen werden. Nehmen wir statt unserer Klasse MyRange, die einen abhängigen Iterator liefert, die vordefinierte Funktion range,
r3 = range(3)
for i in r3 :
for j in r3 :
print i,j
dann verhält sich die doppelte Schleife so wie erwartet und gibt 0 0 bis 2 2 aus. Mit einer kleinen Modifikation kann auch unsere Klasse MyRange dazu gebracht werden, dass ihre Iteratoren unabhängig sind:
class MyRange :
... wie bisher ..
def __iter__(self):
return MyRange(self.limit)
... wie bisher ..
Der Unterschied zu oben besteht darin, dass iter nicht mehr sich selbst, sondern eine Kopie seiner selbst liefert.
Abhängige Iteratoren werden oft auch Cursor genannt. Wenn eine Kollektion gleichzeitig mehrfach durchlaufen
werden soll, dann reicht das einfachere Cursor–Konzept nicht aus.
1.6.3
Bäume und Baum–Besucher
Bäume – im funktionalen Stil
Iteratoren dienen dazu, Datenstrukturen zu traversieren43 , deren Struktur dem Traversierenden nicht offen gelegt
ist. Das müssen nicht unbedingt Listen sein. Mit einem Iteraterator kann eine beliebige Datenstruktur durchlaufen
werden. Hier wollen wir uns mit dem Durchlaufen von Binärbäumen beschäftigen. Binärbäume können wie Listen
mit einem Iterator durchwandert werden. Allerdings sind die entsprechenden Algorithmen etwas komplizierter.
Wie nähern uns darum dem Ziel “Baum–Iterator” langsam an. Zunächst werden Bäume in “funktionaler” Art definiert – auch um zu zeigen, dass es andere als die üblichen OO–Wege zu einer Datenstruktur gibt. Dann betrachten
wir Baumbesucher: Eine einfachere Art einen Baum zu durchlaufen, als ein Iterator. Daraus leiten wir im nächsten
Abschnitt dann den Baum–Iterator ab.
Beginnen wir also mit der Definition von Binärbäumen:
# Baeume im funktionalen Stil
43 durchlaufen
96
Nichtprozedurale Programmierung
# Konstruktoren:
#
def mKnoten(v, tl, tr): return [v, tl, tr]
def mBlatt(v):
return [v]
# Baum mit Unterbaeumen
# Baum als Blatt
# Selektoren:
#
def istBlatt(b):
def sLinks(b):
def sRechts(b):
def sWert(b):
#
#
#
#
return
return
return
return
len(b) == 1
b[1]
b[2]
b[0]
Test ob der Baum ein Blatt ist
linker Unterbaum
rechter Unterbaum
Knotenwert
Bäume werden hier einfach als Listen von Wert, linken und rechtem Unterbaum dargestellt. Wir behalten uns
allerdings vor, diese Darstellung jederzeit zu ändern.44 Will man einen solchen Baum zu durchlaufen, um eine
bestimmte Operation auf allen Knoten auszuführen, dann kann man eine entsprechende Methode oder hier Funktion definieren. Da Bäume eine induktive Datenstruktur sind, werden Traversierungs–Funktionen am bequemsten
rekursiv über die Struktur ihrer Argumente definiert. Wollen wir alle Werte des Baums ausgeben, dann definieren
wir beispielsweise einfach
def druckeAlle(b):
print sWert(b),
if not istBlatt(b):
druckeAlle(sLinks(b))
druckeAlle(sRechts(b))
Führe mich
zu all Deinen Werten
OK,
gib mit Deine Hand
Besucher
Baum
Abbildung 1.20: Besucher-Muster: Anwender als geführter Besucher des Baums
Baumbesucher: Objektorientiert und Funktional
Die Funktion druckeAlle kann leicht zu einer Traversierungsfunktion verallgemeinert werden, die eine übergebene Funktion f auf alle Knotenwerte anwendet:
def traversiere(b, f):
f(sWert(b))
if not istBlatt(b):
traversiere(sLinks(b), f)
traversiere(sRechts(b), f)
Die Funktion traversiere nimmt eine Funktion f und “führt sie zu jedem Knoten”. Dies entspricht im objektorientierten Umfeld dem Besucher–Muster (Visitor–Pattern) einem der klassischen Entwurfsmuster aus [10]. In
etwas vereinfachter Form sieht dieses Muster vor, dass jeder Knoten einen Besucher akzeptiert, der eine Operation
auf seinem Inhalt ausführen kann:
44 Wessen Anwendungscode dann nicht mehr funktioniert muss 500 mal schreiben: “Ich will das Geheimnisprinzip für immer achten und
ehren!”.
Th Letschert, FH Giessen–Friedberg
97
template<typename T>
class Visitor {
public:
virtual void visit(const T &) const = 0;
};
template<typename T>
class BTree {
...
void accept(const Visitor<T> &);
...
};
Ein Baum akzeptiert (Methode accept) Besucher und führt sie zu all seinen Knoten. Dort lässt er sie dann den
jeweiligen Knotenwert besuchen (Methode visit des Besuchers):
template<typename T>
struct BTree<T>::Node {
...
void accept(const Visitor<T> &);
....
};
template<typename T>
void BTree<T>::accept(const Visitor<T> & visitor) {
if ( a != 0) a->accept(visitor);
}
template<typename T>
void BTree<T>::Node::accept(const Visitor<T> & visitor) {
if ( l != 0 ) l->accept (visitor); // Besucher besucht linken Unterbaum
visitor.visit(value);
// Besucher besucht diesen Knoten
if ( r != 0 ) r->accept (visitor); // Besucher besucht linken Unterbaum
}
Ziemlich viel OO–Geplapper für “Führe mich zu all Deinen Knoten und lass mich dort arbeiten!”, denn, passen
wir traversiere an diese Terminologie an, dann wird daraus nicht mehr als:
def accept(b, visit):
visit(sWert(b))
if not istBlatt(b):
accept(sLinks(b), visit)
accept(sRechts(b), visit)
Jetzt Hole ich mir
den nächsten Wert
Iterator
Anwendung
Baum
Abbildung 1.21: Iterator-Muster: passiver Baum, aktiver Anwender
98
Nichtprozedurale Programmierung
1.6.4
Bäume und Baum–Iteratoren
Sequenzialisierung der Traversierung
Bei einer Struktur nach dem Besucher–Muster liegt die Kontrolle im Behälter: Er nimmt einen Besucher und führt
ihn herum (siehe Abbildung 1.20). Bei einer Code–Struktur nach dem Iterator–Muster wird dagegen dem Anwendungscode die Initiative übertragen. Der Besucher durchläuft die Struktur aus eigener Initiative (siehe Abbildung
1.21). Das hat den Vorteil, dass er jederzeit seinen Besuch abbrechen kann, oder auch, dass mehrere Besuche
(Durchläufe) gleichzeitig ablaufen können.
Für einen Iterator ist das rekursive Traversieren der Baumstruktur, wie mit der traversiere–Funktion, nicht
geeignet! Das Iterator–Muster setzt ja voraus, dass der Durchlauf iterativ ist. Leider ist traversiere nicht repetitiv rekursiv. Es ist nicht einmal linear– , sondern allgemein rekursiv. Die Sequenzialisierung der Rekursion ist
also nicht sofort offensichtlich. Glücklicherweise haben wir im letzten Kapitel gesehen, dass Fortsetzungsfunktionen eine gute Hilfestellung bei der Sequenzialisierung bieten können. Wir rufen die Traversierungsfunktion noch
einmal kurz ins Gedächtnis:
def traversiere(b, f):
f(sWert(b))
if not istBlatt(b):
traversiere(sLinks(b), f)
traversiere(sRechts(b), f)
Dies lässt sich leicht in eine Variante mit Fortsetzungsfunktion umformen:
def traversiereC(b, f, c): # Baum b mit f bearbeiten, dann weiter mit c
f(sWert(b))
# Knotenwert bearbeiten
if istBlatt(b):
c()
# Baum fertig bearbeitet, weiter mit Fortsetzung
else:
traversiereC(sLinks(b), f,
# erst linker Unterbaum
lambda : traversiereC(sRechts(b), f, # dann rechter Unterbaum
c))
# dann Fortsetzung
traversiereC wendet f auf den Knotenwert an. Wenn der Knoten ein Blatt war, dann kann sich die Verarbeitung der Fortsetzung c zuwenden. Andernfalls wird der linke Unterbaum, dann der rechte Unterbaum bearbeitet
und dann schließlich die ursprüngliche Fortsetzung c. Da von Verarbeitungsschritt zu Verarbeitungsschritt keine
Werte weitergegeben werden, trotzdem die Fortsetzung aber eine Funktion sein muss, verwenden wir hier einen
Lambda–Ausdruck ohne formalen Parameter.
Dies ist eine repetitiv rekursive Funktion. Wir können darum die (äussere) Rekursion nach Schema in eine Schleife
umwandeln:
def traversiereCW(b, f, c):
while not istBlatt(b):
f(sWert(b))
c = (lambda b,c : lambda : traversiereCW(sRechts(b), f, c))(b,c)
b = sLinks(b)
f(sWert(b))
c()
Die Lambda–Konstruktion innerhalb der Schleife erzwingt die Auswertung von b und c um sie so mit ihrem
aktuellen Wert als Teil der aufgebauten Fortsetzungsfunktion zu fixieren.
Die Schleife baut eine Fortsetzungsfunktion auf, in der im Wesentlichen die Information über unverarbeitete rechte
Teilbäume steckt. Wir können sie also leicht durch einen Repräsentanten ersetzen, der später durch eine Decodier–
Funktion ausgewertet wird:
def traversiereCWR(b, f, c):
def decodiere(c):
#gespeichterte Unterbaeume verarbeiten
Th Letschert, FH Giessen–Friedberg
99
if c != []:
bb = c[0]
c = c[1:]
traversiereCWR(bb, f, c)
while not istBlatt(b):
f(sWert(b))
c = [sRechts(b)]+c #unbearbeiteten rechten Unterbaum speichern
b = sLinks(b)
#weiter nach links unten
f(sWert(b))
#links unten angekommen
decodiere(c)
#Unverarbeitetes verarbeiten
Die Decodier–Funktion ruft rekursiv wieder traversiereCWR auf. Da das jedoch in repetitiv–rekursiver Manier
erfolgt, kann das Dekodieren leicht in die Schleife hineingezogen werden und wir erhalten die gesuchte iterative
Traversierungsfunktion für Binärbäume:
def traversiereIterativ(b, f):
c = [[]]
# Rep der Fortsetzung = Stapel unverarbeiteter Teilbaeume
bb = b
while bb != []:
f(sWert(bb))
if not istBlatt(bb):
c = [sRechts(bb)]+c
bb = sLinks(bb)
else:
bb = c[0]
c = c[1:]
Insgesamt haben wir bis jetzt
• die Funktion in eine Variante mit Fortsetzungsparameter umgewandelt.
• Dann wurde die Fortsetzungsfunktion durch eine repräsentierende Datenstruktur plus Dekodierfunktion ersetzt.
• Schließlich konnte die Dekodierfunktion und die Schleife der “Hauptrekursion” in einer Schleife vereinigt
werden.
Ein Iterator auf Bäumen
Für einen Iterator, der binäre Bäume durchläuft, müssen wir also das rekursive Traversieren des Baums sequenzialisieren und dann mit Unterbrechungen versehen. Der Iterationsprozess soll ja bei jedem Knoten halten und
ihn der Anwendung anbieten. Die Sequenzialisierung ist erledigt. Die Einführung von Haltepunkten ist jetzt kein
besonderes Problem mehr: Jeder Schleifendurchlauf wird einfach zu einem eigenen Funktionsaufruf. Wir ersetzen
also while durch die Definition einer Funktion next, die dann die gesuchte Iterator–Funktion ist:
def baumIter(b):
global bb, cc
bb = b
# Zustandscc = [[]]
# Variablen
def next():
# Iterator-Funktion
global bb, cc
if bb == [] : raise StopIteration
res = sWert(bb)
if not istBlatt(bb):
cc = [sRechts(bb)]+cc
bb = sLinks(bb)
else:
bb = cc[0]
100
Nichtprozedurale Programmierung
cc = cc[1:]
return res
return next
Ein Aufruf von baumIter liefert die Funktion next, mit der dann ein Baum Schritt für Schritt durchlaufen
werden kann:
# Ein Beispielbaum:
t = mKnoten(1, mKnoten(2,mBlatt(3),mBlatt(4)), mKnoten(5,mBlatt(6),mBlatt(7)))
# Baum mit Iterator durchlaufen:
next = baumIter(t)
while True:
try :
x = next()
except StopIteration : break
print x
Die Funktion next ist ein “funktionaler Iterator” dessen Zustand in bb und cc steckt.
Die etwas seltsame Konstruktionen mit global rühren daher, dass Python bedauerlicherweise keine lokalen Variablen an lokale Funktionen weiter gibt.45 Diese Variablen müssen darum global gehalten werden. Das ist unschön,
aber machen wir aus dieser Not eine Tugend und definieren den Baum-Iterator gleich objektorientiert:
class BaumIter :
def __init__(self, b):
self.bb = b
self.cc = [[]]
def __iter__(self):
return self
def next(self):
if self.bb == [] : raise StopIteration
res = sWert(self.bb)
if not istBlatt(self.bb):
self.cc = [sRechts(self.bb)]+self.cc
self.bb = sLinks(self.bb)
else:
self.bb = self.cc[0]
self.cc = self.cc[1:]
return res
Die Klasse erfüllt das Iterator–Protokoll von Python und kann sofort in Einsatz gehen:
for i in BaumIter(t):
print i
Mit baumIter und next haben wir ein Beispiel für eine Funktion G, mit lokalen Variablen l, die eine Funktion
f als Ergebnis liefert, die l benutzt:
def G(...):
l = ...
def f(.):
... l ...
return f
45 Die notwendige lokale Bindung kann natürlich mit einem OO-Konstrukt erreicht werden. Die Bindung lokaler Variablen in funktionalen
Ergebnissen ist aber eine gängige Technik um einfach, elegant und ohne OO–Geplapper Zustands-Informationen aufzuheben. Das Fehlen dieser
Möglichkeit disqualifiziert Python als “richtige” funktionale Sprache. Diese Einschränkung ist unverständlich, da Python sowieso schon mit
Closures hantieren muss.
Th Letschert, FH Giessen–Friedberg
101
Eine solche Konstruktion ist typisch für die Art in der in funktionalen Sprachen “objekt-orientiert” programmiert
wird. Die lokale Variable l zusammen mit f stellt ein Objekt dar. Dabei agiert l als “Zustandsvariable” und f als
“Methode”. Leider wird dieses Muster in Python nur mühsam auf global–Krücken unterstützt.
102
Nichtprozedurale Programmierung
1.7
Generatoren und Datenströme
Wie fremd und wunderlich das ist,
Dass immerfort in jeder Nacht
Der leise Brunnen weiterfließt,
Vom Ahornschatten kühl bewacht.
H. Hesse
1.7.1
Generatoren
Das Generator–Konzept
Ein Generator46 ist ein Objekt, das in der Lage ist, einen Strom von Dingen/Objekten zu generieren. Generatoren sind also, neben dem Besucher– und Iterator–Muster, ein weiteres Entwurfs– (Denk–) Muster, mit dem die
Beziehungen zwischen einem “Besitzer” und einem “Verarbeiter” von Werten beschrieben werden können. Der
Generator als Besitzer der Daten wird dabei aber nicht als reiner Verwalter von Werten betrachtet. Kollektionen
verwalten die Werte, die ihnen von außen übergeben wurden. Ein Generator kann dagegen seine Werte selbständig
erzeugen. (Siehe Abbildung 1.22.)
Das Aktive des Generators ist seine Fähigkeit, wie eine Funktion, Werte erzeugen zu können. Das macht ihn zu
einem wesentlichen Element der funktionalen Programmierung. Die Vertreter der funktionalen Schule haben das
Konzept erfunden. Sie sehen in einem Generator eine Funktion, die ihren aktuellen Zustand zwischenspeichert.
Für die Vertreter der Objektorientierung ist er ein Objekt, das aus sich heraus andere Objekt erzeugen kann.
Damit haben wir jetzt drei Entwurfsmuster für die Beziehung zwischen Besitzer und Verarbeiter einer Kollektion
von Werten:
• Besucher Ein Kollektions–Objekt (Liste, Baum, etc.) besitzt eine Menge von Werten. Die Kollektion
empfängt Besucher, führt sie in eigener Regie zu den Werten und lässt sie auf ihnen arbeiten. Das Besucher–
Muster ist eng mit der Technik der Call–Backs verwandt, bei der (vor–OO–zeitlich) Zeiger auf Funktionen,
statt Besucher–Objekten durch die Kollektion geführt und aktiviert werden. Die Kontrolle liegt in der Kollektion.
• Iterator Ein Kollektions–Objekt stellt einen Iterator (als “Handle”) zur Verfügung, der von der Anwendung
durch abstrakte Verschiebe–Operationen von Wert zu Wert durch die Kollektion bewegt werden kann. Die
Kontrolle liegt dabei bei der Anwendung.
• Generator Ein Generator–Objekt besitzt oder erzeugt Werte und liefert sie bei Bedarf an den Verarbeiter
aus. Die Kontrolle wechselt dabei zwischen Generator und Verarbeiter.
Bei diesen Mustern handelt es sich um Arten der Organisation von Software, die eventuell alle gleichermaßen
auf eine bestimmte Problemstellung angewendet werden können. Eine Liste von 100 Zahlen kann beispielsweise
zuerst erzeugt und in einer Kollektion gespeichert werden. Nach dem Besucher– oder Iterator-Muster werden die
Werte anschließend durchlaufen und verarbeitet. Ebensogut kann man aber auch einen Generator verwenden, der
Zahl für Zahl erst auf Anforderung des Verarbeiters generiert.
Generatoren sind von Vorteil, wenn die zu durchlaufende Kollektion sehr groß ist, vielleicht sogar unendlich groß,
und nur zu einem kleinen Teil durchlaufen werden muss. In vielen Fällen sind Generatoren auch mit wesentlich
weniger Aufwand zu implementieren als Iteratoren. Man denke an den Baum–Iterator. Wir werden unten sehen,
dass entsprechender Generator deutlich einfacher ist.
46
von lat. generator Erzeuger, Stammvater, Züchter; generieren = erzeugen
Th Letschert, FH Giessen–Friedberg
103
Augenblick !
Maschine läuft noch.
Generator
Gib mir
Deinen nächsten Wert
Anwendung
Abbildung 1.22: Generator-Muster: aktiver Erzeuger, aktiver Verarbeiter von Werten
Generatoren in Python
Python unterstützt die Definition von Generatoren47 im funktionalen Stil. Ein Generator ist (in Python) also eine
Variante einer Funktion. Ein Trivalbeispiel wäre der Generator, der die ersten n natürlichen Zahlen erzeugt.
Beginnen wir mit einer Iterator–Lösung. Dazu definieren wir eine Funktion welche die gesuchten Werte als Liste
- also als passive Kollektion – erzeugt:
def listN( n ) :
res = []
i = 0
while i < n :
res.append(i)
i = i+1
return res
Listen sind iterierbare Objekte und können darum mit einem Iterator durchlaufen werden:
for x in listN(10):
.... Aktion auf x ....
Angenommen, wir haben ein Problem, bei dem nicht im voraus bekannt ist, wieviele der Werte gebraucht werden
oder bei dem potentiell unendlich viele Werte gebraucht werden. Für eine kryptographische Anwendung könnten
beispielsweise beliebig viele Primzahlen gebraucht werden:
for x in listN(??):
.... falls x prim und Code mit x geknackt: STOP ....
In dem Fall wäre es besser die Werte bei Bedarf zu erzeugen und gleich zu verbrauchen, statt sie in einer gigantischen Liste abzulegen und diese dann zu durchlaufen. Dazu müsste unsere Funktion einen Wert zurückgeben und
dann beim nächsten Aufruf an der alten Stelle weitermachen. Genau das können Generatoren in Python:
def genN( n ) : # ein GENERATOR
i = 0
while i < n :
yield i # <-- return mit Wiederaufsetz-Punkt
i = i+1
for x in genN(10):
print x
Diese Funktion enthält das Schlüsselwort yield. Sie wird damit zu einem Generator. Ein yield entspricht einem
return mit der Möglichkeit wieder zurück zu kehren. Es liefert einen Wert an die Aufrufstelle ohne dabei die
47 ab Version 2.3 ohne Umstände; bei Version 2.2 verwenden Sie bitte
from future import generators
104
Nichtprozedurale Programmierung
Funktionsinstanz zu vernichten. Beim nächsten Aufruf geht dann an genau dieser unterbrochen Stelle weiter. Ein
yield ist damit ein Rückkehr– und gleichzeitig ein Wiederaufsetz–Punkt.
Generatoren erfüllen das Iterator–Protokoll von Python. Bei einem Aufruf werden sie, genau wie die iterierbaren
Kollektionen, nicht ausgeführt, sondern erzeugen ein Iterator–Objekt. Dessen next–Methode hangelt sich dann
von yield zu yield bis die Funktion an ihrem Ende angelangt ist und StopIteration auswirft.
Generatorausdrücke in Python
Ab Version 2.4 unterstützt Python Generator Ausdrücke. Ein Generatorausdruck sieht wie eine List–
Comprehension aus, nur dass die eckige Klammer durch eine runde ersetzt wird. Ein Beispiel ist:
>> l1 = [2*x for x in range(10)]
>> l2 = (2*x for x in range(10))
# List-Comprehension
# Generatorausdruck
Der Wert von l1 ist die Liste [0,2,4,6,8,10,12,14,16,18]. Der Wert von l2 ist dagegen ein Generator,
der die Listenelemente erzeugen kann:
>>> print l1
[0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
>>> print l2
<generator object at 0x4039cc6c>
for i in l2: print i
...
0
2
4
6
8
10
12
14
16
18
Generatorausdrücke sind dann sinnvoll, wenn eine Liste nicht vollständig erzeugt werden soll. Beispielsweise weil
sie zu groß wird, oder weil wahrscheinlich nur ein kleiner Teilbereich benötigt wird.
Ein Generatorausdruck ist ein Ausdruck dessen Wert ein anonymer Generator ist. In ähnlicher Weise, wie ein
Lambda–Ausdruck ein Ausdruck ist, dessen Wert eine anonyme Funktion ist.
Der Ausdruck 3*x for x in range(10) entspricht also der Definition eines anonymen Generators
def <ANONYMUS>() :
for x in range(10):
yield 3*x
Generatorausdrücke können an allen Stellen verwendet werden, an denen Generatoren erlaubt sind. Sie können
also beispilesweise als Parameter an eine Funktion übergeben werden, die ein iterierbares Objekt erwartet:
[ reduce(lambda x,s:x+s, (i for i in range(10)), 0)
Wird der Generator–Ausdruck als einziger parameter an eine Funktion übergeben, dann können die runden Klammer wegegelassen werden. Statt sum((3*j for j in range(10))) ist also auch
sum(3*j for j in range(10))
erlaubt.
Th Letschert, FH Giessen–Friedberg
1.7.2
105
Generator–Beispiele
Generator für Primzahlen
Ein Generator zur Erzeugung einer beliebig langen Liste von Primzahlen kann damit in Python leicht definiert
werden:
def teilt(x, y):
while y > x-1 :
y = y-x;
return y==0
def prim(n):
t = 1
while t < n/2 :
t = t+1
if teilt(t, n) : return 0
return 1
def genPrim() : # Generator zur Erzeugung beliebig vieler Primzahlen
i = 1
while 1 :
if prim(i): yield i
i = i+1
Damit lässt sich etwa sehr einfach die elftausendste Primzahl berechnen, ohne im voraus zu wissen, wieviele
Zahlen dazu zu prüfen sind:
count = 0
for x in genPrim():
count = count+1
if count == 11000 :
print x
break
Das verwendete Verfahren ist nicht sehr effizient. Es kann also dauern, bis die elftausendste Primzahl gefunden
wird.
Rekursiver Generator zur Traversierung eines Baums
Generatoren werden als Funktionen definiert und können somit rekursiv sein. Das erspart uns die mühsame Sequenzialisierung der Traversierungscodes, der oben bei der Definition des Iterators in C++ benötigt wird.
Rufen wir uns kurz noch einmal die Traversierungsfunktion in Erinnerung, die eine Funktion f auf jeden Knotenwert eines Baums anwendet:
def traversiere(b, f):
f(sWert(b))
if not istBlatt(b):
traversiere(sLinks(b), f)
traversiere(sRechts(b), f)
Ein Generator liefert die Werte nach außen, statt sie, wie traversiere, selbst zu verarbeiten. An die Stelle der
Verarbeitung tritt darum ein yield. Im ersten Ansatz sieht unsere Generator–Funktion also folgendermaßen aus:
def genBaumWert(b):
yield(sWert(b))
if not istBlatt(b):
genBaumWert(sLinks(b))
genBaumWert(sRechts(b))
# Generator fuer Baumknoten
# erster Ansatz, NICHT KORREKT
106
Nichtprozedurale Programmierung
Der Generator ruft sich hier selbst auf und das funktioniert so leider nicht. Mit einem yield wird die Funktion zu
einem Generator. Ein Generator ist aber keine Funktion und kann darum auch nicht wie eine Funktion verwendet
werden, auch nicht in einem rekursiven Aufruf. Generatoren erfüllen das Iterator–Protokoll! Sie müssen also wie
Iteratoren genutzt werden – auch dann wenn sie sich selbst benutzen. Formen wir den rekursiven Aufruf also so
um, dass genBaumWert(.) wie ein Iterator behandelt wird:
def genBaumWert(b):
# Generator fuer Baumknoten
yield(sWert(b))
if not istBlatt(b):
for wl in genBaumWert(sLinks(b)):
yield(wl)
for wr in genBaumWert(sRechts(b)):
yield(wr)
Man beachte die rekursiven Aufrufe und ihre Einbettung. Jeder rekursive Aufruf liefert selbst wieder einen Generator, der dann wieder als iterierbares Objekt in einer Schleife durchlaufen werden muss.
Mit diesem Generator kann der Baum jetzt ganz einfach mit einer for–Schleife durchlaufen werden:
for i in genBaumWert(t):
print i
Dieser Code ist definitiv einfacher übersichtlicher und vor allem schneller und müheloser geschrieben als die
Iterator–Lösung weiter oben. Die Einfachheit liegt in der Möglichkeit Rekursion nahezu unverändert übernehmen
zu können, statt sie mühsam entfernen zu müssen.
Generator für Permutationen
Viele praktische relevante Fragestellungen können nur dadurch gelöst werden, dass systematisch alle Möglichkeiten der Lösung durchforstet werden. Sehr oft können dabei die Möglichkeiten als Permutationen einer Sequenz
aufgefasst werden. Bei dem Problem des kürzesten Wegs durch einen Graph beispielsweise sind die möglichen
Wege die Permutationen der Zwischenstationen auf dem Weg vom Start zum Ziel.
Im letzten Kapitel haben wir einen funktionalen Algorithmus vorgestellt mit dem sich Permutationen erzeugen
lassen. Unter Verwendung der Listenumformung (list comprehension) statt der map–Funktion kann dieser Algorithmus wie folgt formuliert werden:
def allInsertionsF(x, l):
if l == []:
return [[x]]
else:
return cons (cons(x,l),
[cons(car(l),r) for r in allInsertionsF(x,cdr(l))]
)
def allPermsF(l):
if l == []:
return [[]]
else:
return join([allInsertionsF(car(l), p) for p in allPermsF(cdr(l))])
Die Funktionen cons, car und cdr sind dabei die üblichen Lisp–artigen Listenfunktionen und join verschmilzt
eine Liste von Listen zu einer Liste.
Der Aufruf allPermsF(l) erzeugt die Liste aller Permutationen der Liste l, also eine Liste von Listen. Die
Permutationen können dann durchlaufen und untersucht werden. Da Listen iterierbare Objekte sind, geht das mit
einer for–Anweisung:
for p in allPermsF(l):
... untersuche p ...
Th Letschert, FH Giessen–Friedberg
107
In diesem kurzen Programmstück werden zuerst alle Permutationen in einer mehr oder weniger langen Liste gespeichert; aus dieser wird dann ein Iterator erzeugt, mit dem die Liste dann schließlich durchlaufen wird.
Leider wächst die Zahl der Permutationen sehr schnell an und eventuell ist es ist entweder nicht notwendig, oder
gar nicht möglich, alle Permutationen zu erzeugen und in einer Liste zu speichern. Die Liste wird vielleicht zu
lang, die zu ihrer Erzeugung notwendige Rekursionstiefe zu groß, oder man findet die gesuchte Lösung bereits
durch die Betrachtung einer Teilmenge aller Permutationen. In all diesen Fällen wäre es besser, die Permutationen
parallel zu erzeugen und zu untersuchen, statt sie zuerst alle zu erzeugen und dann zu untersuchen.
Das ist ein Fall für einen Generator, denn der ist in der Lage, die benötigten Werte auf Anforderung zu erzeugen.
Um allPermsF in einen Generator zu verwandeln, müssen die return–Anweisungen durch yield ersetzt
werden. Dabei ist zu beachten, dass allPermsF mit return alle Permutationen auf einmal liefert, der Generator
dagegen mit seinem yield stets nur eine einzige zurück geben soll. Im Basisfall geben wir darum nur eine
leere Liste zurück, statt einer Liste mit der leeren Liste als Element. Im komplexen Fall muss die Rückgabe der
Gesamtliste in eine sukzessive Rückgabe ihrer Elemente umgewandelt werden. Das ist die gleiche Umformung,
wie sie oben bei der Umwandlung des Code zum rekursiven Baumdurchlauf in einen entsprechenden Generator
angewendet wurde. In unserem Fall muss dieser “Trick” zweimal angewendet werden, da der rekursive Aufruf
von allPermsF die Funktion allInsertionsF benutzt, die beide in Generatoren zu verwandeln und zu
durchlaufen sind:
def allPermsG(l):
if l == []:
yield []
else:
for p in allPermsG(cdr(l)):
for i in allInsertionsG(car(l),p):
yield i
In der gleichen Art formen wir noch die rekursive Funktion allInsertionsF in den entsprechenden Generator
allInsertionsG um:
def allInsertionsG(x, l):
if l == []:
yield [x]
else:
yield cons(x,l)
for r in allInsertions(x,cdr(l)):
yield cons(car(l),r)
Man beachte wie leicht es das Generator–Konzept macht, die Klarheit und Eleganz rekursiver Algorithmen in
Iteratoren zu transformieren, die über ihre im Flug erzeugenden Mengen laufen.48
1.7.3
Implementierung von Generatoren durch Threads
Generatoren sind Coroutinen
Generatoren und ihre Benutzer wechseln sich dabei ab, die Kontrolle über die Verarbeitung auszuüben. Der Generator erzeugt einen Wert und stoppt, sein Kunde nimmt den Wert an, verbraucht ihn und es geht wieder zurück
zum Generator. Beide bewegen sich dabei in ihrem eigenen Kontext. Die Kontrolle wechselt zwischen Generator
und seinem Kunden, der Kontext der beiden bleibt dabei auch in ihren inaktiven Phasen erhalten: Alle lokalen
Variablen mit all ihren Werten existieren weiter während der jeweils andere aktiv ist.
So etwas nennt man Coroutine. Eine Coroutine ist eine Routine die ihren eigenen Kontext verwaltet, beliebig in
andere Coroutinen verzweigt und später nach Belieben wieder reaktiviert wird. Eine Funktion wird dagegen nur
einmal aktiviert und läuft dann bis zu ihrem Ende. Eine Methode (oder auch eine Funktion in Gegenwart globaler Variablen) verhält sich wie eine Funktion, zusätzlich können jedoch Zustandsinformationen im zugeordneten
48 Zweifler mögen ohne die Verwendung von Generatoren einen Iterator definieren, der Permutationen auf Anforderung, Schritt für Schritt,
erzeugt.
108
Nichtprozedurale Programmierung
Objekt von Aufruf zu Aufruf aufgehoben werden. Der Aufruf einer Funktion/Methode kann aber niemals unterbrochen und später wieder aufgenommen werden. Coroutinen können dagegen beliebig unterbrochen und suspendiert
werden. Sie müssen damit alle und beliebige Zustandsinformationen aufheben können.
Der Leser wird es inzwischen erkannt haben: Eine Coroutine ist eine Sonderform eines Threads (bzw. eines Prozesses) der nicht wirklich parallel ausgeführt wird und der nicht ohne seinen Willen unterbrochen wird. Coroutinen
sind also Threads/Prozesse mit kooperativem Multitasking und ohne echte Parallelverarbeitung.
Generatoren als Threads
Sind Threads verfügbar, so können sie – als das allgemeinere Konzept – unmittelbar zur Implementierung von
Coroutinen eingesetzt werden. Da Generatoren wiederum eine spezielle Variante von Coroutinen sind, können
auch sie durch Threads implementiert werden. Nehmen wir als Beispiel den Generator für natürliche Zahlen in
Java:
public class NatStream implements Runnable {
private int _von;
private int _bis;
Buffer
b = new Buffer();
public NatStream (int von, int bis) {
_von = von;
_bis = bis;
}
public void run() { // Zahlen erzeugen
int i = _von;
while ( i <= _bis ) {
yield(i);
// Zahl abgeben
++i;
}
b.close();
// Fertig
return;
}
private void yield(int i) { // Zahl im Puffer ablegen
b.put(i);
// wird von run aufgerufen
}
public int next() {
int x = b.get();
return x;
}
// Zahl aus dem Puffer holen
// wird vom Kunden aufgerufen
}
Ein NatStream–Objekt wird als Thread gestartet, der dann bis zum ersten yield49 aktiv ist. yield stellt einen
Wert im Puffer b bereit, der dann vom Kunden des Generators, mit einem Aufruf der Methode get, abgeholt
werden kann. Damit wird der Generator wieder reaktiviert, erzeugt den nächsten Wert und stellt ihn im Puffer
bereit. Da run und get in unterschiedlichen Threads aktiv sind, müssen sie über ein synchronisiertes Medium
kommunizieren. Wir haben dazu einfach einen Puffer b verwendet.
Generatoren in Java 5
Selbstverständlich ist das Aufzählen von Integer–Werten ein wenig überzeugendes Beispiel für den Einsatz von
Generatoren. Man sieht aber leicht das Prinzip nach dem Generatoren als Threads realisiert werden:
49 yield ist hier eine Benutzer–definiert Methode. Sie hat vage Beziehungen zu einer obsoleten vordefinierten yield–Methode, sollte
aber nicht mit ihr verwechselt werden. Am Besten vergisst man das alte vordefinierte yield völlig.
Th Letschert, FH Giessen–Friedberg
• ein Überschreiben von run liefert den Generierungsprozess;
• innerhalb von run werden die erzeugten Werte mit run in einen lokalen Puffer geschrieben;
• aus dem sie dann die Anwendung mit get herausholt.
Dieses Muster kann in einer Basisklasse für Generatoren codiert werden:
public class StopIteration extends Exception {}
public class Generator<T> extends Thread {
class Buffer {
int front
= 0; //Schreibposition
int rear
= 0; //Leseposition
int count
= 0; //Belegte Pufferpositionen
private static final int SIZE = 3;
Object[] buf
= new Object[SIZE];
boolean _closed
= false;
public synchronized void put(T i) {
while (count == SIZE) {
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
buf[front] = i;
count++;
front = (front + 1) % SIZE;
notifyAll();
}
public synchronized T get() throws StopIteration {
T v;
while (count == 0) {
if ( _closed ) {
throw new StopIteration();
}
try {
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
v = (T)buf[rear];
rear = (rear + 1) % SIZE;
count--;
notifyAll();
return v;
}
public synchronized void close () {
_closed = true;
notifyAll();
}
}
109
110
Nichtprozedurale Programmierung
Buffer b = new Buffer();
// Objekt abliefern
// wird nur in run der Ableitung benutzt
protected void yield(T o){
b.put(o);
}
// Objekt holen
// wird von Anwendungen benutzt
public T next() throws StopIteration{
return b.get();
}
// Iteration am Ende
protected void stopIter() {
b.close();
}
}
Diese Klasse besteht im Wesentlichen aus der Definition des Puffers. Wir haben Java 5 verwendet, da der Typ der
erzeugten Objekte beliebig ist. In einer älteren Java–Version würde man mit Object als generischem Typ arbeiten
und müsste dann geeignete Cast–Operationen einfügen. Die Generics (parametrisierten Typen) ersparen uns diese
lästigen Casts.
Mit dieser Basisklasse wird der Generator für natürliche Zahlen zu:
public class NatStream extends Generator<Integer> {
Integer _von;
Integer _bis;
public NatStream(Integer von, Integer bis) {
_von = von;
_bis = bis;
this.start();
}
// Werte produzieren
// durch Definition von run
public void run() {
Integer i = _von;
while ( i <= _bis ) {
yield( i );
++i;
}
stopIter();
}
}
Ein Generator im Einsatz:
public class NatStreamTester {
public static void main(String[] args) {
NatStream nS = new NatStream(1, 15);
try {
while (true) {
Integer w = nS.next();
Th Letschert, FH Giessen–Friedberg
111
System.out.println(w);
}
} catch (StopIteration e) {
System.out.println("Fertig");
}
}
}
Baumdurchlauf mit Thread–basiertem Generator
Suspendierte rekursive Funktionen haben einen relativ komplexen Zustand, dessen explizite Verwaltung eine Elimination der Rekursion voraussetzt. Das ist der Grund warum die Traversierung eines Baums eine Art von Standardbeispiel für Generatoren in Python ist (vergleiche auch [26]). In Java, ausgerüstet mit der Ausdruckskraft von
Threads und unserer Generatorklasse, ist es ein Leichtes, den Python–Code zum Baumdurchlauf mittels Generator
nachzubilden:
public class Baum {
// Der Knotentyp
private class Knoten {
Integer wert;
Knoten links;
Knoten rechts;
Knoten (Integer wert, Knoten links, Knoten rechts) {
this.wert= wert;
this.links = links;
this.rechts = rechts;
}
}
// der Anker der Knoten
Knoten anker = null;
// eine Einfuegeoperation
public void einfuege(Integer i){
if ( anker == null ) {
anker = new Knoten(i, null, null);
return;
}
Knoten p = anker;
while(true) {
if (p.wert == i) { return; }
if ( p.wert < i ){
if ( p.rechts == null ) {
p.rechts = new Knoten(i, null, null);
return;
} else
p = p.rechts;
} else {
if ( p.links == null ) {
p.links = new Knoten(i, null, null);
return;
} else
p = p.links;
}
}
}
// Die Klasse der Generatoren:
112
Nichtprozedurale Programmierung
//
class BaumIter extends Generator<Integer> {
public BaumIter () {
this.start();
}
private void traversiere(Knoten k) {
if ( k.links != null ) traversiere(k.links);
yield(k.wert);
if ( k.rechts != null ) traversiere(k.rechts);
}
public void run() {
if ( anker != null) traversiere(anker);
stopIter();
}
}
// Einen Generator erzeugen
//
Generator<Integer> iter() {
return new BaumIter();
}
}
Der Gernerator kann dann beispielsweise wie folgt genutzt werden:
public class BaumTest {
public static void main(String[] args) {
Baum b = new Baum();
for ( Integer i = 1; i < 5; i++) {
b.einfuege(i);
b.einfuege(-i);
}
Generator<Integer> g = b.iter();
while(true) {
Integer x;
try {
x = g.next();
} catch (StopIteration e) {
System.out.println("Fertig");
break;
}
System.out.println(x);
}
}
}
Dieser Iterator/Generator im Python–Stil bietet seinen Kunden die Methode next. Mit ihr kann der jeweils nächste
Wert aus dem Baum angefordert werden. Am Ende des Durchlaufs wird StopIteration ausgeworfen.
Wir sehen, dass in Java (oder äquivalent in C++) mit Hilfe von Threads der gleiche “Generator–Stil” möglich ist,
wie in Python. Der (anwendungsorientierte Quell–) Code einer Java/C++ Lösung als solcher vergrößert sich im
Vergleich zu Python dabei zwar kaum, aber der Aufwand ist doch größer. Zum einen ist, durch eventuelle Synchronisationsprobleme, der Umgang mit Generator–Threads anspruchsvoller als der mit Generator–Coroutinen.
Zum anderen erfordern Threads natürlich eine komplexere Laufzeitunterstützung als die Coroutinen auf denen
Generatoren in Python wohl basieren.
Th Letschert, FH Giessen–Friedberg
113
Wir überlassen es den C++/Java–Programmieren unter den Lesern den Generator–inspirierten “funktionalen” Iterator im Vergleich zu einem Iterator “im klassischen Stil” zu bewerten. Natürlich kosten die notwendigen Threads
einiges an zusätzlichen Maschinen–Code und Laufzeit. Die für diese Generator–Variante verbrauchte Entwicklungszeit ist aber wesentlich geringer als die für den Baumiterator im klassischen Stil (mit Elimination der Rekursion, Stapel, ..., wie weiter oben in Python gezeigt) – bei deutlich erhöhtem Spassfaktor.
Es gibt eine Softwaretechnik jenseits von OO
Die Java–Implementierung der Generatoren kann jetzt verwendet werden. Beispielsweise könnten wir, mit unseren einsatzbereiten Generatoren, die Generatorlösung des Permutationsprogramms in Python ohne besonderen
Aufwand in eine entsprechende Java–Variante transformieren. Vom ersten rekusiven Prototypen in Python, bis zur
Produktions–Variante in Java mit seinen Threads hätten wir dann ein etwas anders Beispiel für eine systematische
Programmentwicklung und damit für einen Einsatz von Softwaretechnik, der über die üblichen OO–Weisheiten50
hinausgeht:
1. Rekursion ist eine erfolgreiche Programmentwicklungsstrategie.
2. Prototypen in dynamisch typisierten Skriptsprachen machen Sinn.
3. Programmiersprachen abseits des Üblichen geben auch dann nützliche Anregungen wenn man andere Sprachen verwenden will oder muss.
4. Nebenläufigkeit ist ein mächtiges Denkmuster, auch ausserhalb ihres üblichen Einsatzbereichs.
5. Modularisierung kann auch so ausgelegt werden, dass man Paradigmen nutzt, die von der Sprache nicht
vorausgedacht wurden und diese unabhängig vom aktuellen Problem realisiert.
Permutationsproblem
Rekursion als Programmiertechnik
rekursives Python−Programm
Konzepte der funkt. Programmierung
Python−Programm mit List−Comprehension
Puffer
Threads
Idee der Generatoren
Python−Programm mit Generatoren
Konzepte der nebenl. Progr.
Generatoren in Java
’mechanisches’ Umsetzen
Java−Programm für Permutationen
Prototyp−Linie
Zielsystem−Linie
Abbildung 1.23: Es gibt mehr Softwaretechnik als Eure OO-Weisheit Euch träumen lässt
50
So wichtig und richtig sie auch sind!
114
1.7.4
Nichtprozedurale Programmierung
Datenströme
Strom–orientierte Programmierung
Strom–orientiert nennt man den Programmierstil, bei dem ein Programm als ein System angesehen wird, durch
das Daten fließen. Der Schwerpunkt der Betrachtungsweise liegt damit auf dem Datenfluss statt, wie sonst meist
üblich, auf dem Kontrollfluss. Das bekannteste Konstrukt zur Unterstützung strom–orientierter Programmierung
ist sicher die Pipe in Unix. In einem Pipe–Konstrukt wie
ls | grep -i flow | sort
wird von ls ein Datenstrom erzeugt, der dann durch zwei Filter, grep -i flow und sort, zur Ausgabe geleitet
wird. Damit haben wir bereits zwei wesentliche Grundbestandteile eines strom–orientierten Programms: einen
Generator, der den Strom erzeugt, und Transformatoren, die die durchströmenden Werte modifizieren oder filtern.
Strom–orientierte Programme eigenen sich zur graphischen Darstellung in einem Datenfluss–Diagramm. Beispielsweise kann die Struktur eines Programms, das die Summe der Quadrate aller ungeraden Zahlen zwischen
2 und 100 berechnet, als System aus einem Generator, zwei Transformatoren und einem Akkumulator dargestellt
werden (siehe Abbildung 1.24).
Gen 2 − 100
filter ungerade
Abb quadrat
akku 0, +
Abbildung 1.24: Datenfluss–Diagramm
Am Anfang erzeugt der Generator die Zahlen von 2 bis 100. Der erste Filter filtert die ungeraden Zahlen heraus.
Es folgt ein Transformator, der die durchströmenden Zahlen quadriert und am Schluss steht ein Akkumulator, der
sie sammelt und mit 0 beginnend aufsummiert.
Strom–orientierte Programmierung in Python
Es liegt nahe, strom–orientierte Programme in Python mit Generatoren zu definieren. Ein Generator zur Erzeugung
von Zahlen in einem definierten Bereich ist einfach:
def natS( von, bis ) :
i = von
while i <= bis :
yield i
i = i+1
# Der Datenstrom der natuerlichen Zahlen
# als Generator
Generatoren werden von “außen über ihren Iterator getrieben”. Ihr zugeordneter Iterator muss erzeugt und für
jeden Wert aufgerufen und weiterbewegt werden. Am bequemsten geht das implizit mit einer For–Schleife:
for i in natS(1, 100):
print i
Als Filter kann die vordefinierte filter–Funktion von Python genutzt werden, denn filter akzeptiert nicht
nur Listen, sondern auch Iteratoren und alle Container, die Iteratoren erzeugen können. Die ungeraden Zahlen
liefert damit folgendes Programm:
for i in filter( lambda x:x % 2,
natS(1, 100) ) :
print i
Hier wird filter mit einem Iterator konfrontiert, denn, durch die Verwendung von yield, ist natS ein Generator und damit ein Iterator. Eine selbstgeschriebene Filter–Funktion für Ströme müsste auf deren Charakter als
Generatoren natürlich Rücksicht nehmen:
Th Letschert, FH Giessen–Friedberg
115
def filterS(p, S):
for i in S:
if p(i): yield i
Ein Transformator, der alle Werte eines Stroms S mit einer Funktion f bearbeitet ist folgende stromorientierte
map–Variante:
def mapS( f, S ):
for i in S:
yield f(i)
Die Summe der Quadrate aller ungeraden Zahlen zwischen 1 und 100 druckt folgendes Programmstück mit Hilfe
von mapS aus:
for i in mapS (
lambda x:x*x, filter( lambda x:x % 2,
natS(1, 100) ) ) :
print i
Aus einem Strom S können die Werte mit einem Akkumulator aufgesammelt werden. Der Akkumulator startet
mit einem Startwert start und akkumuliert mit der Sammelfunktion f. Der Akkumulator für Ströme ist eine
stromorientierte Variante von reduce:
def reduceS( start, f, S ):
r = start
for i in S:
r = f(r,i)
return r
Achtung: reduceS akzeptiert eine Strom als Parameter, liefert selbst aber nur einen Wert und keinen Strom.
Mit diesen Bestandteilen kann jetzt ein strom–orientiertes Programm zur Berechnung der Summe aller Quadrate
der ungeraden Zahlen zusammengesetzt werden:
print reduceS( 0, lambda x,y:x+y,
mapS( lambda x:x*x,
filterS( lambda x:x % 2,
natS(1,10) )
)
)
#
#
#
#
addiere auf
das quadrierte
das ungerade ist
in der Zahlenfolge 1..10
Unendliche Ströme: Quadratwurzel
Der vom Bedarf seiner Kunden getriebene Charakter der Generatoren ermöglicht die Konstruktion unendlicher
Ströme. Natürlich sind Ströme, die wirklich unendlich lang sind, nicht möglich, aber man kann problemlos Ströme
definieren, die potentiell unendlich lang sind. Darüber, wie lang sie wirklich werden sollen, bestimmt ihr Kunde.
In mathematischen Anwendungen hat man es gelegentlich mit unendlichen Folgen oder Reihen zu tun. Selbstverständlich werden diese nicht wirklich unendlich weit berechnet, aber es ist nicht die Folge oder Reihe, die ihr
Ende bestimmt, sondern deren Anwendung. Hier liegt es nahe, einen Generator zu definieren, der einen unendlichen Strom erzeugt, der dann bis zu einem von der Anwendung bestimmten Ende verbraucht wird.
Betrachten wir als Beispiel die Berechnung der Quadratwurzel. Die Quadratwurzel kann als unendliche Folge von
immer besseren Näherungswerten definiert werden:
def sqrtS(x):
def mittelwert(a,b):
return (a+b)/2
116
Nichtprozedurale Programmierung
def verbessere(a,b):
return mittelwert(a, b/a)
w = 1.0
while True:
yield w
w = verbessere(w,x)
Die von diesem Generator erzeugte Folge der Annäherungen kann dann beispielsweise so lange entwickelt werden,
bis die Werte ausreichend dicht zusammen liegen:
def SqrtS(x):
def limitS(S, t):
v0 = 1000.0
for v in S:
if abs(v0-v) < t: return v
v0 = v
T = 0.001
return limitS(sqrtS(x), T)
Ströme generieren: Fibonacci–Zahlen
Generatoren können andere Generatoren erzeugen, auch rekursiv Varianten ihrer selbst. Damit ist es nicht nur
möglich (potentiell) unendlich lange Ströme zu erzeugen, sondern auch sie in (potentiell) unendlich großen
Datenfluss–Programmen zu verarbeiten.
Ein einfaches Beispiel liefern die Fibonacci–Zahlen. Eine Fibonacci–Zahl ist die Summe ihrer Vorgänger. Ein
Generator, der die Fibonacci–Zahlen liefert, kann damit definiert werden, als ein Generator, der – ausgehend von
zwei Startzahlen – die erste Zahl liefert und dann einen Generator erzeugt, der, mit der Summe beginnend, alle
weiteren liefert:
def fibgen( a, b ):
yield a
for i in fibgen( b, a+b ):
yield i
def fibs():
return fibgen(0,1)
Hier stellt fibs nicht nur einen unendlichen Strom dar, das Generator–System zu seiner Erzeugung ist ebenfalls
unendlich groß. Jeder Aufruf von fibgen führt zur Erzeugung eines Generators, der selbst wieder einen neuen
fibgen–Aufruf enthält und so wieder einen Generator erzeugt. Die Unendlichkeit ist bedarfsgetrieben und damit
nur potentiell. Die ersten 150 Fibonacci–Zahlen beispielsweise liefert das zwar große, aber immer noch endliche
System aus 150 Generatoren (Programmcode in diesem und dem folgenden Beispiel adaptiert nach [11]):
c = 0
for i in fibs():
print i
c = c+1
if c == 150 : break
Ströme generieren: Primzahlen
Ein anderes Beispiel für den Einsatz eines rekursiven Generators ist das bekannte Sieb des Eratosthenes zur Berechnung der Primzahlen als Datenfluss–Programm, das ein potentiell unendliches System aus einem Generator
und vielen Filtern erzeugt:
Th Letschert, FH Giessen–Friedberg
117
def siebe( strom ):
ii = iter(strom)
x = ii.next()
yield x
for i in siebe ( filterS( lambda y : not teilt(x, y),
strom
)
):
yield i
def primzahlen():
for i in siebe ( natStream() ):
yield i
Hier arbeiten wir mit iter, also mit explizitem Code zur Erzeugung des Iterators, statt, wie sonst, implizit über
eine for–Schleife, da der erste Wert eine Sonderbehandlung benötigt.
Der Strom der Primzahlen beginnt mit zwei. Die Zwei ist die erste Zahl von natStream(), siebe gibt sie
sofort aus. Mit der Zwei erzeugen wir ein neues Sieb, das alle Vielfachen von 2 aussiebt. So geht es rekursiv
weiter. Jedes Sieb nimmt seine erste Eingabe als Primzahl und erzeugt ein neues Sieb, das alle Vielfachen dieser
Zahl aussiebt. Die ersten 150 Primzahlen werden dann von einem System aus 150 Sieben erzeugt:
c = 0
for i in primzahlen():
print i
c = c+1
if c == 150 : break
Dieses Beispiel bringt noch einmal den entscheidenden Sinn der Generatoren zum Ausdruck: Wir schreiben Code,
der scheinbar eine luftig leichte Funktion definiert und der auch als Funktion verstanden werden kann. Tatsächlich
werden Objekte erzeugt, die sich in der Kontrolle über die Verarbeitung abwechseln und die ihre aktuellen Zustände
verwalten. Es sind die Zustände von gerade, im Zyklus der herumgereichten Kontrolle, unterbrochenen Berechnungen. Der gesamte Verwaltungsaufwand kann dabei aber getrost dem System überlassen werden.
Kapitel 2
Relationale Programme
118
Th Letschert, FH Giessen–Friedberg
2.1
2.1.1
119
Sprachen mit Mustererkennung
Reguläre Ausdrücke: Reguläre Textstrukturen erkennen
Was ist ein regulärer Ausdruck
Ein regulärer Ausdruck ist die kompakte Beschreibung einer regulären Sprache, also einer Menge von Zeichenketten, die von einem endlichen Automaten erkannt werden kann. Der reguläre Ausdruck r
((0|1) ∗ 11) ∗
beschreibt beispielsweise alle Zeichenketten die aus beliebigen Wiederholungen von Zeichenketten bestehen, die
selbst eventuell leere Folgen von Nullen und Einsen, gefolgt von zwei Einsen, sind. Beispiele sind
011
011011011011
001011
001011001011001011
Ein regulärer Ausdruck definiert also eine Menge von Zeichenketten. Umgekehrt sagt man auch, dass die Zeichenkette, die zu dieser Menge gehört, zum regulären Ausdruck passt, von ihm erkannt oder “gematcht” wird.
Informatiker kennen reguläre Ausdrücke aus dem Compilerbau, wo sie als Mittel zur Beschreibung einfacher
syntaktischer Konstrukte, der sogenannten Tokens, eingesetzt werden (vergl. z.B. [14]). Beispiele sind Bezeichner,
Literale, Schlüsselworte und andere Elemente der lexikalischen Analyse, die vom Scanner–Modul des Compilers
erkannt werden.
Reguläre Ausdrücke sind im Wesentlichen eine besondere Notationsform für reguläre Grammatiken. Wobei eine
reguläre Grammatik dadurch gekennzeichnet ist, dass in ihr nur links– oder rechts–rekursive Regeln wie
X → aX
X → Xb
vorkommen, allgemeine Rekursion wie in der Regel
X → aXb
aber verboten ist. Die syntaktische Struktur der entsprechenden Sprachen ist einfach – regulär eben. Listen beliebiger Länge sind möglich, Verschachtelungen beliebiger Tiefe aber verboten. Die Einfachheit der regulären
Ausdrücke und der durch sie beschriebenen Sprachen besteht darin, dass ihre Struktur repetitiv rekursiv ist. Sie
können darum ohne einen Stapel, mithin von einem endlichen(!) Automaten1 erkannt und analysiert werden.
Wir gehen davon aus, dass die Leser mit regulären Ausdrücken Bekanntschaft gemacht haben und listen nur kurz
ihre wichtigsten Elemente auf (der Leser konsultiere bei Bedarf die angegebene Literatur oder andere Quellen zu
regulären Ausdrücken):
• Sonderzeichen sind: | * + ( ) ? [ ] ˆ . $. Ist ein Sonderzeichen nicht als Sonderzeichen gemeint,
dann muss ihm ein Schrägstrich \ vorangestellt werden.
• Jedes Zeichen, das kein Sonderzeichen ist, steht für sich selbst.
• Der Punkt . steht für ein beliebiges Zeichen.
• Der Strich | trennt Alternativen. a|b passt sowohl zu a als auch zu b .
• Der Stern * steht für beliebige Wiederholungen. x* passt zu beliebig langen Folgen von x-en, inklusive
solchen der Länge 0.
• Das Pluszeichen + steht für Wiederholungen mit Mindestlänge 1. x+ ist damit das Gleiche wie xx*.
• Die Klammern ( ) gruppieren zu einer Einheit. Beispielsweise passt (ab)+ zu allen Wiederholungen der Gruppe ab.
• Mit eckigen Klammern [ ] wird eine Zeichenklasse definiert, die für eines ihrer Elemente steht. Eine
Zeichenklasse kann durch Aufzählung ( [xyz] ), durch Bereichsangabe ( [a-z] ), oder eine Kombination von beidem gebildet werden.
1 Ein endlicher Automat ist nichts anderes als eine repetitiv rekursive Funktion. “Endlich” heißt: wir brauchen keinen (potentiell unendlichen) Stapel.
120
Nichtprozedurale Programmierung
• Ein ˆ bedeutet “am Anfang” ( ˆx.* passt zu allem mit einem x am Anfang), es sei denn es erscheint
in einer Klasse, dort definiert es das Komplement, [ˆxyz] ist also alles was nicht x, y, oder z ist.
Aus Sicht der Programmierer sind reguläre Ausdrücke, zusammen mit ihrer Erkennungs– und Transformations–
Funktionalität, nichts anderes als eine kleine, nicht–prozedurale Spezialsprache.
Regulärer Ausdrücke in Python
Die von regulären Ausdrücken beschriebenen syntaktischen Strukturen und ihre Analyse sind ein derart wichtiges
Anwendungsfeld, dass sie von praktisch allen Skriptsprachen als Spezifikation von Textmustern akzeptiert werden.
Natürlich auch von Python.
In Python bietet das Modul re Zugriff auf die Funktionalität regulärer Ausdrücke. Dieses Modul unterstützt
sowohl einen funktionalen wie auch einen objektorientierten Programmierstil. Es bietet RA–Funktionen2 und RA–
Objekte die sich in ihren Anwendungsmöglichkeiten überschneiden. Am einfachsten ist die Anwendung der Funktionen. Im folgenden Beispiel wird ein regulärer Ausdruck benutzt, um alle Worte in einer Zeichenkette zu identifizieren:
# funktionaler Stil der Verwendung regulaerer Ausdruecke
wortMuster = r’[a-zA-Z][a-zA-Z0-9_\-]*’ # RA fuer Worte (Bezeichner)
print re.findall(wortMuster, "Es stehen 12 einsame Schafe am Wegesrand.")
Der Aufruf der RA–Funktion re.findall liefert in diesem Beispiel die Liste
[’Es’, ’stehen’, ’einsame’, ’Schafe’, ’am’, ’Wegesrand’]
Das Gleiche kann auch mit einem RA–Objekt erreicht werden:
# OO Stil der Verwendung regulaerer Ausdruecke
wortMuster = r’[a-zA-Z][a-zA-Z0-9_\-]*’
ra1 = re.compile(wortMuster);
print ra1.findall("Es stehen 12 einsame Schafe am Wegesrand.")
# RA-Objekt erzeugen
# und anwenden
zuerst wird mit der RA–Funktion re.compile das RA–Objekt ra1 erzeugt. Dann übernimmt dessen Methode
findall die Erkennungsarbeit. Das Ergebnis ist das Gleiche wie in der funktionalen Variante. Bei regelmäßig
wiederkehrender Anwendung eines regulären Ausdrucks sollte allerdings aus Effizienzgründen die objektorientierte Variante genutzt werden, bei der regulären Ausdrücke nur einmal in eine interne Datenstruktur übersetzt (compiliert) und so effizienter genutzt werden. Im Folgenden betrachten wir nur noch RA–Funktionen. Zu den Funktion
gibt es in der Regel entsprechende Methoden. Der Leser konsultiere dazu eventuell ein Python–Tutorium.
Die Funktion re.match (und die entsprechden Methode der RA–Objekte) versucht ein Muster am Anfang einer
Zeichenkette zu finden. So wird in
# match: Muster ab dem Anfang erkennen
if re.match(wortMuster, "Es stehen 12 einsame Schafe am Wegesrand."):
print "Passt!"
# <- OK !
else:
print "Passt nicht!"
erfolgreich das Wort Es erkannt und folglich Passt ausgegeben. Dagegen wird in
if re.match(wortMuster, "12 einsame Schafe stehen am Wegesrand."):
print "Passt!"
else:
print "Passt nicht!" # <- !
2 RA
= Regulärer Ausdruck
Th Letschert, FH Giessen–Friedberg
121
das Wort–Muster nicht erkannt: Die Zeichenkette beginnt nicht mit dem Muster.
Soll das Muster irgendwo in der Zeichenkette erkannt werden, verwendet man search:
# search: ein Muster irgendwo finden
if re.search(wortMuster, "12 einsame Schafe stehen am Wegesrand."):
print "Passt!"
# <- OK !
else:
print "Passt nicht!"
Muster erkennen
Die Funktionen re.match und re.search liefern bei Erfolg ein sogenanntes Match–Objekt. In einem Match–
Objekt werden im Wesentlichen die erkannten Text–Gruppen gespeichert.3 Gruppen werden im RA durch ein
Klammerpaar gekennzeichnet. Im folgenden Beispiel wird der Anfangsbuchstabe des ersten Worts ausgegeben:
# Eine Gruppe identifizieren
# Muster: Wort mit Anfangsbuchstaben als Gruppe
#
v
v
wortMuster = r’([a-zA-Z])[a-zA-Z0-9_\-]*’
mo = re.match(wortMuster, "Es stehen 12 einsame Schafe am Wegesrand.")
print mo.groups()
# Ausgabe: (’E’,)
Im Wort–Muster ist der Teilausdruck eingeklammert, der den Wortanfang spezifiziert. Die match–Funktion findet
das erste Wort (Es) und speichert dessen Anfangsbuchstaben als erste und einzige Gruppe. Wenn der Erkennungsprozess nicht mit dem Anfang der Zeichenkette beginnen, verwendet man search statt match.
Mit match/search können mehrere und auch verschachtelte Gruppen erkannt werden. Im folgenden Beispiel
wird eine Datei mit Zeilen als Gleichungen der Form
< N ame > = < W ert >
eingelesen und Zuordnung in einer Abbildung nameWertAbb gespeichert:
import re
nameMuster
wertMuster
= r’([a-zA-Z][a-zA-Z0-9_\-]*)’ # enthaelt Gruppe 1
= r’([0-9]+)’
# enthaelt Gruppe 2
# Zeile:
<Name>
Leerzeichen/Tab
= Leerzeichen/Tab <Wert>
zeilenmuster = nameMuster + r’[ \t]*’ + r’=’ + r’[ \t]*’ + wertMuster
dateiName = input(’Dateiname (in Hochkommas): ’)
nameWertAbb = {}
for l in open(dateiName):
mo = re.match(zeilenmuster, l)
if mo:
nameWertAbb[mo.group(1)] = mo.group(2)
print nameWertAbb
Das Muster für Namen enthält die erste Gruppe, das Muster für Werte die zweite. Aus beiden Mustern wird
das Muster für Zeilen zusammengesetzt. In der Schleife wird geprüft, ob die jeweilige Zeile dem Zeilenmuster
entspricht und, wenn ja, wird Gruppe 2 (der Wert) als Wert der Gruppe 1 (der Name) gespeichert. Gruppe 0
(mo.group(0)) enthält die gesamte erkannte Zeichenkette.
3
Für eine komplette Liste der Funktionalität von Match–Objekten konsultiere man das Python–Tutorium.
122
Nichtprozedurale Programmierung
Runde Klammern haben eine Doppelfunktion: Sie kennzeichnen Gruppen und gleichzeitig haben sie noch ihre
ursprüngliche Funktion der Zusammenfassung. Gelegentlich muss man Klammern einsetzen, möchte das eingeschlossene Muster aber nicht als Gruppe definieren. Beispielsweise werden mit
(e|E)[a-zA-Z0-9_\-]*
alle Worte erkannt, die mit einen großen oder kleinen “E” beginnen. Gleichzeitig wird das große oder kleine “E”
aber als Gruppe definiert:
eEwortMuster = r’(e|E)[a-zA-Z0-9_\-]*’
mo = re.search(eEwortMuster, "12 einsame Schafe stehen am Wegesrand.")
print mo.groups()
# Ausgabe (’e’,)
Will man nicht den Buchstaben, sondern das ganze Wort erkennen, dann muss es als Ganzes geklammert werden und gleichzeitig die “Gruppenfunktion” der Klammern um die E’s aufgehoben werden. Durch Fragezeichen
Doppelpunkt wird der Klammer die Gruppierungsfunktion genommen, sie ist nur noch eine Zusammenfassung:
# Klammerung mit und ohne Gruppenfuktion
#
mit-VV-ohne
eEwortMuster = r’((?:e|E)[a-zA-Z0-9_\-]*)’
mo = re.search(eEwortMuster, "12 einsame Schafe stehen am Wegesrand.")
print mo.groups()
# Ausgabe (’einsame’,)
Mit re.match und re.search kann eine Zeichenkette insgesamt darauf untersucht werden, ob sie zu einem
vorgebenen Muster passt. Gruppierungen helfen dabei, erkannte gegebenen zu identifizieren. Sie sind nicht dazu
geeignet, alle Wiederholungen des gleichen Musters innerhalb einer Zeichenkette zu erkennen.
Jede Gruppe stellt nur einen Speicherplatz zur Verfügung. Dieser kann und wird eventuell überschrieben. Im folgenden Beispiel wird darum nur das letzt E–Wort, Eimer, ausgegeben.
# Worte die mit e/E beginnen
eWortMuster
= r’((?:e|E)[a-zA-Z0-9_\-]*)’
# Worte die nicht mit e/E beginnen
neWortMuster
= r’(?:[a-df-zA-DF-Z])[a-zA-Z0-9_\-]*’
# Beliebige Worte
wortMuster
= r’(?:’ + eWortMuster + r’|’ + neWortMuster + r’)’
# Zeichenkette mit e/E-Worten
zkMuster
= r’(?:’ + r’(?:[ˆa-zA-Z]| )*’ + wortMuster + r’)+’
mo = re.match(zkMuster, "Es stehen einsame Schafe an einem Eimer.")
print mo.groups()
# Ausgabe: (’Eimer’,)
Um alle Instanzen eines Muster zu identifizieren, sollte man darum besser findall verwenden.
Text ersetzen
Eine der häufigsten Aufgaben der einfachen Textverarbeitung besteht darin, in einem Text alle Vorkommen einer
Zeichenfolge durch eine andere zu ersetzen. Am einfachsten geht das mit der replace–Funktion des string–
Moduls bzw. mit der entsprechenden Methode. Ein einfaches Beispiel (mit replace als Methode) ist:
s0 = "Ein einsames Schaf steht am Waldesrand."
s1 = s0.replace("Schaf", "Mondkalb");
Th Letschert, FH Giessen–Friedberg
123
Mit Hilfe regulärer Ausdrücke können nicht nur feste Zeichenfolgen, sondern auch solche, die zu einem Muster
passen, ersetzt werden. Beispielsweise werden im folgenden Beispiel alle Worte, die mit einem großen oder kleinen
“E” beginnen, durch XX ersetzt:
s0 = "Ein einsames Schaf steht am Waldesrand."
eWortMuster = r’\b(e|E)[a-zA-Z0-9_\-]*’
s1 = re.sub(eWortMuster, "XX", s0)
Mit \b wird ein Muster am Beginn eines Wortes “verankert”. Damit kann auf einfache Weise gewährleistet
werden, dass keine mit “e” beginnenden Teilworte erkannt werden (“e” mitten im Wort).
Der zweite Parameter von re.sub kann ebenfalls ein Muster sein. In ihm kann auch auf Gruppen Bezug genommen werden, die mit dem ersten Muster erkannt wurden. Im folgenden Beispiel werden die “E”-s am Anfang von
E–Worten durch ein großes “A” ersetzt:
s0 = "Ein einsames Schaf steht am Waldesrand."
eWortMuster = r’\b(?:e|E)([a-zA-Z0-9_\-]*)’
s1 = re.sub(eWortMuster, r’A\1’, s0)
Mit \1 wird auf die erste Gruppe des e–Wort–Musters Bezug genommen. Man beachte, dass dort die Gruppenfunktionalität des ersten Klammerpaars deaktiviert und ein zweites Klammerpaar eingefügt wurde. Mit \1 ist
also der Rest des Wortes gemeint (alles ausser dem ersten Buchstaben).
Eine Variante der sub–Funktion4 akzeptiert statt Ersatz–Ausdrücken eine Funktion mit Match–Objekt als Argument und einer Zeichenkette als Wert. Damit kann eine beträchtliche Flexibilität im Ersetzungsprozess erreicht
werden. Im folgenden Beispiel werden alle erkannten Zeichenfolgen entsprechend einer Abbildung ersetzt:
import re
import string
s =
r’Ein einsames Schaf steht am Waldesrand.’
eWortMuster
= r’\b(?:e|E)([a-zA-Z0-9_\-]*)’
# Die Abbildung: ein Katalog der Ersetzungen
#
abb = { ’Ein’:’Das’, ’einsames’:’betrunkenes’ }
print re.sub( eWortMuster,
lambda mo: abb[mo.group(0)],
s)
# Muster
# Erkanntes -> Ersatz
# zu Transformierendes
Die Funktion kann außer zu Ersetzungen, zu beliebigen anderen oder weiteren Zwecken genutzt werden, etwa um
die Zahl der erkannten Worte zu zählen. Die Zeichenkette muss dabei nicht einmal modifiziert werden:
s
eWortMuster
= ...
= ...
z=0
def zaehleEWorte(mo):
global z
z=z+1
return mo.group(0)
re.sub( eWortMuster, zaehleEWorte, s )
print "Zahl der E-Worte: ", z
4 und
der entsprechenden Methode
124
Nichtprozedurale Programmierung
Reguläre Ausdrücke und ihre zugeordneten Funktionen sind ein wesentliches Element aller Skriptsprachen und
unentbehrliches Hilfsmittel der Alltagsarbeit unzähliger Programmierer. Ihr Einsatz hat einen ausgeprägt deklarativen Charakter und damit haben sie viele Programmierer mit diesem Stil von Programmen in Kontakt gebracht,
die nicht einmal die Bedeutung der der Begriffe “deklarativ” oder “nicht–prozedural” kennen.
Reguläre Ausdrücke: Eine weitverbreitete eingebettete deklarative Spezialsprache
Reguläre Ausdrücke sind deklarative Spezialsprache zur Beschreibung regulärer “Sprachen. Also solche sind sie
nicht nur in Python eingebettet. Skriptsprachen unterstützen typischerweise reguläre Ausdrücke, aber nicht nur diese. Die Klassen–Bibliothek von Java beispielsweise unterstützt reguläre Ausdrücke mit dem Paket java.util.regex
Man kann sich jetzt fragen, warum reguläre Sprachen eine so ausgedehnte Unterstützung erfahren, kontextfreie
jedoch nicht. Reguläre Sprachen sind die meisten Probleme der Textverarbeitung ausreichend mächtig und können
trotzdem effizient implementiert werden. Ein entsprechender allgemeiner Mechnismus für Kontextfreie Sprachen
ist zwar möglich, erfordert aber einen wesentlich größeren Aufwand vor allem beim Erkennen der Strukturen.
Der Leser sei daran erinnert, dass Parser–Generatoren üblicherweise nur eine eingeschränkte Unterklasse der kontextfreien Grammatiken akzeptieren um halbwegs effiziente Erkennungsalgorithmen liefern zu können. Mit diesen
Einschräkungen kann ein so handliches Werkzeug wie es die regulären Ausdrücke sind, für nicht geschaffen werden, ohne diese Einschränkungen ist der algorithmische Aufwand nicht akzeptabel.
2.1.2
XML
XML
Im Rahmen von XML haben viele Techniken aus dem weiteren Umfeld der KI eine Wiederbelebung erfahren
und neue Anwendungsfelder gefunden. Darunter ist auch, in Gestalt von XSLT, der regelbasierte deklarative
Programmierstil. Die Reinkarnationen alter Paradigmen innerhalb von XML sind zwar oft von bemerkenswerter Schwerfälligkeit, man erkennt ihre Vorfahren kaum, trotzdem (deshalb ?) bewegen sie sich sie erfolgreich
im Hauptstrom der IT–Branche. An dieser Stelle soll keine Einführung in die XML–Technologien erfolgen, dazu ist das Thema zu komplex und ausgedehnt. Es soll hier nur kurz auf die Rolle hingewiesen werden, die der
deklarativen (nicht–prozeduralen) Programmierung in diesem Umfeld zukommt. Ausserdem wollen wir auf die
Möglichkeiten hinweisen, die Python bei der Bearbeitung von XML–Dokumenten bietet.
Wir gehen davon aus, dass die Leser im Prinzip mit den elementaren Grundprinzipien und Begriffen von XML
vertraut sind. In [27] findet sich eine gute knappe Zusammenfassung der wichtigsten Konzepte, die auch als Auffrischung geeignet ist.
Jedes XML–Dokument ist die textuelle Darstellung eines Baums, d.h. einer hierarchisch strukturierten Informationsmenge. Das Dokument
<?xml version="1.0"?>
<!DOCTYPE gedicht SYSTEM "file:///home/hg4711/gedicht.dtd">
<gedicht>
<autor>
<vorname>Wilhelm</vorname>
<name>Busch</name>
</autor>
<titel>Langeweile</titel>
<strophe>
<zeile>Guten Tag Frau Eule! </zeile>
<zeile>Habt Ihr Langeweile? -</zeile>
<zeile>Ja eben jetzt,
</zeile>
<zeile>solang Ihr schwaetzt. </zeile>
</strophe>
</gedicht>
hat die in Abbildung 2.1 dargestellte Struktur.
Th Letschert, FH Giessen–Friedberg
125
gedicht
autor
vorname
name
strophe
zeile
zeile
zeile
zeile
Abbildung 2.1: Struktur des Gedichts in XML
Der DOCTYPE–Tag verweist auf eine Datei gedicht.dtd mit der DTD (Document Type Definition), der Definition einer Struktur von XML–Dokumenten, der dieses Dokument folgt. Die DTD gedicht.dtd ist:
<!ELEMENT
<!ELEMENT
<!ELEMENT
<!ELEMENT
<!ELEMENT
<!ELEMENT
<!ELEMENT
gedicht
strophe
zeile
autor
vorname
name
titel
(autor, titel, strophe*)
(zeile*)
(#PCDATA)
(vorname, name)
(#PCDATA)
(#PCDATA)
(#PCDATA)
>
>
>
>
>
>
>
DTDs haben eine einfache Syntax. Mit ihr kann in Form einer, an erweiterte kontextfreie Grammatiken angelehnten Notation, die Struktur von XML–Dokumenten definiert werden. Dokumente, deren Struktur einer DTD
entsprechen, werden wohlgeformt genannt. Zur Erinnerung: Erweiterte Kontextfreie Grammatiken haben reguläre
Ausdrücke als rechte Seiten. Die damit definierbaren Sprachen sind kontextfrei.
SAX–Parser
XML–Dokumente werden von XML–Parsern analysiert. Eine Vielzahl von XML–Parsern ist frei verfügbar und
auch Python–Entwickler haben leichten Zugang zu XML–Parsern (für eine ausführliche Darstellung siehe [15]
und auch die Python–Dokumentation).
XML–Parser gibt es in zwei Varianten, die kurz als SAX– bzw. DOM–Parser bezeichnet werden. Die ereignisgetriebenen SAX–Parser analysieren das Dokument und informieren die Anwendung “im Flug” über die vorgefundenen Bestandteile des Dokuments. In der Schnittstelle des Parsers werden Rückrufroutinen registriert, die dann
vom Parser während der Analyse des Dokuments aktiviert werden. Die Rückrufroutinen sind die Methoden einer
Ableitung der Klasse ContentHandler. Im folgenden Beispiel wird mit startElement, als Methode der
Klasse myHandler, einer Ableitung von ContentHandler, eine einzige Rückrufroutine installiert. Sie wird
jedesmal aufgerufen, wenn der Parser ein neues Element in Form eines Start–Tags findet. Für alle wichtigen Ereignisse des Parsers können entsprechende Routinen definiert werden. Wichtige Ereignisse sind Elemente, Tags,
Attribute, etc. die der Parser bei der Analyse des Dokuments antrifft.
126
Nichtprozedurale Programmierung
# sax-notice-tags: gib alle gefundenen Tags aus.
#
from xml.sax.handler import ContentHandler
from xml.sax
import make_parser
import sys
class myHandler(ContentHandler):
def startElement(self, name, attr):
print name, " mit ", attr.getLength(), " Attributen"
def parsefile(file):
parser = make_parser()
parser.setContentHandler(myHandler())
parser.parse(file)
try:
parsefile(sys.argv[1])
print sys.argv[1], "wurde erfolgreich geparst!"
except Exception, e:
print sys.argv[1], "ist fehlerhaft: ",e
Auf das Beispieldokument von oben angewendet
python sax-notice-tags.py gedicht.xml
erzeugt es die Ausgabe:
gedicht mit
autor mit 0
vorname mit
name mit 0
titel mit 0
strophe mit
zeile mit 0
zeile mit 0
zeile mit 0
zeile mit 0
0
Attributen
Attributen
0 Attributen
Attributen
Attributen
0 Attributen
Attributen
Attributen
Attributen
Attributen
DOM–Parser
DOM–Parser sind baumorientiert. Sie analysieren das Dokument, aber statt die Anwendung über gefundene Elemente sofort zu informieren, bauen sie intern eine entsprechende Baumstruktur auf. Der Baum bietet dann eine
Schnittstelle mit Funktionen zur Navigation innerhalb des Baums und der Extraktion von Informationen oder auch
zu dessen Modifikation. “DOM” steht für Document Object Model und bezieht sich auf die logische Struktur des
Baums, die über eine standardisierte Schnittstelle – DOM eben – abgefragt werden kann. Im folgenden kleinen
Beispiel wird gedicht.xml von einem DOM–Parser geparst und der dabei erzeugte Baum anschließend traversiert und ausgedruckt:
from xml.dom.minidom import parse
import sys
def traverse(node, indent):
if node.nodeType == node.TEXT_NODE:
print indent*"\t",node.data
else:
print indent*"\t",node.nodeName,
if node.hasChildNodes():
print "["
for c in node.childNodes:
Th Letschert, FH Giessen–Friedberg
127
traverse(c,indent+1)
print indent*"\t","]",
try:
dom = parse(sys.argv[1])
print sys.argv[1], "wurde erfolgreich geparst!"
traverse(dom, 0)
except Exception, e:
print sys.argv[1], "ist fehlerhaft: ",e
Dieses kleine Beispiel ist sicher weitgehend selbsterklärend. Weitere Informationen entnehme man der angegebenen Literatur.
XSLT
Die Analyse von XML–Dokumenten mit Hilfe eines SAX– oder DOM–Parsers folgt dem klassischen prozeduralen Schema der Programmierung. Mit einigem Aufwand an Klassen– und Methoden–Definitionen, Schleifen und
Bedingungen können XML–Dokumente damit beispielsweise in HTML–Dokumente transformiert oder sonst in
beliebiger Weise manipuliert werden.
Dies ist nicht die Art in der “normale” Anwendungsentwickler mit XML–Dokumenten umgehen sollten, zumindest nicht nach Meinung des W3C und anderer wesentlicher Verfechter der XML–Technologie. Die Einbettung
eines Parsers in ein XML–Anwendungsprogramm ist zu elementar. XML–Anwendungen sollten im Regelfall auf
“höherer Ebene” erstellt werden. Dabei werden natürlich auch Parser benötigt, aber die Anwendungsentwickler
sollten vom direkten Umgang mit ihnen befreit werden. Die empfohlene Art des Umgangs mit XML–Dokumenten
ist der Einsatz deklarativer Techniken. Man definiert Transformations–Muster die implizit auf alle passenden Elemente eines Dokumentes angewendet werden. Mit XSLT (Extensible Stylesheet Language Transformation) definierte das W3C eine entsprechende entsprechende Sprache (siehe [30]).
XSLT ist ein komplexer Standard, der selbst Bestandteil der weiter gefassten Spezifikation XSL ist. Hier kann nicht
ansatzweise versucht werden, XSLT zu erläutern. Mit einem kleinen Beispiel wollen wir lediglich einen Eindruck
vom deklarativen Charakter dieser Sprache vermitteln. Ein XSLT–Programm – ein XSLT–Stylesheet –, das unser
Gedicht von oben in eine HTML–Form bringt ist:
<?xml version="1.0"?>
<xsl:stylesheet
version="1.0"
xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
<!-- xsl::output method="html" -->
<xsl:template match="/">
<html>
<head><title>Gedicht</title></head>
<body>
<xsl:apply-templates select="gedicht"/>
</body>
</html>
</xsl:template>
<xsl:template match="gedicht">
<p>
<h1><xsl:apply-templates select="titel"/></h1>
<xsl:apply-templates select="strophe"/>
</p>
</xsl:template>
128
Nichtprozedurale Programmierung
<xsl:template match="strophe">
<ul>
<xsl:apply-templates select="zeile"/>
</ul>
</xsl:template>
<xsl:template match="titel">
<xsl:value-of select="."/>
</xsl:template>
<xsl:template match="zeile">
<li><xsl:value-of select="."/></li>
</xsl:template>
</xsl:stylesheet>
Das gesamte Stylesheet (das XSLT–Programm) besteht, neben dem obligatorischen und hier ignorierten Vorlauf,
aus fünf Templates (Schablonen), jeweils eins für /, gedicht, strophe, titel und zeile. Jedes Template ist
eine Transformations–Schablone, die auf bestimmte Elemente des XML–Baums angewendet wird. Welche Baum–
Elemente das sind, wird durch das Pfadmuster im Match–Attribut (match=< P f adM uster >) angegeben. Die
Pfadmuster sind hier sehr einfach: / bezeichnet die Wurzel des Baums, gedicht jeden Knoten der ein Gedicht–
Element ist, strophe jeden Strophen–Knoten und zeile jeden Zeilen–Knoten.
Als erstes wird das Template (die Transformationsregel) für die Wurzel angewendet. Mit apply-templates
wird das Template für Strophen–Knoten auf alle Strophen–Knoten unterhalb der Wurzel angewendet und so weiter.
<xsl:template match="/">
<<-- Anwenden auf die Wurzel /
<html>
<head><title>Gedicht</title></head>
<body>
<xsl:apply-templates select="gedicht"/> <<-- Ergebnis anderer
</body>
Transformationen hier einsetzen
</html>
</xsl:template>
In aktuellen Versionen von Python lässt sich auch eine XSLT–Unterstützung integrieren.5 Mit folgendem Python–
Programm, wird das Stylesheet (das XSLT–Programm in der Datei gedicht.xslt) auf das XML–Dokument
(in gedicht.xml) angewendet:
# XSLT in Python
import libxml2
import libxslt
styledoc
style
doc
result
=
=
=
=
libxml2.parseFile(
"gedicht.xslt"
libxslt.parseStylesheetDoc( styledoc
libxml2.parseFile(
"gedicht.xml"
style.applyStylesheet(
doc, None
)
)
)
)
print style.saveResultToString(result)
XPATH
Die Pfadausdrücke der Match–Attribute eines Templates identifizieren Knoten oder Knotenmengen in einem
XML–Baum. Im Beispiel oben waren sie sehr einfach, z.B. identifiziert zeile in
<xsl:template match="zeile"> ... </xsl:template>
5
z.B. durch die Installation von libxslt und libxslt-python, siehe http://xmlsoft.org/XSLT
Th Letschert, FH Giessen–Friedberg
129
alle Zeilen–Knoten unterhalb des aktuellen Knotens. Pfadausdrücke können auch wesentlich komplexer sein. Ihre
Syntax und Semantik wird in der XML Path Language (XPATH) Empfehlung des W3C definiert (siehe [31]).
XPATH wird nicht nur in XSLT verwendet, ist dort aber von zentraler Bedeutung. In XPATH werden Baum–
Muster definiert, mit denen Knoten in einem Baum identifiziert werden können. Das ist durchaus vergleichbar mit
regulären Ausdrücken (Textmustern), die Bestandteile einer Zeichenkette identifizieren.
XPATH–Ausdrücke sind ähnlich aufgebaut wie die Pfadausdrücke, mit denen Verzeichnisse und Dateien identifiziert werden. Allerdings sind immer alle passenden Knoten gemeint. Beispielsweise bezeichnet
/gedicht/strophe
alle Strophen des Gedichts und mit
/gedicht/strophe/zeile
sind alle Zeilen aller Strophen gemeint. Die Zeilen nur der ersten Strophe werden mit
/gedicht/strophe[1]/zeile
angesprochen. Mit
/gedicht/strophe[1]/zeile/text()
wird der Text aller Zeilen der ersten Strophe identifiziert.
XPATH ist eine erstaunlich komplexe Sprache mit Ausdrucksmöglichkeiten, deren Vielfalt hier nicht dargestellt
werden kann. Es sei nur noch erwähnt, dass XPATH–Ausdrücke in Python–Programmen leicht getestet oder auch
anderweitig eingesetzt werden können. Folgendes Programm liest eine XML–Datei von der Standardeingabe und
gibt alle Knoten aus, die dem angegebenen Pfadausdruck entsprechen.
import sys
from xml.dom.ext.reader
from xml.xpath
import PyExpat
import Evaluate
path = "/gedicht/strophe[1]/zeile/text()"
# der Pfadausdruck
reader = PyExpat.Reader()
dom = reader.fromStream(sys.stdin)
# XML-Datei parsen
nodes = Evaluate(path, dom.documentElement) # XPATH-Auswerter starten
for node in nodes:
print node.nodeValue
# gefundenen Knoten ausgeben
Mit diesem knappen Ausblick auf die Anwendung deklarativer Techniken und Denkweisen im Umfeld von XML
wollen wir uns hier bescheiden. Eine systematische und substantielle Einführung in die XML–Technologien erfordert mindestens eine ganze Lehrveranstaltung. Es sollte zumindest deutlich geworden sein, welche bedeutende
Rolle das nicht–prozedurale Programmieren hier spielt.
Muster sind deklarative Programme
In diesem Kapitel haben wir uns in zwei Beispielen damit beschäftigt, wie syntaktische Strukturen analysiert
werden können. XML–Parser erkennen XML–Dokumente und erlauben uns Informationen über das erkannte Dokument zu extrahieren, bzw. Aktionen mit dem Erkennungsprozess zu verknüpfen. Eine Probe, das Dokument,
und ein (implizit) vorgegebenes Muster, die XML–Spezifikation, führen zu einer erkannten Struktur. Ein regulärer
Ausdruck ist ein Muster f”ur Worte. Das Modul für reguläre Ausdrücke ermöglicht es uns die Menge der Übereinstimmungen mit diesem Muster zu erzeugen. Probe und Muster führen also zu einer Menge an erkannten Strukturen. Die XPATH–Implementierung arbeitet in ähnlicher Weise indem sie die Knoten in einem Dokument findet,
die zu dem Pfadausdruck passen. Mit XSLT geht man noch einen Schritt weiter. Es werden nicht nur eine Probe
und ein Muster vorgegeben, zu denen dann eine Menge der erkannten Strukturen geliefert werden, diese werden
gleich in andere Strukturen umgeformt.
130
Nichtprozedurale Programmierung
Reguläre Ausdrück, ein Pfadausdrück, ein XSLT–Stylesheets können als Programme angesehen werden. Es sind
allerdings Programme, die sich radikal vom üblichen Verständnis eines Programms unterscheiden. Es sind Programme mit einem ausgeprägt deklarativen Charakter. Sie geben nur das Ziel, das “Was”, der Berechnung vor,
ohne etwas über das “Wie” zu sagen. Die Einteilung in deklarative (“Was ist zu berechnen”) und nicht deklarative
Sprachen (“Wie wird etwas berechnet”) ist bei genauerer Betrachtung allerdings etwas naiv. In einem Programm
jeder Programmiersprache, es sei denn es handelt sich um reinen Maschinencode, geben wir vor, was geschehen
soll und überlassen es dann der Sprachimplementierung die unangenehmen Details auszuarbeiten. Die Details sind
hier zwar andere als die gewohnten, aber trotzdem handelt es sich bei dem, was die Implementierung aus unseren
Vorgaben macht, nicht um irgendeinen magischen Mechanismus, sondern um ein regeläßiges Muster. Andernfalls
könnte es nicht automatisch von einem Compiler oder Interpreter erzeugt werden.
Die Implementierungen der Mustererkennungs–Sprachen liefern uns einen Suchmechanismus. Sie erzeugen also
automatisch Kontrollstrukturen die eine Suche organisieren. Das ist ungewohnt und führt zu drastisch anderen
Programmen, aber in der Sache ist die automatische Erzeugung eines Suchalgorithmus nicht wesentlich komplexer
als die automatische Implementierung der Rekursion oder des Polymorphismus.
Th Letschert, FH Giessen–Friedberg
131
Nihil tam difficile est,
quim quae rendo investigari possit.
Terenz
Dann geh zu dm Baum zurück. Dem umgestürzten Baum.
Stell dich dorthin, wo du hegekommen bist, und sieh geradeaus.
Das ist die Richtung in die du gehen musst, die Richtung zum Hauptweg.
– Aber stimmt das?
S. King, Das Mädchen
2.2
2.2.1
Suche, Nichtderminismus, Relationen
Mustererkennung als Suchproblem
Backtracking
Mit dem Begriff Backtracking (siehe auch Abschnitt 1.5.5) bezeichnet man eine robuste allgemeine Such–Methode
zum systematischen Auffinden einer oder aller Lösungen eines Problems, das auf direktem Weg nicht gelöst werden kann oder soll. Ganz allgemein besteht ein Backtrack–Algorithmus aus folgenden Schritten:
1. Gehe einen Schritt
2. Versuche von der neuen Position eine Lösung zu finden
3. Falls eine Lösung gefunden wurde:OK
Falls nicht: nimm den Schritt zurück (Backtrack) und gehe einen anderen Schritt
4. Falls keine anderer Schritt mehr möglich: Misserfolg
Vergleich von Zeichenketten
Das Prinzip des Backtrackings kann auf die Mustererkennung angewendet werden. Nehmen wir an, wir wollten
einen String p, das Muster, einem Text t auffinden. Das Muster soll hier eine einfache Folge von Zeichen sein,
ohne die Sonderzeichen eines regulären Ausdrucks. Etwa p = abcabd und t = abcabcabdabba. Wenn wir die die
beiden miteinander vergleichen, dann stellen wir fest, dass die ersten fünf übereinstimmen, der sechste aber nicht:
abcabcabdabba
|||||?
abcabd
Bei einem solchen Misserfolg müssen wir zum Anfang zurück kehren (Backtrack), einen Schritt weitergehen und
das ganze noch einmal probieren. Diesmal stellen wir sofort fest, dass das Muster nicht zum Text passt:
abcabcabdabba
?
abcabd
Es geht wieder zurück und wir probieren den nächsten und so weiter bis schließlich eine Übereinstimmung gefunden wird.
abcabcabdabba
||||||
abcabd
132
Nichtprozedurale Programmierung
Backtracking ist ein einfaches Verfahren zur Mustererkennung. Es ist klar, dass es außerordentlich ineffizient sein
kann. Besteht der Text etwa nur aus a–s und das Muster aus einer langen Folge a–s gefolgt von einem b, dann kann
man sich leicht ausmalen, wie der Algorithmus immer und immer wieder die lange Folge von a–s vergleicht, dann
fehlschlägt und nach einem Backtrack das Gleiche ein Zeichen weiter versucht.
Mit einer genaueren Analyse des Problems kann man zu wesentlich besseren aber auch komplexeren Verfahren
kommen.6
Das Prinzip des Backtrackings kann auch zum Erkennen von Mustern mit “Jokerzeichen” (Wildcards) wie “x?”
oder “x*” und Alternativen “x|y” eingesetzt werden. Das Prinzip ist klar. Man versucht eine Variante zu erkennen
und wenn dies fehlschlägt probiert man die nächste.
Syntax Regulärer Ausdrücke
Reguläre Ausdrücke werden üblicherweise zur Beschreibung der Syntax von textuellen Strukturen eingesetzt. Sie
haben aber auch selbst eine textuelle Struktur. Diese kann in einer Syntaxanalyse erkannt werden. Das ist der
erste Schritt zu deren Behandlung als Programmiersprache. Die exakte Syntax regulärer Ausdrücke variiert. Wir
beschränken uns hier auf einen einfachen Kern, der durch folgende Grammatik definiert wird:
<RA>
<RT>
<RF>
<EB>
<L>
::=
::=
::=
::=
|
::=
<RT> [ ’|’ <RA> ]
<RF> [ <RT> ]
<RB> [’*’ | ’?’ ]
<L>
’(’<RA>’)’
’a’|’b’...|’z’
Die Alternative bindet schwächer als die Reihung, diese schwächer als + und *. Entlang dieser Grammatik lässt
sich leicht eine Parsing–Routine für die entsprechenden Ausdrücke schreiben:
def parseRE(re):
def isStarterRT(c):
return (c >= ’a’ and c <= ’z’) or c == ’(’
def parseRA(txt):
print(’parseRA(’+txt+’)’)
(tree,txt) = parseRT(txt)
while txt[0] == ’|’ :
(treeR,txt) = parseRT(txt[1:])
tree = [’ALT’, tree, treeR]
return (tree, txt)
def parseRT(txt):
print(’parseRT(’+txt+’)’)
(tree,txt) = parseRF(txt)
while isStarterRT(txt[0]) :
(treeR,txt) = parseRT(txt)
tree = [’SEQ’, tree, treeR]
return (tree, txt)
def parseRF(txt):
print(’parseRF(’+txt+’)’)
(tree,txt) = parseRB(txt)
if txt[0] == ’*’:
return ([’*’, tree], txt[1:])
if txt[0] == ’?’:
return ([’?’, tree], txt[1:])
else:
return (tree,txt)
def parseRB(txt):
print(’parseRB(’+txt+’)’)
6
z.B. der Knuth–Morris–Pratt Algorithmus, siehe [6] oder ein anderes Standardwerk zu Algorithmen.
Th Letschert, FH Giessen–Friedberg
133
if txt[0] == ’(’:
(tree, txt) = parseRA(txt[1:])
if txt[0] == ’)’:
return (tree, txt[1:])
else:
return ([’SYM’, txt[0]], txt[1:])
return parseRA(re+’$’)[0]
Diese Routine transformiert einen String in Baumform. Beispielsweise liefert
parseRE(’a(c+|b)*c’)
den Wert
[’SEQ’, [’SYM’, ’a’], [’SEQ’, [’*’, [’ALT’, [’+’, [’SYM’, ’c’]], [’SYM’,
’b’]]], [’SYM’, ’c’]]]
Dieser Wert stellt einen abstrakten Syntaxbaum zu dem übergebenen “Programm” dar. Ein Baumknoten wird als
zwei oder dreielementige Liste dargestellt mit Komponenten Typ, linker Unterbaum und gegebenenfalls rechter
Unterbaum. Die Typbezeichnung SEQ steht für Sequenz, ALT für Alternative.
Naiver Erkennungsalgorithmus
Auf dem abstrakten Syntaxbaum kann jetzt ein Interpretierer arbeiten. Ein erster naiver Ansatz wäre etwa:
# Simpel und naiv: ohne Backtrack
def matchS(pat, txt):
if pat[0] == ’SYM’
:
if txt[0] == pat[1]: return txt[1:]
else:
return ’FAIL’
if pat[0] == ’SEQ’ :
txt = matchS(pat[1], txt)
if txt != ’FAIL’ :
return matchS(pat[2],txt)
else:
return ’FAIL’
if pat[0] == ’ALT’:
txtL = matchS(pat[1], txt)
# erkenne Alternative 1
if txtL != ’FAIL’ : return txtL
# wenn erkannt, OK
else:
return matchS(pat[2],txt) # sonst versuche Alternative 2
if pat[0] == ’?’ :
txtOpt = matchS(pat[1], txt)
if txtOpt != ’FAIL’:
return txtOpt
else:
return txt
if pat[0] == ’*’ :
while (txt[0] != ’$’):
txtS = matchS(pat[1], txt)
if txtS != ’FAIL’:
txt = txtS
else:
return txt
# erkenne optinales Muster
# oder uebergehe es
# laengste passende
# Sequenz suchen
Das Ergebnis einer match–Operation ist der Wert ’FAIL’, Muster nicht erkannt, oder der Rest der Zeichenkette
hinter dem erkannten Muster. Soll ein Symbol (SYM) erkannt werden, und beginnt der Text mit diesem Symbol,
dann ist der Wert von matchS der Rest des Textes hinter dem Symbol. Beginnt der Text mit einem anderen
134
Nichtprozedurale Programmierung
Zeichen dann ist das Ergebnis FAIL. Eine Sequenz (SEQ) wird erkannt, indem zuerst der erste Teil erkannt wird
und dabei einen Reststring liefert auf den dann matchS mit dem zweite Teil der Sequenz angesetzt wird.
Bei einer Alternative ([’ALT’ p1 p2]) wird zuerst versucht pat1 zu erkennen. Gelingt das, dann gilt die
Alternative als erkannt. Gelingt das nicht, dann muss pat2 erkannt werden. Dieser Algorithmus sieht auf den
ersten Blick korrekt aus. Einfache Fälle können damit auch korrekt erkannt werden. Aber schon mit dem Muster
(abc|ab)c
wird
abc
nicht erkannt werden. Das Problem entsteht, wenn, wie im Beispiel bei der Alternative, beide Varianten passen,
das Erkennen der ersten aber später in eine Sackgasse führt. Damit der Algorithmus funktioniert, muss er in der
Lage sein die erfolgreiche Erkennung eines Teilstrings später wieder zurück zu nehmen.
Backtracking mit Fortsetzungsfunktionen
Das Problem mit der Rücknahme einer erfolgreichen Aktion besteht darin, dass wir in dem Augenblick, in wir sie
treffen, nicht wissen können, ob sie später die Ursache eines Fehlschlags sein wird. Wir können schließlich nicht in
die Zukunft sehen. Backtracking–Algorithmen lösen dies durch eine Strategie des versuchsweisen Weitergehens.
Auf unser Beispiel des Erkennens einer Alternative angewendet sieht das so aus:
• Erkenne erste Teilalternative.
• Falls das fehlschlägt: erkenne zweite Teilalternative.
• Falls nicht: mach weiter mit der ersten Teilalternative.
• Falls das Weitermachen fehlschlägt: erkenne zweite Teilalternative.
Das Kennzeichen der Backtrack–Algorithmen ist, dass sie an jedem Entscheidungspunkt erst einmal versuchsweise
zu Ende laufen, um, falls der Versuch fehlschlägt, eine andere Alternative wählen zu können. Dieses Prinzip wird
sehr gut sichtbar, wenn wir den Mustervergleich in einer Version mit Fortsetzungsfunktion, also in Continuation–
Form formulieren:
# Mustervergleich durch Tiefensuche (Backtracking)
# in Continuation-Form
#
def matchC(pat, txt, cont):
if pat[0] == ’SYM’
:
if txt[0] == pat[1]:
return cont(txt[1:])
else:
return cont(’FAIL’)
if pat[0] == ’SEQ’ :
return matchC(pat[1], txt,
lambda t : ((t == ’FAIL’)
and cont(’FAIL’))
or matchC(pat[2], t, cont)
)
if pat[0] == ’ALT’:
return matchC(pat[1], txt,
# erstes Teilmuster erkennen
lambda t : ( (t == ’FAIL’)
# Fehlschlag:
and matchC(pat[2],txt, cont) ) # zweites Teilmuster
or ( (cont(t) == ’FAIL’ # Weg versuchsweise zuende gehen
and matchC(pat[2], txt, cont)) # Backtrack!
or cont(t))
Th Letschert, FH Giessen–Friedberg
135
)
if pat[0] == ’?’ :
return matchC(pat[1], txt,
lambda t : ( (t == ’FAIL’)
and cont(txt) )
or ( ( (cont(t) == ’FAIL’)
and cont(txt) )
or cont(t)
)
)
if pat[0] == ’*’ :
if cont(txt) == ’FAIL’ :
return matchC(pat[1], txt,
lambda t : ((t == ’FAIL’) and cont(’FAIL’))
or
matchC(pat, t, cont)
)
else: return cont(txt)
Betrachten wir kurz die Alternative: Zuerst wird versucht die erste Teilalternative zu erkennen. Schlägt das fehl,
dann kommt der Resttext FAIL zur Fortsetzung und diese versucht es mit der zweiten Alternative. Geht es mit der
ersten Alternative gut, dann wird versucht den Weg mit der ersten Teilalternative zu Ende zu gehen und wenn das
nicht funktioniert, dann bleibt uns noch die zweite Alternative (Backtrack!).
Dieser Algorithmus findet die Lösung in einer Tiefensuche. An jedem Entscheidungspunkt wird die erste Alternative gewählt. In einer Sackgasse wird die zuletzt getroffene Entscheidung zurück genommen und, falls vorhanden,
durch ihre Alternative ersetzt. Gibt es keine Alternative mehr, dann wird die vorhergehende Entscheidung zurück
genommen etc. Gibt es keinen Entscheidungspunkt mit unversuchten Alternativen mehr, dann ist die gesamte Suche fehlgeschlagen. Der (virtuelle) Entscheidungsbaum wird dabei in einer so lange Tiefensuche durchlaufen, bis
das Ziel erreicht ist.
Backtracking ohne Fortsetzungsfunktionen
Contiuations sind natürlich nicht dazu gedacht in “produktiven” Algorithmen eingesetzt zu werden. Wenn es sich
nicht experimentelle Prototypen handelt, wird man versuchen ohne die dynamische Konstruktion von Funktionen
auszukommen. Die einfachste Methode zur Elimination der Fortsetzungsfuktion ist, die sie durch einen Repräsentanten zu ersetztn, also durch eine Datenstruktur, mit die den gleichen Informationsgehlt hat wie die Funktion.
In unserem Fall ist es recht einach die Funktion durch einen Repräsentanten zu ersetzen. Die Fortsetzung eines
Vergleichsvorgangs besteht ja aus nicht anderem, als dem Vergleich eines Restmusters. Die austehenden Vergleichoperationen stecken wir also nicht in eine Funktion, sondern übergeben sie als Liste von noch zu vergleichenden
Mustern.
def match(pat, txt, patS):
if pat[0] == ’SYM’:
if txt[0] == pat[1]:
if len(patS) == 0:
return txt[1:]
else :
return match(patS[0], txt[1:], patS[1:])
else:
return ’FAIL’
if pat[0] == ’SEQ’ :
136
Nichtprozedurale Programmierung
return match(pat[1], txt, [pat[2]]+patS)
if pat[0] == ’ALT’:
txtR = match(pat[1], txt, patS)
if txtR == ’FAIL’:
print ’BACKTRACK ALT’
return match(pat[2], txt, patS)
else:
return txtR
if pat[0] == ’?’ :
txtR = match(pat[1], txt, patS)
if txtR == ’FAIL’:
if len(patS) == 0:
return txt[1:]
else : #BACKTRACK
return match(patS[0], txt, patS[1:])
else:
return txtR
if pat[0] == ’*’ :
txtR = match(pat[1], txt, [[’*’, pat[1]]]+patS)
if txtR == ’FAIL’:
if len(patS) == 0:
return txt
else : #BACKTRACK
return match(patS[0], txt, patS[1:])
else:
return txtR
Diese Variante mit des Algorithmus mit Repßentanten der Fortsetzungsfunktionen entspricht der mit Fortstzungsfunktionen oben. Den Stern–Fall P* haben wir allerdings leicht abgewandelt. Im Gegensatz zu der Variante, von
der diese abgeleitet ist, versucht dieser Algorithmus eine möglichst lange Sequenz von P–s zu erkennen.
Breitensuche
Während bei einer Tiefensuche die erste mögliche Lösung gesucht wird, in dem man sich an einem Entscheidungspunkt versuchsweise auf eine Alternative festlegt, erkundet eine Breitensuche alle Alternativen gleichzeitig. Eine
Breitensuche beansprucht natürlich mehr Speicherplatz, hat aber den Vorteil, dass der Lösungsraum gleichmäßiger
abgesucht wird und damit das erfolglose Suchen in einer unendlichen Sackgasse vermieden wird.
Ein Algorithmus zur Mustererkennung mit Breitensuche kann liefert mit jedem Aufruf die Menge aller Reststrings,
einer der möglichen erfolgreichen Mustererkennung. Das Text–Argument muss ebenfalls zu einer Liste verallgemeinert werden. Der Algorithmus versucht das Muster als Beginn jedes Textes der Liste zu erkennen und liefert
den Reststring alle erfolgreichen Erkennungsoperationen:
# Breitensuche: alle Alternativen ’parallel’ berechnen
#
def matchB(pat, txtList):
if pat[0] == ’SYM’
:
txtRes = []
for txt in txtList:
if len(txt) > 1:
if txt[0] == pat[1]:
txtRes = txtRes+[txt[1:]]
return txtRes
if pat[0] == ’SEQ’ :
Th Letschert, FH Giessen–Friedberg
137
txtRes = []
for txt in txtList:
txtR1 = matchB(pat[1], [txt])
for txta in txtR1:
txtRes = txtRes+matchB(pat[2], [txta])
return txtRes
if pat[0] == ’ALT’:
for txt in txtList:
txtL = matchB(pat[1], [txt])
txtR = matchB(pat[2], [txt])
return txtL+txtR
if pat[0] == ’?’ :
txtRes = []
for txt in txtList:
txtRes = txtRes+matchB(pat[1], [txt])
txtRes = txtRes+[txt]
return txtRes
if pat[0] == ’*’ :
txtRes = []
for txt in txtList:
txtRes = txtRes + [txt]
txtRest = [txt]
while txtRest != [] :
txtRest = matchB(pat[1], txtRest)
txtRes = txtRes + txtRest
return txtRes
else: print ’PAT ??? = ’, pat
Breitensuche mit Generatoren
Generatoren können verwendet werden, um die Vergleiche sukzessive zu erzeugen. Die Umstellung von des obigen
Algorithmus in eine Generatorversion ist unproblematisch:
#
# Breitensuche mit Generator
#
def matchG(pat, txt):
if pat[0] == ’SYM’
:
if txt[0] == pat[1]:
yield txt[1:]
if pat[0] == ’SEQ’ :
for txtRest1 in matchG(pat[1], txt):
for txtRest2 in matchG(pat[2],txtRest1):
yield txtRest2
if pat[0] == ’ALT’:
for txtRest1 in matchG(pat[1], txt):
yield txtRest1
for txtRest2 in matchG(pat[2],txt):
yield txtRest2
if pat[0] == ’?’ :
for txtRest1 in matchG(pat[1], txt):
yield txtRest1
138
Nichtprozedurale Programmierung
yield txt
if pat[0] == ’*’ :
yield txt
tt = [txt]
while tt != [] :
ttS = tt[0]
tt = tt[1:]
for txtRest in matchG(pat[1], ttS):
yield txtRest
tt = tt + [txtRest]
Damit wollen wir die Diskussion der Erkennung regulärer Ausdrücke beenden. Selbstverständlich sind die vorgestellten Algorithmen nicht die bestmöglichen. In einem Lehrwerk zum Compilerbau (etwa [2]) kann der interessierte Leser sich tiefergehend informieren. Die Suche kann besser organisiert werden, als in der hier vorgestellten
Art, allen Implementierungen bleibt aber letztlich keine andere Wahl, als den Raum der möglichen Lösungen
systematisch zu untersuchen, so lange bis eine oder alle Lösungen gefunden wurden.
Einbettung der Mustererkennung in den Sprachrest
Ein Muster kann als Programm der Programmiersprache “regulärer–Ausdruck“ betrachtet werden. Ein zu prüfender Text ist die Programmeingabe und der Erkennungsalgorithmus der Interpretierer dieser Sprache. In der Sprache
werden keine Anweisungen formuliert. Es wird nur eine Frage gestellt: Passt ein vorgelegter Text zu diesem Muster.
Mini–Sprachen zur Erkennung regulärer Ausdrücke sind Bestandteil jeder ernsthaften Skriptsprache und finden
jetzt auch ihren Weg in konventionelle Sprachen. So enthält beispielsweise Java ab Versionen 1.4 das Paket
java.util.regex die Klassen Pattern und Matcher. Ein Beispiel ist:
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class TestregEx {
public static void main(String[] args) {
Pattern p = Pattern.compile("(ab|abc)*c*d");
Matcher m = p.matcher("ababcabcdxababcabcdy");
while(m.find()) {
System.out.println("
Match \"" + m.group() + "\" at positions "
+ m.start() + "-" + (m.end() - 1));
}
}
}
mit der Ausgabe:
Match "ababcabcd" at positions 0-8
Match "ababcabcd" at positions 10-18
Dieses Programm entspricht dem Pythoncode:
import re
pat = r’((?:ab|abc)*c*d)’
str = ’ababcabcdxababcabcdy’
mo = re.findall(pat, str)
print mo
Th Letschert, FH Giessen–Friedberg
139
mit der Ausgabe:
[’ababcabcd’, ’ababcabcd’]
Man sieht, die Schnittstelle der “Muttersprache” (Python/Java) zu Subsprache der regulären Ausdrücke ist in beiden Fällen sehr ähnlich gestaltet. Der Mustervergleich läuft in einer Methode ab und erzeugt dabei ein Match–
Objekt, das Informationen über den Vergleichsprozess enthält.
Unsere ad–hoc–Matcher haben eine andere Schnittstelle. Sie liefern entweder, bei Tiefensuche, ein Ergebnis, oder,
bei Breitensuche, eine Folge von Ergebnissen. Die Integration in die “Muttersprache” ist im ersten fall trivial: eine
Funktion mit maximal einem Ergebnis. Im Fall einer Breitensuche könnte das Ergebnis ein iterierbares Objekt sein,
das, in Python, auch ein Generator sein kann. Eine solche Schnittstelle wäre sicherlich eleganter als als die üblichen
“RegEX”–Operationen, Die Frage, ob sich die Erkennung von Gruppen in ein derartiges Muster integrieren lässt,
mag der Leser allerdings an Hand eigener Überlegungen entscheiden.
Mustererkennung als Abfrage
Reguläre Ausdrücke und ihrer Erkennungsautomaten stellen ein wichtiges Hilfsmittel der praktischen Programmierung dar. Manche sind sogar der Meinung, dass sie der entscheidente Grund für den Erfolg von Perl waren,
der ersten Allzweck–Programmiersprache mit einer integrierten Unterstützung regulärer Ausdrücke.7 Neben ihrer
Bedeutung an sich, sind sie aber auch ein schönenes und einfaches Beispiel für eine Abfragesprache. Eine Abfragesprache (engl. Query Language) dient dazu aus einem Datenbestand bestimmte passende Informationen heraus
zu filtern. Bei dem Wort “Abfragesprache” denkt man natürlich zunächst an Datenbanken und SQL. Zeichenketten
und Muster sind ein Mikrokosmos eines Datenbestands und einer Abfrage. Sowohl die Abbarbeitung der Abfrage mit einer Backtracking–Strategie, als auch die Einbettung in eine übergeordnete Sprache können auf andere
Bereiche übertragen werden.
2.2.2
Nichtdeterministische Programme
Rekursion, Backtracking, künstliche Intelligenz
Das Konzept der rekursiven Funktionen hielt Ende der 50–er Jahre des letzten Jahrhunderts seinen Einzug in die
Programmiersprachen.8 Um allgemeine Anerkennung zu finden, brauchte sie allerdings danach aber noch mehr als
10 Jahre. Nicht, dass die Programmierer das Konzept nicht verstanden hätten, aber die Idee, dass die Maschine –
Interpreter oder Compiler – etwas so “intellektuelles” wie Rekrusion selbständig realisieren kann, hatte für viele
etwas geheimnisvoll Verstörendes. Man wandelte die Funktionen lieber selbst in iterative Varianten um, statt sich
der “interessanten Spielerei moderner Systeme”[4] hinzugeben. Die Begründung der mangelnden Effizienz war
angesichts des damaligen Standes der Hardware– und Compiler–Technik nicht völlig unangebracht, aber sehr oft
gab es eine heute, nach Jahrzehnten der Gewöhnung und umgeben von einer Alltagswelt voll unbegreiflicher Automaten, kaum noch nachvollziehbare Scheu davor, sich fortgeschrittene intellektuelle Leistungen von Maschinen
abnehmen zu lassen.
Wo die einen erschaudern ob dessen, was auf sie zukommt, sind andere sofort voller Begeisterung. Rekursion beruht auf einer Konservierung des augenblicklichen Zusands um später auf ihn zurück greifen zu können. Rekursion
ist darum bestens geeignet um Backtrack–Programme zu realisieren. Backtracking wiederum ist eine allgemeine
Suchtechnik, mit der Lösungen einer Vielzahl von Problemstellungen aufgespürt werden können, ohne, dass eine
besondere problemspezifisches Wissen eingesetzt werden muss. Einfach nur Versuch und Irrtum und igrendwann
haben wir die Lösung.
Ist nicht die Art in der intelligente Wesen viele Probleme lösen? Schon 1959 erscheint die Beschreibung eines
“Allgemeinenen Problemlösers” (General Problemsolver (GPS), [25]) der in “menschenänlicher Art” Probleme
löst und im Wesentlichen auf Suchtechniken beruht. In den 60–ern des letzten Jahrhundert wird die sind Suchtechniken, möglichst in Form einer “heuristischen Suche” unterstützt durch problemspezifisches Wissen, nicht nur ein
7
Die These erscheint plausibel, denn welchen Grund könnte es sonst geben, Programme zu schreiben, die aussehen, wie die Aufzeichnungen
eines wahnsinnig gewordenen APL–Programmierers.
8 Mit Sprachen wie LISP ([22]), Algol 60 ([24]) und deren längst vergessenen Vorgängern IPL, Algol 58, etc.)
140
Nichtprozedurale Programmierung
wichtiger Teilbereich der künstlichen Intelligenz, sie gelten in jener Zeit sogar weithin als das solideste und praktikableste Teilgebiet der KI. Ein Teilgebiet das beeindruckenden Ergebnissen wie den schachspielenden Computern
aufwarten kann, statt in fruchtlosen Spekulationen über des Wesen der Intelligenz zu verlieren.
Suche und Nichtdeterminismus
Suchstrategien beruhen darauf, dass man regelmäßig Entscheidungssituationen antrifft, bei denen man eine Wahl
treffen muss, ohne zu wissen welche Wahl letztlich zum Ziel führen wird. Beim Mustervergleich kann es passieren,
dass bei einer Alternative (p1|p2 sowohl p1, als auch p2 passen, aber langfristig nur eine der beiden Alternativen
zum Ziel führen wird. Statt nun einen der möglichen Alternativen auszuprobieren um dann im Fall eines Misserfolgs alles zurück rollen zu müssen, wäre es schön, ween es Orakel gäbe, dass uns gleich sagt, welche Alternative
zu wählen ist.
Nehmen wir an, es gibt ein solches Orakel mit dem Namen choice. Der Algorithmus zum Mustervergleich, und
nicht nicht nur dieser wäre dann deutlich einfacher zu formulieren:
def matchCoice(pat, txt):
...
if pat[0] == ’ALT’:
return choice(
matchS(pat[1], txt),
matchS(pat[2],txt)
)
...
Natürlich gibt es kein Orakel, das in die Zukunft sehen kann, aber die Spezifikation einer Problemstellung deutlich
vereinfacht, wenn wir Nichtdeterminismus wie etwa in Form eines solchen Auswahloperators zulassen.9 Der Leser
erinnert sich hierbei sicherlich an nichtderterministische endliche Automaten die eine reguläre Sprache deutlich
einfacher beschreiben als deterministische endliche Automaten, Erkennungsalgorithmen basieren aber auf immer
auf deterministischen Automaten. Zu jedem Nichtdeterministischen Automaten gibt es einen deterministischen
Automaten der den letztlich den Suchalgorithmus organisiert, der in seinem nichtdeterministischen Gegenpart
steckt . In einer “Produktionsimplemetierung” regulärer Ausdrücke erzeugt ein “Compiler” den deterministischen
Automaten aus dem nichtdeterministischen Automaten (in Gestalt des regulären Ausdrucks). Was dabei passiert
ist nichts anderes, als dass eine “Sprach”–Implementierung den Code für die Suche aus einer Spezfikation in Form
von Nichtdeterminismaus generiert. (Vergl. dazu die Literatur zum Compilerbau, z.B. [14]).
Nichtdeterminismus als Sprachkonstrukt
Um Nichtdeterminismus in einer Sprache verfügbar zu machen genügen zwei Konstrukte:
• choise(x,y)
: liefert eine nichtderministische Auswahl zwischen x und y
• fail()
: signalisiert, dass die letzte Auswahl in eine Sackgasse geführt hat und durch eine andere Wahl
ersetzt werden muss.
Mit Hilfe dieser beiden Elemente kann ein Backtrack–Algorithmus übersichtlich formuliert werden. Ein einfaches
Beispiel ist:10
def g() :
return choice(2,3)
def p(x)
9 Bereits 1963 disktierte McCarthy einen nichtdeterministischen Operator amb vor ([23]). Der Ausdruck nichtderministischer Algorithmus
und der choice–Operator wurden 1967 von Floyd ([9]) eingeführt. Das Ziel war die übersichtliche Beschreibung von Suchalgorithmen.
10 Achtung Python enthält den choise–Operator (noch?) nicht. Die Programme in diesem Unterabschnitt sind darum nur Pseudocode,
keine legalen Python–Programme.
Th Letschert, FH Giessen–Friedberg
141
if x < 3:
fail
else:
return x
print p(g())
Dieses Programm sucht in {2,3} nach einem Wert der größer oder gleich 3 ist. Etwas anspruchsvoller ist die
Suche nach einer Permutation einer Liste, die eine bestimmte Bedingung erfüllt. Wir definieren dazu eine nichtderministische Funktion zum Einfügen eines Elemetes irgendwo, oder systematisch an allen Positionen, in eine
Liste:
def insert(x,l):
if l == []:
return [x]
else:
return choice(
[x] + l,
[l[0]] + insert(x,l[1:]
)
Damit kann die Permutation definiert werden:
def perm(l):
if l == []:
return l
else:
insert(l[0], perm(l[1:])
Man vergleiche diese Definition mit der in Abschnitt 1.5.5. Dort werden alle möglichen Einfügungen explizit
erzeugt. Hier wird irgendeine genommen, allerdings mit der impliziten Maßgabe, dass, wenn die Wahl nicht passt,
eine andere zu nehmen ist. Die Suche nach einer passenden Permutation ist jetzt ganz einfach:
p = perm([1,2,3,4,5,6,7,8,9,0])
if ok(p):
print p
else fail
#
#
#
#
Auswahl
Auswahl pruefen
OK
-> fertig
sonst -> Backtrack
Ein Programm mit choice und fail kann als Spezifikation eines Programms gelesen werden. Die Implementierung erfolgt dann vom Programmierer an Hand seiner Fertigkeiten und den zur Verfügung stehenden Sprachmittel:
mit Tiefensuche als Backtrackprogramm, in einer Breitensuche mit Hilfe von Generatoren oder Ergebnislisten,
oder wie auch immer.
Mit choice und fail als Sprachbestandteilen ist der Programmierer von dieser Pflicht befreit. Die Sprachimplementierung hat dann für eine Realisation der, als Nichtdeterminismus formulierten Suche zu sorgen. Führt man
in einer Programmiersprache ein nichtderministisches Konstrukt wie den choice–Operator ein, dann hat der
Programmierer eine elgante Möglichkeit Such–Algorithmen zu formulieren und der Compiler/Interpretierer die
mühsame Aufgabe sie zu realiseren. Solche Sprachen gab und gibt hat es tatsächlich. Planner, MicroPlanner, Conniver waren Sprachen mit automatischer Generierung von Backtrack–Suchen.11 Planner wurde in den 60–ern von
C. Hewitt definiert, aber niemals vollständig implementiert. Diese Sprachen wurden in den 70–ern mit einigem Erfolg für Aufgabenstellungen in der künstlichen Intelligenz, eingesetzt12 die Sprachlinie verlor sich später zwischen
Lisp, das einen konventienelleren Programmierstil und effizientere Programme erlaubte, und Prolog.13 Neuerdings
ist das Interesse an Sprachen mit entsprechenden Fähigkeiten wieder größer geworden. Die “Multiparadigma–
Sprache” Mozart (siehe [29]) beispielsweise enthält choice und fail als Sprachelemente.
11
Literaturhinweise finden sich in [1]
Beispielsweise war SHRDLU, ein Pinoniersystem der KI, in der ersten Version in MicroPlanner implementiert. In SHRDLU gab der
Benutzer in natürlicher Sprache einem “Roboter” in einer Blockwelt Kommandos (“Nimm den roten Block auf dem gelben und lege ihn neben
die blaue Pyramide”), die dieser dann ausführte, oder durch Nachfragen präzisierte. Literaturhinweise und weitere Informationen in Wikipedia.
13 das von Hewitt als Neuerfindung einer Untermenge von Planner betrachtet wurde.
12
142
Nichtprozedurale Programmierung
Implementierung des Nichtdeterminismus
Hat man kein funktionsfähiges Orakel zu Hand, dann muss Nichtdeterminismus durch eine Suche implementiert
werden. Die einfachste Such–Methode ist die Backtrack–Methode des Versuch und Irrtums. Bei jeder choice–
Operation muss der aktuelle Ausführungszustand aufgehoben werden, um eventuell später mit ihm und einer anderen Alternative weitermachen zu können. Bei einem fail wird die Ausführung abgebrochen und mit der nächsten
Alternative der letzen coice–Operation fortgesetzt, die noch eine “unverbrauchte” Alternative hat.
Diese Logik kann nicht so einfach emuliert werden. D.h. es können nicht einfach zwei Funktionen choice und
fail definiert werden, mit denen sich das gewünschte Verhalten implementieren läßt. Betrachten wir dazu ein
einfaches Beispiel:
def f(x):
if x>1:
print x
else: fail()
f(choice(1,2))
Wenn hier fail() ausgeführt wird, dann muss die Ausführung in die Parameterauswertung der gerade laufenden Funktion zurückkehren. Dazu können wir die Funktionen choice und fail definieren wie wie wollen, der
alte Ausführungszustand ist weg und kann mit keiner Ausnahme oder irgend einem anderen Trick zurück geholt
werden. Nichtderminismus kann darum nur als echte Spracherweiterung oder durch Umformen in einen Suchalgorithmus realisiert werden. Bei einer Implementierung, egal ob per Hand oder autoamtisch hat die Wahl zwischen:
• Einsatz eines Orakels
• Suche nach einer Lösung
– per Tiefensuche
– per Breitensuche
• Suche nach allen Lösungen
– per Tiefensuche
– per Breitensuche
∗ mit Listen von (Teil–) Lösungen
∗ mit Generatoren
2.2.3
Relationale Programme
Relationen Relationen sind eine Verallgemeinerung von Funktionen. Wo eine Funktion einem oder mehreren
Argunmenten genau einen 14 Wert zuordnet, können bei einer Relation beliebig viele Zuordnungen existieren.
Damit macht es keinen Sinn mehr zwischen Argumenten und Werten zu unterscheiden. Relationen haben keine
“Richtung”.
Nichtdeterminismus Eine nichtderministische Funktion kann viele Werte haben. Es ist darum eigentlich nicht
angebracht, sie Funktion zu nennen. Es ist eine Relation. Ein Nichdeterministisches Programm berechnet damit
nicht einen Ausgabewert zu einem oder mehreren Eingabewerten, sondern es berechnet einen oder alle Werte die
in einer Relation R zu zu den Eingabewerten stehen. Die Relation R wird dabei durch das Programm repräsentiert.
Mithin, ein nichtdeterministisches Programm ist nicht mehr funktional sondern relational.
14
bei partiellen Funktionen maximal einen
Th Letschert, FH Giessen–Friedberg
143
Richtung Der relationale Charakter eines nichtdeterministischen Programms ist aber noch nicht voll ausgeprägt.
Wie in einem “normalen” funktionalen Programm haben wir noch eine strenge Unterscheidung zwischen Eingabe und Ausgabe. Von einem relationalen Programm würde man aber in letzter Konsequenz erwarten, dass alle
Elemente einer Relation gleich behandelt werden, dass es also keinen Unterschied zwischen Eingabe– und Ausgabevariablen gibt. Ein relationales Programm das eine Relation R implementiert sollte also nicht nur in der Lage
sein zu einer Vorgabe x und y alle (oder ein) z berechnen mit R(x, y, z), sondern bei Bedarf auch das oder die
Wert z mit R(y, z, x) oder R(z, x, y).
Ein relationales Programm ist damit durch zwei Eigenschaften gekennzeinchet:
• Eine Berechnung kann beliebig (null bis unendlich) viele Werte haben.
• Die Parameter einer Berechnung sind nicht in Eingabe– und Ausgabeparameter aufgeteilt.
Abfragesprachen Nichtderministischen/Backtrack– Programmen liefern potentiell beliebig viele Werte, sie sind
aber gerichtet. Welchen Sinn könnte eine nicht gerichtete Berechnung haben. Statt Welchen Wert hat dieser Ausdruck? fragt man bei ihnen Für welche Argumente ist dieser Ausdruck richtig?. Eine solche Frage wird typischerweise vor dem Hintergrund eines Datenbestands oder einer “Wissensbasis” gestellt. Es wird damit zu einem
Ausdruck einer Abfragesprache. Abfragesprachen (engl. Query–Languages) haben somit typischerweise einen relationalen Charakter. Die Frage entspricht der Eingabe und das Programm dem Datenbestand der abgefragt wird.
Dabei können beliebig viele Antworten generiert oder auch keine einzige.
144
Nichtprozedurale Programmierung
Logiker: [...] Alle Katzen sind sterblich.
Sokrates ist gestorben.
Also ist Sokrates eine Katze.
Älterer Herr: Und hat vier Pfoten.
Richtig, ich habe eine Katze
die heißt Sokrates.
Logiker: Sehen Sie ...
Älterer Herr: Sokrates war also eine Katze!
Logiker: Die Logik hat es uns eben bewiesen.
Eugène Ionescu, Die Nashörner
2.3
2.3.1
Logik–Programme
Prolog
Entstehung
Der Begriff der Logik–Programmierung ist eng mit der Programmiersprache Prolog (Programmation en Logique)
verbunden. Prolog wurde zu Beginn der 70–er und Alain Colmerauer und Phillipe Roussel im Rahmen eines KI–
Projekts (was sonst) zur Analyse natürlicher Sprachen definiert und implementiert. Es war die erste und ist die
bekannteste Logik–Programmiersprache. Sie basiert auf zwei Kerngedanken:
• Eine Logische Ableitung sind Berechnungsprozesse.
• Fakten und Regeln sind Programme.
Die Idee, dass logische Ableitungen (Deduktionen) als Berechnungen mechanisch ausgeführt werden können,
ist alt und war Ende der 60–er ziemlich virulent. Bereits 1965 hatte Robinson mit dem Resolutionsprinzip ein
automatisierbares Verfahren zum Beweis prädikatenlogischer Formeln definiert. Das Resolutionsprinzip beruht
auf einer Breitensuche. Der Beweis, dass eine Formel f aus einer Menge von Formeln M , wird dadurch geführt,
dass nach einem Widerspruch gesucht wird, der sich aus M und ¬f ableiten lässt.
Der innovative Charakter von Prolog bestand darin, aus der mechanischen Beweisführung eine Programmiersprache gemacht zu haben. Systeme wie Planner versuchten Backtracking als Sprachkonstrukt einzuführen und sind
damit (vorläufig?) gescheitert. Prolog beruht zwar auch auf Suchstrategien, die automatisch implementiert werden,
aber dies wurde nicht in eine Restsprache integriert, sondern in Reinform und konzentriert auf den Ableitungsprozess angewendet. Im Gegensatz zu Sprachen mit integriertem Backtracking war das Ergebnis elegant, klein, leicht
verständlich und überaschenderweise sogar praktisch einsetzbar – und das nicht nur im ursprünglich geplanten
Anwendungsgebiet.
Programme: Fakten, Regeln, Fragen
Eine interaktive Sitzung mit einem Prolog–System beginnt damit, dass es gestartet wird15 und dann mit seinem
Prompt anzeigt, dass es auf eine Abfrage wartet:
?-
Als erstes wird man das System mit “Wissen” füttern. Dazu nehmen wir an, dass in einer Datei drinks.pl
folgende Regeln und Fakten zu finden sind:
trinkt(peter,Drink) :alkoholisch(Drink).
15
Die Beispiele hier beziehen sich auf SWI–Prolog, siehe http://www.swi-prolog.org.
Th Letschert, FH Giessen–Friedberg
145
trinkt(karla,Drink) :suess(Drink),
alkoholisch(Drink).
alkoholisch(X) :wein(X).
alkoholisch(X) :bier(X).
bier(koelsch).
bier(bitburger).
bier(radler).
wein(riesling).
wein(chianti).
wein(lambrusco).
suess(limo).
suess(lambrusco).
suess(radler).
Wir lassen das System die Datei einlesen
- [drinks].
% drinks compiled 0.01 sec, 2,140 bytes
Yes
?-
und es ist mit den Regeln und Fakten vertraut.
Ein Fakt ist bier(koelsch), was nichts anderes aussagen soll, als dass auf koelsch das Prädikat bier
zutrifft. Faktenwissen kann direkt geprüft werden. Wir können testen ob es sich bei riesling um bier handelt.
Erwartungsgemäß ist die Amtwort No:
?- bier(riesling).
No
?-
Die Regel
drinkt(peter,Drink) :alkoholisch(Drink).
sagt aus, dass peter einen Drink trinkt, wenn dieser alkoholisch ist. Wir testen, ob peter chianti drinkt:
?- trinkt(peter,chianti).
Yes
?-
Die Regel
trinkt(karla,Drink) :suess(Drink),
alkoholisch(Drink).
sagt dass karla nur solche Getränke zu sich nimmt die alkoholisch und suess sind. Ein Abfrage zu karla
ist:
?- trinkt(karla,lambrusco).
Yes
?-
146
Nichtprozedurale Programmierung
Existenz–Fragen
In Prolog können wir nicht nur fragen, ob ein Prädikat auf etwas zutrifft, wir können uns auch alles ausgeben
lassen, auf das ein Prädikat zutrifft. Durch den Einsatz von Variablen können Fragen gestellt werden, die nicht nur
eine Ja/Nein–Antwort haben. Variablen beginnen mit einem Großbuchstaben.
?- bier(X).
X = koelsch ;
X = bitburger ;
X = radler ;
No
Die Frage (bier(X).) und die Semikolons (;) sind Benutzereingaben. Das System listet alle möglichen Bindungen der Variablen X auf, für die bier(X) wahr ist. Die Frage ist “Gibt es ein X, mit bier(X)”. Das System
beantwortet die Frage indem es nach entsprechenden Belegungen für X sucht. Das letzte No bedeutet, dass es keine
weiteren Bindungen für X gibt.
Bei einer Frage können die Variablen an beliebiger Position auftreten. Mit den gleichen “Programm”, mit dem wir
danach fragen, was karla trinkt
?- trinkt(karla,X).
X = lambrusco ;
X = radler ;
No
können wir feststellen, wer alles radler zu sich nimmt:
?- trinkt(X,radler).
X = peter ;
X = karla ;
No
oder auch, wer was trinkt:
?- trinkt(X,Y).
X = peter
Y = riesling ;
....
X = karla
Y = radler ;
No
Datentypen und Datenstrukturen
Das Standardbeispiel einer Prolog–Einführung ist die Verknüpfung zweier Listen mit der append–Funktion:
append(X,Y,Z) :X = [],
Z = Y.
append(X,Y,Z) :X = [H | T],
Z = [H | U],
append(T, Y, U).
Hier steht [] für die leere Liste. [H | T] ist die Liste mit einem ersten Element (Kopf, Head) H und einem Rest
(Tail) T. Listen werden also wie in Lisp durch Paare dargestellt und [H | U] entspricht (cons H T) in Lisp.
Wie in Lisp sind derartige Listen, also eigentlich Binärbäume, die universelle und einzige Datenstruktur.
Die beiden Regeln können wie folgt gelesen werden:
Th Letschert, FH Giessen–Friedberg
147
• append(X,Y) = Z falls X leer ist und Y und Z gleich sind. Oder:
• append(X,Y) = Z falls es H, T und U gibt mit:
– X hat den Kopf H und den Rest T und
– Z hat den Kopf H und den Rest U und
– append(T,Y) = U
Wieder kann eine Rechnung in beliebiger Richtung laufen. Konventionell, von X und Y nach Z
?- append([1,2],[3,4],Z).
Z = [1, 2, 3, 4] ;
No
von X und Z zu
?- append([1,2],Y,[1,2,3,4,5]).
Y = [3, 4, 5] ;
No
oder komplett rückwärts von Z zu X und Y:
?- append(X,Y,[1,2,3]).
X = []
Y = [1, 2, 3] ;
X = [1]
Y = [2, 3] ;
X = [1, 2]
Y = [3] ;
X = [1, 2, 3]
Y = [] ;
No
In Prolog kann auch gerechnet werden. Beispielsweise die Fakultätsfunktion:
fact(0,1).
fact(N,FN) :N > 0,
M is N-1,
fact(M,FM),
FN is N*FM.
Das Typkonzept ist allerdings sehr rudimentär. Außer Listen, die eigentlich Binärbäume sind, werden keine Datenstrukturen unterstützt.
Terme
Ähnlich wie in Lisp, gibt es auch in Prolog keine scharfe Trennung zwischen der Programmebene und der Datenebene. Datenstrukturen können darum auch als Terme modelliert werden. Ein Baum mit Blättern (leaf) und
Knoten (node) kann also auch durch einen Term wie
node(leaf(a), node(leaf(b), leaf(c)))
statt einer Liste
[node, a, [node [leaf b], [leaf c]]]
dargestellt werden. Die Termdarstellung ist häufig günstiger als die Baumdarstellung, da das “Funktionssymbol”
(hier node und leaf) in einer Regel verwendet werden kann. Beispielsweise kann mit der Definition
148
Nichtprozedurale Programmierung
flatten(leave(X), X).
flatten(node(X, Y), Z) :flatten(X, XF),
flatten(Y, YF),
[XF, YF] = Z.
ein Baum als Term in Listeform umgewandelt werden:
?- flatten(node(node(leave(a),leave(b)),leave(c)),X).
X = [[a, b], c] ;
Negation als fail
Prolog interpretiert eine vorgelegte Frage als Behauptung, die zu beweisen versucht. Enthält die Frage Variablen,
dann sucht es nach Belegungen der Variablen, für die die Behauptung bewiesen werden kann. Das ganze basiert
auf einem Backtracking–Prozess, bei dem Variablen versuchsweise mit Werten belegt werden. Ergibt sich aus
einer Belegung ein Widerspruch, dann wird sie zurück genommen und die nächste ausprobiert. Wenn die die letzte
Alternative erfolglos probiert wurde, dann kann kein Beweis gefunden werden und das System antwortet mit No.
Die Negation einer Formel entspricht also einem erfolglosen Beweisversuch und dies einem endgültigen fail in
einer Backtrack–Suche. Wir führen in unsere Wissensbasis über Getränke die Regel ein, dass charlotte alles
trinkt, was suess und nicht alkoholisch ist:
trinkt(charlotte,X) :suess(X),
not(alkoholisch(X)).
dann liefert das System erwartungsgemäß als charlottes Getränke limo:
?- trinkt(charlotte,X).
X = limo ;
No
In seiner Suche bindet das System irgendwann X an limo versucht dann alkoholisch(limo) zu beweisen.
das schlägt fehl und dieser Fehlschlag wird durch not in einen Erfolg umgekehrt.
Trinkt aber charlotte alles, was nicht alkoholisch ist:
trinkt(charlotte,X) :not(alkoholisch(X)).
dann liefert die Frage, was charlotte trinkt die überraschende Antwort No:
?- trinkt(charlotte,X).
No
Das System versucht zuerst eine Bindung für X zu finden die alkoholisch(X) erfüllt. Die ist schnell gefunden,
beispielsweise mit X=koelsch. Dieser Erfolg wird dann durch not in einen endgültigen Misserfolg umgedeutet.
Wir sehen, die Benutzung von Prolog setzt voraus, dass man sich nicht nur mit den logischen Aspekten der Sprache
beschäftigt, sondern auch mit deren Implementierung als Backtrack–Algorithmus.
Cut
Prolog–Programmierer können direkt in den Backtrack–Mechanismus eingreifen. Entweder um Programme effizienter zu machen, oder um sie überhaupt funktionsfähig zu machen. Der Eingriff besteht aus einem erzwungenen
Abbruch des Backtrackings durch den sogenannten Cut–Operator (ein Ausrufezeichen “!” in Prolog). Angenommen wir haben die Regeln
Th Letschert, FH Giessen–Friedberg
149
searchBT(K, [K,_,_]).
searchBT(K, [X,L,_]) :K<X,
searchBT(K, L).
searchBT(K, [X,_,R]) :K>X,
searchBT(K, R).
zur Suche eines Elements in einem Binärbaum. Der Unterstrich ( ) steht für beliebige Variablen. Ein Knoten des
Baums wird durch eine dreielementige Liste dargestellt, bei der das erste Element der Knoteninhalt ist und die
beiden folgenden die Unterbäume.
Das Programm ist eine triviale Fallunterscheidung über den Wert von K und dem Schlüssel des Knotens. Zuerst
wird der aktuelle Knoten geprüft. Sind Knoten und Wert nicht gleich, dann geht die Suche weiter in den linken
bzw. den rechten Unterbaum.
Das Programm enthält eine kleine Ineffizienz. Angenommen wir suchen in einem Baum mit der Wurzel 5 den Wert
3. Die 3 findet sich entweder im linken Unterbaum, oder sie ist gar nicht vorhanden. Das Programm vergleicht 5 mit
3 (erste Regel) und sucht dann im linken Unterbaum (zweite Regel). Ist die Suche erfolglos, dann wird es trotzdem
versuchen der dritten Regel zu folgen, obwohl klar ist diese Regel nicht erfolgreich angewendet werden kann. Die
Fallunterscheidung ist exklusiv, wenn ein zutreffender Fall gefunden wurde, dann kann es keinen anderem mehr
geben.
Mit einem Cut können wir das System von weiteren unnützen Backtrack–Aktionen abhalten:
searchBT(K, [K,_,_]).
searchBT(K, [X,L,_]) :K<X,
!,
searchBT(K, L).
searchBT(K, [X,_,R]) :K>X,
searchBT(K, R).
In die zweite Regel wird ein Cut in Form eines Ausrufezeichens eingefügt. Es verhindert ein Backtrack zurück
hinter den Cut. Wenn searchBT(K, L) in der zweiten Regel fehlschlägt. dann ist das gesamte Suche fehlgeschlagen. Es wird sofort ein fail ausgelöst und damit alle weiteren Alternativen auf dieser Ebene damit übersprungen.
Prolog zu Hause: Permutationen und Mustererkennung
Die Möglichkeiten von Prolog und eines modernen Prolog–Systems sind wesentlich vielseitiger als man erwartet
und hier dargestellt werden kann. Wir tun uns allerdings schwer damit es als allgemeine Programmiersprache zu
empfehlen. Vieles wird doch zu bemüht und erzwungen. Wenn es zu Hause ist und seine Stärke und Eleganz an
Backtrack–Programm zeigen kann, dann ist es unschlagbar. Betrachten wir dazu das Programm zur Generierung
aller Permutationen einer Liste:
permut([],[]).
permut([H|T],Y) :permut(T,Z),
insert(H,Z,Y).
insert(X,[],[X]).
insert(X,[H|T],[X,H|T]).
insert(X,[H|T],[H|Z1]) :insert(X,T,Z1).
Das Programm ist kurz und so selbsterklärend, dass es keinerlei weiteren Kommentars bedarf.
Entsprechend elegant sieht es bei der Mustererkennung aus.
150
Nichtprozedurale Programmierung
match(sym(X), [X|T], T).
match(seq(X,Y), T, Z) :match(X, T, TR),
match(Y, TR, Z).
match(alt(X,Y),
match(X, T,
match(alt(X,Y),
match(Y, T,
T, Z) :Z).
T, Z) :Z).
match(qm(X), Y, TR) :match(X, Y, TR).
match(qm(X), T, T).
match(st(X), Y, TR):match(X, Y, TR1),
match(st(X), TR1, TR).
match(st(X), T, T).
Muster werden hier als Terme eingeben. a*b also als seq(st(sym(a)),sym(b)). Zeichenketten als Listen.
Das Muster a*b wird in aaaaaaaaabc mit der Frage
match(seq(st(sym(a)),sym(b)), [a,a,a,a,a,a,a,a,a,b,c], X).
gesucht. Das Ergebnis ist auch in dieser Version der Mustererkennung der Vergleichsrest, hier also [c].
2.3.2
Unifikation
Unifikation
Geben wir einem Prologsystem die Anfrage
?- f(a,X) = f(Y,b).
dann an antwortet es mit
X = b
Y = a ;
No
Das System belegt die Variablen mit Werten die kompatibel mit der Anfrage sind. Dazu werden die beiden Seiten der Gleichung vereinheitlicht, oder – wie man auch sagt, unifiziert. Der Term f(a,b) ist das Ergebnis der
Unifikation von f(a,X) und f(Y,b). Er entsteht indem die Substitution [X:b, Y:a] auf die Ausgangsterme
angewendet werden. Die Substitution, die zur Unifikation führt, ist der Unifikator.
• Unifikation: Prozess indem die Variablen zweier Terme so substituiert werden, dass die beiden gleich werden. Auch das Ergebnis dieses Prozesses f(a,b).
• Unifikator: Die Substitution die zur Unifikation führt [X:b, Y:a].
Man beachte, dass die Unifikation eine rein syntaktische Operation ist. Es ist nicht notwendig, dass außer dieser
Gleichung irgendetwas über f bekannt ist.
Ein anderes Beispiel ist:
?- g(X,X) = g(h(a),b).
No
Das No bedeutet, dass die Unifikation scheitert. Die beiden Terme können nicht vereinheitlicht werden. Das System
versucht also sich nicht in “semantischen Schlussfolgerungen” wie h(a)=b. Die folgende Unifikation gelingt
wieder:
Th Letschert, FH Giessen–Friedberg
151
?- g(X,X) = g(h(a),X).
X = h(a) ;
No
Der Unifikator ist [X: h(a)] und die Unifikation g(h(a),h(a)).
Allgemeinster Unifikator
Meist können zwei Terme durch viele verschiedene Substitutionen unifiziert werden. Beispielsweise werden die
Terme X und f(Y) durch [X = f(Z), Y = Z] zu f(Z) unifiziert. Es gibt aber noch beliebig viele andere Unifikatoren: [X = f(a), Y = a] ist ein Unifikator, oder [X = f(b), Y = b], aber auch [X =
f(g(b,f(h(a)))), Y = g(b,f(h(a)))], und so weiter.
Jede Substitution, die aus [X = f(Z), Y = Z] gebildet wird, indem Z durch irgendeinen Term ersetzt wird,
ist ein Unifikator von X und f(Y). Jeder dieser Unifikatoren ist spezieller als der allgemeinste Unifikator [X =
f(Z), Y = Z]. Ein Unifikator ist der allgemeinste Unifikator, wenn aus ihm jeder andere durch das Ersetzten
von Variablen durch Terme erzeugt werden kann. Mit “Unifikator” ist meist der allgemeinste Unifikator gemeint.
Das Problem der Unifikation kann also wie folgt beschrieben werden:
Gegeben sind die Terme t1 und t2 ,
Bestimme eine Substitution u (einen Unifikator), derart, dass u(t1 ) = u(t2 )
Der allgemeinste Unifikator ua in Bezug auf zwei unifizierbare Terme t1 und t2 ist die Substitution ua die sich zu
jedem anderen Unifikator instantiieren lassen:
Gegeben sind die Terme t1 und t2 ,
Der allgemeinste Unifikator ua von t1 und t2 ist ein Unifikator derart, dass
Für jeden Unifikator u von t1 und t2 gibt es eine Substitution s gibt mit s(ua ) = u (s unifiziert u und ua ).
Die Rolle der Unifikation in Prolog
Unifikation ist ein wesentliches Element der Ausführung eines Logik–Programms. Jedesmal, wenn versucht wird
eine Regel auf einen Term anzuwenden, dann wird der Kopf der Regel mit dem Term unifiziert. Angenommen
unsere Regeln sind:
append([H|X],Y,[H|Z]) :append(X,Y,Z).
append([],Y,Y).
und wir wollen den Term append([a],A,[a,b,c]) auswerten. D.h. wir wollen festellen, für welches A er
wahr ist. Der Term append([a],A,[a,b,c]) ist unserer erstes Ziel Das Ziel kann mit dem Kopf der ersten
Regel (append([H|X],Y,[H|Z])) und dem Unifikator
[H:a, X:[], Y:A, Z:[b,c]]
unifiziert werden. Dieser Unifikator wird auf das Unterziel append(X,Y,Z) angewendet und es ergibt sich der
Term append([], A, [b,c]) als neues Ziel. Dieses neue Ziel kann nur mit dem Kopf der zweiten Regel
unifiziert werden. Der Unifikator ist [Y:[b,c], A:[b,c]]. Zusammen mit dem ersten Unifikator haben wir
insgesamt die Substitution
[H:a, X:[], Y:[b,c], Z:[b,c], A:[b,c]]
Die zweite Regel ist ein Faktum. Es gibt nicht weiter mehr zu tun. Insgesamt wurde bewiesen, dass
append([a],A,[a,b,c]) mit dem Wert [b,c] für A wahr ist.
Unifikation spielt in einem Logikprogramm die Rolle, die in imperativen Sprachen von der Zuweisung übernommen wird. Variablen werden mit Werten belegt. Im Gegensatz zu einer Zuweisung gibt es dabei aber keine
definierte Richtung und keine klare Trennung der Rolle “Variable” und “Wert”. Variable und Werte können so
durcheinander gehen, weil letztlich alles ein Term ist.
Die Auswertung eines Logikprogramms ohne Backtracking ist im Prinzip recht einfach:
152
Nichtprozedurale Programmierung
• Starte mit der Anfrage als erstem aktuellen Ziel.
• Suche nach der ersten Regel, deren Kopf mit dem ersten aktuellen Ziel unifizierbar ist.
• Berechne den Unifikator und wende ihn auf die Unterziele der Regel und alle anderen ausstehenden Ziele
an.
• Alle, durch den Unifikator modifizierten Unterzeile und die ausstehenden Ziele sind aktuelle Ziele
• Wende das Verfahren auf das erste aktuelle Ziel an.
• Gibt es kein Ziel mehr: Fertig.
Backtracking und Unifikation
Backtracking kommt dann ins Spiel, wenn zu einem Ziel keine Regel gefunden werden kann, deren Kopf mit
dem Ziel unifizierbar ist. Der Basisalgorithmus wählt stets die erste Regel aus, die mit dem Ziel unifizierbar ist.
Diese kann sich jedoch als Sackgasse herausstellen und wir hätten besser eine andere Regel ausgewählt. Aus der
Sackgasse kommt der Algorithmus durch Backtracking heraus. Stellt sich irgendwann heraus, dass es keine Regel
gibt, die mit dem aktuellen Ziel unifizierbar ist, dann wird einfach die letzte Regelauswahl zurück genommen und
eine andere gewählt. Der Auswertungs–Algorithmus muss dazu nur leicht erweitert werden:
• Starte mit der Anfrage als erstem aktuellen Ziel.
• Suche nach der ersten Regel, deren Kopf mit dem ersten aktuellen Ziel unifizierbar ist.
• Es gibt eine solche erste Regel:
– Berechne den Unifikator und wende ihn auf die Unterziele der Regel und alle anderen ausstehenden
Ziele an.
– Alle, durch den Unifikator modifizierten Unterzeile und die ausstehenden Ziele sind aktuelle Ziele
– Wende das Verfahren auf das erste aktuelle Ziel an.
• Es gibt keine Regel die mit dem aktuellen Ziel unifizierbar ist:
– Backtrack: gehe zurück zur letzten Regelauswahl und verwerfe sie
– Nimm alle Substitutionen zurück die sich aus der Regelauswahl und der Unifikation ergeben haben
– Wähle die erste nächste Regel die mit dem Ziel unifizierbar ist und setzte das Verfahren fort
• Es gibt keine Regel die mit dem aktuellen Ziel unifizierbar ist und es gibt keine zurücknehmbare Regelauswahl: Anfrage ist nicht erfüllbar.
• Gibt es kein Ziel mehr: Fertig, Anfrage ist der aktuellen Substitution erfüllbar.
Die gesamte Berechnung eines Logikprogramms besteht also darin, dass durch eine Serie von Unifikationen
schließlich eine endgültige Varariablensubstitution berechnet wird. Im Backtracking wird jeweils die letzte Substitution zurück genommen. Insgesamt werden dabei die möglichen Variablenbelegungen in einer Tiefensuche über
eine Kombination von Regelanwendungen untersucht.
Unifikationsalgorithmus
Ein Unifikationsalgorithmus bestimmt, ob zwei Terme durch eine Substitution der in ihnen vorkommenden Variablen vereinheitlicht werden können. Dabei wird üblicherweise die allgemeinste Form einer solchen Substitution
berechnet: der allgemeinste Unifikator. Die Unifikation hat eine gewisse Ähnlichkeit mit dem Mustererkennen,
aber es ist nicht das Gleiche.
Der einfachste Unifikationsalgorithmus ist der “Original–Algorithmus” von Robinson ([28]). Er arbeitet genau so
wie man es erwartet rekursiv über die Struktur der zu unifizierenden Terme:16
16
In [28] wird eine Menge von Termen unifiziert, die Menge haben wir hier auf zwei Terme reduziert.
Th Letschert, FH Giessen–Friedberg
153
boolean occur( Variable x, Term t ) {
return ( Die Variable x kommt im Term t vor )
}
(boolean, Substitution) unify ( Term t1, Term t2 ) {
// unifiziere t1 und t2
// Ergebnis: (Miss-)Erfolgsmeldung, allgemeinster Unifikator
boolean success;
Substitution s, s1;
if ( t1 oder t2 ist eine Variable ) {
Sei x die Variable und t der andere Term
if ( x == t ) {
success := true;
s = [];
} else {
if ( occur(x, t) ) {
success := false
} else {
success := true;
s = [ x:t ];
}
FI
} else {
Sei t1 = f(x1, ... xn) und t2 = g(y1, ... ym) >
if ( f != g or n != m ) {
success := false
} else {
k := 0;
success := true;
s = [];
while ( k < n AND success ) {
k = k + 1;
(success, s1) = unify ( s(xk), s(yk) );
if ( success ) {
s = s+s1;
}
}
}
}
return (success, s);
}
Dieser Algorithmus hat zwei Nachteile. Die ständigen occur–Prüfungen benötigen viel Laufzeit. Der zweite
Nachteil besteht darin, dass Terme aufgebaut werden, die viele Teilterme gemeinsam haben können.
Die occur–Prüfung wird sehr oft einfach weggelassen. Das Unifikationsergebnis kann dann rekursiv (zyklisch)
sein, ohne, dass es einen sinnvollen kleinsten Fixpunkt gibt. Beispielsweise ergibt sich
unify(X,f(X)) = [X : f(X)], d.h. X = f(X)
im syntaktischen Bereich der Terme hat diese Gleichung nur einen unendlich großen Term als Lösung.
Unifikationsalgorithmus mit gemeinsamen Untertermen
Das Problem der expandierenden Terme kann mit einem Umgebungskonzept gelöst werden. Wir ersetzen keine
Variablen durch Terme, sondern merken und alle Variablenbindungen in einer – Umgebung (Environment) gennannten – Substitution. Der Unifikationsalgorithmus (ohne occur–Prüfung) wird dann zu (in Python):
154
Nichtprozedurale Programmierung
# Terme
# konstruieren
#
def mkVar(
v
def mKConst( c
def mKPred( p
und analysieren
)
: return [’VAR’,
v
)
: return [’CONST’, c
, l ) : return [’PRED’, p,
]
]
l ]
def isVar(
t ) : return t[0] == ’VAR’
def isConst( t ) : return t[0] == ’CONST’
def isPred( t ) : return t[0] == ’PRED’
def tag( t ) : return t[0]
def getId( t ) : return t[1]
def getParam( t ) : return t[2]
# Unifikation
#
def unify( t1, t2, env ):
if tag(t1) == tag(t2):
if isVar(t1) :
if env.has_key(getId(t1)):
t1 = env[getId(t1)]
return unify(t1, t2, env)
if env.has_key(getId(t2)):
t2 = env[getId(t2)]
return unify(t1, t2, env)
if getId(t1) == getId(t2) :
return env
env[getId(t1)] = t2
return env
if isConst(t1):
if getId(t1) == getId(t2):
return env
else: raise ’FAILURE’
if isPred(t1):
if getId(t1) == getId(t2):
if len(getParam(t1)) != len(getParam(t2)) :
raise ’FAILURE’
return reduce( lambda e, p : unify(p[0], p[1], e),
zip(getParam(t1), getParam(t2)),
env)
else: raise ’FAILURE’
else:
if isVar(t1):
if env.has_key(getId(t1)):
return unify(env[getId(t1)], t2, env)
else:
env[getId(t1)] = t2
return env
if isVar(t2):
return unify(t2, t1, env)
raise ’FAILURE’
Das Ergebnis einer Unifikation mit diesem Algorithmus wird gemeinsame Unterterme in Form von “expandierbaren” Variablenbindungen speichern. Beispielsweise ist das Ergebnis der Unifikation von
Th Letschert, FH Giessen–Friedberg
155
[’PRED’, ’p’, [[’CONST’, ’u’],
[’VAR’, ’A’],
[’PRED’, ’f’, [[’PRED’, ’g’, [[’VAR’, ’X’]]]]]]]
und
[’PRED’, ’p’, [[’VAR’, ’X’],
[’PRED’, ’f’, [[’VAR’, ’Y’]]],
[’PRED’, ’f’, [[’VAR’, ’Y’]]]]]
die Umgebung (Substitution):
’A’: [’PRED’, ’f’, [[’VAR’, ’Y’]]],
’X’: [’CONST’, ’u’],
’Y’: [’PRED’, ’g’, [[’VAR’, ’X’]]]
in der X “unaufgelöst” im Wert von
Y erscheint. Eine Routine, die auflösbare Variablen durch ihren Wert ersetzt, ist jedoch schnell geschrieben. In eine
solche Routine auch leicht die occur–Prüfung integriert werden.
Terme erscheinen hier als Bäume der abstrakten Syntax. Wenn das zu umständlich ist: ein Parser und Pretty–Printer
sollten ebenfalls schnell geschrieben sein.
2.3.3
Prolog–Interpreter
Logische und Operationale Semantik
Logik–Programme unterscheiden sich von Programmen in anderen Programmiersprachen dadurch, dass ihre
Ausführung sehr viel lockerer definiert ist. Bei üblichen Programmiersprachen legt das Programm fest, was “passieren soll”. Ein Logik–Programm ist dagegen zunächst einmal nur eine Aussage, die wahr oder falsch sein kann.
Damit ist in keine Art der “Ausführung” verbunden. Die logische Semantik (Was bedeutet es?) des Programms
definiert also keine operationale Semantik (Wie wird es ausgeführt?).
Implementierungen logischer Sprachen sind darum vergleichsweise frei in der Gestaltung eines Interpreters oder
Compilers. Es ist klar, dass der Ableitungsbaum durchsucht werden muss, die Art, in der dies zu geschehen hat,
Breitensuche, Tiefensuche, parallel, sequentiell, etc. bleibt offen. In dieses Bild der weitgehend unspezifizierten
operationalen Semantik passt ein Konstrukt wie der Cut (“!”) natürlich nicht, bezieht er sich doch ausdrücklich
auf eine Tiefensuche im Ableitungsbaum. Andererseits ist der Cut in der Praxis sehr oft notwendig, um effiziente
oder auch nur funktionierende Logik–Programme formulieren zu können. Im klare und hellen Licht der logischen
Semantik erscheinen Konstrukte, die sich auf eine bestimmte operationale Semantik beziehen, deplatziert und
“unrein”. Sie werden traditionell mit dem Missbehagen akzeptiert.17
Logikprogramme
Die übliche operationelle Semantik eines Prolog–Systems ist die Auswertung durch einen Backtrack–
Interpretierer. So wie wir ihn weiter oben skizziert haben. Um etwas konkreter zu werden muss zunächst die
abstrakte Syntax eines Logik–Programms definiert werden. Eine minimalisitische Version ist:
Program :: facts:Rule*
query:Pred
Rule
:: lhs:Pred
rhs:Term*
Pred
=
symbol:Const parameters:Term*
Term
=
Var | Const | Pred
17 Diese Sicht erscheint uns übertrieben puristisch. Jede Programmiersprache sollte eine klar definierte operationale Semantik haben zu der
sich alle ihre Programme und Programmierer frei bekennen können.
156
Var
Const
Nichtprozedurale Programmierung
=
=
A | B ... | Z
a | b ... | z
Diese abstrakte Syntax kann Python–Definitionen umgesetzt werden:
def mkVar(
v, i )
def mkConst( c )
def mkPred( p , l )
return [’PRED’,
: return [’VAR’,
v,
: return [’CONST’, c
:
p, l ]
i ]
]
def isVar(
t ) : return t[0] == ’VAR’
def isConst( t ) : return t[0] == ’CONST’
def isPred( t ) : return t[0] == ’PRED’
def tag( t ) : return t[0]
def getId( t )
: return t[1]
def getIndex( t ) : return t[2]
def getParam( t ) : return t[2]
def mkLogProg( facts, query ): return [’LP’, facts, query]
def getQuery( logProg ):
return logProg[2]
def getFacts( logProg ):
return logProg[1]
def mkRule( lhs, rhs ) : return [’RULE’, lhs, rhs]
def getLhs( r )
: return r[1]
def getRhs( r )
: return r[2]
Dazu kann und sollte auch eine passende konkrete Syntax und ein Parser angegeben werden.
Modifizierte Unifikation
Bei der Auswertung eines Logikprogramms werden mittels Unifikation passende Regeln gesucht. Dabei werden
die Variablen der Regeln und der Anfrage belegt. Typischerweise werden in den Regeln Variablen mit gleichen
Namen verwendet. Der Gültikeitsbereich einer Variablen bezieht sich aber nur auf eine Regel. Gleichnamige Variablen in unterschiedlichen Regeln haben nichts miteinander zu tun. Um unterschiedliche Variablen mit gleichem
Namen unterscheiden zu können, versehen wir jede noch einen Index. Zwei Variablen sich nur dann gleich, wenn
sie den gleichen Namen und den gleichen Index haben. Der Unifikationsalgorithmus muss natürlich an die erweitere Definition der Variablen angepasst werden.
class UnifyError(Exception):
def __init__(self, value):
self.value = value
def __str__(self):
return repr(self.value)
def unify( t1, t2, env ):
if tag(t1) == tag(t2):
if isVar(t1) :
if env.has_key((getId(t1),getIndex(t1))):
t1 = env[(getId(t1),getIndex(t1))]
return unify(t1, t2, env)
if env.has_key((getId(t2),getIndex(t2))):
t2 = env[(getId(t2),getIndex(t2))]
return unify(t1, t2, env)
if (getId(t1) == getId(t2)) and (getIndex(t1) == getIndex(t2)) :
return env
Th Letschert, FH Giessen–Friedberg
157
env[(getId(t1),getIndex(t1))] = t2
return env
if isConst(t1):
if getId(t1) == getId(t2):
return env
else:
raise UnifyError(’Unify-FAILURE’)
if isPred(t1):
if getId(t1) == getId(t2):
if len(getParam(t1)) != len(getParam(t2)) :
raise UnifyError(’Unify-FAILURE’)
return reduce( lambda e, p : unify(p[0], p[1], e),
zip(getParam(t1), getParam(t2)),
env)
else:
raise UnifyError(’Unify-FAILURE’)
else:
if isVar(t1):
if env.has_key((getId(t1),getIndex(t1))):
return unify(env[(getId(t1),getIndex(t1))], t2, env)
else:
env[(getId(t1),getIndex(t1))] = t2
return env
if isVar(t2):
return unify(t2, t1, env)
raise UnifyError(’Unify-FAILURE’)
Für fehlgeschlagene Unifikationen haben wir eine Ausnahme definiert, damit der umgebende Algorithmus sie
geordnet abfangen kann.
Logikprogramme auswerden
Ein Interpreter für Logikprogramme kann jetzt als Backtrackalgorithmus formuliert werden. Er sucht die Regeln
ab bis er die erste, zum aktuellen Ziel passende, gefunden hat. Passend heißt unifizierbar. Gibt es keine passende
Regeln dann wird mit der Ausnahme des Unifikationsalgorithmus ein Backtrack ausgelöst und es geht mit der
nächsten weiter:
...
...
def run( fileName ):
def renameTerm( t, index ) :
if isConst(t):
return mkConst(getId(t))
if isVar(t):
return mkVar(getId(t), index)
if isPred(t):
return mkPred(getId(t), reduce( lambda l,p : l+[renameTerm(p, index)],
getParam(t),
[]))
def renameTermList( tl, index ):
res = []
for t in tl:
res = res + [renameTerm(t,index)]
return res
158
Nichtprozedurale Programmierung
def proveList( queryList, facts, env, index ):
def proveLiteral( p, f, e, index ):
if len(f) > 0:
try:
nEnv = unify(p, renameTerm( getLhs(f[0]), index), e )
proveList( renameTermList( getRhs(f[0]), index) + queryList[1:],
facts, nEnv, index )
except UnifyError, errorenv:
proveLiteral( p, f[1:], e, index )
if len(queryList) == 0:
print ’Solution:’, getQuery(logProg)
print ’
in :’, env
else :
proveLiteral( queryList[0], facts, env, index+1 )
logProg = parseProg(fileName)
proveList( [getQuery(logProg)], getFacts(logProg), {}, 0 )
Dieses Programm zeigt die prinzipielle Einfachheit eines Interpreters für Logikprogramme. Es ist funktionsfähig,
aber selbstverständlich noch sehr weit von einer realistischen Prolog–Implementierung entfernt.
Th Letschert, FH Giessen–Friedberg
2.4
2.4.1
159
Constraint Programmierung
Programmieren mit Beschränkungen
Was ist Constraint Programmierung
Constraint Programmierung (Constraint Programming, CP) kann als Verallgemeinerung und Weiterentwicklung
der logischen Programmierung verstanden werden. Auch hier gibt man, wie es dem Ideal der deklarativen Programmierung entspricht, nicht den Weg zur Lösung an, sondern lediglich deren gewünschte Eigenschaften. Das
System hat dann die Aufgabe nach einer passenden Lösung zu suchen. Der Begriff “Constraint” bedeutet “Einschränkung” oder “Beschränkung” und beschreibt diese spezielle Art der Programmierung recht gut, denn es geht
um die Suche nach Variablenbelegungen die bestimmte Einschränkungen erfüllen. Das Programm besteht aus der
Formulierung dieser Einschränkungen. Seine Ausführung ist die Suche nach Variablenbelegungen die den Einschränkungen genügen. Statt, wie in der logischen Programmierung, nach Variablenwerten zu suchen, die eine
logische Formel “wahr” werden lassen, wird also nach Variablenbelegungen gesucht, die ganz allgemein irgendwelchen Bedingungen genügen.
Ein einfaches Beispiel ist die Suche nach einem Rechteck mit ganzzahliger Seitenlänge mit der Fläche 24 und und
einem Umfang von 20 (siehe [29]). Die Variablen sind die die Seitenlängen x und y und die Einschränkungen, denen sie genügen müssen, sind folgende (die Bedingung, dass x kleiner–gleich y sein soll, verhindert symmetrische
Lösungen):
2 ∗ (x + y) = 20
x ∗ y = 24
x≤y
x, y ganzzahlig im Bereich 1 · · · 20
Eine “blinde” Suche nach einer passenden Variablenbelegung ist in der Regel viel zu aufwändig. Ein CP–System
verbindet darum die Suche mit Techniken, die den Suchraum einschränken.
Hintergrund der CP
Die Ursprünge der CP liegen, wenig überraschend, in der KI–Szene der 60–er und 70–er Jahre des letzten Jahrhunderts. Als Mutter aller CP–System wird allgemein Ivan Sutherlands Sketchpad18 angesehen, ein interaktives
Graphik–System mit dem der Benutzer graphische Objekte manipulieren konnte, die bestimmte Bedingungen zu
erfüllen hatten. Als zweiter Ursprung der CP kann die Erkenntnis angesehen werden, das Logik–Programme eine
spezielle Art der Constraint–Programmierung darstellen. Also eine Art der Programmierung, die sinnvoll erweitert
und verallgemeinert werden kann.
Trotz seiner eher esoterischen Ursprünge ist die CP ist heute von großer praktischer Bedeutung. Planungs– und
Optimierungsaufgaben können sehr leicht in einem CP System formuliert werden. Das ergibt eine Vielzahl von
Anwendungen vom Ingenieurwesen bis zu kommerziellen Fragestellungen.
2.4.2
Lösungssuche
Endliche und unendliche Lösungsräume
CP kann in zwei Hauptrichtungen aufgeteilt werden, die sich jeweils in der Art des Problems und der der Strategie der Lösungssuche unterscheiden. Die beiden Richtungen werden gelegentlich als Constraint Satisfaction und
Constraint Solving bezeichnet.
Die Constraint Satisfaction beschäftigt sich mit Problemen in endlichen Problembereichen. Die weitaus größte
Zahl der praktischen Anwendungen bewegt sich in endlichen Problembereichen. Constraint Satisfaction ist darum
die wichtigere Lösungstechnik.
18
Sutherland I., Sketchpad: A man-machine graphical communication system, 1963
160
Nichtprozedurale Programmierung
Constraint Solving bewegt sich auf schwierigerem Gelände da die Bereiche, in denen nach einer Lösung gesucht
werden muss, unendlich sind oder sein können. Statt kombinatorische werden hier mathematische Methoden eingesetzt, um zu entscheiden ob und wie die Beschränkungen erfüllt werden können.
Suche
Ein endlicher Lösungsbereich kann, zumindest im Prinzip, immer vollständig durchsucht werden. Ein CP–Problem
kann also gelöst werden, indem alle möglichen Belegungen der Variablen generiert und dann anschließend gegen
die Einschränkungen geprüft werden. Selbstverständlich ist dieses Vorgehen sehr ineffizient.
Etwas besser verhält sich eine Backtrack–Suche bei der die Variablen Schritt für Schritt belegt und sofort gegen
die Einschränkungen geprüft werden.
Constraint Propagation
Der Suchaufwand kann oft erheblich verringert werden, wenn die Beschränkungen durch äquivalente aber einfachere ersetzt werden. Das Beispiel von oben etwa
2 ∗ (x + y) = 20; x ∗ y = 24; x ≤ y
x, y ganzzahlig im Bereich 1 · · · 20
kann vereinfacht werden durch eine simple Schlussfolgerung von 2 ∗ (x + y) = 20 auf x + y = 10:
x + y = 10; x ∗ y = 24; x ≤ y
x, y ganzzahlig im Bereich 1 · · · 20
Damit ist dann aber auch schon der mögliche Wertebereich stärker eingegrenzt:
x + y = 10; x ∗ y = 24; x ≤ y
x, y ganzzahlig im Bereich 1 · · · 9
Wenn die größere Zahl maximal den Wert 9 hat und alle Werte ganzzahlig sind, dann muss die kleinere mindestens
den Wert 3 haben, denn 2 ∗ 9 = 18 ≤ 24. Kombiniert mit der Beschränkung x + y = 10 kann man folgern, dass
der größere Wert höchstens 7 sein kann:
x + y = 10; x ∗ y = 24; x ≤ y
x, y ganzzahlig im Bereich 3 · · · 7
Wenn aber y maximal 7 ist, dann muss x mindestens den Wert 4 haben:
x + y = 10; x ∗ y = 24; x ≤ y
x, y ganzzahlig im Bereich 4 · · · 7
Diesen Prozess des Schlussfolgerns nennt man Constraint Propagation.
Wechsel von Constraint Propagation und Suche
Im Anschluss an eine Constraint Propagation kann eine Suche gestartet werden. Etwa indem x auf 4 gesetzt wird.
Dieser Versuch führt uns zu zwei neue Beschränkungssystemen, einem mit x = 4 :
x + y = 10; x ∗ y = 24; x ≤ y
x = 4 und y ganzzahlig im Bereich 4 · · · 7
und einem mit x 6= 4
x + y = 10; x ∗ y = 24; x ≤ y
x ganzzahlig im Bereich 5 · · · 7
y ganzzahlig im Bereich 4 · · · 7
Beide Systeme können als erstes wieder mit einer Constraint Propagation vereinfacht werden. Im ersten System
kommen wir damit sofort zu einer Lösung (x = 4, y = 6).
Im zweiten System beginnt das Wechselspiel von Propagation und Suche erneut.
Th Letschert, FH Giessen–Friedberg
161
Es ist klar, dass die in einem CP–System verwendeten Algorithmen wesentlich vielfältiger und komplexer sind, als
die der Logik–Programmierung. Ihre praktische Bedeutung ist allerdings auch wesentlich größer.
Literaturverzeichnis
[1] H. Abelson, G.J. Sussman
Struktur und Interpretation von Computerprogrammen
Springer 1993
[2] A. Aho, R. Sethi, J.D. Ullmann
Compilerbau
Addison-Wesley 1988
[3] John Backus
Can Programming be Liberated from the Von–Neumann Style
CACM, 21,8; August 1978
[4] D.W. Barron
Recursive Techniques in Programming Languages
MacDonand & Co Ltd, 1968
[5] J.O. Coplien
Advanced C++ Programming
Addison-Wesley 1992
[6] Thomas H Cormen, Charles E Leiserson, Ronald L Rivest
Introduction to Algorithms
MIT Press 1990
[7] Richard Courant, Herbert Robbins
Was ist Mathematik
Springer–Verlag 2–te Auflage 1967
[8] Fritz Reinhardt, Heinrich Soeder
dtv–Atlas zur Mathematik, Band I Grundlagen, Algebra, Geometrie
DTV 3te Auflage 1978
[9] R. Floyd
Nondeterminstic Algorithms
Journal of the ACM, Oktober 1967
[10] Erich Gamma, Richard Helm, Rapph Johnson, John Vlissides
Desgin Patterns
Addison–Wesley 1995
[11] Hellwig Geisse
Nichtprozedurale Programmierung
Vorlesungkonzept, FH Giessen–Friedberg 2002
[12] P. Henderson
Functional Programming
Prentice–Hall, 1980
162
Th Letschert, FH Giessen–Friedberg
[13] John Hughes
Why Functional Programming Matters
In D. Turner: Research Topics in Functional Programming
Addison–Wesley 1994
[14] Michael Jäger
Compilerbau – Eine Einführung
Vorlesungmanuskript, FH Giessen–Friedberg 2003
[15] Christopher Jones, Fred Drake
Python & XML
O’Reilly 2002
[16] A. Martelli, U. Montanari
An Efficient Unification Algorithm
ACM Transaction on Programming Languages and Systems, April 1982
[17] N. Josuttis
The C++ Standard Library
Addison–Wesley 1999
[18] Donald Knuth
The Art of Computer Programming
2te Auflage, 3ter Nachdruck
Addision Wesley 1977
[19] Thomas Kühne
A Functional Pattern System for Object–Oriented Design
Verlag Dr. Kovac, 1999
[20] Udi Mamber
Introduction to Algorithms
Addision Wesley 1989
[21] A. Martelli
Python in a Nutshell
O’Reilly 2003
[22] John McCarthy
Recursive Functions of Symbolic Expressions and their Computation by Machine, Part I
Communications of the ACM, April 1960
[23] John McCarthy
A Basis for a Mathematical Theory of Computation
in P. Braffort, D. Hirschberg, Eds.:
Computer Programming and Formal Systems (1963)
North-Holland 1963
[24] P. Naur (Ed.)
Revised Report on the Algorithmic Language ALOGOL 60
Communications of the ACM, Januar 1963
[25] A. Newell, J. Shaw, H. Simon
Report on a General Problem Solver
Proc. International Conference on Information Processing, Paris 1959
[26] Tim Peters
PEP–255: Simple Generators in Python
http://python.sourceforge.net/peps/pep-0255.html, Mai 2001
163
164
Nichtprozedurale Programmierung
[27] Burkhardt Renz
Die Kleine XML–Apotheke
http://homepages.fh-giessen.de/ hg11260/mat/xa.pdf, Januar 2001
[28] J.A. Robinson
A Machine-Oriented Logic Based on the Resolution Principle
Journal of the ACM, Januar 1965.
[29] P. van Roy, Seif Haridi
Concepts, Techniques, and Model of Computerprogramming
MIT Press, 2004
[30] W3C, James Clark et al.
XSL Transformations (XSLT)
http://www.w3.org/TR/xslt.html
[31] W3C, James Clark, S. DeRose
XML Path Language (XPATH)
http://www.w3.org/TR/xpath
Herunterladen