Fachbereich Informatik Universität Hamburg Deklarativ, wenn möglich; imperativ, wenn nötig Deklarativer Programmierstil heute Karsten Pietrzyk Matrikelnummer: 6209622 31.7.13 Bachelorarbeit Erstgutachter: Prof. Dr.-Ing. Wolfgang Menzel Zweitgutachter: Prof. Dr. rer. nat. Leonie Dreschler-Fischer Inhaltsverzeichnis 1 Einleitung 1.1 Diese Arbeit . . . . . . . . . . . . . . . 1.2 Was ist deklarative Programmierung? 1.3 Wozu deklarative Programmierung? . 1.4 Imperative Programmierung . . . . . . 1.5 Objektorientierung . . . . . . . . . . . 1.6 Aufbau dieser Arbeit . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 2 Deklarativer Programmierstil um 1980 2.1 Prolog . . . . . . . . . . . . . . . . . . . . . . . . 2.1.1 Unifikation . . . . . . . . . . . . . . . . . 2.1.2 Einordnung von Prolog . . . . . . . . . . 2.1.3 Prolog und funktionale Programmierung 2.2 Miranda . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 3 Deklarativer Stil um 2000 3.1 Analyse von deklarativen Sprachelementen . . . . . 3.2 Deklarative Elemente von F# . . . . . . . . . . . . . 3.2.1 Definition und Verwendung von Typen . . . Funktionstyp . . . . . . . . . . . . . . . . . . Algebraischer Datentyp . . . . . . . . . . . . Datensatz . . . . . . . . . . . . . . . . . . . . Klasse und Interface . . . . . . . . . . . . . . Struct . . . . . . . . . . . . . . . . . . . . . . 3.2.2 Pattern matching . . . . . . . . . . . . . . . . 3.2.3 Active Patterns . . . . . . . . . . . . . . . . . Klassifizierer . . . . . . . . . . . . . . . . . . Validierer und Parameterised Active Pattern Konvertierter . . . . . . . . . . . . . . . . . . Parser und Partial Active Patterns . . . . . . 2 . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 5 5 6 7 8 8 8 . . . . . 10 12 13 13 15 15 . . . . . . . . . . . . . . 19 20 21 21 22 23 24 25 25 26 29 30 31 31 31 Deklarativ, wenn möglich; imperativ, wenn nötig Übersicht der Anwendungsfälle 3.2.4 Computation Expressions . . . 3.3 Domänenspezifische Sprachen . . . . 3.3.1 Was sind DSLs? . . . . . . . . . 3.3.2 Reguläre Ausdrücke . . . . . . 3.4 Metaprogrammierung . . . . . . . . . 3.4.1 Was ist Metaprogrammierung? 3.4.2 Quotations . . . . . . . . . . . . . . . . . . . 33 34 38 38 39 41 41 42 4 Imperativer Stil 4.1 Motivation . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.2 Imperative Konsistenzprüfungen . . . . . . . . . . . . . . . . . . . 4.3 Imperativ-objektorientierte Typen . . . . . . . . . . . . . . . . . . 4.3.1 Modellieren einer festen Anzahl an Varianten . . . . . . . . 4.3.2 Bezug zu Typ-Definitionen in F# . . . . . . . . . . . . . . . 4.3.3 Structs . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.4 Imperativ-objektorientierte Fallunterscheidungen . . . . . . . . . . 4.4.1 Mittel der Fallunterscheidungen reiner imperativer Programmierung . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4.4.2 Mittel der imperativ-objektorientierten Programmierung . 4.4.3 Wann ist welche Technik zu benutzen? . . . . . . . . . . . 44 44 46 47 49 50 50 51 5 Evaluation der Fallunterscheidungstechniken 5.1 Pattern matching . . . . . . . . . . . . . . . . 5.2 Subtyp-Polymorphie . . . . . . . . . . . . . . 5.3 Philip Wadlers expression problem . . . . . . . 5.4 Imperative Ansätze . . . . . . . . . . . . . . . 5.5 Das Besucher-Entwurfsmuster (Visitor pattern) 5.6 Abschließende Betrachtung . . . . . . . . . . . . . . . . 54 55 58 59 59 60 61 6 Fazit 6.1 Dargestellte Aspekte . . . . . . . . . . . . . . . . . . . . . . . . . . 6.2 Ausgelassene Aspekte . . . . . . . . . . . . . . . . . . . . . . . . . 6.3 Schlusswort . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 63 63 64 65 7 Anhang 7.1 Listing 1: Miss Grant’s Controller in F# . . . . . . . . . . . . . . . 7.2 Listing 2: XML-Traversierung mit Active Patterns in F# . . . . . . 7.3 Listing 3: Generische Variante von Counting Sort in F# . . . . . . 7.4 Listing 4: Prolog-ähnliche Quotation in F# . . . . . . . . . . . . . 7.5 Listing 5: Evaluator - algebraische Datentypen / Pattern matching 7.6 Listing 6: Evaluator - datenorientiert mit Typtests . . . . . . . . . 7.7 Listing 7: Evaluator - datenorientiert mit switch über Type code . 7.8 Listing 8: Evaluator - verhaltensorientiert mit Polymorphie . . . . 7.9 Listing 9: Evaluator - verhaltensorientiert mit Visitor pattern . . . 7.10 Listing 10: Test der Evaluator-Implementierungen in C# . . . . . . 70 70 72 73 74 75 79 82 85 88 93 3 . . . . . . . . Karsten Pietrzyk . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 52 52 53 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig Zusammenfassung In dieser Arbeit geht es darum, den deklarativen Programmierstil zu untersuchen, weil er den Fokus darauf legt, dem Computer mitzuteilen, was gemacht werden soll, im Gegensatz zum imperativen Stil, bei dem es um das Wie geht. Gerade heute beinhalten Programmiersprachen deklarative Elemente, wodurch mit ihnen produktiver gearbeitet werden kann. Eine moderne, multiparadigmatische Programmiersprache mit vielen deklarativen Elementen ist F#. Die Verwendung deklarativer Programmiersprachenelemente führt zu elegantem Quellcode und das damit oft assoziierte zustandslose Programmieren bringt insbesondere Vorteile bei parallelen Berechnungen. Dennoch müssen nicht alle Probleme zwingend zustandslos gelöst werden. Die Alternative, imperativ und zustandsorientiert zu programmieren, hat vor allem bei der Implementierung effizienter Algorithmen ihre Anwendung. Ziel der Arbeit ist es, eine konkrete Idee von deklarativer Problemlösung zu vermitteln und die entgegengesetzten Programmierstile abzuwägen. Im Rahmen dieser Arbeit werden deklarative Programmiersprachenelemente von F# untersucht und ihren imperativen Pendants gegenübergestellt. 4 1 Einleitung 1.1 Diese Arbeit Diese Arbeit stellt die deklarative Programmierung vor und vergleicht Konzepte aus diesem Programmierstil mit dem gegensätzlichen imperativen Paradigma. Letztendlich werden auch deklarative Programme auf einer niedrigen Maschinenebene umgesetzt, deshalb ist das Wissen um die Arbeitsweise deklarativer Sprachen eine Voraussetzung, um moderne Programmiersprachenfeatures zu bewerten und ihre Verwendung abzuwägen. Diese Arbeit ist aber keine Auflistung von beliebigen Programmiersprachenfeatures. Geeignete Modularisierung und hohes Abstraktionsniveau sind die zwei Kernanforderungen, die an eine Programmiersprache gestellt werden sollten, daher werden sie als Leitlinie benutzt. F# tritt in dieser Arbeit als Vertreter der modernen deklarativen Programmierung auf und orientiert sich an der ML-Familie (ML steht für meta language) und ist funktional geprägt. Zuerst gehe ich auf Anfänge deklarativer Sprachen ein, weil heutige Programmiersprachenkonzepte auf Ideen alter Programmiersprachen basieren. Dann werden deklarative Ansätze in F# untersucht, um eine konkrete Idee von deklarativer Programmierung zu geben. In der Einleitung werden zuerst verschiedene Erklärungen und Definitionen von „deklarativer Programmierung“ vor- und gegenübergestellt. Daraus resultiert eine eigene Definition, die in der Arbeit als Leitfaden verwendet wird. Mit einem kurzen Überblick über verwendete Paradigmen und der Zielsetzung dieser Arbeit schließt das Kapitel. 5 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig 1.2 Was ist deklarative Programmierung? Definition nach [InformatikLexikon]: „deklarativ: Erklärend, verkündend; Programmiersprachen der 4. Generation; in einer d. Programmiersprache erklärt der Progammierer, was er erhalten oder erreichen möchte, aber die Einzelschritte zum Ziel entscheidet der Compiler oder Interpreter; man erklärt das Was, nicht aber das Wie; Abfragesprachen sind im Allgemeinen d., weil wir nur eine Datenmenge beschreiben: im Beispiel SELECT name FROM mitglieder übernimmt das System die Öffnung der Datei und/oder die Steuerung des Cursors durch alle Datensätze hindurch sowie Optimierungen u. a.; funktionale Programmiersprachen sind d., weil es in ihnen keine Anweisungen, sondern nur Funktionen gibt, vergleiche Lambda-Kalkül; siehe als Gegensatz: deskriptiv“ [InformatikLexikon, S. 215] Nach [PractAdvantDecl]: „Informally, declarative programming involves stating what is to be computed, but not necessarily how it is to be computed. [...] The key idea of declarative programming is that a program is a theory (in some suitable logic) and that computation is deduction from the theory. “ [PractAdvantDecl, S. 1] Nach [DeklarativeProgrammierung]: „Gemeinsam ist allen deklarativen Sprachen, daß die Lösung eines Problems auf einer hohen Abstraktionsebene spezifiziert, und nicht auf einer niedrigen Maschinenebene ’ausprogrammiert’ wird. Offensichtlich sind gemäß dieser Unterscheidung die Grenzen fließend! Entscheidend ist hierbei, was wir als ’high-level’ vs. ’low-level’ bezeichnen. Gemeint ist in diesem Zusammenhang etwa nicht der Unterschied zwischen maschinennahen (Assembler-) und problemorientierten (’höheren’) Programmiersprachen. “ [DeklarativeProgrammierung, S. 7] Nach [MultiparadigmenProg]: „Die zustandsfreien Sprachen (auch oft deklarative Sprachen genannt) werden im Wesentlichen in funktionale, logische und constraint-basierte Programmiersprachen unterteilt. “ [MultiparadigmenProg, S. 8] Folgende Eigenschaften und Ideen lassen sich extrahieren: • Was statt Wie (der Computer entscheidet die konkrete Umsetzung) • deklarative Programme sind zustandsfrei / referentiell transparent • Problem wird auf einer hohen Abstraktionsebene spezifiziert • funktionale, logische und constraint-basierte Programmiersprachen sind deklarativ Insbesondere finde ich die Haltung des deklarativen Programmierers wichtig, d.h. Konzentration auf das Problem, nicht auf die Umsetzung auf Maschinenebene. Für diese Arbeit schlage ich deshalb eine eigene, kurze Formulierung vor, welche die Tätigkeit des Programmierers beschreibt, ohne mich dabei auf Programmier6 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk sprachfamilien einzuschränken. So können auch domänenspezifische Sprachen und geeignete Konzepte aus imperativ-objektorientierten Programmiersprachen deklarativ genannt werden. „Deklarative Programmierung ist das Formulieren von Regeln und Zusammenhängen, die in ein ausführbares Programm überführt werden können. “ Die Kernpunkte dieser Definition werden hier einmal aufgeschlüsselt: • Das Formulieren geschieht üblicherweise durch das Schreiben von Text oder Quelltext, aber auch grafische Symbolmanipulation ist denkbar. • Regeln beschreiben Bedingungen, um Fälle zu klassifizieren oder zu behandeln. • Zusammenhänge sind z.B. funktionale Zusammenhänge oder logische Relationen. • Ein ausführbares Programm ist entweder eine fertige Anwendung oder ein Programmbaustein, der in einer anderen Anwendung benutzt wird. • Überführt werden die Beschreibungen in ausführbare Programme z.B. durch Codegenerierung und Kompilierung oder Programmtransformation und Interpretation. Obwohl Definitionen wie „a program is a theory (in some suitable logic) and [...] computation is deduction from the theory“ [PractAdvantDecl] der deklarativen Idee recht nahe kommen, halte ich es dennoch für wichtig genug, zwei zentrale Elemente der Programmierung in den Vordergrund zu stellen: Regeln zur Unterscheidung und Zusammenhänge zur Berechnung. Insbesondere da gängige Programmierung bisher noch mit Quellcode und (noch) nicht mit Formeln aus der Mathematik funktioniert. 1.3 Wozu deklarative Programmierung? „Bei deklarativer Programmierung konzentriert man sich auf die wesentliche Problemlösung und nicht auf Details der Umsetzung. “ • Problemlösung ist der Zweck von Programmen. • Details der Umsetzung lenken von der Problemlösung ab und können komplex (z.B. Speicherhierarchie), fehlerträchtig (z.B. Speicherfreigabe) oder lästig (z.B. Datenprüfung) sein. Bei der Arbeit mit solchen Details kann fehlende Programmierdisziplin oder Unwissen zu fatalen Fehlern führen. Unterstützt eine Programmiersprache nicht die nötige Modularisierung, sind einfache Aufgaben nur durch Schreiben von sogenanntem Boilerplate-Code (größere Quellcode-Ausschnitte, die geringfügig abgeändert an mehreren Stellen auftreten) zu lösen [RealWorldFP, vgl. S. 130]. 7 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig 1.4 Imperative Programmierung Der Gegensatz von deklarativer Programmierung ist imperative Programmierung (auch deskriptive oder prozedurale Programmierung [InformatikLexikon]). Die Kernidee imperativer Programmierung ist es, den Zustand während des Programmablaufs durch die Ausführung von Anweisungen zu verändern, sodass das Ziel erreicht wird. Durch die aus der prozeduralen Programmierung ermöglichten Prozeduren kann Quellcode organisiert werden, was Programmbibliotheken und Unterprogramme ermöglicht [MultiparadigmenProg, vgl. S. 11-12]. Imperative Programmierung ist wichtig, weil sie mit der Arbeitsweise des Computers übereinstimmt und gängige Algorithmen, die in Pseudocode notiert sind, sich auf imperative Programmierung berufen. 1.5 Objektorientierung Ein besonders wichtiges Programmier-Paradigma der heutigen Zeit ist Objektorientierung (kurz OO). Der wichtigste Vertreter in der Anfangszeit dieses Paradigmas ist die Programmiersprache Smalltalk. Die zentrale Idee ist die Strukturierung von Programmen durch Klassen und Organisierung von Programmlogik durch Methoden. Viele heutige Frameworks basieren auf diesem Paradigma und es ist Teil des Lehrplans vieler Schulen und Universitäten. Die bekanntesten Vertreter des objektorientierten Paradigmas sind Java, C# und C++ [InformatikLexikon, vgl. S. 701]. Beim objektorientierten Entwurf wird oft UML (Unified Modeling Language) verwendet, eine grafische Beschreibungssprache zur Kommunikation von Zusammenhängen von Klassen und anderen Bausteinen. Es haben sich Entwurfsmuster aus zahlreichen objektorientierten Entwürfen herauskristallisiert, die in dem Buch [Entwurfsmuster] strukturiert dargestellt wurden. Diese Entwurfsmuster lösen häufig auftretende Probleme in objektorientierten Programmen, und gehören zum Handwerkszeug für OO-Programmierer. In anderen Programmierparadigmen können vergleichbare Probleme durch Konzepte des Paradigmas elegant gelöst werden. Eine Gegenüberstellung der objektorientierten Entwurfsmuster mit Konzepten des Funktionalen Paradigmas gibt [AnalyseEntwurfsmusterFP]. 1.6 Aufbau dieser Arbeit Als frühe Vertreter der deklarativen Programmierung werde ich Prolog und Miranda vorstellen. Es folgt sodann ein Katalog der deklarativen Programmierung mit F#. Im Kapitel Imperativer Stil (S. 44) werden einerseits einige imperativeobjektorientierte Konzepte beleuchtet, insbesondere alternative Techniken und Herangehensweisen im Vergleich zum deklarativen Stil. Andererseits werden Vorteile imperativer Programmierung dem deklarativen Ansatz gegenüber gestellt und Kombinationsmöglichkeiten vorgestellt. So können aus beiden Denkweisen 8 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk einige Vorteile vereint werden. Ausführlicher werden Fallunterscheidungsmöglichkeiten aus beiden Paradigmen gegenübergestellt und verglichen. Dazu dient als Beispiel ein Code-Interpreter. Ein Fazit schließt die Arbeit. Zuvor habe eine Definition und Erklärung gegeben: „Deklarative Programmierung ist das Formulieren von Regeln und Zusammenhängen, die in ein ausführbares Programm überführt werden können. “ „Bei deklarativer Programmierung konzentriert man sich auf die wesentliche Problemlösung und nicht auf Details der Umsetzung. “ Die beiden oben genannten Sätze lassen sich auch zu einem zusammenfassen: „Deklarative Programmierung ist das Formulieren von Regeln und Zusammenhängen, die in ein ausführbares Programm überführt werden können, wobei man sich auf die wesentliche Problemlösung und nicht auf Details der Umsetzung konzentriert. “ Zwei Techniken außerhalb von Programmiersprachenkonzepten möchte ich auch behandeln: DSLs und Metaprogrammierung. Zu deklarativen Sprachen zählt man auch domänenspezifische Sprachen (domain-specific languages, kurz DSLs), eingeschränkte fachspezifische Sprachen, die Vorzüge gegenüber generellen Programmiersprachen haben [DSLs, vgl. S. 33] und im gleichnamigen Abschnitt (S. 38) vorgestellt werden. Metaprogrammierung (S. 42) ist einerseits eine TechnikenSammlung für DSLs und andererseits werden dadurch Programmtransformationen möglich, die den deklarativen Horizont erweitern. Beispielsweise können Programmteile in F# geschrieben werden, die in Code einer anderen Programmiersprache transformiert werden. Dies ist Thema des gleichnamigen Kapitels. 9 2 Deklarativer Programmierstil um 1980 Der Begriff deklarative Programmierung wird ebenfalls als Sammelbegriff für funktionale, logikbasierte und constraintbasierte Programmierung verwendet [MultiparadigmenProg, vgl. S. 8]. Frühe Vertreter der deklarativen Programmierung sind LISP (1958), Miranda (1985) und Prolog (1972), sowie weniger bekannte Spezifikationssprachen, die jedoch im Allgemeinen nicht ausgeführt werden können und nur Beschränkungen formulieren [FPWithMiranda, vgl. S. 4]. Insbesondere ist der mit diesen genannten Sprachen verfolgte deklarative Ansatz ein völlig anderer als der Ansatz der imperativen Programmierung mit C-ähnlichen Sprachen. Der deklarative Ansatz soll mit meiner Definition abgeglichen und anhand von Prolog und Miranda als alte deklarative Sprachen illustriert werden. Eine wichtige Eigenschaft in diesem Zusammenhang ist die referenzielle Transparenz. Sie bedeutet, dass eine Funktion bei gleichen Argumenten immer denselben Wert produziert und der Wert nur von den Argumenten abhängig ist. Die Idee ist es, Gleiches mit Gleichem zu ersetzen; das heißt konkret, dass die Funktionsapplikation mit dem Ergebnis der Applikation ersetzt werden könnte. Diese Ersetzung nennt sich auch Auswertung oder Reduktion. In [FPWithMiranda, S. 2] wird der Ausdruck (z.B. der arithmetische Ausdruck 2 * a + b * c) als Gemeinsamkeit aller deklarativen Sprachen genannt. Dieses Konstrukt taucht auch in der imperativen Programmierung oft auf. Es gibt aber Unterschiede - sogar innerhalb von deklarativen Sprachen: • Miranda und reine funktionale Programmiersprachen: keine Seiteneffekte und verzögerte Auswertung (laziness). Das Schreiben eines Ausdrucks führt dazu, dass gespeichert wird, wie das Ergebnis berechnet wird. Dies geschieht nur, wenn der Wert ausdrücklich für die weitere Berechnung benötigt wird. • Prolog: verzögerte Auswertung dank Strukturen. Das Schreiben eines Ausdrucks führt zur Konstruktion einer Struktur, d.h. eines Datenbehälters. Der Prolog-Ausdruck 1 + 2 * X ist lediglich eine Infix-Operator-Schreibweise der geschachtelten Struktur ’+’(1, ’*’(2, X)). Mithilfe des is-Prädikats kann 10 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk der Wert berechnet werden. Arithmetik in Prolog wird dadurch erschwert und durch den relationalen Charakter müssen Rechenergebnisse an Parameter des Prädikats gebunden werden (anstatt wie in funktionalen Sprachen implizit einen Rückgabewert zu berechnen). • Funktionale Programmiersprachen ohne Laziness (z.B. F#): Arithmetik funktioniert intuitiv (wie in der Mathematik), evtl. muss aber auf Zahl-Typen geachtet werden (Ganz- und Gleitkommazahlen mit unterschiedlicher Genauigkeit) und Überläufe müssen behandelt werden. • Imperativ(-objektorientierte) Programmiersprachen (z.B. C#): Ausdrücke können nur an einigen Stellen stehen, etwa nach einem return, auf der rechten Seite einer Zuweisung oder in einer Argumentstelle eines Prozedur- bzw. Methodenaufrufs. Ansonsten verhalten sich Ausdrücke wie in funktionalen Programmiersprachen ohne Laziness. Auch wenn es Unterschiede bei Ausdrücken in deklarativen Sprachen gibt, ist es ihnen gemeinsam, dass die Ermittlung des Ergebnisses (Auswertungsstrategie) eines solchen Ausdrucks vom System übernommen wird. Es wird nur das Ergebnis spezifiziert, weniger der Weg, um es zu berechnen. Bei einfachen Ausdrücken, mag dies auf imperative Programmiersprachen ebenfalls ∏ zutreffen, aber bei aufn wendigeren Ausdrücken wie der Fakultätsvorschrift n! = i=1 i, n > 0 zeigt sich der große Unterschied: Seq.reduce (*){1..n} als deklarativer F#-Ausdruck und in C# als imperative Berechnungsvorschrift: int prod = 1; for(int i = 1; i <= n; i++) { prod *= i; } In imperativen Programmiersprachen wird ein Prozess dargestellt, in deklarativen das Ergebnis formuliert. Referenzielle Transparenz wird auch als Voraussetzung für deklarative Programmierung angesehen. Dies ist auf die Verwendung von „deklarativer Programmierung“ als Sammelbegriff für funktionale, logikbasierte und Constraintprogrammierung zurückzuführen. Viele frühe Vertreter dieses Paradigmen wiesen referenzielle Transparenz auf. Dennoch sollten die Konzepte aus der funktionalen Programmierung nicht nur an referenzielle Transparenz gebunden sein und Zugriff auf veränderbare Daten haben. Dies ist in multiparadigmatischen Programmiersprachen der Fall und bringt Vorteile für die imperative und deklarative Welt. Eine Abschwächung der referenziellen Transparenz ist die Verwendung veränderbarer Datenstrukturen innerhalb einer Funktion aus Effizienzgründen, sofern die veränderbaren Daten nur innerhalb der Funktion erzeugt und verändert wurden. Die entsprechende Funktion bleibt referenziell transparent. Das produzierte Ergebnis (z.B. ein Array) wird üblicherweise unter einer unveränderbaren Sicht (immutable-vector in Racket/Scheme) zurückgegeben, um den funktionalen Charakter weiterhin zu garantieren. 11 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig 2.1 Prolog Prolog ist eine deklarative Programmiersprache, die auf der Prädikatenlogik der ersten Stufe basiert und deren zentraler Mechanismus die Unifikation ist. Programmiert wird, indem in einer Wissensbasis (d.h. einer Datenbank) Prädikate mithilfe von Fakten und Regeln definiert werden, die in Abfragen (queries) benutzt werden können, um Informationen zu erhalten. Dazu wird vom System die Eingabe mit passenden Klauseln (Fakten oder Regeln) aus der Datenbank unifiziert und es werden Variablenbindungen ausgegeben. Der UnifikationsMechanismus wird im Anschluss an die Beispiele erklärt. Prolog ist ein hervorragendes Beispiel für den deklarativen Stil, weil hier nur das Was definiert werden kann. Zuweisung und Sprünge sind nicht möglich. Es wird stattdessen mit Variablenbindung, Rekursion und Backtracking gearbeitet. Zudem ist Prolog eine mächtige, aber recht kompakte Sprache. Eine abschließende Bewertung folgt am Ende dieses Abschnitts. Ein zentraler Vorteil von Prologprogrammen ist die Richtungsunabhängigkeit von Prädikaten. Das klassische Beispiel ist die das Prädikat Append, die zwei Listen zu einer konkatenierten Liste transformieren kann. Gleichzeitig kann es in anderen Aufrufvarianten benutzt werden, z.B. zur Extraktion von Prä- und Suffixen und zur Generierung von Listen-Teilen. Eine Liste ist entweder die leere Liste (geschrieben als [], auch Nil genannt) oder ein Element gefolgt von einer Liste (angedeutet durch [_|_], auch Cons genannt). append([], X, X). append([Head|Tail], X, [Head|Result]) :- append(Tail, X, Result). /* Beispiele: append([1,2], [3,4], L). -> L = [1,2,3,4]. append([1,2], Suf, [1,2,3,4]). -> Suf = [3,4]. append(Pre, [3,4], [1,2,3,4]). -> Pre = [1,2]. append(Pre, Suf, [1,2,3,4]). -> Pre = [], Suf = [1,2,3,4]; Pre = [1], Suf = [2,3,4]; Pre = [1,2], Suf = [3,4]; Pre = [1,2,3], Suf = [4]; Pre = [1,2,3,4], Suf = []. */ Auffällig ist die Kürze des Append-Prädikats. Nur Zwei Zeilen sind nötig, um die Eigenschaften von Append zu formulieren: Die erste Zeile lässt sich so beschreiben: „Die Konkatenation der leeren Liste mit einer anderen Liste ist die andere Liste.“ Diese Zeile stellt den Rekursionsabbruch dar. Die zweite Zeile besagt: „Um eine nicht-leere Liste aus Head und Tail mit einer anderen Liste zu konkatenieren, wird der Head in die Ergebnisliste übernommen und der Rest des Ergebnisses wird durch die Konkatenation des Tails und der anderen Liste geliefert.“ Hier geschieht die Konsumption, die für das Terminieren der Rekursion wichtig ist, auf der ersten Argumentstelle. 12 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk Ein anderes wichtiges Prädikat, das mehrere Aufgaben erfüllt, die mit der Enthaltenseinbeziehung zusammenhängen, ist das Member-Prädikat: member(Head, [Head|_]). member(Element , [_|Tail]) :- member(Element , Tail). /* Beispiele: member(E, [1,2,3,4]). -> E = 1; E = 2; E = 3; E = 4. member(3, [1,2,3,4]). -> yes. member(5, [1,2,3,4]). -> no. member(5, L). -> L = [5|_]; L = [_,5|_]; L = [_,_,5|_]; ... */ Wieder lässt sich eine natürlichsprachliche Formulierung finden: „Der Kopf der Liste ist Element der Liste, ebenso wie Elemente des Restes der Liste.“ Mit dem Prädikat kann man durch eine Liste iterieren, Werte auf Enthalten-sein prüfen und es lassen sich (unendlich viele) Listen generieren, die ein bestimmtes Element enthalten. Die letzte Ausgabe zeigt außerdem eine weitere Besonderheit von Prolog: die Verwendung von freien Variablen in Datenstrukturen. Eine weitere Eigenschaft von Prädikaten wird deutlich: Die Klauseln, aus denen das Prädikat besteht, müssen einander nicht ausschließen. 2.1.1 Unifikation Der Unifikations-Mechanismus versucht, zwei Werte „gleich zu machen“, indem Variablenbindungen hergestellt werden. Sind zwei Terme nicht unifizierbar, ist das Ergebnis false (was zum Abbruch des Prädikats führt), andernfalls werden die Bindungen hergestellt und das Ergebnis ist true (was zur weiteren Ausführung des Prädikats führt). Der Mechanismus unterscheidet, welche zwei Werte miteinander unifiziert werden sollen. Es gibt in Prolog als Werte nur Atome (wie Symbole, Zahlen, Bool’sche Werte), Variablen und Strukturen. Da Unifikation symmetrisch ist, gibt es nur die folgenden Fälle (gebundene Variablen entsprechen ihrem gebundenen Wert, daher gibt es hier nur freie Variablen): FreieVariable = FreieVariable führt zur Koreferenz von zwei Variablen (wird im Folgenden eine der beiden gebunden, wird es die andere auch). FreieVariable = konstante führt zur Bindung der Variablen an die Konstante. FreieVariable = struktur(...) führt zur Bindung der Variablen an die Struktur. konstante = konstante unifiziert, weil die Konstanten gleich ist. struktur(...)= struktur(...) unifiziert, wenn die Stelligkeit der Strukturen übereinstimmt und alle Argumente rekursiv unifiziert werden können. 2.1.2 Einordnung von Prolog Prolog wird oft für Wissensdatenbanken verwendet, denn Datenbankanfragen sind leicht zu schreiben, z.B. ist kunde(2, Name) die Abfrage des Namens des Kun13 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig dens mit der Nummer 2. Voraussetzung ist das Wissen über die genaue Struktur der Datenbank; in dem Beispiel die Kenntnis, dass kunde eine zweistellige Relation ist, deren ersten Argumentstelle die Kundennummer und die zweite Stelle der Name ist. Eine Datenbank besteht aus Fakten: kunde(1, kunde(2, kunde(3, kunde(4, john). paul). george). ringo). Die Ausgabe für die Beispielabfrage kunde(2, Name) ist Name = paul. Der Bezug zur Definition „Formulieren von Regeln und Zusammenhängen“ ist durch folgende Eigenschaften hergestellt: • Formulieren ist das Schreiben von Prädikaten. • Regeln sollen Fälle unterscheiden: Das passiert in Prolog mithilfe von Prädikaten, die Fälle klassifizieren. Die Unterscheidungen nimmt der PrologProgrammierer durch geeignete Klausel-Köpfe vor (im Append-Beispiel durch Unterscheidung nach [] oder [Head|Tail]). Zudem können Fälle im KlauselKörper mithilfe von beliebigen Prädikaten genauer unterschieden werden. • Zusammenhänge führen zum Berechnungsergebnis. Dies ist mit funktionalen Zusammenhänge der Form X is Y + Z möglich, aber vor allem durch Verwendung der mithilfe von Prädikaten definierten Relationen. Mithilfe von Prolog kann das Problem auf hoher Abstraktionsebene beschrieben werden und das System übernimmt die Suche nach Lösungen. Prolog eignet sich zur Lösung von Logikrätseln, Beschreibung von Spielregeln, Datenbankabfragen, Listenverarbeitung, Suche in Hierarchien, Sprachverarbeitung und Anwendungen im Bereich der Künstlichen Intelligenz (KI). Eine weitere große Stärke ist Metaprogrammierung (Code-Generierung und -Auswertung) und die Definition eigener Operatoren, um eine eigene (domänen-spezifische) Sprache zu entwickeln. Berechnung von arithmetischen Ausdrücken wird durch die Vorgabe, Prädikate zu verwenden, erschwert. Das Schreiben von funktionalen Ausdrücken wie N - 1 führt zur Erstellung der Struktur -(N, 1), welche mit dem is-Prädikat ausgerechnet werden kann. Die Quadrierungsfunktion x 7→ x · x ist beispielsweise in Prolog als richtungsabhängiges Prädikat zu schreiben: square(N, NmalN):- NmalN is N * N.. Zustandsabhängige Programmierung ist durch spezielle Prädikate wie assert und retract (zur Veränderung der Wissensbasis) möglich. Außen vor gelassen wurde in dieser Betrachtung die Negation und der Cut, welche beide in realen Prolog-Anwendungen häufig verwendet werden. Prolog ist dynamisch typisiert, was insbesondere Listenverarbeitung leicht macht, wodurch sich aber einige Programmier-Fehler erst zur Laufzeit bemerkbar machen. 14 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk 2.1.3 Prolog und funktionale Programmierung Die Erben von Prolog sind unter anderem Curry und Mercury, zwei logikfunktionale Sprachen, die unter anderem schnellen C-Quelltext produzieren und dem Programmierer Features von Prolog und funktionalen Programmiersprachen wie Haskell (im Fall von Curry) bieten. Aktuell sind funktional-objektorientierte Sprachen wie Scala und F# auf dem Weg, populär zu werden. Das lässt sich durch aussagekräftigeren und kürzeren Quelltext, aber auch durch Vorteile bei paralleler Programmierung erklären. Es ist abzuwarten, ob sich logikfunktionale Sprachen durchsetzen werden. An der Universität in Kiel wird beispielsweise Curry gelehrt, wobei funktionale und Logik-Programmierung mittels einer Programmiersprache unterrichtet werden kann [Curry, S. 334]. Das Konzept Pattern matching spielt in Miranda und anderen funktionalen Programmiersprachen eine wichtige Rolle. Pattern matching ist eine eingeschränkte Form der Unifikation, bei der nur die linke der beiden Seiten Variablen enthalten darf. 2.2 Miranda In dem Buch Functional Programming with Miranda [FPWithMiranda] wird nicht nur die rein-funktionale Programmiersprache Miranda vorgestellt, es geht auch darum, verständlichen Quelltext zu schreiben, der an der Mathematik angelehnt ist. Dazu wird beispielsweise auf Funktionen und Rekursion zurückgegriffen. Programme, die in funktionalen Programmiersprachen geschrieben sind, bestehen aus Funktionen, die Eingabe-Werte auf Ausgabe-Werte abbilden. Das Attribut „rein-funktional“ fordert, dass alle Funktionen referenziell transparent (S. 10) sind. Gleichungen bestehen aus dem Musterabgleich (Pattern matching) auf der linken Seite und der Berechnungsvorschrift auf der rechten Seite. Dies stellt den Bezug zur Definition „Formulieren von Regeln und Zusammenhängen“ her: Regeln werden durch Muster auf der linken Seite formuliert und die rechte Seite stellt den funktionalen Zusammenhang dar. Es können für eine Funktion mehrere Gleichungen geschrieben werden, um Fälle zu unterscheiden. Wie in anderen funktionalen Programmiersprachen werden anstelle von SchleifenKonstrukten rekursive Funktionen verwendet. Referenzielle Transparenz verhindert Seiteneffekte 1 und führt zur Gleichbehandlung von Gleichheit und Identität von Werten. Außerdem bringt sie noch den Vorteil der Wiederverwendung von Datenstrukturen. Nur wenn auf Seiteneffekte verzichtet wird, ist verzögerte Auswertung (laziness) sinnvoll, welche wieder1 Eine Manifestation von Seiteneffekten ist das Verändern des Zustandes eines Objektes, welches unter einem Alias, d.h. mehr als einem Namen [SICP, S. 295] zugreifbar ist. 15 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig um die Arbeit mit unendlichen Listen ermöglicht. Gleichzeitig werden änderbare Arrays als wichtigste imperative Datenstruktur verhindert [FPWithMiranda, S. 93]2 . Eine gute Übersicht, wie mit dem Problem umgegangen wurde und welche Arten von „funktionalen Arrays“ entwickelt wurden, gibt [FuncArrays, S. S. 2-3]. In neueren multiparadigmatischen Sprachen wie F# und Scala gibt es jedoch die Möglichkeit, auf imperative Arrays wie in C# oder Java zuzugreifen, weshalb nicht weiter auf funktionale Arrays eingegangen wird. Miranda verfügt als rein-funktionale Programmiersprache nicht über Zuweisungen. Stattdessen gibt es Variablenbindungen, die einen Bezeichner an einen Wert binden. Dank Laziness wird der Wert erst dann berechnet, wenn er wirklich zur Ermittlung eines Berechnungsergebnisses benötigt wird. Eine Besonderheit der Sprache Miranda ist, dass auf stukturierende Klammern wie etwa in if (Condition){ ThenBlock } else { ElseBlock } verzichtet wird. Stattdessen wird die sogenannte off-side rule verwendet, in der die Einrückungstiefe den Geltungsbereich von Variablen bestimmt, sodass gänzlich auf strukturierende Block-Klammern verzichtet werden kann. 3 Eine zweite Besonderheit ist die Typinferenz, dank derer auf Typ-Annotation verzichtet werden kann, wodurch der Code von Typisierungs-Details befreit wird. Allein aus der Verwendung von Parametern kann deren Typ abgeleitet werden. Werden im Quellcode keine besonderen Operationen mit Werten durchgeführt, die auf den Typ schließen lassen, bleibt der Typ offen und funktioniert für alle konkreten Typen (generische Programmierung). Diese Eigenschaften führen zu schönen Programmen und Programmbestandteilen, wie etwa die bekannten Funktionen Map, Filter, Fold, die viele Iterationsaufgaben elegant lösen. Dazu werden Listen rekursiv verarbeitet, indem eine Funktion auf den Kopf und Rest der Liste hat zugreift und diese zu einem Ergebnis transformiert. ||| ||| map map Abbildung einer Liste mit Transformationsfunktion Beispiel: map (+1) [1,2,3,4] -> [2,3,4,5] f [] = [] f (head:tail) = (f head) : (map f tail) ||| Filterung einer Liste mit Prädikat ||| Beispiel: filter (>1) [1,2,3,4] -> [2,3,4] filter pred [] = [] filter pred (head:tail) = head : (filter pred tail), if pred(head) = filter pred tail, otherwise ||| Zusammenfalten einer Liste mit Startwert und 2-stelliger Funktion ||| Beispiel: fold 0 (+) [1,2,3,4] -> 10 fold seed f [] = seed fold seed f (head:tail) = fold (f seed head) f tail 2 Stattdessen werden oft verschiedene Arten von Bäumen verwendet. Die off-side rule wird ebenfalls in F# verwendet und bezieht sich dort unter anderem auf die Einrückungstiefe bei let-Bindungen. [FSharpSpec, vgl. S. 232ff.] 3 16 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk Abbildung 2.1: Vereinfachte Funktionsweise von Map, Filter und Fold [FPWithMiranda, nach S. 42ff.]. Die Funktionsweise von Map, Filter und Fold lässt sich wie in Abbildung 2.2 illustieren. Map, Filter und Fold sind sogenannte Funktionen höherer Ordnung, weil sie als Parameter Funktionen aufnehmen (hier f und pred). Pattern matching wird ähnlich wie in den Prolog-Prädikaten aus Abschnitt Prolog (S. 12) wieder benutzt, um den Rekursionsabbruch darzustellen. Mit partieller Anwendung von Funktionen (currying) lassen sich aus vorhandenen Funktionen neue Funktionen erstellen, die weniger Argumente benötigen. In dem Beispiel fold 0 (+)[1,2,3,4] wird deutlich, dass man die Summe einer ∑Liste bilden möchte. Mathematiker kennen dafür die (n-stellige) Summe, die mit dargestellt wird. Programmierer sollten auch Zugriff auf dieses Konstrukt haben und könnten sich ∏ eine entsprechende Funktion definieren. Für das (n-stellige) Produkt gibt es , welches sich auch definieren ließe. Die Integration von erweiterten UnicodeZeichen in Programmiersprachen ist noch nicht gelungen, was an Schwierigkeiten bei der Eingabe mit der Tastatur und unflexiblen Parsern liegt. 4 Daher werden sprechende Namen statt mathematischer Symbole verwendet: sum = fold 0 (+) prod = fold 1 (*) ||| n-stellige Summenfunktion ||| n-stellige Produktfunktion Obwohl fold eine Funktion ist, die zur Berechnung drei Argumente benötigt, lassen sich auch weniger Argumente übergeben, sodass eine Funktion konstruiert wird, die nur noch auf das letzte Argument „wartet“, um das Ergebnis zu berechnen. Zwecks Dokumentation verzichtet man in Funktionsdefinitionen gelegentlich auf diese Eigenschaft und schreibt sum numbers = fold 0 (+)numbers. Abgesehen von Typen, die später genauer betrachtet werden, sind dies die Kernideen vieler funktionaler Programmiersprachen. Eine kurze Auflistung von Eigenschaften funktionaler Programmierung, mit Miranda als Beispiel für eine rein funktionale Programmiersprache, bietet [FPWithMiranda, S. 8-9]: • Algorithmen können funktional mit Klarheit und Kürze formuliert werden. • Kürzere Programme sind leichter zu schreiben, zu testen, anzupassen und zu warten. • Funktionale Programmiersprachen sind mathematisch formalisierbar und lassen sich durch referenzielle Transparenz leichter parallelisieren. 4 Eine Betrachtung des Themas Unicode in Programmiersprachen findet in [FunktProg, S. 4-5] statt. 17 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig • Die Nachteile gegenüber prozeduralen Programmen waren jedoch geringere Verfügbarkeit (der Laufzeitumgebung für Miranda beispielsweise), fehlende Interoperabilität zur Systemprogrammierung (Betriebssystem-, Datenbankbibliotheken) und geringere Performanz. Die eben genannten Nachteile wurden durch intensive Forschung reduziert und neue funktionale Programmiersprachen lösen diese Anforderungen zufriedenstellend. Die Integration mit anderen Programmiersprachen und Interoperabilität von Programmbibliotheken ist auch durch gemeinsame Frameworks und Laufzeitumgebungen gelungen. 18 3 Deklarativer Stil um 2000 Im Zusammenhang mit paralleler Programmierung und Daten-basierten Programmen sind funktionale Programmiersprachen für heutige Programmierung wichtig geworden. Da Objektorientierung oft auf einem imperativen Kern aufbaut, ist das Paradigma für parallele Ausführung weniger geeignet, wohingegen reine funktionale Programmierung weiterhilft [RealWorldFP, S. 10-11]. Logikprogrammierung ist im Bereich der KI wichtig, für typische Anwendungs-Programmierung aber nicht ausgelegt (gemeint sind z.B. grafische Benutzerschnittstellen, Webentwicklung oder Computer-Spiele). Symbol- und Zahlenmanipulation ist einer der Kernbereiche der funktionalen Programmierung und die Integration mit objektorientierten popuären Frameworks wie .NET und dem Java-Framework erlaubt den Umstieg von objektorientierten Programmiersprachen zu funktionalen Sprachen. Es gibt viele funktionale Programmiersprachen, die ihre Vorteile und Eigenheiten haben, vor allem was Effizienz und Interoperabilität anbelangt. Eine neue bzw. neu entdeckte Entwicklung sind multiparadigmatische Programmiersprachen. Mit ihnen lassen sich Paradigmen wie Objektorientierung und funktionale Programmierung vereinen. Bekannte Vertreter sind Scala für die JVM als Programmiersprache, die gut mit Java-Bibliotheken zusammenarbeitet, und F# für das .NET-Framework. Beide sind statisch typisierte funktionale Programmiersprachen, verfolgen aber im Gegensatz zu Miranda strikte Auswertung und übernehmen das objektorientierte Typsystem aus dem zugrundeliegenden Framework (Java oder .NET). Scala ist als besseres Java entworfen worden und löst viele typische Probleme aus Java. Die vielen funktionalen Aspekte von Scala lassen sich bei der Programmierung für die Java-Plattform gewinnbringend einsetzen. Der Name „Scala“ steht für scalable Language und bezieht sich auf die (im Vergleich zu Java) flexible Syntax und Erweiterbarkeit der Programmiersprache. F# wurde ursprünglich als OCaml-Variante für .NET entwickelt, bietet nun aber von ML unabhängige Konzepte, welche die Programmierung durch geeignete Modularisierung erleichtern. Für C#-Programmierer ist F# durch seine Kürze und 19 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig Aussagekraft eine gute Alternative. Dem Programmierer wird nicht nur oftmals das lästige Schreiben von Typannotationen abgenommen, die Programme sind durch ihren funktionalen Charakter meist kürzer, verständlicher und näher an der Intention des Autors als vergleichbare C#-Programme. Da F# zudem auch Werkzeugunterstützung bietet, ist es eine gute Wahl für .NET-Programmierer. An dieser Stelle soll keine Beschreibung der Werkzeugunterstützung stattfinden, es soll nur erwähnt sein, dass der Editor Autovervollständigung und Kontext-Hilfe besitzt sowie über einen interaktiven Prompt verfügt. Der interaktive Prompt nennt sich F# interactive und wird im Rahmen von Quellcode-Beispielen in dieser Arbeit benutzt. Die Notation ist wie folgt zu lesen: > Ausdruck ;; val it: Typ = Wert Hinter dem Zeichen > zur Eingabeaufforderung wird ein Ausdruck eingegeben; die Eingabe wird mit der Endesequenz ;; abgeschlossen. Die Ausgabe enthält den Typ und den Wert des ausgewerteten Ausdrucks. Modularisierung und Aussagekraft sind zwei treibende Faktoren bei der Verwendung einer Programmiersprache. F# bietet in dieser Hinsicht neue Techniken, die richtig verwendet zur deklarativen Programmierung führen. Deshalb wird im Folgenden F# als Beispiel für moderne deklarative Programmierung benutzt und analysiert. Dazu wird zunächst ein Muster vorgestellt, mit dem die wichtigen Konzepte besprochen und abgewägt werden können. 3.1 Analyse von deklarativen Sprachelementen Programmiersprachen werben mit Features: In der einen Programmiersprachen wird eine typische Aufgabe mit weniger Schreibaufwand gelöst, als in einer anderen. Design von Programmiersprachen ist eine hohe Kunst, jedoch keine elitäre Angelegenheit mehr. Tools und Tutorials können dem Laien die Konstruktion einer Programmiersprachen erleichtern. Viele Programmiersprachen sind Verschmelzungen von anderen ähnlichen Programmiersprachen unter Hinzunahme von Eigenschaften und Konstrukten nach Geschmack des Konstrukteurs. Gerade deshalb ist ein roter Faden, der im Programmiersprache-Design zu finden sein muss, Pflicht. Die Orientierung an einem Paradigma wie dem funktionalen oder dem objektorientierten kann solch ein Faden sein; die Angst jedoch, einer anderen Sprache in etwas nachzustehen, verführt jedoch wieder zur Hinzunahme von beliebigen Features und zum Verlust dieses Fadens. 1 Ich schlage eine Struktur vor, mit der Programmiersprachenfeatures im Allgemeinen und deklarative Sprachelemente im Speziellen analysiert werden können. In dieser Arbeit liegt das Ziel der Analyse darin, deklarative Sprachelemente zu 1 In dem Zusammenhang könnten multiparadigmatische Programmiersprachen als unstrukturiert erscheinen; bei genauerer Betrachtung werden jedoch Paradigmen integriert, um einen eleganten Problemlösungsweg auch auf konzeptueller Ebene zu ermöglichen. 20 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk vergleichen. Das Schema beleuchtet kurz folgende Aspekte eines Sprachelements oder einer Technik der Programmiersprache: Technik: Aussagekräftige Bezeichnung für das Konstrukt Zweck: Kurze Beschreibung des Nutzens Problem: Probleme, die damit gelöst werden können Lösung: Verwendung dieses Konstruktes Performanz: Illustration der Umsetzung / Ausführung des Konstruktes Alternative: Verwandte Techniken, die ähnliche Probleme lösen können oder alternativer Ansatz der Lösung Anhand von Beispielen werden danach Vor- und Nachteile bei der Verwendung illustriert. Aus den Beispielen muss außerdem die Syntax des Sprachelementes erkennbar sein. 3.2 Deklarative Elemente von F# Die folgenden Elemente von F#, die deklarativen Charakter haben, lassen sich wie folgt einteilen: • Typen: strukturierte Daten und polymorphe Objekte – Definition und Verwendung von Typen (S. 21) • Patterns: Arbeiten mit Daten und Objekten sowie Möglichkeiten der Fallunterscheidung – Pattern matching (S. 26) – Active patterns (S. 29) • Interne DSLs: eine Sprache in der Sprache – Computation expressions (S. 34) – Quotations (S. 42) 3.2.1 Definition und Verwendung von Typen Unter einem Datentyp (im folgenden nur Typ) versteht man „eine Menge von zulässigen Werten als Operanden und Ergebnisse sowie von darauf anwendbaren Operationen in einer Programmiersprache“ [InformatikLexikon, S. 215]. Typ-Definitionen in F# sind dank der aus ML geborgten Notation aussagekräftiger als in C#. Insbesondere algebraische Datentypen sind bei der Programmierung hilfreich und wären in C# und anderen objektorientierten Sprachen aufwendig zu formulieren. 21 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig In F# gibt es folgende Arten von Typen2 : • Funktionstypen (S. 22) • Algebraische Datentypen und generische algebraische Datentypen (S. 23) • Datensätze (S. 24) • Klassen (S. 25) • Interfaces (S. 25) • Structs (S. 25) Funktionstyp In einer funktionalen Programmiersprache ist das wichtigste Element die Funktion. Eine Funktion bildet Eingabe-Werte auf Ausgabe-Werte ab. Funktionen sind Werte von Funktionstypen. Funktionstypen haben die Form Eingabetyp -> Ausgabetyp, wobei Eingabe- und Ausgabetyp beliebige Typen sind. Der Typ der Quadrierungsfunktion (x 7→ x2 ) ist leicht verständlich: int -> int. Im Fall von partieller Applikation sehen Funktionstypen etwas interessanter aus. Arithmetische Operationen (etwa +, -, *, /) und Vergleiche (wie >, <=) erwarten zwei Argumente, um ausgeführt zu werden. Die Applikation des Operators auf das erste Argument liefert eine Funktion, die nur noch das zweite Argument erwartet. Das wird im Fall der Addition für Ganzzahlen so dargestellt: int -> (int -> int). Der Eingabetyp ist int, d.h. der erste Eingabewert ist eine ganze Zahl und der Ausgabetyp ist eine Funktion vom Typ int -> int. Dies stellt den Typen der Funktion dar, die das zweite Argument erwartet. Die Klammerung von Funktionstypen ist rechtsassoziativ, deshalb wird statt int -> (int -> int) gerne int -> int -> int geschrieben. Ein Sonderfall von Funktionstypen sind die Typen von Funktionen höherer Ordnung. Map funktioniert auf Listen eines beliebigen aber festen Typs (nennen wir den Elementtyp ’a). Der Funktionstyp von Map lautet (’a -> ’b)-> ’a list -> ’b list. Es wird als erstes Argument eine Funktion erwartet, die einen Wert vom Typ ’a auf einen Wert vom Typ ’b abbildet (man sagt kürzer „ein ’a auf ein ’b abbildet“. Die Klammerung um Funktionstypen bei Parametern ist wichtig.) Als zweites Argument wird eine Liste von ’as erwartet; wird die Funktion darauf angewendet, wird eine Liste von ’bs geliefert, deren Elemente aus den Elementen der Eingabeliste und der Abbildungsfunktion entstehen. Die Definition von Funktionstypen geschieht in F# implizit durch das Definieren von Funktionen. 2 Enum-, Delegat-, Exception- und Maßeinheit-Typen werden hier nicht aufgeführt, weil es sich um besondere Structs oder Klassen bzw. Maßeinheiten-Metadaten handelt. Genaueres über diese Typen findet sich in der F#-Spezifikation [FSharpSpec, vgl. S. 115-116]. 22 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk Algebraischer Datentyp Algebraische Datentypen (kurz ADT, auch union types) sind Typen, die eine abgeschlossene Anzahl an Varianten haben. Sind den Varianten keine zusätzlichen Daten zugeordnet, sind dies Werte des Typs; andernfalls nennt man die instanziierten Varianten Werte des Typs. Varianten sind oft Daten von Produkttypen zugeordnet. Produkttypen sind die Typen von n-Tupeln (Typen die durch Bildung des kartesischen Produktes über n Typen entstehen). Beispiele sind Bool’sche Werte (Typ bool mit den zwei Varianten bzw. Werten true und false) oder das Nulltupel (Typ unit mit dem Wert ()), aber auch Datenbehälter wie etwa der Typ aus einer Variante Punkt (mit allen Werten der Form Punkt(a, b) mit konkreten Ganzzahlen a und b). Wenn man von mehr als einer Variante spricht, nennt man diese auch Summentypen und verwendet sie um „inhaltlich verwandte Elemente, die aber strukturell unterschiedlich aufgebaut sein können, zusammenzufassen“ [FunktProg, S. 17]. Wichtig sind vor allem generische algebraische Datentypen (kurz GADT), die beliebige aber festen Elementtypen enthalten können, sodass strukturierte Daten konstruiert und verarbeitet werden können. Das klassische Beispiel ist eine Liste eines bestimmten Elementtyps. Eine Liste von ’a (Liste von Elementtyp ’a) lässt sich so definieren: • Die leere Liste ist eine Liste von ’a. • Eine Element vom Typ ’a gefolgt von einer Liste von ’a ist eine Liste von ’a. • Nur diese beiden Varianten erzeugen Listen von ’a. Den beiden Varianten müssen Namen gegeben werden, um mit ihnen zu arbeiten: Die leere Liste wird Nil und das Paar von Element und Liste wird Cons genannt. [1,2,3,4] ist eine Liste von Ganzzahlen (int list) und ist aus den beiden Varianten konstruiert: Cons(1, Cons(2, Cons(3, Cons(4, Nil)))). Eine wichtige InfixSchreibweise ist 1::2::3::4::[], wobei [] für Nil und a :: b für Cons(a, b) steht und rechtsassoziativ ist (a :: b :: c = a :: (b :: c)). Die Liste stellt eine der wichtigsten Datenstrukturen in der funktionalen Programmierung dar. Sie ist ein Beispiel für rekursiv definierte Typen; ein anderes Beispiel ist der Typ von Binärbäumen. Der Binärbaum Node(2, Node(1, Node(0, Empty, Empty), Empty), Node(4, Node(3, Empty, Empty), Node(5, Empty, Empty))) lässt sich wie in Abbildung 3.1 visualisieren. Andere wichtige generische algebraische Datentypen sind die Typen von Tupeln (’a * ’b), Tripeln (’a * ’b * ’c), Quadrupeln (’a * ’b * ’c * ’d) usw., wobei die Typen der Komponenten beliebig aber fest sind. Algebraische Datentypen mit einer Variante sind nützlich, aber eher ungebräuchlich, da auf Tupel oder Datensätze für diesen Zweck zurückgegriffen werden kann. Außerdem wird im Falle von partiell definierten Funktionen Gebrauch vom generischen Option-Typ gemacht, der aus den Varianten None oder Some(x) mit Werten x vom Typ ’a besteht. 23 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig Abbildung 3.1: Ein Binärbaum Z.B. kann die ganzzahlige Division so dargestellt werden, dass für Werte a und b das Ergebnis None im Fall von b = 0 und Some(a/b) sonst ist. Algebraische Datentypen werden in F# folgendermaßen definiert: type Unit = Unit // eingebaut als Typ unit type Bool = True | False // eingebaut als primitiver Typ bool type Punkt = Punkt of int * int type List<’a> = Nil | Cons of ’a * List<’a> // eingebaut als Typ list<’a> type Binary <’a> = Empty | Node of ’a * Binary <’a> * Binary <’a> type Option <’a> = None | Some of ’a // eingebaut als Typ option<’a> type Tupel<’a, ’b> = Tupel of ’a * ’b // eingebaut als Typ ’a * ’b type Tripel <’a, ’b, ’c> = Tripel of ’a * ’b * ’c // eingebaut als Typ ’a * ’b * ’c Wie eingangs erwähnt und wie aus den Beispielen deutlich wird, ist die Notation äußerst kompakt. Vergleichbare Definitionen in objektorientierten Sprachen sind wesentlich länger, weil dem Programmierer auferlegt ist, Klassen, Unterklassen, Konstruktoren und Felder zu schreiben. Außerdem sind korrekte Equals- und GetHashCode-Methoden zu implementieren. Gerade bei algebraischen Datentypen muss sichergestellt werden, dass keine weiteren Varianten durch Unterklassenbildung hinzugefügt werden können. Der deklarative Stil der obigen Defintionen ist bemerkenswert. Nicht nur bei der Definition, sondern auch bei der Verwendung haben algebraische Datentypen Vorteile gegenüber Klassen, siehe dazu den Eintrag „Algebraischer Datentyp“ im Abschnitt Pattern Matching (S. 27). Datensatz Datensätze (record types) sind Typen, die als Datenbehälter fungieren. Die Verwendung von Datensätzen ist Tupeln vorzuziehen, wenn komplexere Datenstrukturen als Argument übergeben oder von Funktionen geliefert werden. Tupeln gegenüber haben die Bestandteile von Datensätzen keine feste Ordnung, jedoch einen Namen. Eine Person sollte in einem Programm nicht als Tupel vom Typ (string * string) dargestellt werden, wobei die Komponenten Vorname und Nachname darstellen sollen. Stattdessen kann ein Datensatz definiert werden: 24 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk type Person = { Vorname: string; Nachname: string } // Beispiel für ein Datensatzobjekt: let MsCarter = { Vorname = ”Samantha”; Nachname = ”Carter” } Die Definition solcher Datenbehälter ist auf das Nötigste reduziert; insbesondere muss man keine Konstruktoren oder Equals-Methoden implementieren, was fehlerträchtig sein kann und in vielen objektorientierten Sprachen für jeden Typen von Hand getan werden muss; eine Betrachtung dazu findet sich im Abschnitt Imperativ-objektorientierte Typen (S. 47). Zwei zusätzliche Eigenschaften sind bei Datensätzen zu erwähnen: 1. Muster zur Extraktion von Daten stehen zur Verfügung, siehe dazu den Eintrag „Datensatz“ im Abschnitt Pattern Matching (S. 27). 2. Geringfügige Änderungen von Datensätzen können mit dem with-Operator durchgeführt werden, wobei eine neuer Datensatz erstellt wird, welche dem Vorlage-Datensatz-Objekt bis auf die angegeben Felder gleicht: let MrsMcKay = { MsCarter with Nachname = ”Mc Kay” } Klasse und Interface Eine Klasse ist der Typ eines Objektes. Objekte haben einen internen Zustand, der nur durch Operationen verändert oder sondiert werden darf. Klassen können voneinander abgeleitet sein, wodurch die Unterklasse Eigenschaften (Zustand und Verhalten) von der Oberklasse übernimmt. Der Zustand kann in Unterklassen erweitert und Verhalten abgeändert werden. Die Menge der Operationen einer Klasse ist ihre Schnittstelle. Interfaces sind lediglich eine Menge von Operationen ohne Implementierung. Klassen können Interfaces implementieren, wodurch Objekte dieser Klassen unter der Sicht des entsprechenden Interfaces benutzt werden können. Das Aufrufen von Operationen an Objekten und das Auswählen der klassenspezifischen Methode, um die Operation auszuführen, nennt sich dynamisches Binden. Die Idee ist, dass der Aufruf einer Operation nicht fest an eine Methode gebunden ist, sondern flexibel zur Laufzeit ausgetauscht werden kann. Das Austauschen geschieht über Subtyp-Polymorphie (Subtypen sind die oben erwähnten Unterklassen; Polymorphie steht für Vielgestaltigkeit), d.h. Klassenvererbung und Überschreiben von Methoden. Diese Flexibilität ist Grundlage vieler Entwurfsmuster [Entwurfsmuster, vgl. S. 16] und wird als Technik im Abschnitt Imperativ-objektorientierte Fallunterscheidungen (S. 51) erläutert. Struct Structs stellen Typen von Werten dar, die nicht wie Objekte auf dem Heap sondern auf dem Aufrufstack gespeichert werden. Dieser Aspekt hat und Vor- und 25 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig Nachteile, die erst betrachtet werden müssen, wenn Performanz eine Rolle spielt. Diese Betrachtung wird im Abschnitt Imperativ objektorientierte Typen (S. 47) vorgenommen. 3.2.2 Pattern matching Technik: Pattern matching Zweck: Fallunterscheidung und Wertextraktion Problem: Typprüfungen gehen mit Typecasts und Datenextraktion einher, sodass die zu unterscheidenden Fälle aufwendig zu programmieren sind. SelektorFunktionen (OO: Getter) werden exzessiv genutzt, um Daten zu extrahieren. Lösung: Pattern matching auf Berechnungsergebnisse und Parameter anwenden. Konstrukte: match Ausdruck with Fälle oder function Fälle Performanz: Die Fälle werden in if-else-Ketten umgewandelt, switch wird im Fall von primitiven Datentypen und algebraischen Datentypen verwendet. Alternative: if-else-Ketten, explizite Typprüfungen und Datenextraktion, switch, Try-Methoden, Subtyp-Polymorphie, Visitor-Pattern Bereits in Miranda war Pattern matching eine Technik, um Fälle zu unterscheiden und gleichzeitig Werte aus strukturierten Daten zu extrahieren. Explizit zu schreibende Typprädikate und Selektoren wie etwa in Scheme im Sinne von [?] werden damit unnötig, gleichzeitig geht aber auch die in dort motivierte Repräsentationsflexibilität verloren. Mehr zu diesem Punkt findet sich in der Evaluation der Fallunterscheidungen (S. 54). Um Pattern matching zu verstehen, müssen die verschiedenen Muster (Patterns) analysiert werden, vom einfachsten zum komplexesten: Variable Wenn auch nicht sofort als Pattern ersichtlich, ist eine Variable ein Muster, das immer funktioniert und eine Variable bindet. Ein Spezialfall der Variable ist der Unterstrich, mit dem ausgedrückt wird, dass die Variable nicht gebunden werden soll. Literal Literale sind Werte primitiver Datentypen, z.B. true, 42, 0.1, ”string”, ’x’. Sie passen genau auf diese Werte. N-Tupel Muster der Form (a,b), (a,b,c) usw. passen auf Werte vom entsprechenden Tupel-Typ. Wichtig ist hier, dass a, b und c wiederum Muster sind (in diesem Fall Variablen). Liste [] passt genau auf die leere Liste. a::b passt auf ein Cons-Paar (a und b sind wieder Muster). Listen mit expliziter Länge wie etwa [a;b;c] sind nur eine alternative Schreibweise für a::b::c::[]. Array Da Arrays immer eine feste Länge haben, wird die Länge im Muster implizit angegeben: Beispielsweise passt [||] auf das leere Array und [|a;b;c|] passt genau auf ein Array der Länge 3. 26 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk Algebraischer Datentyp Die Varianten eines algebraischen Datentyps können als Muster verwendet werden. Dazu wird der Variantenname mit den zugehörigen Daten notiert. Für den Typ, der mit type Punkt = Punkt of int * int definiert wird, wäre ein Muster, das auf einen Wert vom Typ Punkt passt Punkt(x,y). Auch Punkt tupel ist möglich, da der Inhalt eines Punktes ein Tupel ist. Datensatz Ein Datensatz-Muster kann Felder eines Datensatz-Objektes extrahieren. Für den Typ, der mit type Person = { Name: string; Geburtstag: DateTime } definiert wird, stellt z.B. { Name = x } ein Muster dar, der den Wert des Feldes Name an die Variable x bindet. Anstelle von x kann wieder ein Muster verwendet werden; eine beliebige Anzahl an Feldern (mindestens jedoch eines) kann so extrahiert werden. Konjunktion (&-Muster) Zwei Muster können mit & verknüpft werden, um ein neues Muster zu bilden, das passt, wenn beide Bestandteile passen. Ein Beispiel findet sich bei Active Patterns (S. 34). Disjunktion (|-Muster) Zwei Muster können mit | verknüpft werden, um ein neues Muster zu bilden, das passt, wenn mindestens ein Bestandteil passt. (Trifft das erste Muster zu, passt die Disjunktion; ansonsten wird das zweite Muster abgeglichen.) Z.B. ist (1|2|3) ein Muster, das auf eine Zahl passt, die 1, 2 oder 3 ist. Stellt eines der Muster Bindungen her, müssen alle Bestandteile diese Bindungen herstellen, sodass nach Erfolg des Musters auf dieselben Variablen zugegriffen werden kann. Muster mit Bindung Einem Muster kann eine Bindung hinzugefügt werden, sodass der Wert, auf den das Muster passt, an eine Variable gebunden wird. Die Syntax ist Muster as Bezeichner. Typtest Das Typtest-Muster passt auf einen Wert, wenn der Laufzeittyp des Wertes gleich oder ein Subtyp vom angegebenen Typ ist. Die Syntax ist :? Typ . Insbesondere in Verbindung mit einer Bindung (as) kann ein Typtests mit einem Typecast kombiniert werden. Ein gutes Beispiel ist die Equals(Object)Methode: type Pos(x: int, y: int) = member this.X = x member this.Y = y override this.GetHashCode() = this.X ^^^ this.Y // X xor Y override this.Equals(other: obj) = match other with | :? Pos as other -> this.X = other.X && this.Y = other.Y | _ -> false Muster mit Guard Einem Muster kann ein Guard hinzugefügt werden, d.h. ein Bool’scher Ausdruck, der ausgewertet wird, wenn das Muster passt, und true sein muss, damit das Muster mit Guard passt. Die Syntax ist Muster when Ausdruck. Ein Muster mit Guard ist kein kompositionierbares Muster. 27 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig Active Patterns Active Patterns werden im gleichnamigen Abschnitt (S. 29) erklärt, wobei sie sogar parametrisiert sein können und zusätzliche Bindungen herstellen können. Die vorgestellten Patterns werden üblicherweise in Konstrukten wie match Ausdruck with Fälle verwendet, wobei Fälle eine Auflistung von Fällen der Form Muster -> Behandlung ist. Da folgende gedankliche Äquivalenzen gelten3 , können Muster an vielen Stellen im Quelltext auftauchen: let Muster = Ausdruck in Körper ≈match Ausdruck with Muster -> Körper fun (Muster)Parameter -> Körper ≈fun x Parameter -> match x with Muster -> Körper function Muster -> Körper ≈fun x -> match x with Muster -> Körper Dies stellt die beiden wichtigsten Anwendungsgebiete außerhalb von match-with dar: lokale Variablen und Parameter. An dieser Stelle sind Muster nur als Alternative für Typ-/Datenprüfung und Datenextraktion zu sehen, erst im nächsten Abschnitt Active Patterns (S. 29) wird die damit verbundene Modularität deutlich. Muster sind deklarativ, weil deutlich formuliert wird, welchen Fall dieses Muster abdeckt und Zugriff auf Werte gegeben wird, ohne diese explizit extrahieren zu müssen. Der Gegensatz dazu ist z.B. explizite Typprüfung und Wert-Extraktion. Diese und andere Herangehensweisen werden im Teil Imperativ-objektorientierte Fallunterscheidungen (S. 51) untersucht. Muster wurden im Prolog-Abschnitt (S. 12) schon für ihre kompakte Darstellung von Fallunterscheidungen gezeigt; ein wichtiger Unterschied ist jedoch, dass in F# (anders als in Miranda) Koreferenz in Mustern nicht erlaubt ist. Von in Muster vorkommenden Variablen mit gleichen Namen wird in Miranda gefordert, dass sie den gleichen Wert besitzen. In F# muss dies explizit geschrieben werden. Das Member-Beispiel aus Prolog ist in Miranda dank Koreferenz ähnlich aussagekräftig: member x [] = false member x (x:_) = true member x (_:tail) = member x tail In F# muss die Gleichheit explizit (z.B. mit einem Guard) geschrieben werden. Außerdem ist member als Schlüsselwort zur Definition von Methoden reserviert, weshalb hier der Name isMember benutzt wird: 3 Diese Äquivalenzen gelten z.B. nicht, wenn in Körper auf in der Funktion definierte mutable-Variablen zugegriffen wird (bei Klassen-Feldern ist dies wiederum erlaubt), weil für fun/function gewisse Eigenschaften von Closures eingehalten werden. Details dazu finden sich in [FSharp, vgl S. 56]. 28 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk let rec isMember x = function | [] -> false | head :: tail when x = head -> true | _ :: tail -> isMember x tail Die gezeigte rekursiven Definition lässt sich durch Funktionen höherer Ordnung in F# kürzer ausdrücken: let isMember x = List.exists((=)x) Hier geschieht die Iteration der Liste nicht explizit durch Rekursion, sondern es wird Folgendes ausgedrückt: Ein Wert x ist enthalten, wenn es ein Element gibt, das gleich x ist. Die Funktion List.exists führt die Iteration durch. Pattern matching kann wie viele andere Techniken auch falsch verwendet werden. Sofern nur die Iteration einer Liste oder eine ähnlich einfache Aufgabe durchgeführt werden soll, sollte auf Funktionen höherer Ordnung zurückgegriffen werden. Wenn mit Zahlenbereichen und Relationen wie < gearbeitet wird, sollte if verwendet werden. In Verbindung mit Option-Werten werden ggf. längere Pattern-matching-Ketten, wobei eine Computation Expression wie der OptionWorkflow (S. 36) verwendet werden sollte. Der Option-Typ definiert auch Funktionen höherer Ordnung, die zur Verarbeitung von Option-Werten benutzt werden können, wobei dies nur selten sinnvoll ist. Andersherum arbeiten Pattern matching und Funktionen höherer Ordnung sehr gut zusammen, insbesondere beim Option-Typ. Seq.tryFind predicate sequence hat den Typ (’a -> bool)-> ’a seq -> ’a option und gibt das erste Element der Sequenz zurück, die das Prädikat erfüllt (als Some(element)); erfüllt kein Element das Prädikat, wird None zurückgegeben. Eine sinnvolle Verwendung ist z.B. das Anwenden der Funktion und sofortige Abgleichen mit den Option-Varianten: > let gibGeradeZahlAus zahlen = match Seq.tryFind(fun x -> x % 2 = 0) zahlen with | Some(geradeZahl) -> printfn ”%d ist die erste gerade Zahl” geradeZahl | None -> printfn ”Keine Zahl war gerade”;; val gibGeradeZahlAus : seq<int> -> unit > gibGeradeZahlAus [1; 2; 3];; 2 ist die erste gerade Zahl 3.2.3 Active Patterns Technik: Active Patterns Zweck: Funktionale Erweiterung von Pattern Matching Problem: Nur primitive und algebraische Datentypen können in Pattern Matching verwendet werden, aber oft wird mit objektorientierten Klassen gearbeitet. Wiederholende Aufgaben wie Typprüfung und Wertextraktion von Objekten können in Funktionen gekapselt werden, die häufige Nutzung von Hilfs29 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig funktionen verschleiert aber die Intention. Vorhandene Patterns sind nicht aussagekräftig oder tauchen als Duplikate auf. Lösung: Active Patterns erweitern die Möglichkeit von Pattern Matching, indem Funktionen vor oder während des Matchings auf den Ausdruck angewendet wird. Konstrukte: (|X|), (|X|_|), (|X|Y|) Performanz: Hängt von Pattern Matching und benutzten Funktionen ab. Alternative: Explizite Funktionsaufrufe und explizite Definition von algebraischen Datentypen für das Ergebnis der Funktionen. Um Active Patterns zu verstehen, ist es sinnvoll, kleine Beispiele dafür in expliziter Form zu sehen und diese mit dem entsprechenden Active Pattern zu vergleichen. Der einfachste Anwendungsfall ist der Klassifizierer. Klassifizierer type Vorzeichen = Negativ | Null | Positiv let vorzeichen x = if x < 0 then Negativ else if x = 0 then Null else Positiv let expliziteVerwendungVonFunktionen = match (vorzeichen -23, vorzeichen 0, vorzeichen 42) with | (Negativ , Null, Positiv) -> printfn ”Test bestanden.” | _ -> failwith ”Test nicht bestanden.” Hier wird zunächst ein algebraischer Datentyp definiert, der das Ergebnis der Klassifikation bestimmt. Dann wird eine Funktion geschrieben, die eine ganze Zahl nach ihrem Vorzeichen klassifiziert. In einem kleinen Beispiel wird dies getestet. Im folgenden wird eine kürzere Variante gezeigt, die Active Patterns benutzt und dasselbe leistet. Auffällig ist dabei der Name der zuvor vorzeichen genannten Funktion und das Wegfallen der Typdefinition und der Funktionsaufrufe. Active Patterns sind lediglich Funktionen, d.h. (|Negativ|Null|Positiv|) ist eine Funktion mit einem speziellen Namen, die in Pattern Matching automatisch verwendet wird. let (|Negativ|Null|Positiv|) x = if x < 0 then Negativ else if x = 0 then Null else Positiv let impliziteForm = match (-23, 0, 42) with | (Negativ , Null, Positiv) -> printfn ”Test bestanden.” | _ -> failwith ”Test nicht bestanden.” An dieser Stelle wird deutlich: Das Muster (Negativ, Null, Positiv) passt auf alle Tripel-Werte, deren erste Komponente eine negative Ganzzahl ist, deren zweite 0 und deren dritte eine positive Ganzzahl ist. Auch wenn dieses Spielbeispiel nicht besonders hilfreich erscheint, ließen sich damit schon folgende Refactorings 30 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk durchführen: Muster mit guard der Form ...x... when x > 0 lassen sich durch Benutzung von (Positiv as x) vereinfachen. Und wo auch immer Funktionen mit Ganzzahlen arbeiten, die positiv sein müssen, ließe sich ein anfänglicher Test der Form let f(x)= if not(x > 0)then invalidArg ”x””x > 0 muss gelten”else Körper auf der Parameter-Stelle von x in der Form let f(Positiv as x)= Körper einbauen (die Fehlernachricht ist dann allerdings nicht aussagekräftig). Der erste Vorschlag führt zur zweiten Form von Active Patterns: Partial Active Patterns, die in Kürze gezeigt werden. Der zweite genannte Anwendungsfall, die Validierung eines Argumentes, führt zu einem Active Pattern, welches eine Exception werfen kann. Diese Art von Active Pattern nenne ich Validierer. In der folgenden Variante handelt es sich um ein Parameterised Active Pattern, da name ein Parameter ist, der wie beispielsweise ”n” in (NichtNegativ ”n”n) übergeben wird. Validierer und Parameterised Active Pattern let (|NichtNegativ|) name x = if x >= 0 then x else invalidArg name (name + ” darf nicht negativ sein.”) let kleinerGauß(NichtNegativ ”n” n) = n * (n+1) / 2 Eine sehr einfache Verwendung der Form (|X|) ist eine Abbildung, die immer gelingt und einen Wert liefert: Konvertierter let (|Betrag|) x = abs x let (|AlsString|) x = string x let (|AlsDouble|)(x: obj) = System.Convert.ToDouble(x) Auffällig ist vielleicht die Verwendung von Funktionen, die lediglich angewendet werden. Ein Muster der Form let (|Apply|)f x = f x könnte diese beiden gezeigten ersetzen (als Muster (Betrag betrag) oder (Apply abs derBetrag) und (AlsString text) oder (Apply string derText)). Dieses Active Pattern ist im Allgemeinen nicht sinnvoll, weil Active Patterns die Lesbarkeit verbessern sollen, indem sprechende Namen für häufige Umwandlungen und Prüfungen verwendet werden. Parser und Partial Active Patterns Partial Active Patterns zeichnen sich dadurch aus, dass sie partiell definiert sind. Dies wird durch die Verwendung des Option-Typs als Rückgabetyp ausgedrückt und im Namen durch einen abschließenden Unterstrich gekennzeichnet. open System // Dort ist der Typ Int32 definiert let (|AlsGanzzahl|_|) x = 31 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig match Int32.TryParse(x) with | true, wert -> Some(wert) | _ -> None let test = match ”24” with | AlsGanzzahl x -> ”die Ganzzahl ” + string x | _ -> ”keine Ganzzahl” Als Nächstes zeige ich, dass die zwei Konzepte partielle Definition und Klassifikation kollidieren, insbesondere mit der beliebigen Kombinierbarkeit von Patterns: Die Variante (|X|Y|_|) darf (und kann glücklicherweise) nicht verwendet werden, wie folgendes hypothetisches Beispiel zeigt. Im Folgenden wird auch auf die Ausführungsreihenfolge eingegangen, insbesondere auf den Zeitpunkt, wann die als Active Patterns definierten Funktionen auf den Wert angewendet werden. open System // Dort sind die Typen Int32 und Double definiert let (|Ganzzahl|Kommazahl|_|) x = // Anm.: Dieser Funktionsname wird vom Parser nicht akzeptiert. match Int32.TryParse(x) with // Versuche Wert als Ganzzahl zu parsen: | true, wert -> Some(Ganzzahl(wert)) // OK: Ganzzahl | _ -> match Double.TryParse(x) with // Ansonsten als Kommazahl: | true, wert -> Some(Kommazahl(wert)) // OK: Kommazahl | _ -> None // Sonst: weder Ganz- noch Kommazahl let test = match ”24” with // führt (|Ganzzahl|Kommazahl|_|) aus, // liefert Some(Ganzzahl(42)) | Ganzzahl 42 -> ”die Ganzzahl 42” // Scheitert bei match 24 with 42. | Kommazahl x -> ”irgend eine Kommazahl” // Wird ignoriert , denn // die Zahl wurde als Ganzzahl , nicht als Kommazahl klassifiziert. // Intuitiv wäre ein erneutes Parsen , das Kommazahl(24.0) liefert. | _ -> ”gar keine Zahl” // Dieser Fall trifft zu! Stattdessen werden hier die kleinstmöglichen Einheiten, die als Partial Active Patterns definiert sind, (|Ganzzahl|_|) und (|Kommazahl|_|) verwendet, die sich erwartungsgemäß verhalten. Diese Varianten stellen durch die Verwendung Klassifizierer dar, in jedem Fall aber sind es Parser. open System // Dort sind die Typen Int32 und Double definiert let (|Ganzzahl|_|) x = match Int32.TryParse(x) with | true, wert -> Some(Ganzzahl(wert)) | _ -> None let (|Kommazahl|_|) x = match Double.TryParse(x) with | true, wert -> Some(Kommazahl(wert)) | _ -> None let test = match ”24” with 32 Deklarativ, wenn möglich; imperativ, wenn nötig | Ganzzahl 42 -> ”die Ganzzahl 42” // Führt // liefert Some(24), scheitert bei match 24 | Kommazahl x -> ”irgend eine Kommazahl” // // liefert Some(24.0), trifft zu und bindet | _ -> ”gar keine Zahl” Karsten Pietrzyk (|Ganzzahl|_|) aus, with 42. Nächster Fall: Führt (|Kommazahl|_|), 24.0 an x. Übersicht der Anwendungsfälle Durch diese Anwendungsfälle lassen sich folgende Nutzungen von Active Patterns herauskristallisieren: (|X|) Konvertiere zu X (Konvertierer), Überprüfe auf Eigenschaft X (Validierer) und wirf eine Exception im Fehlerfall. (|X|Y|) Klassifiziere nach Eigenschaften X, Y, ... (bis zu 7 Eigenschaften sind möglich) (Klassifizierer) (|X|_|) Klassifiziere nach Eigenschaft X oder scheitere (Klassifizierer), parse Wert als X (Parser) Die Benutzung von Active Patterns kann zu verständlicherem Code führen, indem Pattern Matching damit auf beliebigen Datentypen benutzt werden kann. In der Arbeit über Active Patterns werden unter anderem für häufig verwendete objektorientierten Typen wie Type und XmlDocument Active Patterns definiert, die eine Verwendung der Objekte dieses Typs wie Varianten eines algebraischen Datentypen zulässt [ActivePattern, vgl. S. 4, 7-8]. Ein Beispiel, das besonders durch Active Patterns deklarativ ist, sind diese drei Definitionen, die die Verarbeitung von XML-Dokumenten enorm erleichtert. Hier werden für die Typen der System.Linq.XObject-Hierarchie Active Patterns definiert. Das anschließende Beispiel zeigt, wie diese zu verwenden sind. open System.Xml.Linq // (|Node|_|): string -> XNode -> XNode seq let (|Node|_|)(name: string)(node: XNode) = match node with | :? XElement as element when element.Name.LocalName = name -> Some(element.Nodes()) | _ -> None // (|Text|_|): XNode -> string option let (|Text|_|)(node: XNode) = match node with | :? XElement -> None | _ -> Some(node.ToString()) // (|Attribute|_|): string -> XNode -> string option let (|Attribute|_|)(name: string)(node: XNode) = match node with | :? XElement as element -> 33 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig match element.Attribute(XName.Get(name)) with | null -> None | x -> Some(x.Value) | _ -> None let rec traverseAll = Seq.iter traverseNode and traverseNode = function | Text text -> printfn ” %s” (text.Trim()) | Node ”Matches” children -> traverseAll children | Node ”Match” children & Attribute ”Winner” winner & Attribute ”Loser” loser & Attribute”Score” score -> printfn ”%s won against %s with score %s” winner loser score traverseAll children traverseNode(XElement.Load(”matches.xml”)) Für das Beispieldokument matches.xml: <Matches > <Match Winner=”A” Loser=”B” Score=”1:0”> Description of the first match... </Match> <Match Winner=”A” Loser=”C” Score=”1:0”> Description of the second match... </Match> </Matches > erscheint diese Ausgabe: A won against B Description A won against C Description with score 1:0 of the first match... with score 1:0 of the second match... Durch dieses Beispiel wird vor allem die Kompositionierbarkeit (Konjunktion mit &) von Active Patterns deutlich. Das folgende Muster passt genau auf einen Knoten Match mit den Attributen Winner, Loser sowie Score und extrahiert gleichzeitig Kindelemente des Knotens und die Attributwerte: Node ”Match” children & Attribute ”Winner” winner & Attribute ”Loser” loser & Attribute”Score” score Dieser Ansatz führt zu einer weiteren Denkweise hinter deklarativer Programmierung: Domänenspezifische Sprachen (domain-specific languages, DSL). Die Regeln, die zuvor definiert wurden stellen eine interne DSL dar. 3.2.4 Computation Expressions Technik: Computation Expressions Zweck: Alternative Auswertung von F#-Konstrukten 34 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk Problem: Grundlegende Konstrukte wie Variablenbindungen und Schleifen werden standardmäßig sequenziell ausgeführt, ohne dass darauf Einfluss genommen werden kann. Eine parallele Ausführung, Logging, schrittweiseunterbrechbare Ausführung oder verzögerte Ausführung müssen explizit behandelt werden. Lösung: Computation expressions bieten die Möglichkeit, über Continuations die Ausführung zu beeinflussen. Das primäre Konstrukt dafür ist der Computation Builder. Performanz: Hängt von den Methoden des Builders ab; führt zusätzlich Funktionsobjekte für die Continuations ein. 4 Alternative: Alternative Ausführung muss explizit formuliert werden, was aufwendig oder lästig sein kann. Um Computation Expressions zu verstehen, muss man die Funktionsweise des Computation Builders verstehen. Ein Computation Builder ist ein Objekt einer Klasse, die mindestens diese beiden Methoden besitzt: Bind(value, continuation) Für das Konstrukt let! identifier = expression in body. Dies ermöglicht Kontrolle über Wertbindungen und die weitere Ausführung nach dieser Bindung. Return(value) Für das Konstrukt return expression. Damit wird die Ausführung der Computation Expression beendet und liefert einen Wert des gesamten Ausdrucks der Computation Expression. Ein häufiges Idiom in der imperativen Programmierung sind null-Prüfungen. Da in der funktionalen Programmierung null kein gültiger Wert eines Typs ist, wird der Option-Typ (siehe Option-Typ im Abschnitt Typen (S. 24)) verwendet. Die Intention des fehlenden Wertes wird dadurch deutlicher, anstatt für alle ReferenzTyp null als Wert zu erlauben. Der Option-Typ enthält zwei Varianten: je eine Variante für einen undefinierten oder definierten Wert. type Option <’a> = None | Some of ’a Um mit diesem Typ zu arbeiten, muss mithilfe von Pattern matching unterschieden werden, ob Zwischenergebnisse definiert sind und ggf. die Berechnung mit dem undefinierten Wert zu beenden. Als Beispiel dient hier eine Funktion, die zwei Zahlen aus der Konsole liest und die Summe zurückgibt. Nur für den Fall, dass beide Eingaben Zahlen darstellen, ist das Ergebnis definiert. open System /// readIntegerOptionFromConsole: unit -> int option let readIntegerOptionFromConsole() = match Int32.TryParse(Console.ReadLine()) with 4 Continuations können in Einzelfällen zu Performanz-Problemen führen (z.B. http://www.quanttec.com/fparsec/users-guide/where-is-the-monad.html#why-the-monadicsyntax-is-slow). 35 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig | true, value -> Some(value) | _ ->None match readIntegerOptionFromConsole() with | None -> None | Some(firstValue) -> match readIntegerOptionFromConsole() with | None -> None | Some(secondValue) -> Some(firstValue + secondValue) Da bei der Programmierung mit externen Datenquellen (wie etwa der Konsole, Dateien oder Webinhalten) diese Daten nicht immer ein gültiges Format haben, ist der Code, der mit diesen Daten arbeitet, mit Gültigkeits-Prüfungen versehen. Im Idealfall wird in objektorientierten Programmiersprachen mit Exceptions gearbeitet, sodass der korrekte Arbeitsablauf innerhalb eines Try-Konstrukts geschrieben werden kann und im Falle eines Fehlers die Fehlerbehandlung durchgeführt wird. In der Tat ließe sich diese Methode auch für das angeführte Beispiel verwenden: open System /// readIntegerFromConsole: unit -> int (throws FormatException) let readIntegerFromConsole() = Int32.Parse(Console.ReadLine()) try let first = readIntegerFromConsole() let second = readIntegerFromConsole() Some(first + second) with :? FormatException as ex -> None In der funktionalen Programmierung ist jedoch die Verwendung des Option-Typs verbreiteter, weil schon über den Typ wie etwa int option ersichtlich ist, dass dieser Vorgang fehlschlagen kann. Bei Exceptions ist dies nur durch Dokumentation ersichtlich oder im Fall von Java mit checked exceptions, die zur Methodensignatur zählen und im verwendenden Code abgefangen werden müssen. In beiden Fällen ist die Fehlerbehandlungsstrategie ersichtlich. Mithilfe von Computation Expressions ist eine alternative Auswertung möglich, sodass das Abfangen von Exceptions oder Prüfen auf definierte Werte ein Belang ist, der nicht explizit im Code wie oben formuliert wird, sondern Aufgabe des Computation Builders ist. Im Fall des Option-Typs ließe sich der Option-Workflow definieren: type OptionBuilder() = member this.Bind(value, continuation) = match value with | None -> None | Some(definedValue) -> continuation(definedValue) member this.Return(value) = Some(value) let optional = OptionBuilder() Dieser lässt sich dann wie folgt verwenden: 36 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk optional { let! first = readIntegerOptionFromConsole() let! second = readIntegerOptionFromConsole() return first + second } Der obenstehende Code benutzt die Methoden des OptionBuilder-Typs. Jedes Vorkommen von let! Pattern = Expression in Body (in kann auch durch einen Zeilenumbruch ersetzt werden) wird durch optional.Bind(Expression, fun Pattern -> Body) ersetzt. Ebenso wird return Value in optional.Return(Value) übersetzt. Hier wird lediglich syntaktischer Zucker verwendet, der unter anderem Ähnlichkeit mit let-Bindungen hat und beim Kompilieren durch Methodenaufrufe des Computation Builders ersetzt wird. Wichtige Beispiele dieser Technik sind seq für Sequenz-Literale, async für nebenläufige Programmausführung und query für Datenbank-Abfragen. Wie eingangs erwähnt, ist vereinfachte nebenläufige Programmausführung ein Vorteil von deklarativen Programmiersprachen. Dies wird insbesondere deutlich, wenn die Integration von nebenläufigen Methodenaufrufen durch einen Computation Builder komfortabler gemacht wird. Andere Sprachen greifen für solche Zwecke auf neue Schlüsselwörter, Syntax-Erweiterungen und Makros zurück. Auch in der Hinsicht ist eine selbstprogrammierbare alternative Ausführungsumgebung praktisch. Insbesondere bei Datenbankabfragen ist es vorteilhaft, wenn der Programmierer keine SQL-Strings manipuliert, um eine Anfrage zu erstellen, sondern stattdessen in einer dafür erstellten Auswertungsumgebung Queries schreibt, die entsprechende Datenbank-Typen verwenden. let articles = query { for article in db.Articles do sortBy article.Price select (article.Name, article. Price) } for (name, price) in articles do printfn ”%s costs %f €” name price sortBy und select sind sogenannte Custom operations, die wie kontextabhängige Schlüsselwörter einer Programmiersprache wirken, jedoch nur spezielle Methoden des Computation Builders sind. Im Fall des Query-Builders wird auch auf Quotations zurückgegriffen, die im nächsten Kapitel behandelt werden und den Einstieg in Metaprogrammierung darstellen. Dazu wird im Computation Builder die Methode Quote eingeführt [FSharpSpec, S. 62]. 37 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig 3.3 Domänenspezifische Sprachen 3.3.1 Was sind DSLs? Domänenspezifische Sprachen (domain specific languages, kurz DSLs) sind Sprachen, in denen nur Probleme eines Fachgebietes (einer Domäne) formuliert werden können. Aufgaben außerhalb des Anwendungsgebietes sollen nicht in DSLs gelöst werden. Dieser Punkt unterscheidet sie stark von generellen Programmiersprachen (general purpose languages, kurz GPLs), die alle programmierbaren Probleme darstellen können. Man unterscheidet interne und externe DSLs. Interne sind solche, die innerhalb einer anderen Programmiersprache auftreten, also mit mitteln der Programmiersprache formuliert sind. Externe DSLs sind jene, die nicht die Mittel einer „Host“Programmiersprache verwenden sondern eine eigene Syntax aufweist. Mit externen DSLs erstellte Texte (bzw. Skripte oder Beschreibungen) werden oft in externen (Text-)Dateien geschrieben, wobei es Ausnahmen wie SQL und Regex gibt, die auch in anderen Programmiersprachen als String-Inhalte verwendet werden. Als Beispiel für eine interne DSL dient hier „Miss Grant’s Controller“ [DSLs], ein Sicherheitssystem, das ein Fach öffnet, nachdem eine bestimmte Folge von Aktionen durchgeführt wurde. In F# werden die Aktionen, Zustandsnamen, Codes und Ereignisse als algebraische Datentypen (S. 23) definiert (im Original werden diese als Strings modelliert, was für externe DSLs sehr flexibel ist, für interne DSLs hingegen Typsicherheit wichtiger ist). Ein Zustand und das System selbst werden als Datensatz (S. 24) definiert. Die Konstruktion des Systems geschieht über Datensatz-Konstruktoren, Listenliterale und mithilfe von Operatoren. 5 type condition = DoorClosed | DrawerOpened | LightOn | DoorOpened | PanelClosed and actions = UnlockPanel | LockPanel | LockDoor | UnlockDoor and codes = D1CL | D2OP | L1ON | D1OP | PNCL | PNUL | PNLK | D1LK | D1UL and stateName = Idle | Active | WaitingForLight | WaitingForDrawer | UnlockedPanel and state = { name: stateName; actions: actions list; transitions: (condition * stateName) list } and machine = { events : (condition * codes) list resetEvents: condition list commands : (actions * codes) list states : state list } 5 Eine Beispielprogramm, das den Nutzer durch den resultierenden Automaten dieses Systems navigieren lässt, findet sich im Anhang im Abschnitt Miss Grant’s Controller (S. 70). 38 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk let inline (=>) a b = (a, b) let inline (:=) name (actions , transitions) = { name = name; actions = actions; transitions = transitions } let machine = { events = [ DoorClosed => D1CL DrawerOpened => D2OP LightOn => L1ON DoorOpened => D1OP PanelClosed => PNCL ]; resetEvents = [ DoorOpened ]; commands = [ UnlockPanel => PNUL LockPanel => PNLK LockDoor => D1LK UnlockDoor => D1UL ]; states = [ Idle := [UnlockDoor; LockPanel] => [DoorClosed => Active] Active := [] => [DrawerOpened => WaitingForLight; LightOn => WaitingForDrawer] WaitingForLight := [] => [LightOn => UnlockedPanel] WaitingForDrawer := [] => [DrawerOpened => UnlockedPanel] UnlockedPanel := [UnlockPanel; LockDoor] => [PanelClosed => Idle] ] } Die Verwendung von DSLs lässt sich auch als Teil von language-oriented programming ansehen, einem Paradigma, das darauf basiert, eine geeignete Notation zur Problembeschreibung zu entwickeln und die Software dann mit dieser Notation zu entwickeln. Dies wird in der Arbeit, die diesen Begriff maßgeblich geprägt hat, auch durch kürzeren Quellcode motiviert: „In computer science it is a great advantage to have a suitable notation in which to express certain classes of algorithms, rather then writing yards of source code.“ [LanguageOrientedProg, S. 9] 3.3.2 Reguläre Ausdrücke Eine sehr beliebte externe DSL sind reguläre Ausdrücke (regular expressions, kurz regex), mit denen sich Text nach bestimmten Mustern durchsuchen lässt. Beispiele für Muster sind konkrete Buchstaben, Ziffern, Leerzeichen; hinter diese Mustern kann eine Vorkommens-Einschränkung notiert werden (z.B. einmal oder keinmal, mindestens einmal oder beliebig oft). Die Integration von regulären Ausdrücken 39 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig in Programmiersprachen ist vielfältig. Hier soll mithilfe von Active Patterns eine möglichst intuitive Verwendung erreicht werden. Regex über Active Patterns: > open System.Text.RegularExpressions let (|RegexGroups|_|) pattern text = let regex = Regex.Match(text, pattern) if regex.Success then Some(List.tail [ for group in regex.Groups -> group.Value ]) else None;; val (|RegexGroups|_|) : pattern:string -> text:string -> string list option > match ”12+13” with | RegexGroups ”(\d+)\+(\d+)” [a; b] -> sprintf ”Zwei Zahlen: %s und %s” a b | _ -> ”keine zwei Zahlen”;; val it : string = ”Zwei Zahlen: 12 und 13” Weitere Techniken zur Programmierung von DSLs mit F# sind möglich, die hier nicht aufgezählt werden können. Einige werden in [RealWorldFP, vgl. S. 425, 433, 451] angesprochen und umgesetzt (neue Operatoren, Typ-Augmentation, Operator-Lifting). Genaueres zu Operatoren findet sich in [FSharp, S. 119]. 6 DSLs und funktionale Programmiersprachen sind seit LISP engverwandt. Martin Fowler merkt in seinem Buch [DSLs, S. 163] an, nicht genug über DSLs und funktionale Programmierung zu wissen, um es in dem Buch darzustellen, weshalb sich das Buch auf DSL-Techniken für objektorientierte Programmiersprachen konzentriert. Eine häufig anzutreffende Technik, die für objektorientierte interne DSLs verwendet wird, sind sogenannte Fluent Interfaces, d.h. Objekte, die Methodenketten erlauben, wodurch nacheinander Eigenschaften spezifiziert werden oder Aktionen ausgeführt werden. Um z.B. für eine Klasse mit zwei Feldern int x, y eine ToString-Methode zu schreiben, ließe sich der StringBuilder verwenden. Der Trick liegt darin, dass die Append-Methode dasselbe Exemplar zurückgibt, sodass die nächste Methode aufgerufen werden kann. Die letzte Zeile der MEthode lautet also return this;. class Point { public int X, Y; public override string ToString() { return new StringBuilder() .Append(”[”).Append(X) .Append(” | ”).Append(Y) .Append(”]”).ToString(); } 6 Eine praxisnahe Übersicht über DSL-Techniken bietet https://github.com/dungpa/dsls-inaction-fsharp/blob/master/DSLCheatsheet.md. 40 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk } Im folgenden Kapitel geht es das Konzept Metaprogrammierung, das sich mit Programmerzeugung, -Analyse und -Transformation befasst, was für DSLs essenziell ist. Außerdem wird mit Quotations eine mächtige Technik für interne DSLs eingeführt. 3.4 Metaprogrammierung 3.4.1 Was ist Metaprogrammierung? Für Metaprogrammierung gibt es verschiedene Definitionen und Erklärungen; die Folgenden stammen aus dem Buch [MetaprogDotNet]. 1. „A computer program that writes new computer programs.“ [MetaprogDotNet, S. 6] • Code-Generierung ist in der Tat ein wichtiges Gebiet der Metaprogrammierung. Immerhin müssen Compiler den Quellcode der Programmiersprache in Maschinen-nahen Code umsetzen. Dennoch schreiben nur wenige Programmierer jemals selbst einen Compiler. Nur selten wird Code generiert, z.B. im Fall von Datenbank-Programmierung und der Generierung von Klassen aus dem Datenbank-Schema. Eine anderer anwendungsbezogener Aspekt von Metaprogrammierung ist Analyse oder Inspektion von Programmen (bzw. von Objekten) zur Laufzeit (dies ist das Gebiet Reflection von Programmiersprachen). 2. „Try to think of it [metaprogramming] as after-programming or besideprogramming. The Greek prefix meta allows for both of those definitions to be correct. Most of the examples in this book demonstrate programming after traditional compilation has occurred, or by using dynamic code that runs alongside other processes.“ [MetaprogDotNet, S. 6] • Die Umschreibung mit „Danach-Programmierung“ ist bei Codetransformation sehr passend, weil vom Benutzer geschriebener Code vor dem eigentlichen Kompilieren um Aspekte wie Persistenz von Objekten7 oder Datenprüfungen8 erweitert wird. „Nebenbei-Programmierung“ kann das Arbeiten mit generiertem Code sein, d.h. beim Programmieren kann auf generierte Klassen und Methoden zugegriffen werden. Das ist insbesondere wichtig bei GUI-Programmierung, wenn GUI-Designer Code erzeugen, den der Programmierer sofort verwenden muss, z.B. Referenzen auf Steuerelemente; oder auch bei aus Datenbank-Schemata generierten Klassenbibliotheken. 7 Wie etwa für das Serializable-Attribut bei der Programmiersprache Nemerle: http://nemerle.org/wiki/index.php?title=Macros_tutorial#Macros_in_custom_attributes. 8 Wie in Design-by-Contract-Frameworks für NotNull-Attribute. 41 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig Im Folgenden wird Metaprogrammierung anhand von Syntaxbäumen erklärt, weil diese auf anschauliche Weise zeigen, dass Programmcode Daten sind und die übliche Datenverarbeitung dadurch zur Verarbeitung von Programmen wird. 3.4.2 Quotations Es kann vorteilhaft sein, auf Teile des Quellcodes als Objekt zugreifen zu können, z.B. um diesen zu analysieren, zu transformieren, zu persistieren oder zu übertragen. In F# sind Quotations Ausdrücke der Form <@ Ausdruck @>. Innerhalb dieser Klammern lässt sich Quellcode schreiben, der nicht ausgewertet oder kompiliert wird, sondern dessen Syntaxbaum erzeugt wird. Die Einschränkung ist, dass keine Typen und Module deklariert werden können. Wenn Funktionen geschrieben werden, lässt sich über des ReflectedDefinition-Attributes ausdrücken, dass auch auf die „quotierte“ Form der Funktion zugegriffen werden kann, wie folgendes Beispiel zeigt: > [<ReflectedDefinition >] let f(x: int) = 2 * x;; val f : int -> int > open Microsoft.FSharp.Quotations open Microsoft.FSharp.Quotations.Patterns open Microsoft.FSharp.Quotations.DerivedPatterns;; > match <@ f @> with | Lambda(param, Call(target, MethodWithReflectedDefinition def, args)) -> def;; warning FS0025: Incomplete pattern matches on this expression. val it : Expr = Lambda (x, Call (None, Int32 op_Multiply[Int32,Int32,Int32](Int32, Int32), [Value (2), x])) {CustomAttributes = ...; Type = Microsoft.FSharp.Core.FSharpFunc ‘2[System.Int32,System.Int32];} Mithilfe von Quotations (S. 42) lassen sich F#-Ausdrücke möglich, die keinem typischen F#-Programm ähneln. Z.B. lässt sich das Member-Prolog-Prädikat (S. 12) bei geeigneter Definition von memb, E, __ und R in F# schreiben9 . Dieses Programm ließe sich in den entsprechenden Prolog-Code transformieren. Dies ist ein Schritt in polyglotte (mehrsprachige) Programmierung, bei der man verschiedene kompatible Programmiersprachen zur Lösung eines Problems verwendet. prolog <@ memb(E, E :: __) memb(E, __ :: R) <-- memb(E, R) @> Für Codegenerierung sind Syntax-Bäume ein gutes Hilfsmittel. Andernfalls müsste man auf Generierung von Quelltext in Form von Strings zurückgreifen, wobei dies 9 Im Abschnitt Quotations des Anhangs (S. 74) finden sich besagte Definitionen. 42 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk schnell zur Generierung von fehlerhaftem Code führen kann. Andere Aufgaben wie Code-Optimierung und -Transformation können nicht sinnvoll auf der Basis von Strings durchgeführt werden, weil Fallunterscheidungen anhand von Strings fehlerträchtig und aufwendig sind. Syntaxbäume werden z.B. bei der Überführung von Ausdrücken in der Programmiersprache in SQL-Befehle verwendet. Metaprogrammierung und DSLs stehen in starkem Zusammenhang, in dem oben genannten Buch werden DSLs durch Metaprogrammierungstechniken ermöglicht [MetaprogDotNet, vgl. S. 5] Quotations und Computation-Expressions werden oft für Metaprogrammierungszwecke eingesetzt. Ein beliebtes Beispiel sind Datenbank-Queries, die in SQL umgesetzt werden können. query{ for customer in db.Customers do where customer.ID = 42 select customer.Name } Dieser Ausdruck kann innerhalb der Entwicklungsumgebung geschrieben werden, wobei nur gültige Abfragen formuliert werden können. Der Ausdruck kann in den SQL-Befehl select Name from Customers where ID = 42 übersetzt werden. Mit Metaprogrammierung ist es möglich, von Computer-nahen Möglichkeiten zu abstrahieren. Das gilt insbesondere für konkrete Techniken der Fallunterscheidungen, die in den Abschnitten Pattern matching (S. 26) und Imperativ-objektorientierte Fallunterscheidungen (S. 51) betrachtet wurden. Damit wird die Aufgabe der Umsetzung auf einen späteren Zeitpunkt verschoben. Somit kann die Ausführungsumgebung eine andere sein als die standardmäßig verwendete; dies wurde bereits mit einem F#-zu-JavaScript-Übersetzer10 und Ausführung einer Untermenge von F#-Konstrukten auf Grafikkarten durchgeführt 11 . Details zu der GPU- und SQL-Übersetzung finden sich in [FSharpMeta, S. 48, 50]. 10 11 http://fsharp.org/use/html5/ http://fsharp.org/use/gpu/ 43 4 Imperativer Stil In diesem Teil geht es um die imperativen Pendants zu einigen vorgestellten deklarativen Elementen. Diese sind nicht nur deshalb wichtig, um mit Programmierern, die vorwiegend imperativ geprägte Programmiersprachen verwenden, über Quellcode zu reden und zu diskutieren. Die Details der Umsetzung deklarativer Elemente sind für Programmierer interessant, die eine Programmiersprache nicht nur verwenden sondern auch erweitern wollen oder sich für die technische Umsetzung interessieren. Dieses Kapitel ist folgendermaßen strukturiert: • In dem Abschnitt „Imperative Konsistenzprüfungen“ (S. 46) geht es um Veränderlichkeit von Daten und den damit einhergehenden Problemen und Maßnahmen. • Der Abschnitt „Imperativ-objektorientierte Typen“ (S. 47) zeigt kurz, was bei der Definition von Datensatz-Typen in imperativ-objektorientierten Programmiersprachen zu beachten ist. • Im Abschnitt „Imperativ-objektorientierte Fallunterscheidungen“ (S. 51) geht es um Möglichkeiten der Kontrollflussmanipulation. Neben der Durchführung von Berechnungen und Lese-/Schreiboperationen ist die Veränderung des Kontrollflusses eine der Hauptaufgaben des Prozessors. Dank der Idee von Fallunterscheidungen und Schleifen werden Sprungbefehle nicht vom Programmierer geschrieben, sondern vom Compiler. Nur Fallunterscheidungen, keine Schleifen werden hier betrachtet. Eine Evaluation der Techniken findet im darauffolgenden Kapitel Evaluation der Fallunterscheidungstechniken (S. 54) statt. 4.1 Motivation Gängige Algorithmen sind oft in Pseudocode notiert (wie etwa in [Cormen]) und werden vorwiegend mit imperativ geprägten Sprachen implementiert. Vor allem 44 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk Arrays werden für effiziente Algorithmen verwendet. Für andere Anwendungsgebiete wie Matrizen-Rechnungen oder die Initialisierung von komplexen Datenstrukturen eignet sich die imperative Herangehensweise, was vor allem durch Performance-Verbesserungen motiviert wird [FSharp, vgl. S. 49]. In F# lassen sich auch imperative Konstrukte wie while oder for benutzen, jedoch ist das Verlassen einer Schleife mit einem Sprungbefehl (wie return oder break) nicht möglich. Die Verwendung von if then else ist grundsätzlich nicht imperativ; sie wird hier jedoch als solche gezählt, da sie in Form des If statements aus der imperativen Programmierung besonders häufig auftritt. Es folgt als Beispiel eine vereinfachte Version des Algorithmus counting sort [Cormen, vgl. S. 194-195], der Ganzzahlen in einer Laufzeit von O(n) sortiert. Der Code ist in F# geschrieben und ist ein gutes Beispiel für ein effizienten imperativen Algorithmus. Zuweisungen in F# werden mit dem Pseudocode-Pfeil <- notiert; Zuweisungen von veränderbaren Variablen (die mit let mutable deklariert wurden) und von Array-Elementen sind erlaubt. Allein durch die Verwendung von Funktionen und Operationen kann ermittelt werden, dass dieser Algorithmus auf Integer-Arrays arbeitet (int []). 1 > let SortInPlace numbers = let maximum = Array.max numbers let occurences = Array.zeroCreate(maximum + 1) for num in numbers do occurences.[num] <- occurences.[num] + 1 let mutable insertionIndex = 0 for num = 0 to maximum do for times = 1 to occurences.[num] do numbers.[insertionIndex] <- num insertionIndex <- insertionIndex + 1 numbers;; val SortInPlace : numbers:int [] -> int [] > SortInPlace [|1; 2; 3; 4; 13; 7|];; val it : int [] = [|1; 2; 3; 4; 7; 13|] Das Kompilieren dieses Codes erzeugt eine statische Klasse CountingSort mit einer statischen Methode SortInPlace, die ein int[] in-place sortiert. Das Kompilat kann aus jeder .NET-Sprache heraus verwendet werden. 1 Dieser Algorithmus funktioniert auf Arrays mit relativ kleinen Zahlen sehr gut. Der zusätzliche Speicherbedarf ist linear zum Maximum der Zahlen. Außerdem dürfen keine negativen Zahlen vorkommen. Eine Variante, die erweiterte, mit Haskell vergleichbare Generizität nutzt, ist im Anhang im Abschnitt Counting Sort (S. 73) zu finden. Die dortige Variante funktioniert mit allen Ganzzahltypen (byte, short, int, long, BigInteger); die eben genannten Voraussetzungen bleiben jedoch bestehen. 45 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig 4.2 Imperative Konsistenzprüfungen Fehlende Konsistenzprüfungen sind eine gefährliche Fehlerquelle; täglich werden Patches bereitgestellt, bei denen Sicherheitslücken oder Programmabstürze durch Konsistenzprüfungen vermieden werden (häufige Fehler sind fehlende LängenPrüfung oder Null-Checks). In der Tat obliegt es in der imperativ-objektorientierten Programmierung dem Programmierer, seine Methoden mit Konsistenzprüfungen zu versehen, die sicherstellen, dass Parameter von Referenz-Typen nicht null sind und dass Arrays und Strings die richtige Länge haben, um darauf zu arbeiten. Ein Denkmodell, das diese Prüfungen konsequent fordert und explizit Vor- und Nachbedingungen für Methoden verlangt, ist das Vertragsmodell (Design by contract). Neben Parameterprüfungen, insbesondere auf Null-Werte, benötigt man Konsistenzprüfungen, die sicherstellen, dass sich eine Datenstruktur in einem gültigen Zustand befindet. Dies ist insbesondere wegen einer Eigenschaft von imperativorientierten Datenstrukturen notwendig: Veränderlichkeit. Obwohl Felder und Einträge einer Datenstruktur geändert werden können, gibt es meistens Invarianten, die angeben, in welchem Rahmen Änderungen zu einem korrekten Nachfolgezustand führen. Beispielsweise muss bei der Arbeit mit Suchbäumen, die auf veränderbaren Pointern basieren, darauf geachtet werden, dass konsequent alle Pointer geändert werden, um die Suchbaumeigenschaften des Baumes nicht zunichte zu machen. „Programmierdisziplin“ ist das Stichwort, das sich z.B. auch auf saubere Arbeit mit Pointern bezieht. Ein Kommentar dazu aus einem funktionalen Lehrbuch: „Bei imperativen Sprachen muss man nahezu beliebige Konglomerate von untereinander verzeigerten Zellen managen, was auch bei größter Selbstdisziplin zu komplexen Fehlern führt.“ [FunktProg, S. 235]. Imperative Programme sind vor allem durch Veränderlichkeit schnell, genauer gesagt durch selektive Änderung (selective update) [FunktProg, S. 236]. Eine mögliche Sichtweise auf den Zusammenhang zwischen funktionaler Sicherheit und imperativer Effizienz ist in Abbildung 4.1 dargestellt. Konsistenzprüfungen sind eine Technik, um qualitative Software zu schreiben. Es gehört zum defensiven Programmierstil, einem weiteren qualitätsbewussten Denkmodell, die Integrität von Parametern zu prüfen, bevor auf ihnen gearbeitet wird. Zwei bekannte Beispiele, welche die Gefahr von fehlenden Prüfungen deutlich zeigen, sind der Ariane-5- und der Mars-Climate-Orbiter-Unfall: • Im Falle des „Ariane 5 Flight 501“ wurde eine Überlaufprüfung für die horizontale Beschleunigung vergessen (die interessanterweise für die vertikale Beschleunigung durchgeführt wurde). • Im Falle des Mars-Climate-Orbiter-Unfalls haben zwei Entwicklerteams mit unterschiedlichen Maßeinheiten gearbeitet, der SI-Einheit Newton im einen und Pound-Force im anderen Team. 46 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk Abbildung 4.1: Funktionale Qualität und imperative Effizienz als gegensätzliche Enden eines Spektrums [FunktProg, S. 236]. Gerade für solche Anwendungen ist Code-Qualität von enormer Wichtigkeit. In modernen Programmiersprachen gibt es beispielsweise Festkommazahlen mit hoher Genauigkeit und Langzahlarithmetik (big num) mit beliebiger Genauigkeit. Andererseits gibt es Maßeinheiten (units of measurement), damit statt auf einfachen Zahlen zur Entwicklungszeit mit SI-Einheiten gearbeitet werden kann. 4.3 Imperativ-objektorientierte Typen Die wichtigsten Arten von Typen habe ich im gleichnamigen Abschnitt (S. 21) eingeführt. An dieser Stelle werde ich nur die Definition von Datensatz-Typen (S. 24) behandeln, d.h. Typen, die nur als Datenbehälter fungieren. Dazu verwende ich die Sprache C#, die imperativ-objektorientiert geprägt ist. Schon bei diesen einfachen Typen stellen sich Fragen, wie ein solcher Type zu definieren ist, was insbesondere mit der Verwendung des Typen zusammenhängt. Für einen veränderlichen Punkt im zweidimensionalen Raum darstellt, könnte die einfachste Typ-Definition lauten: struct Punkt { public double X, Y; }. Viele Designentscheidungen werden allein durch diese kurze Definition getroffen: • Das Schlüsselwort struct gibt an, dass der Typ nicht auf dem Heap sondern auf dem Stack alloziert wird. Das bedeutet, dass der Garbage Collector Exemplare dieses Typs nicht abräumen muss, sondern diese beim Verlassen des Geltungsbereichs der Variable gelöscht werden. Zuweisungen von Variablen (lokale Variablen, Felder, Parameter) führen zur Kopie des Exemplars. Es können keine Subtypen von Structs erstellt werden. • Der Typ enthält zwei Felder vom Typ double, d.h. Gleitkommazahlen mit doppelter Genauigkeit (64 Bit). Mindestens genauso wichtig sind die Designentscheidungen, die durch das Auslassen von Methoden und Interfaces und Attributen getroffen wurden: 47 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig • Der Typ besitzt keinen Konstruktor, der die Felder initialisiert. • Der Typ ist nicht als serialisierbar gekennzeichnet. • Der Typ verwendet unter Umständen eine ineffiziente Equals-Methode. • Der Typ besitzt keine sinnvolle ToString-Implementierung. Die Folgen sind noch drastischer, wenn statt struct das Schlüsselwort class verwendet wird (class Punkt { public double X, Y; }): • Der Typ wird auf dem Heap alloziert, somit ist jede Variable vom Typ Punkt eine Referenz auf ein Objekt, das sich an einer Speicheradresse befindet. Das führt zu Aliasing-Effekten, d.h. Änderungen an einem Objekt hinter einer Referenz werden auch bei anderen Referenzen auf dasselbe Objekt sichtbar. Außerdem wird der Garbage Collector beansprucht, wenn Exemplare des Typs nicht mehr verwendet werden. • Eine Methode zum Kopieren eines Objektes ist gegebenenfalls auch sinnvoll und müsste implementiert werden. Bei möglicher Unterklassenbildung wird dies erschwert. • Der Typ besitzt keine sinnvolle Standardimplementierung der Equals-Methode, denn es wird auf Gleichheit von Speicheradressen geprüft, was dazu führt, dass zwei Punkte a und b mit denselben Koordinaten (d.h. a.X == b.X und a.Y == b.Y) nicht a.Equals(b) erfüllen). Die Equals-Methode muss von Hand programmiert werden. Bei möglicher Unterklassenbildung wird die korrekte Implementierung erschwert. • Die Verwendung des ==-Operators (sowie !=) verwenden nicht die EqualsMethode sondern prüfen Speicheradressen. Der Gleichheits-Operator (sowie Ungleichheits-Operator) kann für den Datensatz-Typ definiert werden. • Der Typ kann als Oberklasse dienen, d.h. es können von Punkt abgeleitete Klassen erstellt werden. Dieses Verhalten kann mit dem Schlüsselwort sealed unterbunden werden. Wenn also sinnvoll mit Datensatz-Typen gearbeitet werden soll, müssen diese Probleme gelöst werden. Dazu müssen der Konstruktor und folgende Methoden implementiert werden: Equals, GetHashCode, ToString und eventuell Clone. Außerdem kann das Serializable-Attribut an die Typdefinition geschrieben werden, um anzugeben, dass der Typ serialisierbar ist. Sollen noch Konsistenzprüfungen für die Felder eingebaut werden, müssen die Felder durch Properties gekapselt werden. Für einige Typen sind eventuell Vergleichsrelationen wie < gewünscht, weshalb die im IComparable-Interface definierte Methode CompareTo implementiert werden kann (und bei Bedarf die Operatoren <, <=, >, >= implementiert werden können). Es ergeben sich also allein für Datensatz-Typen eine Liste von Anforderungen, die erfüllt werden müssen, damit der Typ korrekt verwendet werden kann. Der angedeutete Code, der die oben angeführten Punkte erfüllt, muss für jeden Typ sinnvoll neu implementiert werden, was zu Boilerplate-Code führt. Im Fall von 48 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk komplexeren Typen, die nicht nur Datensätze darstellen und in einer Klassenhierarchie stehen, ergeben sich viele weitere Anforderungen. Diese Belange (Vergleich, Persistenz, Verwendung von Operatoren, Verwendung in Klassenhierarchien) sind für objektorientierte Programmierer Voraussetzung, um verwendbare Typen zu schreiben. 4.3.1 Modellieren einer festen Anzahl an Varianten Soll eine feste Anzahl an Varianten in einer objektorientierten Programmiersprache definiert werden, kann dies mithilfe von Sichtbarkeitsmodifizierern für Konstruktoren erreicht werden. Unterklassenbildung ist mit Konstruktorverkettung verbunden, was in diesem Fall ausgenutzt wird. • Markieren des Konstruktors als private: Nur innere Klassen können aufgrund der Sichtbarkeitsregel für private Unterklassen werden. Dies entspricht dem typesafe enum pattern [EffectiveJava]: public abstract class CollectionFactory <T> { private CollectionFactory() { } public abstract ICollection <T> Create(); class ArrayListFactory : CollectionFactory <T> { public override ICollection <T> Create() { return new List<T>(); } } public static readonly CollectionFactory <T> ArrayList = new ArrayListFactory(); class LinkedListFactory : CollectionFactory <T> { public override ICollection <T> Create() { return new LinkedList <T>(); } } public static readonly CollectionFactory <T> LinkedList = new LinkedListFactory(); } • Markieren des Konstruktors als internal: So können nur Klassen desselben Kompilats (die also im selben Projekt kompiliert werden) auf ihn zugreifen: public abstract class CollectionFactory <T> { internal CollectionFactory() { } public abstract ICollection <T> Create(); } public class ArrayListFactory <T> : CollectionFactory <T> { public override ICollection <T> Create() { return new List<T>(); } } public class LinkedListFactory <T> : CollectionFactory <T> { public override ICollection <T> Create() { return new LinkedList <T>(); } } 49 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig 4.3.2 Bezug zu Typ-Definitionen in F# Datensatz-Definitionen (und Algebraische-Datentyp-Definitionen) in F# erfüllen bereits standardmäßig viele dieser Punkte: strukturelle Gleichheit, d.h. Equals unter Berücksichtigung aller Felder wird automatisch generiert, ebenso GetHashCode; struktureller Vergleich (CompareTo) wird generiert (Felder werden nacheinander verglichen, beim ersten Unterschied steht das Vergleichsergebnis fest); ein Konstruktor wird erzeugt; der Typ wird als serialisierbar gekennzeichnet. Was die Verwendung des Typen angeht, wurde einerseits bereits auf das Pattern matching auf Datensätzen (S. 24) eingegangen. Zudem werden die Vergleichsoperatoren =, <> (<> steht für ungleich) in Equals-Aufrufe und <, <=, >, >= in Aufrufe der CompareTo-Methode umgewandelt. Ein Nachteil bei Datensätzen in F# ist die Einschränkung, dass Datensätze zu Klassen kompiliert werden und nicht wahlweise zu Structs. Das kann in einigen Fällen zu unnötiger Beanspruchung des Garbage Collectors führen, insbesondere bei großen Datenmengen. 4.3.3 Structs Ein kleiner Test soll dies veranschaulichen. Es werden Datencontainer für zweidimensionale Punkte erstellt, deren Komponenten Ganzzahlen 32-Bit-Genauigkeit sind. Eine Typ-Definition, die eine Datensatz-Klasse definiert, und eine, die einen explizit Structs erzeugt. Als einzige Operation darauf ist die Multiplikation mit einem Skalar definiert, die einen neuen Punkt mit multiplizierten Koordinaten erzeugt. > #time;; --> Timing now on > type Pos = { X: int; Y: int } with static member (*)(p: Pos, s: int) = { X = p.X * s; Y = p.Y * s } [<Struct >] type PosS = val X: int val Y: int new(x, y) = { X = x; Y = y } static member (*)(p : PosS, s) = PosS(p.X * s, p.Y * s);; ... > let erzeugeObjekte = let ausgangsArray = Array.init 1000000 (fun i -> { X = i * 4 + 3; Y = i / 2 }) let gemappt = Array.map(fun p -> p * 13) ausgangsArray ”Ende des Testes”;; Real: 00:00:00.492, CPU: 00:00:00.265, GC gen0: 6, gen1: 3, gen2: 0 ... > let erzeugeStructs = let ausgangsArray = Array.init 1000000 (fun i -> PosS(i * 4 + 3, i / 2)) 50 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk let gemappt = Array.map(fun p -> p * 13) ausgangsArray ”Ende des Testes”;; Real: 00:00:00.015, CPU: 00:00:00.015, GC gen0: 0, gen1: 0, gen2: 0 ... Die Zeitmessung hinter Real gibt die gemessene Zeit zwischen Ausführungsanfang und -ende an. Die Einträge nach GC geben die Anzahl der Garbage Collections an, die durchgeführt wurden. Für den Objekt-Test: 492 ms, 6 Generation-0-Garbage-Collections, 3 Generation1-Garbage-Collections (9 GCs insgesamt). Für den Struct-Test: 15 ms, keine Garbage Collection. Die genaue Zeit und Anzahl von Garbage Collections ist von mehreren Faktoren abhängig. Garbage Collections sollten bei Performanz-kritischen Anwendungen vermieden werden, weil die Ausführung des eigentlichen Programmcodes während einer solchen pausiert wird und für die Laufzeit nachteilige Effekte auftreten, die mit Speicher- und Datenmanagement (Lokalitätseigenschaft) zu tun haben. Auffällig ist aber, dass im Falle der Structs keine Garbage Collection durchgeführt wurde, weil dort nur zwei Objekte, die beiden Arrays erzeugt wurden. Im Gegensatz dazu wurden im anderen Test zwei Millionen Pos-Objekte erstellt (eine Million für das Ausgangs-Array und eine Million nach durchgeführter Multiplikation). Es lohnt sich also, Structs verwenden zu können, auch wenn dadurch auf deklarative Typ-Definitionen und -Operationen verzichtet werden muss. In diesem Zusammenhang muss auch bedacht werden, dass das Verwenden einer Liste zur Erzeugung von vielen Cons-Zellen-Objekte führen kann (z.B. werden für [0..1000000] 1000000 Conszellen erzeugt). Mit Messungen und Programmcodeanalyse kann auf diese Aspekte eingegangen werden. 4.4 Imperativ-objektorientierte Fallunterscheidungen Fallunterscheidungen sind ein zentrales Element der Programmierung. Deshalb ist es wichtig, die Möglichkeiten zu kennen, um die beste auszuwählen und gleichzeitig einzuschätzen, welche Folgen deren Benutzung hat. Im Abschnitt Pattern matching (S. 26) wurde auf das gleichnamige Konstrukt eingegangen. Es ist nicht verwunderlich, dass diese Ausdrücke in Code niedrigerer Abstraktion umgewandelt werden müssen, um vom Computer durchgeführt werden zu können. Pattern matching wird in switch-Aufrufe umgewandelt sofern dies möglich ist. 51 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig 4.4.1 Mittel der Fallunterscheidungen reiner imperativer Programmierung if then else Dieses Konstrukt ist geeignet, wenn ein Code-Block abhängig von einer Bedingung hinter if in Form eines Bool’schen Ausdrucks ausgeführt werden soll. Der Block hinter then wird ausgeführt, wenn der Wert der Bedingung true ist, ansonsten wird die Alternative hinter else ausgeführt. Durch Schachtelung und Verkettung können beliebig komplexe Entscheidungsbäume programmiert werden, wobei ab einer schwer überschaubaren Größe von „Spaghetti-Code“ [InformatikLexikon, S. 842] die Rede ist. Ternärer Operator ? : Eine Variante von if then else, mit dem ternärem Operator Bedingung ? Folge : Alternative geschrieben wird, Ausdrücke aufnimmt und einen Ausdruck produziert. Switch Mit dem Konstrukt switch wird eine Sprungtabelle aufgebaut, die insbesondere dann sinnvoll ist, wenn als Werte Ganzzahlen verwendet werden. Try-Methoden Methoden mit mehreren Rückgabewerten werden gelegentlich über sogenannte Out-Parameter verwendet. Ein eingängiges Beispiel ist die DivRem-Methode, die den ganzzahligen Quotienten und Divisionsrest zurückgibt: int DivRem(int a, int b, out int rem){ rem = a % b; return a / b; } Verwendet wird dies in C# wie folgt: int rem; int quotient = DivRem(21, 5, out rem); //quotient = 4, rem = 1 In F# ist die Nutzung von veränderbaren Variablen zwar möglich, sollte aber für solche eigentlich reinen Funktionen, die lediglich mehrere Rückgabewerte liefern, nicht verwendet werden. Daher können Out-Parameter wie zusätzliche Rückgabewerte behandelt werden: let rem, quotient = DivRem(21, 5) Wenn das Ergebnis partiell definiert ist, lässt sich über einen Bool’schen Rückgabe dies mitteilen. Das bereits verwendete Beispiel ist folgende in dem Typen int definierte Methode: static bool TryParse(string text, out int result) Try-Methoden entsprechen dem Pattern matching am ehesten, weil sowohl eine Unterscheidung der Fälle als auch eine Bindung von Variablen durchgeführt wird. 4.4.2 Mittel der imperativ-objektorientierten Programmierung Subtyp-Polymorphie Im Vergleich zum Pattern matching steht die Funktionalität in verschiedenen Klassen, die jeweils den Fall für den eigenen Typ behandelt, anstatt in einer Funktion, welche die Typen (Varianten des algebraischen Datentyps (S. 23)) unterscheidet und somit alle Fälle behandelt. Die konkreten Klassen besitzen einen gemeinsamen Obertyp und implementieren oder überschreiben eine Methode. Das im Abschnitt Klasse (S. 25) erwähnte dynamische Binden bezieht sich in gängigen objektorientierten 52 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk Abbildung 4.2: Entscheidungsbaum für Fallunterscheidungen. Programmiersprachen auf Single Dispatch, d.h. die Operation hängt vom Typ des Empfängers ab. Diese Vorgehensweise ist in der Objektorientierung zentral, insbesondere für Parameter einer Methode den passenden Obertyp zu wählen. Damit wird nur auf die Schnittstelle, nicht auf eine konkrete Implementierung, hin implementiert. Entwurfsmuster Besucher (Visitor) Das Entwurfsmuster Besucher (auch unter Visitor bekannt) [Entwurfsmuster, S. 269] benutzt den Double-DispatchMechanismus. Bei Double Dispatch hängt die Operation von den Typen zweier Empfänger ab [Entwurfsmuster, S. 277]. Sprachen wie CLOS verwenden Multiple Dispatch, bei dem die Operation vom Typ des Empfängers und den Typen aller Parameter abhängt. 4.4.3 Wann ist welche Technik zu benutzen? Die gerade genannten Techniken sollen im Abschnitt „Evaluation der Fallunterscheidungstechniken“ (S. 54) in einem überschaubaren Beispiel gegenübergestellt werden. Es geht um einen Interpreter, der einfache Ausdrücke auswertet, die aus Funktionsapplikation, Variablenbindung, Fall-Unterscheidung und LambdaAusdrücken bestehen. Die vollständige Implementierung findet sich im Anhang im Abschnitt „Beispiel Evaluator“ (S. 75). Eine Übersicht über die Techniken gibt das Diagramm 4.4.3. 53 5 Evaluation der Fallunterscheidungstechniken Wie im Abschnitt Imperativ-objektorientierte Fallunterscheidungen (S. 51) erwähnt, gibt es mehrere Möglichkeiten, Fälle zu unterscheiden und die AnwendungsLogik für jeden Fall zu programmieren. Ich habe diese Konstrukte und Techniken genannt: • if then else und ternärer Operator ? : • switch • Try-Methoden • Polymorphie • Visitor • Pattern matching Das folgende Beispiel soll diese Techniken in Aktion zeigen und Vor- und Nachteile am Quellcode konkretisieren. Der gesamte Quellcode findet sich im Anhang im Evaluator-Abschnitt (S. 75). Es geht um die Implementierung eines einfachen Interpreters/Evaluators, der einfache Ausdrücke auswertet, die aus Funktionsapplikation, Variablenbindung, Fall-Unterscheidung und Lambda-Ausdrücken bestehen. Unter Verwendung der Scheme-Sytax geht es um Ausdrücke der folgenden Form: • Konstante, z.B. -15, 0, 42, true, false • Variable, z.B. x, eineVariable • Fallunterscheidung (if Dann Sonst) • Bindung (let ([Variablenname Wert])Ausdruck) • Lambda-Ausdruck (lambda (Parameter1 Parameter2 ...)Ausdruck) • Applikation (Funktionsausdruck Argument1 Argument2 ...) • Vordefinierte Funktionen +, =, < 54 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk Im Anhang finden sich ebenfalls Test-Klassen (S. 93), welche die Konstrukte testet. Dazu wird auf dynamische Typisierung (dynamic in C#) zurückgegriffen, da sich die verschiedenen Implementierungen keine gemeinsame Schnittstelle teilen1 . Da die Struktur in allen Klassen dieselbe ist, können Ausdrücke in der TestKlasse mithilfe von Konstruktoren der Typparameter (über das Constraint where T: new()) und Feldzuweisungen (etwa variable.Name = ”x”) Ausdrücke zusammengesetzt werden. Ich fange mit der deklarativen Definition der Typen in F# an: /// Stellt einen Ausdruck oder ein Ergebnis einer /// simplen Programmiersprache dar. type Code = /// Number(Konstante Ganzzahl) | Number of int /// Bool(Konstanter Wahrheitswert) | Bool of bool /// Var(Name) | Var of string /// Binding(Name, Wert, Ausdruck) | Binding of string * Code * Code /// Conditional(Bedingung , Folge, Alternative) | Conditional of Code * Code * Code /// Application(Funktion , Argument -Liste) | Application of Code * Code list /// Lambda(Parameter -Namen, Ausdruck) | Lambda of string list * Code /// Closure = Lambda + Variablenbindungen | Closure of Map<string , Code> * string list * Code /// BuiltInFunc(F#-Funktion) (die Funktion erhält /// ausgewertete Argumente) | BuiltInFunc of (Code list -> Code) Die Bedeutung der Daten, die den einzelnen Varianten zugeordnet sind, lässt sich aus der reinen Definition nur schwer ablesen. Deshalb steht dies in der Dokumentation der Varianten. Bei Pattern matching ist darauf zu achten, dass den Variablen, an welche die Datenfelder gebunden werden sprechende Namen gegeben werden (z.B. Binding(name, value, body) statt Binding(x, y, z)). 5.1 Pattern matching Der Evaluator in F# basiert auf Pattern matching und verwendet immutable maps zur Speicherung von Variablenbindungen. module Evaluator = let rec eval env expr = match expr with 1 Das wäre über diverse Interfaces wie IBinding<T> möglich gewesen, hätte aber die Implementierungen ein wenig umfangreicher gemacht. 55 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig | | | | Number _ | Bool _ | BuiltInFunc _ | Closure _ -> expr Lambda(param, body) -> Closure(env, param, body) Var name -> Map.find name env Binding(name, value, body) -> eval (Map.add name (eval env value) env) body | Conditional(condition , thenExpr , elseExpr) -> match eval env condition with | Bool false -> eval env elseExpr | _ -> eval env thenExpr | Application(func, args) -> match eval env func with | Closure(extendedEnv , names, body) -> let addToMap map (key, value) = Map.add key value map let newEnv = List.fold addToMap extendedEnv (List.zip names (List.map(eval env) args)) eval newEnv body | BuiltInFunc(func) -> func(List.map(eval env) args) | other -> failwithf ”Only closures and built-in functions can be applied , found: %A” other let Evaluate(expr) = eval Map.empty expr Die Funktionsweise des Evaluators ist leicht erklärt: 1. Number ...: Ein konstanter atomarer Wert (Zahl oder Wahrheitswert) oder eine Funktion wird unausgewertet zurückgegeben. 2. Lambda: Ein Lambda-Ausdruck wird mit allen bis dahin erzeugten Variablenbindungen als Closure zurückgegeben. 3. Var: Eine Variable wird in den Variablenbindungen nachgeschlagen. Es kommt zu einem Laufzeitfehler, wenn der Wert zum Variablenbezeichner nicht gefunden wurde. 4. Binding: Eine Variablenbindung wird hergestellt und der Code nach der Bindung wird ausgeführt. 5. Conditional: Die Bedingung wird ausgewertet: Ist sie falsch (Bool false), wird der Alternativ-Zweig ausgewertet; alles außer falsch wird als wahr interpretiert und führt zur Auswertung des Folge-Zweiges. 6. Aplication: Der Funktionsausdruck wird ausgewertet: (a) Closure: Ist er eine Closure, werden die Argumente mit den aktuellen Variablenbindungen ausgewertet. Diese werden an die Parameter innerhalb der zusätzlichen Closure-Variablenbindungen gebunden und der Körper der Funktion wird mit diesen Bindungen ausgewertet. (b) BuiltInFunc: Ist er eine „eingebaute“ Funktion, wird diese auf die ausgewerteten Argumente angewendet. (c) Sonst: Ist der ausgewertete Ausdruck keine Funktion, gibt es einen Laufzeitfehler, der darauf hinweist, dass der zu applizierende Ausdruck ein Funktionsausdruck sein muss. 56 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk Pattern matching hat den großen Vorteil, dass es intuitiv zu verwenden ist. Die Erweiterung um neue Fälle wurde bereits durch die Algebraische-Datentyp-Definition insofern eingeschränkt, als dass der einzige Erweiterungspunkt die eingebauten Funktionen (BuiltInFunc) sind. Diese enthalten eine Funktion, die zusätzliche Funktionalität enthält. Dies ist Komposition auf der Ebene algebraischer Datentypen und wird zum Ende des Kapitels noch einmal zusammengefasst. In [SICP, vgl. S. 120] werden Scheme-Typen durch diese Funktionen definiert: 1. Konstruktor, z.B. (erzeuge-punkt x y) 2. Selektor, z.B. (punkt-x einPunkt) und (punkt-y einPunkt) 3. Typ-Prädikat, z.B. (punkt? einPunkt) Die Forderungen für diese Funktionen sind: (punkt-x (erzeuge -punkt x _)) = x (punkt-y (erzeuge -punkt _ y)) = y (punkt? (erzeuge -punkt _ _)) = true Dies hat den Vorteil, dass nur diese Funktionen zur Verfügung stehen müssen und die interne Repräsentation der Datenstrukturen dahinter verborgen wird. So wird eine starke Repräsentationsflexibilität erreicht. In imperativ-objektorientierten Programmiersprachen kann dies z.B. auch mit Interfaces erreicht werden, die nur Selektor-Methoden (Getter) bereitstellen. In objektorientierten Systemen sind Interfaces für reine Datenklassen nicht sehr üblich, dieselbe Flexibilität kann mit ihnen dennoch dargestellt werden: 1. Konstruktor, z.B. new Punkt(x, y), wobei Punkt das Interface IPunkt implementiert 2. Selektoren, z.B. interface IPunkt { int X { get; } int Y { get; } } 3. Typ-Prädikat, z.B. einPunkt is IPunkt Die Forderungen sind analog: new Punkt(x, _).X = x new Punkt(_, y).Y = y new Punkt(_, _) is IPunkt Mit algebraischen Datentypen und Pattern matching fallen diese Schritte zum intuitiven Muster und Konstruktor zusammen, bieten dadurch allerdings keine Repräsentationsflexibilität. 1. Der algebraische Datentyp type Punkt = Punkt of int * int führt zum Muster (= Typ-Prädikat + Selektor) und Konstruktor Punkt(x,y). 2. Der Datensatztyp type Punkt = { X: int; Y: int } führt zum Muster und Konstruktor { X = x; Y = y }. 3. Der Tupeltyp int * int besitzt das Muster und den Konstruktor (x,y). Auf dieselbe Weise, mit der in Scheme dem Programmierer Konstruktoren und Selektoren über ein Modul zur Verfügung gestellt werden müssen, kann auch ein 57 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig Active Pattern (|Punkt|) zur Verfügung gestellt werden, das einen nicht näher spezifizierten Typen ^a in ein Tupel int * int umwandelt. let inline (|Punkt|)(p: ^a) = let x = (^a: (member get_X: unit -> int) p) let y = (^a: (member get_Y: unit -> int) p) (x, y) Dieser Typ muss die Properties X und Y vom Typ int besitzen. Sofern dies vom Typsystem nachgewiesen werden kann, lässt sich dieses Muster als Selektor verwenden. Der Konstruktor kann wie im objektorientierten Fall eine Klasse mit entsprechenden Properties sein. Repräsentationsflexibilität in F# kann mit objektorientierten Interfaces oder mit generischen Funktionen erreicht werden. Typ-Prädikate sind in diesem Fall in einer statisch typisierten Programmiersprachen nicht notwendig. Zwei verschiedene Repräsentationsformen und eine Funktion, die beide Repräsentationen gleichermaßen verwenden kann, sind hier aufgeführt. // ADT = Algebraischer Datentyp type ADTPunkt = ADTPunkt of int * int with member this.X = let(ADTPunkt(x,_)) = this in x member this.Y = let(ADTPunkt(_,y)) = this in y type DatensatzPunkt = { X: int; Y: int } // Beispiel -Funktion length: ^a -> int * int // when ^a : (member get_X : ^a -> int) // and ^a : (member get_Y : ^a -> int) let inline length(Punkt(x, y)) = sqrt(float(x * x + y * y)) // Verwendung printfn ”%d” (length { X = 1; Y = 1 }) // gibt 1.414214 aus printfn ”%d” (length(ADTPunkt(1, 1))) // ebenso 5.2 Subtyp-Polymorphie Die intuitive Herangehensweise bei objektorientierter Programmierung ist SubtypPolymorphie, die sich wie in Abbildung 5.1 darstellen lässt und im PolymorphieAbschnitt im Anhang (S. 85) zu finden ist. Wie im Diagramm dargestellt wird eine Oberklasse erstellt, die zwei Operationen enthält: Evaluate und Apply, die beide Standard-Implementierungen besitzen (hier: keine weitere Evaluation und keine Applizierbarkeit). In Unterklassen werden Methoden implementiert, um dieses Verhalten für konkrete Fälle umzusetzen, wie etwa Variablen, die nachgeschlagen werden. 58 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk Abbildung 5.1: Auschnitt der Klassenhierarchie für Ausdrücke einer simplen Programmiersprache. 5.3 Philip Wadlers expression problem Ein wichtiger Aspekt bei dieser Diskussion ist Erweiterbarkeit, sowohl um neue Fälle (Varianten) und Operationen (Funktionen). Eine 1998 entstandene Bezeichnung für diesen Sachverhalt ist Philip Wadlers expression problem: „The Expression Problem is a new name for an old problem. The goal is to define a datatype by cases, where one can add new cases to the datatype and new functions over the datatype, without recompiling existing code, and while retaining static type safety (e.g., no casts). For the concrete example, we take expressions as the data type, begin with one case (constants) and one function (evaluators), then add one more construct (plus) and one more function (conversion to a string). “ [ExpressionProblem] Eine Auflistung von bisherigen Lösungsansätzen und ein eigener Ansatz wird in [Scala] präsentiert. In den abschließenden Betrachtungen gehe ich auf diesen Aspekt ein und stelle den Bezug der Techniken auf diese Forderungen grafisch dar. 5.4 Imperative Ansätze Die imperativen Ansätze, switch über Type Code bzw. if-then-else mit Typtests und -casts, orientieren sich an der Funktionsweise von Pattern matching, verwenden jedoch explizite Typecast und Typprüfungen. Auf diese Weise können Funktionen unabhängig von den Varianten definiert werden. Neue Varianten dürfen nicht hinzugefügt werden, weil bestehende Operationen neue Fälle nicht behandeln können. Bei algebraischen Datentypen ist dies per Definition ausgeschlossen, 59 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig für objektorientierte Sprachen wurde dies im Teil über Varianten im Abschnitt über imperativ-objektorientierte Typen (S. 49) gezeigt. Im Beispiel nimmt die Evaluate-Methode des Evaluators diese Unterscheidung direkt vor. Die Fallunterscheidung (ob mit if-then-else oder mit switch) als Programmierer selbst vorzunehmen, führt zu Boilerplate-Code und zu einem schwer um neue Fälle erweiterbaren Entwurf. public Code Evaluate(Dictionary <string, Code> env, Code expr) { Var variable = expr as Var; if(variable != null) { return env[variable.Name]; } ... } Try-Methoden werden in dieser Darstellung nicht weiter betrachtet, weil ihr Einsatz in diesem Beispiel wenig Gewinn im Vergleich zum Typtest und Typecast bringt. Beispielsweise ist für die Variante Var Folgendes vorstellbar, benötigt aber die Methode bool TryVar(out string name) in der Basisklasse, die dort false zurückgibt und nur in der Klasse Var das Feld an den Out-Parameter bindet und true zurückgibt. public Code Evaluate(Dictionary <string, Code> env, Code expr) { string name; if(expr.TryVar(out name)) { return env[name]; } ... } 5.5 Das Besucher-Entwurfsmuster (Visitor pattern) Beim Besucher-Entwurfsmuster geht es um eine einfache Typunterscheidung, sodass die eben genannte Typ-Unterscheidung nicht mit Typtests und Typecasts durchgeführt werden muss. Die oben genannte Voraussetzung bleibt bestehen, dass die Hinzunahme neuer Fälle alte Operationen ungültig macht. Ein Nachteil ist relativ viel Boilerplate-Code, der in jeder Unterklasse eingefügt werden muss. Das Visitor-Interface muss Kenntnis über alle Varianten besitzen. Ein viel gravierender Nachteil ist, dass das Besucher-Entwurfsmuster im Gegensatz zum Pattern matching nur eine einfache Typ-Unterscheidung vornimmt und mit geschachtelten Unterscheidungen nicht umgehen kann. Dies wird im Fall des Evaluator-Beispiels dadurch deutlich, dass bei einer Applikation geprüft wird, was appliziert werden soll. Im Code für den Visitor-Ansatz (S. 88) wird dies mit einer zweiten Visitor-Klasse gelöst, die dann diese Entscheidung vornimmt. Speicherung von Zusatzinformationen, unter anderem die Argumente der Applikation und die Variablenbindungen, werden zur Aufgabe des Programmierers; 60 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk im Pattern-matching- und imperativen Evaluator hingegen befanden sich diese Werte im Variablen-Sichtbereich und standen somit zur Verfügung. Im Fall von Subtyp-Polymorphie wurden die Argumente der Applikation als Parameter übergeben. 5.6 Abschließende Betrachtung Diese Untersuchung hat einen Teil der Möglichkeiten der Fallunterscheidungen dar- und gegenübergestellt. Ein wichtiger Aspekt bei dieser Diskussion ist das Expression Problem und die Frage, wieweit Fälle und Operationen hinzugefügt werden können. Damit im Zusammenhang steht das Open Closed Principle, das besagt, das objektorientierte Klassen offen für Erweiterungen (vor allem Fälle) und geschlossen gegenüber Änderungen sein sollen (Quelltext von Basisklassen soll nicht neukompiliert werden müssen). Gerade durch diese Anforderung an Klassen wird die Notwendigkeit der Kompositionierbarkeit und Modularität deutlich, die Mixins bzw. Traits bieten. Diese Techniken gibt es in beispielsweise in Scala und werden für den Lösungsansatz in [Scala] exzessiv verwendet. Auf der anderen Seite sind deklarative Fallunterscheidungen mit Pattern matching intuitiv zu formulieren, berufen sich aber auf eine abgeschlossene Variantenanzahl. Active Patterns können mit Klassifizierern (S. 30) eine Varianten-ähnliche Sicht z.B. auf eine Klassenhierarchie bieten. Dies wurde in [ActivePattern, S. 42] im Fall vom Typ Type (der einen Typen darstellt) als Beispiel angeführt. Im Beispiel habe ich für den Evaluator definiert, dass die einzige Erweiterung durch eingebaute Funktionen (BuiltInFuncs) stattfinden darf. Im Falle, dass neue Operationen auf diesen Datentypen hinzugefügt werden soll, etwa eine Übersetzung des Ausdrucks in einen Scheme-Ausdruck (z.B. in Form eines Strings), lässt sich dies ebenfalls als Funktion umsetzen, die Pattern matching verwendet. Das Beispiel hat durchaus Praxisrelevanz, nicht nur auf konzeptioneller Ebene, weil Syntaxbäume für Metaprogrammierung sinnvoll sind, sondern weil die konkreten Basisbibliotheken von C# und F# diese Typen verwenden. In F# sind dies die schon genannten Quotations (S. 42), die ausführlicher in [FSharp, S. 498] diskutiert werden und in C# ist dies die Klassenhierarchie der LINQ-Expressions (Details finden sich in [CSharp, S. 377ff.]). Die Verwendung der Typen ist bei F#-Quotations mit Pattern matching und Active Patterns konzipiert2 und bei C#-LINQ-Expressions ist die Visitor-Basisklasse System.Linq.Expression.ExpressionVisitor zu verwenden. Eine andere Überlegung stammt aus der dynamisch typisierten objektorientierten Programmiersprache Smalltalk und geht davon aus, dass der Quelltext von Basisklassen (z.B. die Smalltalk-Klassen Object oder String) offenliegt und während der Anwendungsprogrammierung verändert werden darf. So lassen sich neue (abstrakte) Operationen in der Basisklasse einfügen und in Unterklassen über2 Verschiedene Active Patterns sind unter Microsoft.FSharp.Quotations zu finden. 61 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig Abbildung 5.2: Techniken und ihre Einordnung nach den Kriterien Erweiterbarkeit um neue Fälle (Varianten) und Operationen (Funktionen). schreiben (bzw. implementieren). In der ebenfalls dynamisch typisierten objektorientierten Programmiersprache Ruby ist dies auch vorgesehen und beruft sich auf Metaprogrammierungstechniken wie eval (Ausführen eines Strings als Programmcode), class_eval zur Änderung von Klassen und define_method zum Hinzufügen von Methoden. 62 6 Fazit 6.1 Dargestellte Aspekte Gesamtbild „deklarativ“: Nach einer Defintionsgegenüberstellung habe ich eine eigene Definition gegeben, um unter anderem funktionale Programmiersprachen mit Seiteneffekten als Vertreter einzuordnen und deklarative Techniken (Regeln: Pattern matching und Active Patterns, Zusammenhänge: Computation Expression) zu untersuchen, die auf hoher Abstraktionsebene arbeiten, ohne dass Details immer wieder ausprogrammiert werden müssen. Im Fall von Active Patterns und Computation Expressions müssen diese Details einmal programmiert werden und lassen sich wiederverwenden. Historische Heranführung: Prolog und Miranda haben eine überschaubare Syntax, basieren auf wenigen Konzepten und können damit bereits viele Probleme elegant lösen. Referenzielle Transparenz ist eine nützliche Eigenschaft für das Programm-Verstehen und die Parallelisierung. Multiparadigmatische Programmiersprachen sind komplexer und umfangreicher, dafür aber mächtiger und flexibler, da Zugriff auf mehrere Herangehensweisen (deklarative und imperative) gewährt wird. Das Abwägen, welche sich anbieten war Inhalt dieser Arbeit. Deklarative Typ-Definitionen: In F# lassen sich Datensätze und algebraische Datentypen einfach definieren. Mit wenig Schreibaufwand werden komplexe Typen und Klassenhierarchien definiert. Wert-Verarbeitung (Pattern matching): Deklarativ definierte Typen können mit Pattern matching intuitiv verarbeitet werden. DSLs und Metaprogrammierung: Wenn die Sprache um neue Konstrukte erweitert werden soll, kann dies mit Metaprogrammierung getan werden. Interne DSLs stellen eine Fokussierung der Programmiersprache auf ein Problemfeld dar. Die Umsetzung der somit formulierten Sprache muss nicht an die Standardauswertungsumgebung gebunden sein, sondern kann eine andere sein. 63 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig Passende imperative Techniken und Herangehensweisen wurden dargestellt und Zusammenhänge zur deklarativen Programmierung gezeigt: Veränderlichkeit / Konsistenzprüfung: Konsistenz-Prüfungen dienen einem sicheren Umgang mit Seiteneffekten und null-Werten. Typ-Defintionen und -Belange: Viele Aufgaben sind bei Typ-Definition zu erledigen, um verwendbare Typen zu programmieren: Gleichheit, Initialisierung, Kopieren, Persistierung, Verwendung in Klassenhierarchien. Fallunterscheidungen: Behandelt wurden if-then-else und switch auf der reinen imperativen Seite und Subtyp-Polymorphie und das Entwurfsmuster Besucher auf der objektorientierten Seite. Auf Fallunterscheidungstechniken bin ich besonders eingegangen, weil sie den Regel-Begriff aus meiner Definition darstellen. Die Darstellung gegenüber Pattern matching wurde in der Evaluation gezeigt. In diesem Sinne habe ich Bezug auf das Expression Problem genommen und die zwei Dimensionen der Erweiterbarkeit für die Lösungen dargestellt. 6.2 Ausgelassene Aspekte Scala: Scala ist umfangreich, sowohl die Anzahl der Konzepte als auch die BasisBibliothek. Außerdem gibt es in Scala neue Konzepte, die es vorher in der objekt-funktionalen Form noch nicht gegeben hat. Ein Beispiel sind Module als first class values, die bei der Keynote der Scala-Konferenz flatMap in Oslo (13. - 14.05.2013) vorgestellt wurden1 . Zudem habe ich nicht genügend Praxiserfahrung mit Scala. Weitere Entwurfsmuster und funktionale Konzepte: Entwurfsmuster kann man als objektorientiertes Handwerkszeug ansehen: Man muss Entwurfsmuster verstehen, um mit objektorientierten Programmbibliotheken arbeiten zu können und auf der anderen Seite Entwurfsmuster in eigenen Bibliotheken verwenden, damit andere objektorientierte Programmierer diese wiederverwenden können. In [AnalyseEntwurfsmusterFP] wird ein Vergleich von Entwurfsmustern mit funktionalen Konzepten umfangreich dargestellt, deshalb bin ich bin in dieser Arbeit nicht auf weitere Entwurfsmuster eingegangen. Eine gute Einführung in funktionale Programmierung, in der verzögerte Auswertung (Laziness) und Funktionen höherer Ordnung als Eckpfeiler für modulare Programmierung vorgestellt werden, ist [WhyFPMatters]. Funktionen höherer Ordnung gehören zum Handwerkszeug des funktionalen Programmierers und wurden in dieser Arbeit wie selbstverständlich verwendet. In moderneren objektorientierten Programmiersprachen halten sie zusammen mit Lambda-Ausdrücken als Features Einzug. 1 http://2013.flatmap.no/spiewak.html 64 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk Expression Problem: Bei der Evaluation der Fallunterscheidungstechniken (S. 54) gibt es nicht nur den Aspekt der Eleganz sondern auch die Frage um Erweiterbarkeit mit Funktionen und Fällen. Bei dem oben genannten Vortrag wird das Problem aufgegriffen und es wird eine Lösung aus der dynamisch typisierten funktionalen Programmiersprache Clojure (einem LISP-Ableger von 2007) vorgestellt, die auf extern definierten Funktionen wie in CLOS basiert. Eine statisch typisierte Lösung ist in [Scala] zu finden. Objektorientierte Sicht: Ich habe mich für F# und nicht C# entschieden, weil ich nicht den Weg von der Objektorientierung zur funktionalen Programmierung einschlage, sondern im deklarativ-funktionalen Programmiersprachenraum anfange, Konzepte zu untersuchen. Ein sehr gutes Buch, das C#Programmierer die funktionale Denkweise (mit C# und F#) anschaulich vermittelt, ist [RealWorldFP]. Formale Programmiersprachen-Untersuchung: Formale Untersuchungen über funktionale Programmiersprachen sind aufgrund des mathematischen Anspruchs, den diese Programmiersprachen haben, sinnvoll. Dasselbe gilt für Untersuchungen im Bereich der Logik zur Logik-Programmierung. In diesem Zusammenhang werden deklarative Programmiersprachen gerne „Akademikersprachen“ genannt, die sich eher nicht für typische Anwendungsprogrammierung eignen und nur im universitären Umfeld genutzt werden. Dies wird durch Konzepte wie Monaden und Typklassen aus Haskell verschärft, die Akademiker benutzen, für durchschnittliche Programmierer hingegen kompliziert zu verwenden sind. Haskell steht somit anderen „gängigeren“ Programmiersprachen in der Akzeptanz nach. In diesem Sinne halte ich eine formale Betrachtungsweise von F# im Zuge dieser Bachelorarbeit nicht für sinnvoll. 6.3 Schlusswort Deklarative Programmierung wurde in dieser Arbeit als Kombination von moderner funktionaler Programmierung und der Verwendung von DSLs vorgestellt. Insbesondere Programmiersprachen wie F# und Scala bringen diese beiden Aspekte in die Welt der Anwendungsprogrammierung, sodass Probleme mithilfe von funktionaler Programmierung und objektorientierten Programmbibliotheken gelöst werden können, was insbesondere in Bezug auf das Programmieren von grafischen Benutzeroberflächen und Datenbanken nützlich ist. Um den in einigen Situationen geforderten schnellen wahlfreien Zugriff auf große Datenmengen zu gewährleisten, kann auf das imperative Array zurückgegriffen werden. Nicht nur die deklarative Seite wurde beleuchtet, auch Teile der imperativen Programmierung wurden angeschnitten, wobei die imperative Welt viel umfangreicher ist, als ich in dieser Bachelorarbeit behandeln könnte. Die deklarative Seite von F# hingegen wurde zu großen Teilen vorgestellt, weil sie auf überschaubar wenigen Konzepten basiert (z.B. Pattern matching (S. 26) und einfach zu verwen65 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig dende Typen (S. 21)). Das mag auch der Grund sein, warum einige deklarative Sprachen wie Prolog oder Scheme in kürzerer Zeit gelehrt werden können als imperative-objektorientierte Programmierung, die umfangreich und komplex ist. Damit soll durchaus nicht relativiert werden, dass auch funktionale und LogikProgrammierung ein weites Feld sind. Diese Arbeit lässt sich als Kompendium für deklarative Programmierung mit F# verwenden, wobei Pattern matching und Typen eine zentrale Rolle spielen. 66 Literaturverzeichnis [ActivePattern] Don Syme, Gregory Neverov, James Margetson: Extensible Pattern Matching via a Lightweight Language Extension. ICFP ’07 Proceedings of the 12th ACM SIGPLAN international conference on Functional programming(S. 29-40). ACM Press. 2007. [AnalyseEntwurfsmusterFP] Johannes Pietrzyk: Analyse der Entwurfsmuster der Viererbande unter dem Funktionalen Paradigma. Bachelorarbeit am Fachbereich Informatik an der Universität Hamburg. 2012. [CSharp] Joseph Albahari, Ben Albahari: C# 5.0 in a Nutshell. 5te Auflage. O’Reilly. 2012. [Cormen] Thomas H. Cormen, Charles Leiserson, Ronald L. Rivest, Clifford Stein: Algorithmen - Eine Einführung. 3te Auflage. Oldenbourg Verlag. 2010. [Curry] Michael Hanus: Teaching Functional and Logic Programming with a Single Computation Model. Programming Languages: Implementations, Logics, and Programs(S. 335-350). Springer Berlin Heidelberg. 1997. [DeklarativeProgrammierung] Marc H. Scholl: Deklarative Programmierung. Vorlesungsskript an der Universität Konstanz. 2006. URL: http://www.inf.unikonstanz.de/dbis/teaching/ss06/fp/fp.pdf (abgerufen am 24.07.2013) [DSLs] Martin Fowler, Rebecca Parsons: Domain-specific languages. AddisonWesley. 2011. [EffectiveJava] Joshua Block: Effective Java. 2te Auflage. Addison-Wesley. 2008. [Entwurfsmuster] Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides: Entwurfsmuster - Elemente für wiederverwendbare Software. 6te Auflage. Addison-Wesley. 2010. [ExpressionProblem] E-Mail-Diskussion, E-Mail von Philip Wadler ([email protected]): The Expression Problem. 1998. URL: http://www.daimi.au.dk/~madst/tool/papers/expression.txt (abgerufen am 27.07.2013). 67 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig [FPWithMiranda] Ian Holyer: Functional Programming with Miranda. University College London (ULC) Press. 1993. [FSharp] Don Syme, Adam Granicz, Antonio Cisternino: Expert F# 3.0. 3te Auflage. Apress. 2012. [FSharpMeta] Don Syme: Leveraging .NET Meta-programming Components from F# - Integrated Queries and Interoperable Heterogeneous Execution. Proceedings of the 2006 workshop on ML. ACM New York. 2006. URL: http://research.microsoft.com/apps/pubs/default.aspx?id=147193 (abgerufen am 28.07.2013). [FSharpSpec] Don Syme: The F# 3.0 Language Specification. 2012. URL: http://fsharp.org/about/files/spec.pdf (abgerufen am 21.07.2013). [FunctionalArrays] Melissa E. O’Neil, F. Warren Burton: A New Method for Functional Arrays. Journal of Functional Programming archive Volume 7 Issue 5, September 1997(S. 487-513). Cambridge University Press. 1997. [FunktProg] Peter Pepper, Petra Hofstedt: Funktionale Programmierung: Sprachdesign und Programmiertechnik. eXamen.press, Springer-Verlag. 2006. [InformatikLexikon] Peter Fischer, Peter Hofer: Lexikon der Informatik. 14te Auflage. Springer. 2007. [LanguageOrientedProg] Martin P. Ward: Language Oriented Programming. Software—Concepts and Tools 1995. Band 15. S. 147-161. 1995. [MetaprogDotNet] Kevin Hazzard, Jason Bock: Metaprogramming in .NET. Manning. 2013. [MultiparadigmenProg] Martin Grabmüller: MultiparadigmenProgrammiersprachen. 2003-15 in Forschungsberichte Fakultät IV - Elektrotechnik und Informatik. Technische Universität Berlin. 2003. [PractAdvantDecl] John W. Lloyd: Practical Advantages of Declarative Programming. Joint Conference on Declarative Programming, GULP-PRODE. 1994. [RealWorldFP] Thomas Petricek, John Skeet: Real-world functional programming: with examples in F# and C#. Manning. 2010. [Scala] Martin Odersky and Matthias Zenger: Independently Extensible Solutions to the Expression Problem. EPFL Technical Report IC/2004/33. École Polytechnique Fédérale de Lausanne, Switzerland. 2004. URL: http://lampwww.epfl.ch/~odersky/papers/ExpressionProblem.html (abgerufen am 27.07.2013). [SICP] Hal Abelson, Jerry Sussman, Julie Sussman: Structure and Interpretation of Computer Programs. MIT Press. 1984. URL: http://sicpebook.wordpress.com/2011/05/28/new-electronic-sicp (abgerufen am 21.07.2013). 68 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk [WhyFPMatters] David Turner: Why Functional Programming Matters. Research Topics in Functional Programming, ed. D. Turner(S. 17–42). Addison-Wesley. 1990. 69 7 Anhang 7.1 Listing 1: Miss Grant’s Controller in F# 5 10 15 type condition = DoorClosed | DrawerOpened | LightOn | DoorOpened | PanelClosed and actions = UnlockPanel | LockPanel | LockDoor | UnlockDoor and codes = D1CL | D2OP | L1ON | D1OP | PNCL | PNUL | PNLK | D1LK | D1UL and stateName = Idle | Active | WaitingForLight | WaitingForDrawer | UnlockedPanel and state = { name: stateName; actions: actions list; transitions: (condition * stateName) list } and machine = { events : (condition * codes) list resetEvents: condition list commands : (actions * codes) list states : state list } 20 let inline (=>) a b = (a, b) let inline (:=) name (actions , transitions) = { name = name; actions = actions; transitions = transitions } 25 30 35 let machine = { events = [ DoorClosed => D1CL DrawerOpened => D2OP LightOn => L1ON DoorOpened => D1OP PanelClosed => PNCL ]; resetEvents = [ DoorOpened ]; commands = [ UnlockPanel => PNUL 70 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk LockPanel => PNLK LockDoor => D1LK UnlockDoor => D1UL ]; states = [ 40 Idle := [UnlockDoor; LockPanel] => [DoorClosed => Active] Active := [] => [DrawerOpened => WaitingForLight; LightOn => WaitingForDrawer] WaitingForLight := [] => [LightOn => UnlockedPanel] WaitingForDrawer := [] => [DrawerOpened => UnlockedPanel] UnlockedPanel := [UnlockPanel; LockDoor] => [PanelClosed => Idle] 45 50 ] } 55 60 65 70 75 80 85 90 open System open System.Text.RegularExpressions open Microsoft.FSharp.Reflection let run { events = events; resetEvents = resetEvents; commands = commands; states = states } = let assoc key (k, value) = if k = key then Some value else None let listAssoc key = List.pick(assoc key) let codeForEvent cond = events |> listAssoc cond let codeForAction action = commands |> listAssoc action let start = states.Head let splitOnCamelCase(word: string) = String.concat ”” [ for i in 1..word.Length -1 do let a, b = word.[i-1], word.[i] yield string a if Char.IsLower a && Char.IsUpper b then yield ” ” yield string word.[word.Length -1] ] let print x = splitOnCamelCase(sprintf ”%A” x) let printList select list = String.concat ”, ” (Seq.map (fun x -> print (select x)) list) let cases = let ctor = FSharpValue.PreComputeUnionConstructor FSharpType.GetUnionCases(typeof <condition >) |> Array.map(fun x -> (x.Name.ToUpper(), ctor x [||]:?> condition)) let readCondition() = let input = Console.ReadLine().Replace(” ”, ””).ToUpper() cases |> Array.tryPick(assoc input) let rec repl(state: state)(input: condition) = printfn ”Received %s (%s)...” (print input) (print(codeForEvent input)) match List.tryPick(assoc input) state.transitions with | Some next -> 71 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig state.actions |> List.iter(fun x -> printfn ”Doing %s (%s)...” (print x) (print(codeForAction x))) states |> List.find(fun x -> x.name = next) |> startRepl | None -> if resetEvents |> List.exists((=)input) then startRepl start else startRepl state and startRepl state = printfn ”You’re now in state %s.” (print state.name) printfn ”Transitions: %s” (printList fst state.transitions) let rec findSome() = printf ”> ” match readCondition() with | Some cond -> repl state cond | None -> findSome() findSome() printfn ”Reset transitions: %s” (printList id resetEvents) startRepl start 95 100 105 110 [<EntryPoint >] let main _ = run machine 115 120 125 130 135 140 // // // // // // // // // // // // // // // // // // // // // // // // // Beispiel: Reset transitions: Door Opened You’re now in state Idle. Transitions: Door Closed > Door Closed Received Door Closed (D1CL)... Doing Unlock Door (D1UL)... Doing Lock Panel (PNLK)... You’re now in state Active. Transitions: Drawer Opened, Light On > Light On Received Light On (L1ON)... You’re now in state Waiting For Drawer. Transitions: Drawer Opened > Drawer Opened Received Drawer Opened (D2OP)... You’re now in state Unlocked Panel. Transitions: Panel Closed > Panel Closed Received Panel Closed (PNCL)... Doing Unlock Panel (PNUL)... Doing Lock Door (D1LK)... You’re now in state Idle. Transitions: Door Closed > _ 7.2 Listing 2: XML-Traversierung mit Active Patterns in F# 72 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk #r ”System.Xml.Linq” open System.Xml.Linq 4 9 14 19 let (|Node|_|)(name: string)(xObj: XObject) = match xObj with | :? XElement as element when element.Name.LocalName = name -> Some(element.Nodes()) | _ -> None let (|Text|_|)(xObj: XObject) = match xObj with | :? XElement -> None | _ -> Some(xObj.ToString()) let (|Attribute|_|)(name: string)(xObj: XObject) = match xObj with | :? XElement as element -> match element.Attribute(XName.Get(name)) with | null -> None | x -> Some(x.Value) | _ -> None 24 29 34 39 let rec traverseAll = Seq.iter traverseNode and traverseNode = function | Text text -> printfn ” %s” (text.Trim()) | Node ”Matches” children -> traverseAll children | Node ”Match” children & Attribute ”Winner” winner & Attribute ”Loser” loser & Attribute”Score” score -> printfn ”%s won against %s with score %s” winner loser score traverseAll children let sampleXml = @”<Matches > <Match Winner=’A’ Loser=’B’ Score=’1:0’> Description of the first match... </Match> <Match Winner=’A’ Loser=’C’ Score=’1:0’> Description of the second match... </Match> </Matches >” traverseNode(XElement.Parse(sampleXml)) 7.3 Listing 3: Generische Variante von Counting Sort in F# 1 6 > let inline SortInPlaceGeneric numbers = let maximum = Array.max numbers let occurences = Array.zeroCreate(int maximum + 1) for num in numbers do occurences.[int num] <- occurences.[int num] + 1 let mutable insertionIndex = 0 let mutable num = LanguagePrimitives.GenericZero 73 Karsten Pietrzyk 11 16 21 26 31 36 Deklarativ, wenn möglich; imperativ, wenn nötig while num <= maximum do for times = 1 to occurences.[int num] do numbers.[insertionIndex] <- num insertionIndex <- insertionIndex + 1 num <- num + LanguagePrimitives.GenericOne numbers;; val inline when ^a and ^a and ^a and (^a and ^b SortInPlaceGeneric : ^a [] -> ^a [] : (static member get_Zero : -> ^a) : (static member op_Explicit : ^a -> int) : comparison or ^b) : (static member ( + ) : ^a * ^b -> : (static member get_One : -> ^b) ^a) // Beispielnutzung mit Ganzzahltypen int, int64, sbyte (signed byte), byte, int16 (suffix s für short), BigInteger > SortInPlaceGeneric [| 1; 2; 3; 14; 120; 0; 2; 4 |];; val it : int [] = [|0; 1; 2; 2; 3; 4; 14; 120|] > SortInPlaceGeneric [| 1L; 2L; 3L; 14L; 120L; 0L; 2L; 4L |];; val it : int64 [] = [|0L; 1L; 2L; 2L; 3L; 4L; 14L; 120L|] > SortInPlaceGeneric [| 1y; 2y; 3y; 14y; 120y; 0y; 2y; 4y |];; val it : sbyte [] = [|0y; 1y; 2y; 2y; 3y; 4y; 14y; 120y|] > SortInPlaceGeneric [| 1uy; 2uy; 3uy; 14uy; 120uy; 0uy; 2uy; 4uy |];; val it : byte [] = [|0uy; 1uy; 2uy; 2uy; 3uy; 4uy; 14uy; 120uy|] > SortInPlaceGeneric [| 1s; 2s; 3s; 14s; 120s; 0s; 2s; 4s |];; val it : int16 [] = [|0s; 1s; 2s; 2s; 3s; 4s; 14s; 120s|] > SortInPlaceGeneric [| 1I; 2I; 3I; 14I; 120I; 0I; 2I; 4I |];; val it : System.Numerics.BigInteger [] = [|0I; 1I; 2I; 2I; 3I; 4I; 14I; 120I|] 7.4 Listing 4: Prolog-ähnliche Quotation in F# 3 > open Microsoft.FSharp.Core.Operators.Unchecked let predicate <’a> = ignore<’a> // ignoriert den Parameter (f x = ()) let value<’a> = defaultof <’a> // liefert null/false/0/0.0, je nach Typ // ^- Für das Typsystem , die Funktionen sollen nicht ausgeführt werden. let prolog x = x // Hier käme die Metaprogrammierung // (Verarbeitung der Quotation x) 8 13 18 let memb<’a> = predicate <’a * ’a list> let E<’a> = value<’a> let __<’a> = value<’a> let R<’a> = value<’a list> let (<--) a b = ();; ... > prolog <@ memb(E, E :: __) memb(E, __ :: R) <-- memb(E, R) @>;; val it : Quotations.Expr<unit> = Sequential (Application (Call (None, memb, []), NewTuple (Call (None, E, []), 74 Deklarativ, wenn möglich; imperativ, wenn nötig 23 28 Karsten Pietrzyk NewUnionCase (Cons, Call (None, E, []), Call (None, __, [])))), Call (None, op_LessMinusMinus , [Application (Call (None, memb, []), NewTuple (Call (None, E, []), NewUnionCase (Cons, Call (None, __, []), Call (None, R, [])))), Application (Call (None, memb, []), NewTuple (Call (None, E, []), Call (None, R, [])))])) 7.5 Listing 5: Evaluator - algebraische Datentypen / Pattern matching 2 7 12 17 /// Stellt einen Ausdruck oder ein Ergebnis einer simplen Programmiersprache dar. type Code = /// Number(Konstante Ganzzahl) | Number of int /// Bool(Konstanter Wahrheitswert) | Bool of bool /// Var(Name) | Var of string /// Binding(Name, Wert, Ausdruck) | Binding of string * Code * Code /// Conditional(Bedingung , Folge, Alternative) | Conditional of Code * Code * Code /// Application(Funktion , Argument -Liste) | Application of Code * Code list /// Lambda(Parameter -Namen, Ausdruck) | Lambda of string list * Code /// Closure = Lambda + Variablenbindungen | Closure of Map<string , Code> * string list * Code /// BuiltInFunc(F#-Funktion) (die Funktion erhält ausgewertete Argumente) | BuiltInFunc of (Code list -> Code) 22 27 32 37 module Evaluator = let rec eval env expr = match expr with | Number _ | Bool _ | BuiltInFunc _ | Closure _ -> expr | Lambda(param, body) -> Closure(env, param, body) | Var name -> Map.find name env | Binding(name, value, body) -> eval (Map.add name (eval env value) env) body | Conditional(condition , thenExpr , elseExpr) -> match eval env condition with | Bool false -> eval env elseExpr | _ -> eval env thenExpr | Application(func, args) -> match eval env func with | Closure(extendedEnv , names, body) -> let addToMap map (key, value) = Map.add key value map 75 Karsten Pietrzyk 42 Deklarativ, wenn möglich; imperativ, wenn nötig let newEnv = List.fold addToMap extendedEnv (List.zip names (List.map (eval env) args)) eval newEnv body | BuiltInFunc(func) -> func (List.map (eval env) args) | other -> failwithf ”Only closures and built-in functions can be applied , found: %A” other let Evaluate(expr) = eval Map.empty expr 47 module BuiltInFuncs = let toNumber = function | Number value -> value | other -> failwithf ”Integer expected , found: %A” other 52 let equality = function | Number value1, Number value2 -> value1 = value2 | Bool value1 , Bool value2 -> value1 = value2 | _ -> false 57 let plus() = BuiltInFunc(fun args -> Number(List.sumBy toNumber args)) 62 // let (|>) x f = f(x) // Pipelining [FSharp , vgl. S. 40-41] let times() = BuiltInFunc(fun args -> args |> List.map toNumber |> List.fold (*) 1 |> Number) // geschachtelte Schreibweise ohne (|>) let times ’() = BuiltInFunc(fun args -> Number(List.fold (*) 1 (List.map toNumber args))) 67 let eq() = BuiltInFunc(fun args -> args |> Seq.pairwise |> Seq.forall equality |> Bool) 72 let lt() = BuiltInFunc(fun args -> args |> Seq.map toNumber |> Seq.pairwise |> Seq.forall(fun(a, b) -> a < b) |> Bool) open Evaluator open BuiltInFuncs 77 82 87 #if INTERACTIVE #I ”C:\Program Files (x86)\Microsoft Visual Studio 11.0\Common7\IDE\PublicAssemblies” #r ”Microsoft.VisualStudio.QualityTools.UnitTestFramework.dll” #endif open Microsoft.VisualStudio.TestTools.UnitTesting open System open System.Reflection [<TestClass >] type FunctionalTests() = let (-->) expr result = let raw = function | Number x -> box x | Bool b -> box b | _ -> obj() 76 Deklarativ, wenn möglich; imperativ, wenn nötig 92 97 102 107 112 117 122 127 Karsten Pietrzyk let evaluated = Evaluate(expr) Assert.AreEqual(raw evaluated , raw result , sprintf ”expected %A <> actual %A (expr: %A)” result evaluated expr) static member RunInConsole() = let tester = FunctionalTests() let tests = typeof <FunctionalTests >.GetMethods() |> Array.filter(fun test -> Attribute.IsDefined(test, typeof<TestMethodAttribute >)) let mutable passed = 0 for test in tests do try test.Invoke(tester, null) |> ignore passed <- passed + 1 printfn ”OK: %s” test.Name with :? TargetInvocationException as e -> try raise(e.InnerException) with :? AssertFailedException as e -> printfn ”%s\n%s\n” test.Name (e.Message.Replace(”. ”, ”\n”)) printfn ”\n%d von %d Tests bestanden.” passed tests.Length [<TestMethod >] member this.FunctionalLet() = let sample = // (let ([x 20]) (+ x x 4)) Binding(”x”, Number 20, Application(plus(), [Var ”x”; Var ”x”; Number 4])) sample --> Number 44 [<TestMethod >] member this.FunctionalSquare() = let square = // (lambda (x) (* x x)) Lambda([”x”], Application(times(), [Var ”x”; Var ”x”])) for i = -100 to 100 do Application(square, [Number i]) --> Number(i * i) 132 137 [<TestMethod >] member this.FunctionalMinimum() = let minimum = // (lambda (a b) (if (< a b) a b)) Lambda([”a”; ”b”], Conditional(Application(lt(), [Var ”a”; Var ”b”]), Var ”a”, Var ”b”)) for i = -10 to 10 do for k = -10 to 10 do Application(minimum , [Number i; Number k]) --> Number(min i k) 142 [<TestMethod >] member this.FunctionalFactorial() = // ”Rekursiver” Lambda -Ausdruck: 77 Karsten Pietrzyk 147 152 157 162 167 Deklarativ, wenn möglich; imperativ, wenn nötig // (lambda (cont x) (if (= x 0) 1 (* x (cont cont (- x 1))))) let factorial = Lambda([”cont”; ”x”], Conditional( Application(eq(), [Var ”x”; Number 0]), Number 1, Application(times(), [Var ”x”; Application(Var ”cont”, [Var ”cont”; Application(plus(), [Var ”x”; Number -1])])]))) for i = 0 to 10 do Application(factorial , [factorial; Number i]) --> Number(Seq.fold (*) 1 { 1..i }) [<TestMethod >] member this.FunctionalClosure() = let cons = // (lambda (hd tl) (lambda (takeHead) (if takeHead hd tl))) Lambda([”hd”; ”tl”], Lambda([”takeHead”], Conditional( Var ”takeHead”, Var ”hd”, Var ”tl”))) let empty = Bool false // #f 172 let head = // (lambda (consCell) (consCell true)) Lambda([”xs”], Application(Var ”xs”, [Bool true])) let tail = // (lambda (consCell) (consCell false)) Lambda([”xs”], Application(Var ”xs”, [Bool false])) 177 let isEmpty = // (lambda (consCell) (if consCell #f #t)) Lambda([”xs”], Conditional(Var ”xs”, Bool false, Bool true)) 182 187 192 let oneTwo = // (cons 1 (cons 2 empty)) Application(cons, [Number 1; Application(cons, [Number 2; empty])]) Application(head, [oneTwo]) --> Number 1 Application(head, [Application(tail, [oneTwo])]) --> Number 2 Application(tail, [Application(tail, [oneTwo])]) --> empty Application(isEmpty , Application(isEmpty , Application(isEmpty , --> Bool false Application(isEmpty , [oneTwo])])]) --> Bool true [empty]) --> Bool true [oneTwo]) --> Bool false [Application(tail, [oneTwo])]) [Application(tail, [Application(tail, 78 Deklarativ, wenn möglich; imperativ, wenn nötig 197 Karsten Pietrzyk #if INTERACTIVE FunctionalTests.RunInConsole() #endif 7.6 Listing 6: Evaluator - datenorientiert mit Typtests using System; using System.Collections.Generic; 5 10 15 20 25 30 35 40 45 namespace Typtests { abstract public class Code { } public class Number : Code { public int Value; } public class Bool : Code { public bool Value; } public class Var : Code { public string Name; } public class Binding : Code { public string Identifier; public Code Value, Body; } public class Conditional : Code { public Code Condition , Then, Else; } public class Application : Code { public Code Function; public Code[] Args; } public class Lambda : Code { public string[] ParameterIdentifiers; public Code Body; } public class Closure : Lambda { public Dictionary <string, Code> Env; } abstract public class BuiltInFunc : Code { public abstract Code Apply(Code[] args); } public class Plus : BuiltInFunc { public override Code Apply(Code[] args) { int sum = 0; foreach (var item in args) { sum += ((Number)item).Value; } return new Number { Value = sum }; } } public class Equals : BuiltInFunc { public override Code Apply(Code[] args) { for (int i = 0; i < args.Length - 1; i++) { var x = args[i]; var y = args[i + 1]; if (x is Number && ((Number)x).Value != ((Number)y).Value || x is Bool && ((Bool)x).Value != ((Bool)y).Value) { return new Bool { Value = false }; } } return new Bool { Value = true }; 79 Karsten Pietrzyk 50 55 60 65 70 75 80 85 90 95 100 Deklarativ, wenn möglich; imperativ, wenn nötig } } public class LessThan : BuiltInFunc { public override Code Apply(Code[] args) { for (int i = 0; i < args.Length - 1; i++) { var x = args[i]; var y = args[i + 1]; if (((Number)x).Value >= ((Number)y).Value) { return new Bool { Value = false }; } } return new Bool { Value = true }; } } public class Evaluator { public Code Evaluate(Code expr) { return Evaluate(new Dictionary <string, Code >(), expr); } public Code Evaluate(Dictionary <string, Code> env, Code expr) { if (expr is Number || expr is Bool || expr is BuiltInFunc || expr is Closure) { return expr; } var l = expr as Lambda; if (l != null) { return new Closure { Env = new Dictionary <string, Code >(env), Body = l.Body, ParameterIdentifiers = l.ParameterIdentifiers }; } Var v = expr as Var; if (v != null) { return env[v.Name]; } var binding = expr as Binding; if (binding != null) { Code old = null; if (env.ContainsKey(binding.Identifier)) { old = env[binding.Identifier]; } env[binding.Identifier] = Evaluate(env, binding.Value); var result = Evaluate(env, binding.Body); if (old != null) { env[binding.Identifier] = old; } return result; } var conditional = expr as Conditional; if (conditional != null) 80 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk { var test Bool; if (test { return else { return 105 = Evaluate(env, conditional.Condition) as != null && !test.Value) Evaluate(env, conditional.Else); } Evaluate(env, conditional.Then); } } var app = expr as Application; if (app != null) { var func = Evaluate(env, app.Function); var f = func as Closure; if (f != null) { var envToExtend = f.Env; var len = f.ParameterIdentifiers.Length; var oldValues = new Code[len]; for (int i = 0; i < len; i++) { var name = f.ParameterIdentifiers[i]; if (envToExtend.ContainsKey(name)) { oldValues[i] = envToExtend[name]; } envToExtend[name] = Evaluate(env, app.Args[i]); // Werte die Argumente mit env aus, den } // Körper der Closure mit erweitertem env. var result = Evaluate(envToExtend , f.Body); for (int i = 0; i < len; i++) { var name = f.ParameterIdentifiers[i]; if (oldValues[i] != null) { envToExtend[name] = oldValues[i]; } } return result; } var prim = func as BuiltInFunc; if (prim != null) { var evaluatedArgs = new Code[app.Args.Length]; for (int i = 0; i < evaluatedArgs.Length; i++) { evaluatedArgs[i] = Evaluate(env, app.Args[i]); } return prim.Apply(evaluatedArgs); } throw new Exception( ”Only closures and built-in functions can be applied. Given: ” + expr); } throw new Exception(”Unknown subclass of Code: ” + expr); 110 115 120 125 130 135 140 145 } 150 } } 81 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig 7.7 Listing 7: Evaluator - datenorientiert mit switch über Type code 2 7 12 17 22 27 32 37 42 47 using System; using System.Collections.Generic; namespace TypeCodes { public enum CodeType { Number , Bool, Var, Binding , Conditional , Application , Lambda, Closure , BuiltInFunc } public abstract class Code { public abstract CodeType Type { get; } } public class Number : Code { public int Value; public override CodeType Type { get { return CodeType.Number; } } } public class Bool : Code { public bool Value; public override CodeType Type { get { return CodeType.Bool; } } } public class Var : Code { public string Name; public override CodeType Type { get { return CodeType.Var; } } } public class Binding : Code { public string Identifier; public Code Value, Body; public override CodeType Type { get { return CodeType.Binding; } } } public class Conditional : Code { public Code Condition , Then, Else; public override CodeType Type { get { return CodeType.Conditional; } } } public class Application : Code { public Code Function; public Code[] Args; public override CodeType Type { get { return CodeType.Application; } } } public class Lambda : Code { public string[] ParameterIdentifiers; public Code Body; public override CodeType Type { get { return CodeType.Lambda; } } } public class Closure : Lambda { 82 Deklarativ, wenn möglich; imperativ, wenn nötig 52 57 62 67 72 77 82 87 92 97 102 Karsten Pietrzyk public Dictionary <string, Code> Env; public override CodeType Type { get { return CodeType.Closure; } } } public class BuiltInFunc : Code { public virtual Code Apply(params Code[] args) { throw new Exception(); } public override CodeType Type { get { return CodeType.BuiltInFunc; } } } public class Plus : BuiltInFunc { public override Code Apply(params Code[] args) { int sum = 0; foreach (var item in args) { sum += ((Number)item).Value; } return new Number { Value = sum }; } } public class Equals : BuiltInFunc { public override Code Apply(params Code[] args) { for (int i = 0; i < args.Length - 1; i++) { var x = args[i]; var y = args[i + 1]; if (x is Number && ((Number)x).Value != ((Number)y).Value || x is Bool && ((Bool)x).Value != ((Bool)y).Value) { return new Bool { Value = false }; } } return new Bool { Value = true }; } } public class LessThan : BuiltInFunc { public override Code Apply(params Code[] args) { for (int i = 0; i < args.Length - 1; i++) { var x = args[i]; var y = args[i + 1]; if (x is Number && ((Number)x).Value >= ((Number)y).Value) { return new Bool { Value = false }; } } return new Bool { Value = true }; } } 83 Karsten Pietrzyk 107 112 117 122 127 132 137 142 147 152 157 Deklarativ, wenn möglich; imperativ, wenn nötig public class Evaluator { public Code Evaluate(Code expr) { return Evaluate(new Dictionary <string, Code >(), expr); } public Code Evaluate(Dictionary <string, Code> env, Code expr) { switch (expr.Type) { case CodeType.Number: case CodeType.Bool: case CodeType.BuiltInFunc: case CodeType.Closure: return expr; case CodeType.Lambda: var l = (Lambda)expr; return new Closure { Env = new Dictionary <string, Code >(env), Body = l.Body, ParameterIdentifiers = l.ParameterIdentifiers }; case CodeType.Var: var v = (Var)expr; return env[v.Name]; case CodeType.Binding: var bind = (Binding)expr; Code old = null; if (env.ContainsKey(bind.Identifier)) { old = env[bind.Identifier]; } env[bind.Identifier] = Evaluate(env, bind.Value); var result = Evaluate(env, bind.Body); if (old != null) { env[bind.Identifier] = old; } return result; case CodeType.Conditional: var conditional = (Conditional)expr; var test = Evaluate(env, conditional.Condition) as Bool; if (test != null && !test.Value) { return Evaluate(env, conditional.Else); } else { return Evaluate(env, conditional.Then); } case CodeType.Application: var app = (Application)expr; var func = Evaluate(env, app.Function); switch (func.Type) { case CodeType.Closure: var c = (Closure)func; var envToExtend = c.Env; var len = c.ParameterIdentifiers.Length; var oldValues = new Code[len]; for (int i = 0; i < len; i++) { var name = c.ParameterIdentifiers[i]; if (envToExtend.ContainsKey(name)) 84 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk { oldValues[i] = envToExtend[name]; } envToExtend[name] = Evaluate(env, app.Args[i]); // Werte die Argumente mit env aus, den } // Körper der Closure mit erweitertem env. var functionResult = Evaluate(envToExtend , c.Body); for (int i = 0; i < len; i++) { var name = c.ParameterIdentifiers[i]; if (oldValues[i] != null) { envToExtend[name] = oldValues[i]; } } return functionResult; case CodeType.BuiltInFunc: var b = (BuiltInFunc)func; var length = app.Args.Length; var evaluatedArgs = new Code[length]; for (int i = 0; i < length; i++) { evaluatedArgs[i] = Evaluate(env, app.Args[i]); } return b.Apply(evaluatedArgs); default: throw new Exception(”Cannot be applied ” + func); 162 167 172 177 182 } } throw new Exception(”Wrong CodeType: ” + expr.Type + ” (of object ” + expr + ”)”); 187 } } } 7.8 Listing 8: Evaluator - verhaltensorientiert mit Polymorphie 3 8 13 using System; using System.Collections.Generic; namespace Polymorphie { abstract public class Code { public virtual Code Evaluate(Dictionary <string, Code> env) { return this; } public virtual Code Apply(Dictionary <string, Code> env, Code[] args) { throw new Exception(”Cannot apply ” + this); } } public class Number : Code { public int Value; } 85 Karsten Pietrzyk 18 23 28 33 Deklarativ, wenn möglich; imperativ, wenn nötig public class Bool : Code { public bool Value; } public class Var : Code { public string Name; public override Code Evaluate(Dictionary <string, Code> env) { return env[Name]; } } public class Binding : Code { public string Identifier; public Code Value, Body; public override Code Evaluate(Dictionary <string, Code> env) { Code old = null; if (env.ContainsKey(Identifier)) { old = env[Identifier]; } env[Identifier] = Value.Evaluate(env); var result = Body.Evaluate(env); if (old != null) { env[Identifier] = old; } return result; 38 } } 43 48 53 58 63 68 73 public class Conditional : Code { public Code Condition , Then, Else; public override Code Evaluate(Dictionary <string, Code> env) { var result = Condition.Evaluate(env) as Bool; if (result != null && !result.Value) { return Else.Evaluate(env); } else { return Then.Evaluate(env); } } } public class Application : Code { public Code Function; public Code[] Args; public override Code Evaluate(Dictionary <string, Code> env) { var func = Function.Evaluate(env); return func.Apply(env, Args); } } public class Lambda : Code { public string[] ParameterIdentifiers; public Code Body; public override Code Evaluate(Dictionary <string, Code> env) { return new Closure 86 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk { Env = new Dictionary <string , Code >(env), ParameterIdentifiers = ParameterIdentifiers , Body = Body }; 78 } } 83 public class Closure : Lambda { public Dictionary <string, Code> Env; public override Code Evaluate(Dictionary <string, Code> env) { return this; } public override Code Apply(Dictionary <string , Code> env, Code[] args) { var len = ParameterIdentifiers.Length; var oldValues = new Code[len]; for (int i = 0; i < len; i++) { var name = ParameterIdentifiers[i]; if (Env.ContainsKey(name)) { oldValues[i] = Env[name]; } Env[name] = args[i].Evaluate(env); // Werte die Argumente mit env aus, den } // Körper der Closure mit erweitertem env. var result = Body.Evaluate(Env); for (int i = 0; i < len; i++) { var name = ParameterIdentifiers[i]; if (oldValues[i] != null) { Env[name] = oldValues[i]; } } return result; } 88 93 98 103 108 113 118 123 } abstract public class BuiltInFunc : Code { public override Code Apply(Dictionary <string , Code> env, Code[] args) { var len = args.Length; var evaluatedArgs = new Code[len]; for (int i = 0; i < len; i++) { evaluatedArgs[i] = args[i].Evaluate(env); } return Apply(evaluatedArgs); } protected abstract Code Apply(Code[] evaluatedArgs); } public class Plus : BuiltInFunc { protected override Code Apply(Code[] args) { int sum = 0; 87 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig foreach (var item in args) { sum += ((Number)item).Value; } return new Number { Value = sum }; 128 } } 133 public class Equals : BuiltInFunc { protected override Code Apply(Code[] args) { for (int i = 0; i < args.Length - 1; i++) { var x = args[i]; var y = args[i + 1]; if (x is Number && ((Number)x).Value != ((Number)y).Value || x is Bool && ((Bool)x).Value != ((Bool)y).Value) { return new Bool { Value = false }; } } return new Bool { Value = true }; } } 138 143 148 public class LessThan : BuiltInFunc { protected override Code Apply(Code[] args) { for (int i = 0; i < args.Length - 1; i++) { var x = args[i]; var y = args[i + 1]; if (((Number)x).Value >= ((Number)y).Value) { return new Bool { Value = false }; } } return new Bool { Value = true }; } } 153 158 163 public class Evaluator { public Code Evaluate(Code expr) { return expr.Evaluate(new Dictionary <string , Code >()); } } 168 } 7.9 Listing 9: Evaluator - verhaltensorientiert mit Visitor pattern using System; using System.Collections.Generic; 4 namespace Visitors { 88 Deklarativ, wenn möglich; imperativ, wenn nötig 9 14 19 24 29 34 39 44 49 54 59 Karsten Pietrzyk abstract public class Code { public abstract Code Accept(Visitor v);} public class Number : Code { public int Value; public override Code Accept(Visitor v) { return v.Visit(this); } // Visit(Number) } public class Bool : Code { public bool Value; public override Code Accept(Visitor v) { return v.Visit(this); } // Visit(Bool) } public class Var : Code { public string Name; public override Code Accept(Visitor v) { return v.Visit(this); } // usw. } public class Binding : Code { public string Identifier; public Code Value, Body; public override Code Accept(Visitor v) { return v.Visit(this); } } public class Conditional : Code { public Code Condition , Then, Else; public override Code Accept(Visitor v) { return v.Visit(this); } } public class Application : Code { public Code Function; public Code[] Args; public override Code Accept(Visitor v) { return v.Visit(this); } } public class Lambda : Code { public string[] ParameterIdentifiers; public Code Body; public override Code Accept(Visitor v) { return v.Visit(this); } } public class Closure : Lambda { public Dictionary <string, Code> Env; public override Code Accept(Visitor v) { return v.Visit(this); } } abstract public class BuiltInFunc : Code { public override Code Accept(Visitor v) { return v.Visit(this); } public abstract Code Apply(Code[] args); } 89 Karsten Pietrzyk 64 69 74 79 84 Deklarativ, wenn möglich; imperativ, wenn nötig public class LessThan : BuiltInFunc { public override Code Apply(Code[] args) { for (int i = 0; i < args.Length - 1; i++) { var x = args[i]; var y = args[i + 1]; if (((Number)x).Value >= ((Number)y).Value) { return new Bool { Value = false }; } } return new Bool { Value = true }; } } public class Plus : BuiltInFunc { public override Code Apply(params Code[] args) { int sum = 0; foreach (var item in args) { sum += ((Number)item).Value; } return new Number { Value = sum }; } } 89 94 99 104 109 114 public class Equals : BuiltInFunc { public override Code Apply(params Code[] args) { for (int i = 0; i < args.Length - 1; i++) { var x = args[i]; var y = args[i + 1]; if (x is Number && ((Number)x).Value != ((Number)y).Value || x is Bool && ((Bool)x).Value != ((Bool)y).Value) { return new Bool { Value = false }; } } return new Bool { Value = true }; } } abstract public class Visitor { public virtual Code Visit(Number expr) { return VisitDefault(expr); } public virtual Code Visit(Bool expr) { return VisitDefault(expr); } public virtual Code Visit(Var expr) { return VisitDefault(expr); } public virtual Code Visit(Binding expr) { return VisitDefault(expr); } public virtual Code Visit(Conditional expr) { return VisitDefault(expr); } 90 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk public virtual Code Visit(Application expr) { return VisitDefault(expr); } public virtual Code Visit(Lambda expr) { return VisitDefault(expr); } public virtual Code Visit(Closure expr) { return VisitDefault(expr); } public virtual Code Visit(BuiltInFunc expr) { return VisitDefault(expr); } protected abstract Code VisitDefault(Code expr); 119 124 } 129 134 public class EvaluationVisitor : Visitor { Dictionary <string, Code> env; public EvaluationVisitor() : this(new Dictionary <string, Code >()) { } public EvaluationVisitor(Dictionary <string, Code> env) { this.env = env; } protected override Code VisitDefault(Code expr) { return expr; } 139 public override Code Visit(Var expr) { return env[expr.Name]; } 144 149 154 159 164 169 public override Code Visit(Lambda expr) { return new Closure { Env = new Dictionary <string , Code >(env), Body = expr.Body, ParameterIdentifiers = expr.ParameterIdentifiers }; } public override Code Visit(Binding expr) { Code old = null; if (env.ContainsKey(expr.Identifier)) { old = env[expr.Identifier]; } env[expr.Identifier] = expr.Value.Accept(this); var result = expr.Body.Accept(this); if (old != null) { env[expr.Identifier] = old; } return result; } public override Code Visit(Conditional expr) { var result = expr.Condition.Accept(this) as Bool; if (result != null && !result.Value) { return expr.Else.Accept(this); } else { return expr.Then.Accept(this); } } public override Code Visit(Application expr) 91 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig { 174 var evaluatedFunction = expr.Function.Accept(this); var appVisitor = new ApplicationVisitor(this, env, expr); return evaluatedFunction.Accept(appVisitor); } 179 184 } public class ApplicationVisitor : Visitor { Visitor parentVisitor; Application app; Dictionary <string, Code> env; public ApplicationVisitor(Visitor parentVisitor , Dictionary <string, Code> env, Application app) { this.parentVisitor = parentVisitor; this.env = env; this.app = app; } 189 194 public override Code Visit(Closure expr) { var envToExtend = expr.Env; var len = expr.ParameterIdentifiers.Length; var oldValues = new Code[len]; for (int i = 0; i < len; i++) { var name = expr.ParameterIdentifiers[i]; if (envToExtend.ContainsKey(name)) { oldValues[i] = envToExtend[name]; } envToExtend[name] = app.Args[i].Accept(parentVisitor); } // Argumente mit env auswerten , Körper mit neuem env. var result = expr.Body.Accept( // Dafür neuer Visitor: new EvaluationVisitor(envToExtend)); for (int i = 0; i < len; i++) { var name = expr.ParameterIdentifiers[i]; if (oldValues[i] != null) { envToExtend[name] = oldValues[i]; } } return result; } 199 204 209 214 public override Code Visit(BuiltInFunc expr) { var len = app.Args.Length; var args = new Code[len]; for (int i = 0; i < len; i++) { args[i] = app.Args[i].Accept(parentVisitor); } return expr.Apply(args); } 219 224 protected override Code VisitDefault(Code expr) { throw new Exception(”Cannot apply ” + expr); } 229 } 92 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk public class Evaluator { public Code Evaluate(Code expr) { return expr.Accept(new EvaluationVisitor()); } } 234 } 7.10 Listing 10: Test der Evaluator-Implementierungen in C# 3 8 13 18 23 28 33 using System; using Microsoft.VisualStudio.TestTools.UnitTesting; // Stellt eine Sammlung von Tests dar, die eine Evaluator -Implementierung überprüfen. // Die Typparameter müssen den zu testenden Klassen entsprechen. // Der Evaluator kapselt die Strategie , indem die Evaluate -Methode auf den Ausdruck angewendet und das Ergebnis geprüft wird. // Die Klassen werden instanziiert und zu Test-Ausdrücken zusammengesetzt. // Die statischen Methoden stellen die Tests dar. In Unterklassen müssen sie aufgerufen werden. // Als Kommentar der einzelnen Tests steht ein exemplarischer Scheme-Ausdruck , der den Test darstellt. // Der Test wird für gewöhnlich mit mehreren Prüfungen ausgeführt. // (assert= actual expected) ist eine fiktive Funktion , die der Assert.AreEqual -Methode des Test-Frameworks enspricht. public class DynamicTests <Code, Evaluator , LessThan , Equals , Plus, Number , Bool, Var, Binding , Conditional , Application , Lambda > where Evaluator : new() where LessThan : Code, new() where Equals : Code, new() where Plus : Code, new() where Number : Code, new() where Bool : Code, new() where Var : Code, new() where Binding : Code, new() where Conditional : Code, new() where Application : Code, new() where Lambda : Code, new() { // (assert= (let ([x 20]) (+ x x 4)) // 44) public static void LetPlus() { dynamic bind = new Binding(); dynamic variable = new Var(); dynamic plus = new Plus(); dynamic apply = new Application(); dynamic constant = new Number(); dynamic init = new Number(); 93 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig variable.Name = ”x”; bind.Identifier = ”x”; bind.Value = init; apply.Function = plus; apply.Args = new Code[] { variable , variable , constant }; bind.Body = apply; 38 43 dynamic ev = new Evaluator(); for (int i = -100; i < 100; i++) { for (int k = -100; k < 100; k++) { constant.Value = i; init.Value = k; dynamic res = ev.Evaluate(bind); Assert.AreEqual(2 * k + i, res.Value); } } 48 53 } 58 63 68 73 78 83 // (assert= ((lambda (a b) (if (< a b ) a b)) 1 2) // 1) public static void LambdaIfLess() { dynamic var1 = new Var(); dynamic var2 = new Var(); dynamic lt = new LessThan(); dynamic min = new Lambda(); dynamic cond = new Conditional(); dynamic apply1 = new Application(); dynamic apply2 = new Application(); dynamic num1 = new Number(); dynamic num2 = new Number(); var1.Name = ”a”; var2.Name = ”b”; min.ParameterIdentifiers = new[] { ”a”, ”b”, }; min.Body = cond; cond.Condition = apply1; apply1.Function = lt; apply1.Args = new Code[] { var1, var2 }; cond.Then = var1; cond.Else = var2; apply2.Function = min; num1.Value = 1; num2.Value = 2; apply2.Args = new Code[] { num1, num2 }; dynamic eval = new Evaluator(); 88 for (int i = -100; i < 100; i++) { for (int k = -100; k < 100; k++) { num1.Value = i; 94 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk num2.Value = k; dynamic result = eval.Evaluate(apply2); Assert.AreEqual(Math.Min(i, k), result.Value); 93 } } 98 103 108 113 118 123 } // (assert= // ((lambda (cont x) (if (= x 0) 1 (+ x (cont cont (+ x -1))))) // (lambda (cont x) (if (= x 0) 1 (+ x (cont cont (+ x -1))))) // 4) // 11) // Wie Fakultät nur mit + statt *: 1, 2, 4, 7, 11, 16, 22, 29, ... public void ContinuationRecursion() { dynamic fun = new Lambda(); dynamic cont = new Var(); dynamic x = new Var(); dynamic eq = new Equals(); dynamic input = new Number(); dynamic cond = new Conditional(); dynamic recurse = new Application(); dynamic plus = new Plus(); dynamic applyPlus = new Application(); dynamic applyEq = new Application(); dynamic subtractOne = new Application(); dynamic negOne = new Number(); dynamic zero = new Number(); dynamic one = new Number(); dynamic test = new Application(); zero.Value = 0; one.Value = 1; negOne.Value = -1; x.Name = ”x”; cont.Name = ”cont”; 128 fun.ParameterIdentifiers = new[] { ”cont”, ”x” }; fun.Body = cond; 133 138 143 applyEq.Function = eq; applyEq.Args = new Code[] { x, zero }; cond.Condition = applyEq; cond.Then = one; cond.Else = applyPlus; applyPlus.Function = plus; applyPlus.Args = new Code[] { x, recurse }; recurse.Function = cont; recurse.Args = new Code[] { cont, subtractOne }; subtractOne.Function = plus; subtractOne.Args = new Code[] { x, negOne }; test.Function = fun; test.Args = new Code[] { fun, input }; 95 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig dynamic eval = new Evaluator(); 148 for (int i = 0; i < 100; i++) { input.Value = i; dynamic result = eval.Evaluate(test); var sum = 1; for (int counter = 1; counter <= i; counter++) { sum += counter; } Assert.AreEqual(sum, result.Value); } 153 158 } 163 168 173 178 183 188 193 // cons = (lambda (hd tl) (lambda (takeHead)(if takeHead hd tl))) // head = (lambda (consCell) (consCell true)) // tail = (lambda (consCell) (consCell false)) // isEmpty = (lambda (consCell) (if consCell #f #t)) // oneTwo = (cons 1 (cons 2 #f)) // (assert= (head oneTwo) 1) // (assert= (head (tail oneTwo)) 2) // (assert= (tail (tail oneTwo)) #f) // (assert= (isEmpty oneTwo) #f) // (assert= (isEmpty (tail oneTwo)) #f) // (assert= (isEmpty (tail (tail oneTwo))) #t) public void ListClosure() { dynamic cons = new Lambda(); dynamic consSelect = new Lambda(); dynamic consSelectIf = new Conditional(); dynamic takeHead = new Var(); dynamic headVar = new Var(); dynamic tailVar = new Var(); takeHead.Name = ”takeHead”; headVar.Name = ”head”; tailVar.Name = ”tail”; cons.ParameterIdentifiers = new[] { ”head”, ”tail” }; cons.Body = consSelect; consSelect.ParameterIdentifiers = new[] { ”takeHead” }; consSelect.Body = consSelectIf; consSelectIf.Condition = takeHead; consSelectIf.Then = headVar; consSelectIf.Else = tailVar; dynamic head = new Lambda(); dynamic headApp = new Application(); dynamic trueBool = new Bool(); dynamic consCellVar = new Var(); consCellVar.Name = ”consCell”; trueBool.Value = true; 198 head.ParameterIdentifiers = new[] { ”consCell” }; head.Body = headApp; headApp.Function = consCellVar; headApp.Args = new Code[] { trueBool }; 203 dynamic tail = new Lambda(); 96 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk dynamic tailApp = new Application(); dynamic falseBool = new Bool(); falseBool.Value = false; 208 tail.ParameterIdentifiers = new[] { ”consCell” }; tail.Body = tailApp; tailApp.Function = consCellVar; tailApp.Args = new Code[] { falseBool }; 213 dynamic isEmpty = new Lambda(); dynamic isEmptyIf = new Conditional(); dynamic xs = new Var(); xs.Name = ”xs”; isEmpty.ParameterIdentifiers = new[] { ”xs” }; isEmpty.Body = isEmptyIf; isEmptyIf.Condition = xs; isEmptyIf.Then = falseBool; isEmptyIf.Else = trueBool; 218 223 dynamic one one.Value = dynamic two two.Value = = new Number(); 1; = new Number(); 2; 228 233 dynamic twoList = new Application(); twoList.Function = cons; twoList.Args = new Code[] { two, falseBool }; dynamic oneTwo = new Application(); oneTwo.Function = cons; oneTwo.Args = new Code[] { one, twoList }; 238 dynamic ev = new Evaluator(); dynamic headTest = new Application(); headTest.Function = head; headTest.Args = new Code[] { oneTwo }; dynamic num = ev.Evaluate(headTest); Assert.AreEqual(1, num.Value); 243 248 dynamic tailOfOneTwo = new Application(); tailOfOneTwo.Function = tail; tailOfOneTwo.Args = new Code[] { oneTwo }; dynamic headOfTailTest = new Application(); headOfTailTest.Function = head; headOfTailTest.Args = new Code[] { tailOfOneTwo }; num = ev.Evaluate(headOfTailTest); Assert.AreEqual(2, num.Value); 253 dynamic tailOfTailTest = new Application(); tailOfTailTest.Function = tail; tailOfTailTest.Args = new Code[] { tailOfOneTwo }; 258 dynamic empty = ev.Evaluate(tailOfTailTest); Assert.AreEqual(false, empty.Value); 97 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig dynamic isEmptyTest = new Application(); isEmptyTest.Function = isEmpty; 263 isEmptyTest.Args = new Code[] { oneTwo }; empty = ev.Evaluate(isEmptyTest); Assert.AreEqual(false, empty.Value); isEmptyTest.Args = new Code[] { tailOfOneTwo }; empty = ev.Evaluate(isEmptyTest); Assert.AreEqual(false, empty.Value); 268 isEmptyTest.Args = new Code[] { tailOfTailTest }; empty = ev.Evaluate(isEmptyTest); Assert.AreEqual(true, empty.Value); 273 } } 278 283 288 293 298 303 // Es folgen die Tests der einzelnen Implementierungen. Sie befinden sich in einem Namespace , // damit dort die Klassen mit using importiert werden können. // Dies sind die Typparameter der Test-Sammlung. // Die Namen der Test-Methoden enthalten den Namen des Implementierungsansatzes , // um schnell zu sehen, welcher Implementierungsaspekt welches Ansatzes fehlerhaft war. namespace TypetestsTest { using Typtests; [TestClass] public class Test : DynamicTests <Code, Evaluator , LessThan , Equals , Plus, Number , Bool, Var, Binding , Conditional , Application , Lambda > { [TestMethod] public void TyptestsLambdaIfLess() { LambdaIfLess(); } [TestMethod] public void TyptestsLetPlus() { LetPlus(); } [TestMethod] public void TyptestsContinuationRecursion() { ContinuationRecursion(); } [TestMethod] public void TyptestsClosure() { ListClosure(); } } } namespace TypeCodesTest { using TypeCodes; [TestClass] public class Test : DynamicTests <Code, Evaluator , LessThan , Equals , Plus, Number , Bool, Var, Binding , Conditional , Application , Lambda > { [TestMethod] public void TypeCodesLambdaIfLess() { LambdaIfLess(); } 98 Deklarativ, wenn möglich; imperativ, wenn nötig Karsten Pietrzyk [TestMethod] public void TypeCodesLetPlus() { LetPlus(); } [TestMethod] public void TypeCodesContinuationRecursion() { ContinuationRecursion(); } [TestMethod] public void TypeCodesClosure() { ListClosure(); } 308 313 } } 318 323 328 namespace PolymorphieTest { using Polymorphie; [TestClass] public class Test : DynamicTests <Code, Evaluator , LessThan , Equals , Plus, Number , Bool, Var, Binding , Conditional , Application , Lambda > { [TestMethod] public void PolymorphieLambdaIfLess() { LambdaIfLess(); } [TestMethod] public void PolymorphieLetPlus() { LetPlus(); } [TestMethod] public void PolymorphieContinuationRecursion() { ContinuationRecursion(); } [TestMethod] public void PolymorphieClosure() { ListClosure(); } } } 333 338 343 348 namespace VisitorsTest { using Visitors; [TestClass] public class Test : DynamicTests <Code, Evaluator , LessThan , Equals , Plus, Number , Bool, Var, Binding , Conditional , Application , Lambda > { [TestMethod] public void VisitorsLambdaIfLess() { LambdaIfLess(); } [TestMethod] public void VisitorsLetPlus() { LetPlus(); } [TestMethod] public void VisitorsContinuationRecursion() { ContinuationRecursion(); } [TestMethod] public void VisitorsClosure() { ListClosure(); } } } 99 Karsten Pietrzyk Deklarativ, wenn möglich; imperativ, wenn nötig Eigenständigkeitserklärung Ich versichere hiermit, die Bachelorarbeit im Studiengang Informatik selbstständig verfasst und keine anderen als die angegebenen Hilfsmittel benutzt zu haben, insbesondere keine im Quellenverzeichnis nicht benannten Internet-Quellen. Ich versichere weiterhin, dass ich die Arbeit vorher nicht in einem anderen Prüfungsverfahren eingereicht habe und die eingereichte schriftliche Fassung der auf dem elektronischen Speichermedium entspricht. Ich bin außerdem mit der Einstellung meiner Arbeit in die Bibliothek einverstanden. .................................................................................... Datum, Ort, Unterschrift Karsten Pietrzyk 100