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