Westfälische Wilhelms-Universität Münster Ausarbeitung MetaLanguage (ML) im Rahmen des Seminars Programmiersprachen Philipp Westrich Themensteller: Prof. Dr. Herbert Kuchen Betreuer: Dipl.-Medienwiss. Susanne Gruttmann Institut für Wirtschaftsinformatik Praktische Informatik in der Wirtschaft Inhaltsverzeichnis 1 Motivation.................................................................................................................. 1 2 Grundlagen der Sprache ............................................................................................ 2 2.1 Der Lambda-Kalkül ........................................................................................... 2 2.2 Syntax und Semantik.......................................................................................... 4 2.2.1 2.2.2 2.2.3 3 Wichtige Konzepte der Sprache ................................................................................ 8 3.1 Programmieren mit Funktionen ......................................................................... 8 3.1.1 3.1.2 3.2 3.3 Rekursion .................................................................................................... 8 Higher-order Functions ............................................................................. 11 Modulare und zustandsbehaftete Programmierung .......................................... 12 3.2.1 3.2.2 Modularisierung ........................................................................................ 12 Imperative Elemente ................................................................................. 14 Typdeklarationen.............................................................................................. 14 3.3.1 3.3.2 4 Standardtypen und benutzerdefinierte Typen ............................................. 4 Funktionen .................................................................................................. 6 Auswertung ................................................................................................. 6 Polymorphe Deklaration ........................................................................... 15 Ambige Deklarationen .............................................................................. 16 Verwendung der Sprache ......................................................................................... 16 4.1 Erweiterung des ‟97 Standards......................................................................... 16 4.1.1 4.1.2 4.2 Lazy Evaluation ........................................................................................ 17 Constraint Programming ........................................................................... 18 ML in der Praxis............................................................................................... 20 5 Zusammenfassung ................................................................................................... 22 A Iterative Lösung der „Türme von Hanoi“ ................................................................ 23 B Approximative Berechnung eines Integrals ............................................................. 24 Literaturverzeichnis ........................................................................................................ 25 II Kapitel 1: Motivation 1 Motivation Objektorientierte Sprachen wie C# oder Java dominieren heute die Wahrnehmung junger Programmieranfänger. Dutzende Bücher versprechen einen schnellen Lernerfolg in nur wenigen Tagen und auch im Internet lassen sich viele Tutorials und CodeBeispiele finden. Im Gegensatz dazu wird wenig über funktionale Sprachen wie Lisp, Haskell oder ML geschrieben. Lässt sich daraus etwas über die Güte und Mächtigkeit der funktionalen Sprachen herleiten? Sind sie etwa nur ein überflüssiges Paradigma aus den Anfängen der Programmierung? Ziel dieser Arbeit ist es dieser Frage nachzugehen, die Vorzüge der funktionalen Programmierung mit Hilfe der Sprache ML vorzustellen und anhand von Beispielen zu untermauern. Von den zahlreichen Implementierungen von ML wurde zur Demonstration in dieser Arbeit der New Jersey Compiler und Interpreter (SML/NJ) gewählt, da er zu den populärsten zählt und ständig weiterentwickelt wird [01]. ML steht für meta language (engl. „Meta-Sprache“) und wurde von Robin Milner in Zusammenarbeit mit Malcolm Newey, Lockwood Morris und Weiteren um 1974 in Edinburgh entwickelt, um Aussagen über Programmiersprachen mit Hilfe des interaktiven Beweissystems „Logic for Computable Functions“ (LCF) machen zu können. Ihre Stärke liegt in der mathematischen Ausdruckskraft, inspiriert durch die erste „echte“ funktionale Sprache Lisp (die Milner in seiner Zeit in Stanfort kennen lernte) sowie in der hohen Typsicherheit, die sich durch die Verwendung von Polymorphie jedoch recht flexibel verhält [Fr93, S. 93]. Durch die Integration von imperativen Elementen und einer starken Fehlerbehandlung erlangte ML auch außerhalb Edinburghs viel Zuspruch und wird heute noch zur Vermittlung der Grundlagen der Informatik an Hochschulen verwendet. Funktionale Sprachen bilden zusammen mit den logischen Sprachen (z. B: Prolog) die Menge der deklarativen Sprachen, die sich von den imperativen Sprachen (z. B: C, Pascal, Java) dadurch unterscheidet, dass der Programmierer nicht dem von-NeumannModell folgend, der Maschine über Zuweisungen und Sprünge vorgibt wie ein Problem gelöst werden soll, sondern vielmehr das Problem an sich beschreibt. Der Fokus liegt auf der Beschreibung des Konzeptes in einer nahe an die mathematische Schreibweise angelegten Syntax und Semantik. PEPPER beschreibt das zentrale Anliegen der 1 Kapitel 2: Grundlagen der Sprache funktionalen Sprachen als den Versuch, etwas von der Eleganz, Klarheit und Präzision der Mathematik in die Welt der Programmierung einfließen zu lassen [Pe03, S. 2]. Durch den Einblick in die Konzepte, die die funktionalen von den imperativen Sprachen unterscheiden, soll zusätzlich das Verständnis des Programmierens im Allgemeinen gefördert werden.1 Beispielsweise fördert das Konzept der Funktionen höherer Ordnung eine abstraktere Sicht auf „generalisierte Methoden“ oder das zustandslose Programmieren die Verwendung von Rekursion anstelle von Schleifen. Die gewonnene Erfahrung lässt sich auf andere Sprachen übertragen und erweiterter die Fähigkeit des Programmierers Probleme zu lösen. Im folgenden Kapitel wird auf die mathematische Grundlage der funktionalen Programmierung eingegangen und die Syntax und Semantik von ML dargestellt. Anschließend werden im dritten Kapitel die Besonderheiten von ML betrachtet. Im vierten Kapitel wird exemplarisch der Einsatz von ML in der Forschung und der Wirtschaft vorgestellt. Darauf aufbauend soll im letzten Kapitel die Frage nach der Relevanz funktionaler Sprachen in der heutigen Zeit beantwortet werden. 2 Grundlagen der Sprache 2.1 Der Lambda-Kalkül Der mathematische Grundstein der funktionalen Sprachen wurde in den 1930iger Jahren von Alonzo Church mit der Entwicklung des -Kalküls gelegt, welches die formale Beschreibung des Verhaltens von Computerprogrammen erlaubt [Kl07, S.237ff]. Die Sprache des -Kalküls, die Menge der -Terme, L , mit V als abzählbare Menge von Variablen, ist die kleinste Menge mit folgenden Eigenschaften: 1. V L 2. Für e0 , e1 L ist auch (e, e1 ) L . 3. Für x V , e L ist auch ( x.e) L . Ein -Term der Form (e0 , e1 ) heißt Applikation mit Operator e0 und Operand e1 . Ein Term der Form ( x.e) heißt Abstraktion, wobei x Parameter der Abstraktion heißt und e 1 Denn schon der Philosoph Ludwig J. J. Wittenstein erkannte: „… die Grenzen meiner Welt sind die Grenzen meiner Sprache“ (Wittenstein: Tractatus 5.6, S. 67, 1984) 2 Kapitel 2: Grundlagen der Sprache als Rumpf bezeichnet wird. Der -Kalkül ist ein Reduktionskalkül, der das Verhalten von -Termen festlegt und beschreibt, wie ein -Term in einen anderen, gleichbedeutenden, überführt werden kann. Eine Besonderheit des -Kalküls ist der Umstand, dass es ausschließlich Funktionen gibt, doch lässt sich alles weitere (z. B. Zahlen oder boolesche Werte) mittels Funktionen nachbilden. Allgemein ist eine Funktion definiert als: „… ein Tripel D f ,W f , R f , bestehend aus einer Definitionsmenge D f , einer Wertemenge W f und einer Relation R f D f W f , die Funktionsgraph genannt wird. Dieser Funktionsgraph muss linkseindeutig sein, d. h., es gibt keine zwei Paare a, b1 R f und a, b2 R f mit b1 b2 .“ [Pe03, S.15] Eine einfache Geradengleichung hat beispielsweise die Form m, x, b. mx b und wird -Term oder -Ausdruck genannt. Der Vorspann m, x, b. bindet hierbei die Variablen in der typischen -Notation an den Ausdruck. Im -Kalküls gilt das Prinzip der lexikalischen Bindung: Das Vorkommen einer Variable v als -Term gehört immer zur innersten umschließenden Abstraktion .e.v , deren Parameter ebenfalls v ist. Es gibt zwei Reduktionsregeln im -Kalkül, die -Reduktion und die -Reduktion. Die Erste benennt eine gebundene Variable in eine andere um, die Zweite steht für Funktionsapplikationen: Eine Abstraktion wird angewendet, indem die Vorkommen ihres Parameters durch den Operanden einer Applikation ersetzt werden. Es ist erlaubt, jederzeit beliebige Teilausdrücke zu reduzieren, solange sie nur - oder -Redexe sind. (Dabei ist die Reduktionsreihenfolge laut dem Satz von Chruch/Rosser egal.) Es gibt mehrere populäre Auswertungsstrategien, um denjenigen -Redex innerhalb eines -Terms zu finden, der tatsächlich reduziert werden soll. Während es bei rein funktionalen Sprachen egal ist, ob beispielsweise eine leftmost-outermost reduction oder eine call-by-name reduction angewendet wird, muss bei ML aufgrund der Verwendung imperativer Elemente zwingend eine feste Auswertungsreihenfolge benutzt werden. Im konkreten Fall wird von innen nach außen ausgewertet (call-byvalue reduction). ML wird deshalb auch als strikte Programmiersprache bezeichnet. 3 Kapitel 2: Grundlagen der Sprache 2.2 Syntax und Semantik Obwohl die Prinzipien von ML auf dem -Kalkül aufbauen, wurde die Syntax zu Gunsten der besseren Handhabbarkeit weniger an die mathematische Schreibweise als an die – für große Softwareprojekte besser geeignete – Syntax von Algol und Pascal angelehnt [MQ93, S. 37]. Sie wurde auch von der in Edinburgh verwendeten POP-2 Tradition beeinflusst, der eine leichter lesbare Version der -Schreibweise zugrundeliegt. Mit 52 Schlüsselworten und reservierten Symbolen ist sie dennoch nicht zu umfangreich, wie ein Vergleich mit Pascal (59 Schlüsselworte) und C (76 Schlüsselworte) zeigt. ML verfügt über eine wohldefinierte Semantik. Durch die Veröffentlichung der Definition of Standard ML auf Basis reiner Mathematik anstelle von formaler Sprache konnte die Sicherheit der Sprache garantiert und bewiesen werden. Mehrdeutige oder undefinierte Ausdrücke kommen somit nicht vor [Mi90, vii]. ML wurde für die direkte Kommunikation mit der Maschine entwickelt [Fr93, S. 93], folglich wird vom System eine Eingabe (abgeschlossen mit einem „;„) erwartet, diese ausgewertet und das Ergebnis im Fenster angezeigt. Es folgt ein kleines Beispiel. Der Standard Prompt ist „-„, die Ausgaben des Compilers werden kursiv wiedergegeben. Der Variablen it weist der Compiler automatisch das letzte Resultat zu. Sie wird deshalb auch „Ergebnisbezeichner“ genannt [Sm08, S. 4]: - "Hello World!"; (* This is a commentary *) val it = "Hello World!" : string Um die LCF bestmöglich unterstützen zu können, wurde ML mit einem strikten, statischen Typsystem entworfen [Fr93, S. 93]. Anhand der Syntax der Eingabe erkennt der Compiler automatisch den entsprechenden Typ der Variablen (Typinferenz), d. h. der Typ muss beispielsweise bei einer Variablendeklaration nicht explizit genannt werden. Nachdem die unterstützten Typen im folgenden Abschnitt vorgestellt wurden, soll auf die Besonderheiten der Auswertung eingegangen werden. 2.2.1 Standardtypen und benutzerdefinierte Typen Die Programmiersprache ML verfügt über folgende Standardtypen: Integer, Gleitkommazahlen (Typ: real), boolesche Werte und Strings. Die vordefinierten zusammengesetzten Typen umfassen record, list und tuple. 4 Kapitel 2: Grundlagen der Sprache Ein record ist eine Sammlung von Typen, die zur Referenzierung jeweils mit einem Lable versehen werden. Die Ordnung der Elemente ist daher irrelevant. Das Gegenteil ist die list, in der jedes Element vom gleichen Typ sein muss und die Reihenfolge der Elemente eine Rolle spielt. Die Funktionsweise ist mit der von Lisp oder Haskell identisch. Der Infix Operator „::„ fügt ein Element als Kopf an die Liste an und „@„ verbindet zwei Listen mit einander. Ein tuple ist ein kartesisches Produkt seiner Elemente. Sein Typ ist das Produkt der Typen seiner Elemente. Die Syntax der zusammengesetzten Typen lässt sich in EBNF wie folgt ausdrücken: NAME TYP RECORD LIST TUPLE String, der kein Schlüsselwort darstellt bool | int | real | string | unit | TYP ref | RECORD | TYP LIST | TUPLE { NAME: :TYP { , NAME: :TYP }} } nil | TYP { ::TYP: ::TYP } ::nil | LIST @ LIST (TYP {, , TYP }) ) Mit Hilfe von tuples ist es beispielsweise möglich, musterbasierte Definitionen (engl: pattern matching) anzuwenden, um mehrere Zuweisungen in einer Deklaration abzuwickeln: - val val a val b val c (a,b,c) = (true, ["Hallo", "World!"], (7, 7.0)); = true : bool = ["Hallo","World!"] : string list = (7,7.0) : int * real Eigene Datentypen können in ML, ähnlich wie der Typ enum in Haskell, mit dem datatype Schlüsselwort erzeugt werden. Die Syntax in EBNF lautet: TYPCREATION DTDEF datatype NAME = DTDEF { | DTDEF } NAME { of TYP { * TYP } } Bei der Erzeugung eigener Datentypen wird zwischen null- und mehrstelligen Konstruktoren unterschieden [Sm08, S. 114-115]. Ein Beispiel für den ersten Fall liefert der Datentyp workdays, dessen Werte wie Konstanten verwendet werden können. Im zweiten Fall hingegen erfordert der Wert Triangel drei Fließkommazahlen um einen Datentyp shape bilden zu können. datatype workday = Mo | Tu | We | Th | Fr; datatype shape = Circle of real | Square of real | Trianagle of real * real * real; Zusätzlich zu eigenen Datentypen können mit dem Schlüsselwort type so genannte Typsynonyme erzeugt werden, die lediglich einen neuen Bezeichner für einen 5 Kapitel 2: Grundlagen der Sprache bestehenden Typen einführen. Durch ein Typsynonym Form für shape ließe sich beispielsweise eine einfache Lokalisierung von Englisch nach Deutsch realisieren. 2.2.2 Funktionen In ML werden auch Funktionen als normale Datentypen behandelt (vgl. Abschnitt Fehler! Verweisquelle konnte nicht gefunden werden.) und können mit Hilfe des Schlüsselwortes fun erzeugt werden. Es ist die Kurzschreibweise für die an das Kalkül angelehnte anonyme Funktionsdefinition fn(x) => x… . Auch bei der Funktionsdefinition kann pattern machting zur Vereinfachung eingesetzt werden [Pe03, S. 165]. Um lange if-then-else-Blöcke zu vermeiden, lässt sich beispielsweise eine Funktion zur Berechnung der Fakultät folgendermaßen definieren: fun fac 0 = 1 (* Doesn’t terminate on negative values *) | fac(n)= n*fac(n-1); Wenn eine Funktion als Argument mehrere Variablen übernehmen soll, müssen diese als tuple übergeben werden. Zusätzlich können, wie in Haskell auch, Funktionen auf weniger Argumente angewendet werden als nötig. Diese sogenannte partielle Applikation produziert eine neue Funktion, die bei Anwendung auf die restlichen Argumente das gleiche Ergebnis wie die ursprüngliche vollständig applizierte Funktion liefert. Der Übergang von der Tupelbildung (_ * _) zum Funktionspfeil -> wird dabei als currying bezeichnet [Pe06, S. 16]. Zur Illustration folgt ein Beispiel: - fun abstractLine(a,b) x = a*x+b; (* Currying *) val abstractLine = fn : int * int -> int -> int - val line = abstractLine(3,1); (* Normal function y=3x+1 *) val line = fn : int -> int - line(5); val it = 16 : int (* Usage *) Bei der Deklaration von line wird abstractLine mit nur zwei anstatt drei Argumenten aufgerufen, folglich ist das Resultat kein Integer sondern eine Funktion, die ein Argument erwartet und als Ergebnis einen Integer zurück liefert. 2.2.3 Auswertung Der Benutzer wird in ML gezwungen, seine Operationen genau zu definieren, da der Compiler auf Typkorrektheit prüft und gegebenenfalls eine Fehlermeldung ausgibt, wodurch die dynamische Sicherheit der Sprache garantiert wird. Alle Werte einer 6 Kapitel 2: Grundlagen der Sprache Operation müssen deshalb vom gleichen Typ sein. So gibt folgende Addition beispielsweise eine Fehlermeldung aus, weil im Gegensatz zu Java keine automatische Typumwandlung erfolgt und der Compiler anstelle des „falschen“ real-Wertes 7.0 einen Integer erwartet: - 7 + 7.0; stdIn:2.1-2.8 Error: operator and operand don't agree [literal] operator domain: int * int operand: int * real in expression: 7 + 7.0 Ein Beispiel für die statische Auswertung ist der folgende Aufruf der Funktion cond: - fun cond(b,x,y) = if b then x else y; - cond(true,1,1 div 0); uncaught exception Div [divide by zero] (* Definition *) (* Usage *) ML wertet von innen nach außen aus (vgl. Abschnitt 0), folglich wird y ausgewertet, obwohl es für die korrekte Ausgabe nicht zwingend erforderlich war. Eine verzögerte Auswertungsstrategie (engl: lazy evaluation) hätte nur bis x ausgewertet und 1 zurückgegeben. Auf die Vorzüge von lazy evaluation soll im Abschnitt 4.1.1 noch ausführlicher eingegangen werden. In ML beginnt der Gültigkeitsbereich eines Wertes oder einer Funktion immer erst an der Stelle ihrer Definition [Pe03, S. 124]. Folglich spielt, im Gegensatz zu Haskell, die Reihenfolge, in der sie im Quellcode genannt werden, eine Rolle. Die folgende Deklaration wertet beispielsweise zu 10 aus: let val x=4 in let val x=x+1 in 2*x end end; Der let-Ausdruck dient zur Deklaration eines lokalen Kontextes, der nur für die Ausdrücke zwischen in und end gilt. Er ist folgendermaßen Definiert: EXPR LETEXPR ein oder mehere gültige ML Audrücke (zu denen auch LETEXPR zählt) let EXPR in EXPR end 7 Kapitel 3: Wichtige Konzepte der Sprache 3 Wichtige Konzepte der Sprache 3.1 Programmieren mit Funktionen Hauptmerkmal der funktionalen Sprache, die auch für ihre Namensgebung verantwortlich ist, ist das nicht-exklusive Arbeiten mit Funktionen. Eine Funktion ist ein Objekt wie jedes andere auch und kann deshalb – wie ein primitiver Datentyp – als Argument an Funktionen übergeben, als Resultat zurückgegeben oder in einer Datenstruktur gespeichert werden. Ein komplettes Programm einer funktionalen Programmiersprache ist somit nur eine Funktion, die gewisse Eingabeparameter entgegennimmt und nach einer komplexen Rechnung mit einer beliebigen Anzahl von internen Funktionsaufrufen eine gewünschte Ausgabe produziert [Hu84, S. 2]. In einer rein funktionalen Programmiersprache hat der Aufruf einer Funktion keinen Seiteneffekt, somit kann auch jede Funktion eines Programmes einzeln evaluiert werden. Diese referenzielle Transparenz kann das Auffinden von Fehlern vereinfachen, weil nicht mehr die Ausführungsreihenfolge oder der Zustand des Systems berücksichtigt werden muss. In den folgenden beiden Abschnitten soll beispielhaft die Eleganz und Zweckmäßigkeit, die durch diese Form der Programmierung möglich ist, aufgezeigt werden. 3.1.1 Rekursion Rekursive Funktionen sind ein mächtiges Werkzeug, das nicht allein funktionalen Sprachen vorbehalten, sondern vielmehr in allen gängigen Programmiersprachen wie C, Pascal oder Java möglich ist. Jedoch lassen der einfache Aufbau und die kompakte Syntax Rekursion in funktionalen Sprachen viel natürlicher wirken [Pe03, S. 59]. Als rekursive Funktion gilt jede Funktion, die sich in ihrem Rumpf erneut aufruft, [Pe03, S. 60, 68-69]. Die konkrete Ausgestaltung der Selbstreferenzierung führt zu verschiedenen Arten von Rekursion, auf die im Folgenden näher eingegangen werden sollen. Bei der Lineare Rekursion besteht der Rumpf einer Funktion nur aus einem bedingten Ausdruck, für den in jedem Zweig höchstens ein rekursiver Aufruf vorkommt (ist der Aufruf die äußerste Operation spricht man von repetitiver Rekursion). Damit führt jeder Aufruf der Funktion unmittelbar höchstens zu einem weiteren Aufruf, d. h. es entsteht 8 Kapitel 3: Wichtige Konzepte der Sprache insgesamt eine lineare Kette von Aufrufen. Ein Beispiel ist die im Abschnitt 2.2.2 vorgestellte Funktion fac. Wenn als Argumente eines rekursiven Aufrufs weitere rekursive Aufrufe auftreten können (siehe Ackermannfunktion im Abschnitt 3.1.2), spricht man von geschachtelter Rekursion. Bei der Verschränkte Rekursion rufen sich zwei oder mehr Funktionen in ihrem Rumpf gegenseitig auf. Ein Beispiel sind die nachfolgenden Realisierungen der Funktionen even und odd: fun even(n) = if n=0 then true else odd(n-1) and odd(n) = if n=0 then true else even(n-1); Zu beachten ist, dass bei verschränkt rekursiven Funktionen das Schlüsselwort and verwendet werden muss, da der Compiler Funktionen immer erst nach ihrer Deklaration kennt [Pe03, S.76]. Bei der Baumartige Rekursion können in einem Ausdruck mehrere rekursive Aufrufe nebeneinander vorkommen, d. h. es kommt im Allgemeinen zu einer baumartigen Kaskade von weiteren Aufrufen. Ein Beispiel ist die im Folgenden vorgestellte Lösung des Problems der „Türme von Hanoi“, an der die Eleganz und einfache Lesbarkeit rekursiver Funktionsdefinitionen demonstriert werden soll. Die Aufgabenstellung des vom Französischen Mathematiker Édouard Lucas erfundenen Puzzles lautet [Pe03, 59]: In der Legende der „Türme von Hanoi“ muss ein Stapel von unterschiedlich großen Scheiben von einem Pfahl auf einen zweiten Pfahl übertragen werden unter Zuhilfenahme eines Hilfspfahls. Dabei darf jeweils nur eine Scheibe pro Zug bewegt werden und nie eine größere auf einer kleineren Scheibe liegen. Mit Hilfe von Rekursion lässt sich die Lösung im Pseudocode leicht verständlich niederschreiben. Die iterative Lösung ist hingegen weniger einsichtig und um einiges länger (siehe Anhang A): fun bewegeStein (n Steine, Start, Ziel, Lager) = if n = 0 then Breche ab. else Bewege Stein n-1 vom Start zum Lager. Bewege Stein n vom Start zum Ziel. Bewege Stein n-1 vom Lager zum Ziel. end if end 9 Kapitel 3: Wichtige Konzepte der Sprache Einer der großen Vorteile, die die funktionale Programmierung bietet, ist der Umstand, dass sobald ein Problem mathematisch verstanden wurde, es nur noch „niedergeschrieben“ werden muss [Pe03, 68]. Die mathematische Syntax und Semantik begünstigt zudem die Möglichkeit, die Richtigkeit einer Funktion zu beweisen. So liest sich die Implementierung des Pseudocodes in ML fast genauso: fun hanoi(n) = let fun bewegeStein(x, ziel, start, lager) = if x = 0 then nil else bewegeStein(x-1, lager, start, ziel) @ [(start, ziel)] @ bewegeStein(x-1, ziel, lager, start) in bewegeStein(n, 3, 1, 2) end; Das größte Problem der rekursiven Funktionen ist die Performance, die im Gegensatz zur iterativen Variante deutlich ineffizienter sein kann [Pe06, S. 35-36]. Eine Ausnahme ist die repetitive Rekursion, bei der keine Rechnung nachträglich zum rekursiven Aufruf erfolgt. In der oben aufgezeigten linear rekursiven Funktion fac wird beispielsweise für jedes n > 0 eine Addition auf den Stack gelegt, bis n = 0 erreicht wird und die Multiplikation rückwärts erfolgen kann: fac(4) = 4 * fac(3) = 4 * 3 * fac(2) = 4 * 3 * 2 * fac(1) = 4 * 3 * 2 * 1 * fac(0) = 4 * 3 * 2 * 1 * 1 = 4 * 3 * 2 * 1 = 4 * 3 * 2 = 4 * 6 = 24 Wenn jedoch das Ergebnis der Berechnung als Argument der Funktion mit übergeben wird, lässt sich die Performance verbessern, da die Multiplikationen nicht mehr auf dem Stack gehalten werden müssen. Die nachfolgende Version wird nun als endrekursiv bezeichnet: fun fac2(n) = (* Also doesn’t terminate on negative values *) let fun facER(0, x) = x | facER(a, x) = facER(a-1, a*x) in facER(n,1) end; Interessanterweise ist die rekursive Lösung des Problems der „Türme von Hanoi“ nicht nur die kürzeste und eleganteste, sondern erzielte bei einem Vergleich mit fünf iterativen Varianten im Bezug auf die Laufzeit sogar den zweiten Platz [Er86, S. 100]. 10 Kapitel 3: Wichtige Konzepte der Sprache 3.1.2 Higher-order Functions Das mächtigste Konzept funktionaler Sprachen kommt erst bei der Verwendung von Funktionen höherer Ordnung (engl. higher-order functions) zum Tragen. Diese Funktionen nehmen Funktionen als Argumente an oder liefern als Resultat wieder Funktionen zurück. Im Gegensatz zur imperativen Sprache wie z. B: Java ist das Konzept der Generalisierung somit nicht nur auf Klassen beschränkt, sondern lässt sich auch auf Funktionen übertragen. Die umständliche Definition von Interfaces und die Kapselung von einzelnen Methoden in konkreten Klassen bleiben erspart. Anhand eines Beispiels soll gezeigt werden, wie sich mittels higher-order functions viele Funktionen ohne großen Programmieraufwand aus einer „Grundfunktion“ generieren lassen: - fun hyper (1) (x, y) = x + y | hyper (2) (x, y) = x * y | hyper (n) (x, y) = if y=0 then 1 else hyper (n-1) (x , (hyper (n) (x, (y-1)))); val hyper = fn : int -> int * int -> int Die Funktion hyper liefert eine Funktion zurück, die je nach Wahl des Grades n zwei Argumente aggregiert. Für n = 1 wird eine Additionsfunktion, für n = 2 eine Multiplikationsfunktion und für n > 2 eine n-fache Potenzfunktion erzeugt. Ohne die Funktion höherer Ordnung müsste für jeden benötigten Grad n eine neue Funktion manuell erzeugt werden: fun plus(x, fun times(_, | times(x, fun power(_, | power(x, fun super(_, | super(x, ... y) 0) y) 0) y) 0) y) = = = = = = = x + y; 0 plus(x, (times(x, (y-1)))); 1 times(x, (power(x, (y-1)))); 1 power(x, (super(x, (y-1)))); x Für die Funktion super gilt dann: ( x, y). x x... y mal . Offensichtlich wächst die Funktion hyper sehr schnell. Wenn die Argumente x und y mit n übereinstimmen, erhält man die Ackermannfunktion2: - fun ackermann(n) = hyper(n)(n,n); - ackermann(3); (* Is equivalent to 3^3 *) 2 Für ausführliche Informationen siehe: Ackermann: Zum Hilbertschen Aufbau der reellen Zahlen, Math. Ann. (99) S. 118-133, 1928. 11 Kapitel 3: Wichtige Konzepte der Sprache val it = 27 : int - power(3,3); val it = 27 : int - ackermann(4); (* Equals 1.3407807929942597E154 *) uncaught exception Overflow [overflow] raised at: <file stdIn> 3.2 Modulare und zustandsbehaftete Programmierung Inspiriert durch die Sprachspezifikationen CLEAR und HOPE, wurde 1983 die ursprüngliche Version von ML unter der Aufsicht von Milner um neue Konzepte erweitert. Beispielsweise wurden modulare Spezifikation mit Signaturen und Interfaces sowie eine stream-basierte Ein- und Ausgabe der Sprache hinzugefügt. Darauf aufbauend erschien 1990 der erste Standard für ML (SML) [Mi90, S. 82]. In der aktuellen Version von 1997 wurde dieser Standard noch einmal überarbeitet und um eine Basis-Bibliothek ergänzt, die Programmierern bei der Implementierung von großen Softwareprogrammen unterstützen soll. Durch die Entwicklung des Standards wurde ML auch für andere Forschungseinrichtungen und Softwarefirmen interessant, da nun große Softwareprojekte komfortabel umgesetzt werden konnten. In dem nachfolgenden Abschnitt soll das Konzept der Modularisierung, welches eines der wichtigsten der neu eingeführten Konzepte darstellt und das „Programmieren im Großen“ erleichtert, näher erläutert werden. Ein weiterer Grund für die Verbreitung von ML liegt in der Tatsache begründet, dass ML zustandsbehaftete Programmierung zulässt [Le01, S. 189]. Auf ihre Handhabung soll im Abschnitt 3.2.2 kurz eingegangen werden. 3.2.1 Modularisierung Zur Realisierung von großen Softwareprojekten werden Konzepte benötigt, die es erlauben, Funktionen und Datentypen zu strukturieren und voneinander abzugrenzen. Ein solches Konzept ist die Modularisierung [Pe03, S. 33]. Durch die Definition einer Schnittstelle (in ML signature genannt) ist es Möglich den Zugriff auf Daten und Funktionen eines Paketes steuern: Der Benutzer kann nur auf die Daten und Funktionen zugreifen, die in der Signatur angekündigt werden; die Implementierung (in ML durch eine structure realisiert) bleibt ihm verborgen. Auf diese Weise können z. B: Hilfsfunktionen gekapselt oder die konkrete Implementierung einer abstrakten Datenstruktur im Verborgenen ausgetauscht werden. Das Verbergen von Implementierungsinformationen lässt sich durch die Deklarierung eines signature 12 Kapitel 3: Wichtige Konzepte der Sprache constrains realisieren [Sm08, S. 281]. Allgemein haben die signature-Deklaration und die Strukturdeklaration (mit optionalem signature constraint) die folgende Form: SIGDEKL STRUCTDEKL signature NAME = sig EXPR end structure NAME :[ > ] NAME = struct EXPR end Dabei darf der Name der Struktur nicht mit dem Namen der Signatur, die sie implementiert, übereinstimmen. Es folgt ein Beispiel für die Trennung von Funktionsdeklaration und konkreter Implementierung für geordnete Sequenzen (welches noch im Abschnitt 3.3.1 um Polymorphie erweitert werden wird): signature Order = sig val eq : 'a * 'a -> bool val le : 'a * 'a -> bool end signature OrderedSequence = sig type 'a Seq val smaller : 'a Seq * 'a Seq -> bool end (* Implements Order without signature constraint *) structure OrderedCharacters : Order = struct fun eq(a,b) = … (* Implementation *) val lt(a,b) = … (* Implementation *) end; Um auf die Funktionen und Werte einer Signatur zugreifen zu können, muss die Quelle mit angegeben werden. Beispielhaft kann auf die Math-Schnittstelle – SML besitzt wie Java viele vorimplementierten Bibliotheken – folgendermaßen zugegriffen werden: - Math.pi; [autoloading] [library $SMLNJ-BASIS/basis.cm is stable] [autoloading done] val it = 3.14159265359 : real Alternativ kann mit dem Schlüsselwort open eine Signatur in den globalen Kontext geladen werden. Hierbei ist aber auf Namensgleichheit zu achten, da ggf. Werte ungewollt überschrieben werden, wie folgendes Beispiel demonstriert: - val pi = 3.14; - open Math; [autoloading] … - pi; val it = 3.14159265359 : real 13 Kapitel 3: Wichtige Konzepte der Sprache 3.2.2 Imperative Elemente ML wird nicht als rein funktionale Sprache angesehen, weil die Sprachdefinition auch zustandsbehaftete Operationen zulässt. Diese wurden benötigt, um bestimmte Algorithmen (z. B: Datenstrukturen die auf Graphen basieren) leichter implementieren zu können, da sie imperativ formuliert leichter verständlich sind. Gerade bei der Implementierung von Beweissystemen ist dies ein entscheidender Faktor [Le01, S. 189]. Obwohl die Integration imperativer Elemente in eine funktionale Programmiersprache oft als Makel angesehen wird, verteidigt HUGHES diesen Ansatz mit der Feststellung, dass keine Programmiersprache durch das Weglassen von Features mächtiger werden kann [Hu84, S. 2]. Im Gegenteil, imperative Elemente wären essentiell, um das volle Spektrum der funktionalen Sprachen ausbeuten zu können. Zusätzlich lässt sich der imperative Anteil auch komplett vermeiden, da er nicht von der Sprache aufgezwungen wird. (So wurden bis auf die Beispiele, die imperative Elemente demonstrieren sollen, alle Beispiele dieser Arbeit rein funktional programmiert.) Ein Beispiel aus der iterativen Implementierung der Lösung des Problems der „Türme von Hanoi“ veranschaulicht die Verwendung von Zuständen in ML (vgl. Anhang A): fun hanoiIter(n) = let … val i = ref 0 in while !i < limit do ( … i := !i +1 ); !result end; (* Equivalent to the *) (* nonexisting for-loop *) Mit dem Schlüsselwort ref erfolgt eine Allokation einer Variablen zu einer Speicherzelle. Mit dem Operator „:=„ wird die Speicherzelle mit einem neuen Wert versehen, auf dessen aktuellen Wert mit dem Operator „!„ zugegriffen werden kann. 3.3 Typdeklarationen ML war eine der ersten Programmiersprachen, die Polymorphie in heute üblicher Form bereitstellte. Dabei wird Polymorphie nicht strukturglobal für eine ganze Gruppe von Typen und Funktionen gemeinsam festgelegt, sondern individuell für jeden Typ und jede Funktion einzeln. Als polymorpher Typ gilt jede Typdeklaration einer Datenstruktur, bei der der Basistyp ihrer Elemente als Parameter angegeben wird. 14 Kapitel 3: Wichtige Konzepte der Sprache Mathematisch kann ein polymorpher Typ als Funktion aufgefasst werden, die Typen in Typen abbildet. Eine polymorphe Funktion ist für Argumente unterschiedlicher Typen definiert und lässt sich mathematisch als Familie von Funktionen auffassen. Mit Hilfe der Polymorphie ist es folglich möglich, ein Problem abstrakter und allgemeingültiger zu definieren [Pe03, S. 218-219, S.223-226]. Auf die verschiedenen Deklarationsarten soll im Folgenden näher eingegangen werden. 3.3.1 Polymorphe Deklaration Polymorphe Datentypen erlauben es, Datenstrukturen zu definieren, die unabhängig von einem konkreten Typ sind. Somit kann das starre Typkonzept etwas aufgeweicht und das Programmieren erleichtert werden. Mit den Typvariablen ‘a, ‘b‚‘c… können in ML jegliche Datentypen repräsentiert werden, wie im folgenden Beispiel geschehen: - datatype 'a Pair = pair of ('a * 'a); datatype 'a Pair = pair of 'a * 'a - val intPair = pair(1,1); val intPair = pair (1,1) : int Pair - val boolPair = pair(true,false); val boolPair = pair (true,false) : bool Pair Ebenso können auch Funktionen polymorph programmiert werden. Beispielsweise liefert folgende Funktion wahlweise das erste oder das letzte Element eines Tupels: - fun get(x, val get = fn - get(false, val it = 7 : (a:'a, b:'a)) = if x then a else b; : bool * ('a * 'a) -> 'a (1,7)); (* Usage *) int Auch in Strukturen kann Polymorphie eingesetzt werden. Gegeben sei das in Abschnitt 3.2.1 vorgestellte Beispiel der geordneten Sequenz, dann kann der Funktor OrdSeq eingeführt werden, der als Parameter eine Struktur(variable) namens OrderedElements hat: - functor OrdSeq (OrderedElements : Order) : OrderedSequence = struct datatype ‘a Seq = … (* Implementation *) fun smaller (S1, S2) = … (* Implementation *) end; - structure Words = OrdSeq (OrderedCharacters); (* Usage *) 15 Kapitel 4: Verwendung der Sprache 3.3.2 Ambige Deklarationen Funktionsdeklarationen werden immer monomorph oder polymorph getypt. Bei valDeklarationen hingegen gibt es noch eine weitere Möglichkeit: die ambige Deklaration [Sm08, S. 61]. Hierbei handelt es sich um eine Deklaration, die freie Typvariablen monomorph behandelt, obwohl eigentlich eine polymorphe Typisierung möglich wäre. Dies ist dann der Fall, wenn ihre Ausführung, die Ausführung einer Funktions- oder Operatoranwendung beinhaltet. Ambige Deklarationen werden aufgrund von Speicheroperationen benötigt, wie folgendes Beispiel demonstriert [Sm08, S. 300]: - let val r = ref (fn x => x) in r := (fn() => ()); 1 + (!r 4) end; stdIn:22.9-22.13 Error: operator and operand don't agree [literal] operator domain: unit operand: int in expression: (! r) 4 Die Deklaration von r ist ambig, weil ihre rechte Seite eine Applikation ist. Somit muss r mit ref typisiert werden. Während jedoch das erste benutzende Auftreten von r einen Typ (unit unit) ref verlangt, wird bei der zweiten Benutzung ein Typ (int int) ref benötigt. Folglich ist der Ausdruck unzulässig und es wird eine Fehlermeldung ausgegeben. 4 Verwendung der Sprache 4.1 Erweiterung des ’97 Standards ML wird in vielen Forschungseinrichtungen und einigen Firmen für verschiedene Zwecke eingesetzt und gegebenenfalls erweitert, falls der Funktionsumfang des ‟97 Standards nicht ausreichte. So fügte SML/NJ dem Standard beispielsweise Vektoren, OR Muster oder Module höherer Ordnung hinzu. Eine andere umfassende Erweiterung mit dem Namen Alice ML wurde an der Universität des Saarlandes im Rahmen des Forschungsprojektes Ressourcenadaptive kognitive Prozesse entwickelt und ermöglicht parallele, verteilte und bedingte Programmierung [02]. Zu diesem Zweck musste SML auch um verzögerte Auswertung erweitert werden, die im Folgenden näher vorgestellt 16 Kapitel 4: Verwendung der Sprache wird. Im Anschluss soll anhand des SEND-MORE-MONEY-Beispiels die Vorzüge der bedingten Programmierung (engl: constraint programming) aufgezeigt werden. 4.1.1 Lazy Evaluation Bei der verzögerten Auswertung werden die Argumente einer Funktion erst dann ausgewertet, wenn sie tatsächlich benötigt werden. In Haskell werden alle Funktionen hierfür automatisch nach dem call-by-need-Prinzip ausgewertet, in Alice ML hingegen wurde aufgrund der strikten Auswertung von ML ein anderer Weg gewählt: Ein spezieller Typ (genannt lazy futures) verhindert die selektive Auswertung so lange wie möglich [Ne06, S. 2,11]. Neben der Möglichkeit Funktionen aus Funktionen zu generieren, bietet das Konzept der higher-order functions in Kombination mit der verzögerten Auswertung ein weiteres mächtiges Instrument: Die Möglichkeit ganze Programme miteinander zu verknüpfen [Hu84, S. 8-9]. Da ganze Programme in einer funktionalen Sprache auch nur Funktionen sind, können somit Programme als Argument übergeben oder als Resultat zurückgegeben werden. Seien foo und bar zwei Programme, dann würde in dem Ausdruck foo( bar( input)) das Programm bar durch die verzögerte Auswertung erst dann gestartet werden, wenn foo versucht sein Argument zu lesen. Somit kann bar sogar ein nicht terminierendes Programm sein, denn sobald foo seine Berechnungen abgeschlossen hat, wird bar automatisch (von außen) terminiert. Anstelle von komplexen Abbruchbedingungen kann die Frage der Terminierung von Programmen durch diese Methode elegant umgangen werden. Wenn die verzögerte Auswertung uniform für jeden Funktionsaufruf durchgeführt wird, ist es sogar möglich jeden beliebigen Teil des Programmes auf diese Art und Weise zu modularisieren. Ein vereinfachtes Beispiel ist die näherungsweise Berechnung des Integrals einer Funktion in einem bestimmten Bereich [Hu84, S. 13-14]. Eine grobe Annäherung erhält man bereits mit folgender einfachen Integralfunktion, die jede Funktion als linear ansieht: - fun easyintegrate(f, a, b) = (f(a) + f(b))*(b-a)/2.0;(* Def *) - easyintegrate(Math.sin, 0.0, Math.pi); (* Usage *) val it : real = 0.420735492404 Die Berechnung lässt sich verbessern, indem das Intervall [a, b] halbiert und die Fläche unterhalb der beiden Hälften addiert wird. Bei jedem weiteren Halbierungsschritt erhöht 17 Kapitel 4: Verwendung der Sprache sich die Genauigkeit, sodass sich eine endlose Liste von Annäherungen erstellen lässt, die sich immer mehr dem tatsächlichen Ergebnis annähert. Die Funktion integrate benutzt hierfür eine Reihe von lazy Hilfsfunktionen (die verzögerte Auswertung wird mit dem vorangestelltem Schlüsselwort lazy einleitet). lmap und lzip entsprechen den gängigen Listenfunktionen map und zip, ladd addiert ein durch lzip generiertes Paar zu einem Wert und integ berechnet die Integralfläche der Hälften (der vollständige Quellcode für die folgenden Funktionen ist im Anhang B abgedruckt): fun lazy integrate(f, a, b) = let … fun lazy integ (f, a, b, x, y) = let val m = (a+b)/2.0 val z = f(m) in (x+y)*(b-a)/2.0 :: lmap ladd (lzip( integ(f, a, m, x, z), integ(f, m, b, z, y))) end in integ(f, a, b, f(a), f(b)) end; Da für die Hilfsfunktion integ keine Abbruchsbedingung definiert wurde, produziert sie eine endlose Liste von Fließkommazahlen. Jedoch werden diese nur soweit ausgewertet, wie für die Berechnung eines konkreten Wertes tatsächlich erforderlich ist. Mit der Funktion within wird beispielsweise so lange integriert, bis die Differenz zweier aufeinander folgenden Annäherungen kleiner als der angegebene Wert eps ist: - List.take(integrate(Math.sin, 0.0, 1.0),3); val it : real list = [0.420735492404, _lazy, _lazy] - fun within(eps, (a::b::rest)) = if abs(1.0*a-b) <= eps then b else within(eps, b::rest); - within(0.0001, integrate(Math.sin, 0.0, 1.0)); val it : real = 0.45968834152 Die verzögert ausgewerteten Funktionen lassen sich, genau wie die beispielsweise streams in Java, beliebig verschachteln. So kann die Funktion super verwendet werden, um die Annäherungen noch schneller konvergieren zu lassen: - within(0.0001, (super ( integrate(Math.sin, 0.0, 1.0)))); val it : real = 0.45969769039 4.1.2 Constraint Programming Constraints sind spezielle prädikatenlogische Formeln, mit deren Hilfe der Benutzer Eigenschaften von kombinatorischen Problemen und deren Lösungen durch Bedingungen oder Einschränkungen beschreibt [Ho07, S. 46-53]. Mit der Belegung der 18 Kapitel 4: Verwendung der Sprache Variablen mit konkreten Werten wird ein Constraint entweder erfüllt oder verletzt. In einem baumartigen Suchverfahren übernimmt nun der Computer die Suche nach einer oder mehreren Lösungen; dabei werden alle Pfade abgeschnitten, die eine Bedingung verletzen. Eine Lösung wird als gültig angesehen, wenn für alle Variablen konkrete Werte eingesetzt werden konnten, ohne dass eine Bedingung verletzt wurde. Anstatt also jede mögliche Kombination stumpf auszuprobieren, wird der Suchraum schon vor der Zuweisung konkreter Werte für die Variablen durch die Überprüfung der Erfüllbarkeit aller Bedingungen stark eingeschränkt. Demzufolge können Inkonsistenzen vermieden werden. Ein Beispiel ist das krypto-arithmetische Puzzel SEND-MORE-MONEY. Jeder Buchstabe der Gleichung SEND + MORE = MONEY soll durch eine der Ziffern 0 bis 9 belegt werden, so dass die Geleichung erfüllt wird. Die Definition des Problems mit Hilfe von Alice lautet: import structure import structure import structure import structure open Modeling; FD Modeling Search Explorer from from from from "x-alice:/lib/gecode/FD"; "x-alice:/lib/gecode/Modeling"; "x-alice:/lib/gecode/Search"; "x-alice:/lib/tools/Explorer"; fun money sp = let val letters as #[S,E,N,D,M,O,R,Y] fdtermVec (sp, 8, [0`#9]) in distinct (sp, v, FD.BND); post (sp, S `<> `0, FD.BND); post (sp, M `<> `0, FD.BND); post (sp, `1000`*S `+ `+ `1000`*M `+ `= `10000`*M `+ `1000`*O `+ FD.BND); branch (sp, letters, FD.B_SIZE_MIN, {S,E,N,D,M,O,R,Y} end; = `100`*E `+ `10`*N `+ D `100`*O `+ `10`*R `+ E `100`*N `+ `10`*E `+ Y, FD.B_MIN); Explorer.exploreAll money; Zunächst werden den Buchstaben die möglichen Ziffern von 0 bis 9 zugeordnet. Die Funktion distinct sorgt dafür, dass alle Variablen verschieden belegt werden. Anschließend werden die weiteren Constrains mit post definiert. Dabei lassen sich neben der Gleichung selbst noch zwei weitere Nebenbedingungen ableiten: weder M noch S dürfen 0 sein. 19 Kapitel 4: Verwendung der Sprache ( D=2..8, E=4..7, M=1, N=5..8, O=0, R=2..8, S=9, Y=2..8 ) E=4 () E <> 4 ( D=2..8, E=5..7, M=1, N=6..8, O=0, R=2..8, S=9, Y=2..8 ) E=5 ( D=2, E=5, M=1, N=6, O=0, R=8, S=9, Y=2 ) E <> 5 ( D=2..8, E=6..7, M=1, N=7..8, O=0, R=2..8, S=9, Y=2..8 ) E=6 () Entscheidung Lösung E <> 6 () Verletzt Bedingung Abbildung 4-1: First-Fail Suchbaum Die Funktion branch bestimmt das Verhalten des Suchalgorithmus. In diesem Fall wurde eine first-fail Strategie verwendet, d. h. es wird die Variable überprüft, die die wenigsten möglichen Werte annehmen kann und davon der kleinste Wert zur Verzweigung genutzt. Wie aus der Abbildung 4-1 ersichtlich, wurden auf diese Weise deutlich weniger als die 108 Möglichkeiten untersucht, welches für die Effizienz des Verfahrens spricht. Die Lösung lässt sich aus dem grün unterlegtem Kasten ablesen. 4.2 ML in der Praxis Obwohl sich die ML nach der Veröffentlichung des ‟97 Standards auch für große Softwareprojekte eignete, wurde es außerhalb der Forschung und Lehre nur selten verwendet. PEPPER sieht die Ursache weniger in der Sprache an sich, als in der Tatsache, dass Menschen lieber mit den Sprachen und Konzepten arbeiten, die ihnen vertraut sind, als sich für eine weniger vertraute Sprache zu entscheiden, die für das spezifische Problem geeigneter wäre [Ay99]. Der naheliegende Einsatz von ML ist der Einsatz als Beweissystem, welches auf die Wurzeln von LCF zurückzuführen ist. Ein bekanntes Beispiel ist das an der TU München und der University of Cambridge entwickelte Programm Isabelle [03]. Es erlaubt den Ausdruck mathematischer Beweise in formaler Sprache und unterstützt die formale Verifikation, bei der u. A. die Korrektheit von Hardware und Software sowie Eigenschaften von Computersprachen und Protokollen bewiesen werden können. Zusätzlich können ausführbare Spezifikationen in SML, OCaml oder Haskell generiert 20 Kapitel 4: Verwendung der Sprache werden. Ein weiteres Programm ist das System wHOLe, welches auf der Prädikatenlogik höherer Stufe (eng. higher-order logic) basiert [Wo99]. Es ist in SML/NJ geschrieben und erlaubt Verifizierungen von ganzen Programmen in derselben Sprache. Ein Beispiel aus der Industrie bietet Motorola UK. Dort wird ML verwendet, um die Syntax und Semantik von Message Sequence Charts (MSC) zu validieren [04]. MSC werden hautsächlich in der Telekommunikationsbranche für die einheitliche Darstellung von Nachrichtenfolgen eingesetzt. Das Programm kann zusätzlich Testscipts für die Systeme genieren, die MSC implementieren. Auch bei der Behebung des „Millennium-Bugs“ in Cobol-Programmen, bei dem durch die zweistellige Darstellung der Jahreszahlen sowohl das Jahr 1900 als auch 2000 gemeint sein konnte, setzte der Entwickler Hafnium auf die Sprache ML [05]. Eine andere Domäne von ML ist das Gebiet des Compilerbaus. So existiert eine Implementierung der renommierten Compilertools Lex und YACC (Abk.: Yet Another Compiler Compiler) in ML [06, 07]. Diese Programme ermöglichen die lexigraphische Analyse und Parsing-Funktionen, die das Frontend eines Compilers bilden. Der Entwurf eines effizienten Compilers für eine spezifische Programmiersprache ist sehr zeitaufwendig, besonders wenn mehrere Hardwarearchitekturen unterstützt werden sollen. Um die Programmierer bei der Portierung des Compilers auf andere Architekturen, der Wiederverwendung von Compiler-Konzepten und der abschließenden Optimierung softwaremäßig zu unterstützen, wurde an der New York University in Zusammenarbeit mit Bell Labs ein auf ML basierendes Framework mit dem Namen MLRisc entwickelt [08]. Neben den Compiler für die Sprachen C-- oder Moby gehört auch der für diese Arbeit verwendete Compiler SML/NJ zu den Nutzern von MLRisc. Abschließend soll noch MLton erwähnt werden, welches ganze Programme, die dem ‟97 Standard gehorchen, optimiert kompilieren kann [09]. Selbst große Programme stellen kein Problem dar, beispielsweise hat MLton (140 000 lines of code) sich selbst kompiliert. 21 Kapitel 5: Zusammenfassung 5 Zusammenfassung In den vorherigen Kapiteln wurden die Vorzüge der funktionalen Programmierung, insbesondere die elegante Ausdrucksmöglichkeit und das Arbeiten mit Funktionen höherer Ordnung vorgestellt und anhand von Beispiel-Programmen die erfolgreiche Verankerung dieser Konzepte in ML demonstriert. Der von vielen Unterstützern der funktionalen Sprache als „Makel“ angesehene, imperativer Anteil von ML hat dabei keineswegs zu Komplikationen geführt. Im Gegenteil, durch das Weglassen dieses Features hätte ML an Ausdruckskraft verloren und würde einige Implementierungen nur unnötig erschweren. Auch das von den objektorientierten Sprachen dominierte Konzept der Abstraktion und Verbergung kann mit Hilfe des Modulsystems entsprechend anwendet werden. Durch die Erweiterung um lazy evaluation lassen sich schließlich ganze Programme elegant mit einander verknüpfen. Die Entwicklungen an Alice ML oder Isabelle zeigen, das trotz des großen Zulaufs zu gängigen Programmiersprachen wie Pascal, Java oder C# funktionale Sprachen ihre Daseinsberechtigung nicht verloren haben und in einigen Gebieten, wie zum Beispiel die Validierung von Systemen, sogar bevorzugt verwendet werden. Programmiersprachen wie ML oder Python haben gezeigt, dass die Verschmelzung von funktionalen und imperativen Konzepten durchaus erfolgsversprechend sein kann und man mag gespannt sein, ob in Zukunft andere Programmiersprachen in die selben Fußstapfen treten werden. 22 Anhang A: Iterative Lösung der „Türme von Hanoi“ A Iterative Lösung der „Türme von Hanoi“ Im Folgenden eine iterative Implementierung der Lösung des Problems der “Türme von Hanoi” in Anlehnung an die Implementierung von Mark Allen Weiss [10]. fun hanoiIter(n) = let fun even(x) = x mod 2 = 0 fun shift(f, x, y) = let fun pow(a, 0) = 1 | pow(a, b) = if even(b) then pow(a*a, b div 2) else pow(a*a, b div 2) * a in f(x, pow(2,y)) end (* disk to be moved in step i *) fun getDisk(x) = let val d = ref 0 and i = ref (x+1) in while even(!i) do ( i := !i div 2; d := !d+1 ); !d end (* how many times disk d is moved before stage i *) fun movements(i, d) = let fun opDiv(x,y) = x div y infix 8 >> fun A >> B = shift(opDiv, A, B) in ((i >> d) +1) >> 1 end (* clockwise = 1; 2 the other way *) fun direction(d) = 2 - (n mod 3 +d) mod 2 infix 8 << fun A << B = shift(op*, A, B) val i = ref 0 val limit = 1 << n -1 val disk = ref 0 val start = ref 0 val dest = ref 0 val result = ref [] in while !i < limit do ( disk := getDisk(!i); start := (movements(!i, !disk) * direction(!disk)) mod 3; dest := (!start + direction(!disk)) mod 3; result := !result @ [(!start + 1, !dest + 1)]; i := !i +1 ); !result end; 23 Anhang B: Approximative Berechnung eines Integrals B Approximative Berechnung eines Integrals fun lazy integrate(f, a, b) = let fun lazy lzip(x::xs, y::ys) = [x,y] :: lzip (xs,ys) | lzip _ = nil fun lazy lmap f nil = nil | lmap f (x::xs) = f(x) :: lmap f xs fun lazy ladd(nil) = 0.0 | ladd(hd::l) = hd + ladd(l) fun lazy integ (f, a, b, x, y) = let val m = (a+b)/2.0 val z = f(m) in (x+y)*(b-a)/2.0 :: lmap ladd (lzip( integ(f, a, m, x, z), integ(f, m, b, z, y))) end in integ(f, a, b, f(a), f(b)) end; fun lazy super(s) = let fun lazy lmap f nil = nil | lmap f (x::xs) = f(x) :: lmap f xs fun lazy second (a::b::rest) = b fun lazy repeat(f, a) = a :: repeat(f, f(a)) fun lazy improve(s) = (* reduce error *) let fun lazy elimerror(n, a::b::rest) = let val h = Math.pow(2.0, n) in (b*h-a)/(h-1.0) :: elimerror(n, b::rest) end (* estimate best n for elimerror * ) fun lazy order (a::b::c::rest) = let fun roundR(n) = real (round n) fun log2(x) = Math.ln(x)/Math.ln(2.0) in roundR( log2( (a-c)/(b-c) -1.0)) end in elimerror( order(s), s) end in lmap second (repeat( improve, s)) end; (* within(0.0001, (super ( integrate(Math.sin, 0.0, 1.0)))); *) 24 Literaturverzeichnis Literaturverzeichnis [Ay99] Sibel Aydinc, Amarilis M. Aranya: Interview: OPAL, iCoup (2), 1999. URL: http://user.cs.tu-berlin.de/~icoup/archiv/2.ausgabe/artikel/opal.html Abrufdatum: 07.04.2009. [Er86] M. C. Er: Performance evaluations of recursive and iterative algorithms for the Towers of Hanoi problem, Computing 37(2), S. 93-102, 1986. [Fr93] Kaxen Frenkel: An interview with Robin Milner, Comm. of the ACM 36(1), S. 90-97, 1993. [Ho07] Petra Hofstedt, Armin Wolf: Einführung in die ConstraintProgrammierung, Springer, 2007. [Hu84] John Hughes: Why Functional Programming Matters, 1984 URL: http://www.cs.chalmers.se/~rjmh/Papers/whyfp.html Abrufdatum: 20.03.2009. [Le01] Martin Leucker, Thomas Noll, Perdita Stevens, MichaelWeber: Functional programming languages for verification tools: a comparison of Standard ML and Haskell, Proceedings of the Scottish Functional Programming Workshop, S. 184-194, 2001. [Kl07] Herbert Klaeren, Michael Sperber: Die Macht der Abstraktion, Teubner, 2007. [Mq93] David B. Macqueen: Reflections on Standard ML, Functional Programming, Concurrency, Simulation and Automated Reasoning, S. 32-46, 1993. [Mi90] Robin Milner, Mads Tofte, Robert Harper: The Definition of Standard ML, MIT Press, 1990. [Ne06] Georg Neis: A Semantics for Lazy Types, Bachelorarbeit, Universität des Saarlandes, 2006. URL: http://www.ps.uni-sb.de/Papers/abstracts/lazytypes.html. Abrufdatum: 07.04.2009. [Pe03] Peter Pepper: Funktionale Programmierung in Opal, ML, Haskell und Gofer, 2. Aufl., Springer, 2003. [Pe06] Peter Pepper, Petra Hofstedt: Funktionale Programmierung – Sprachdesign und Programmiertechnik, Springer, 2006. [Sm08] Gert Smolka: Programmierung – eine Einführung in die Informatik mit Standard ML, Oldenbourg, 2008. [Wo99] Mark E. Woodclock: The wHOLe System, Applied Formal Methods – FMTrends 98 1641/1999, S.359-366, 1999. 25 Literaturverzeichnis [01] URL: http:// www.smlnj.org. Abrufdatum: 20.03.2009. [02] URL: http://www.ps.uni-sb.de/alice/. Abrufdatum: 07.04.2009. [03] URL: http://isabelle.in.tum.de/index.html. Abrufdatum: 07.04.2009. [04] URL: http://homepages.inf.ed.ac.uk/wadler/realworld/ptk.html. Abrufdatum: 07.04.2009. [05] URL: http://homepages.inf.ed.ac.uk/wadler/realworld/annodomini.html. Abrufdatum: 07.04.2009. [06] URL: http://www.cs.princeton.edu/~appel/modern/ml/ml-lex/. Abrufdatum: 07.04.2009. [07] URL: http://www.smlnj.org/doc/ML-Yacc/index.html. Abrufdatum: 07.04.2009. [08] URL:http://www.cs.nyu.edu/leunga/www/MLRISC/Doc/html/INTRO.html. Abrufdatum: 07.04.2009. [09] URL: http://mlton.org/. Abrufdatum: 07.04.2009. [10] URL: http://www.cs.cornell.edu/Courses/cs211/2006fa/Lectures/L03Recursion/Hanoi-Iterative.java, Abrufdatum: 03.04.2009. 26