1.2 Was ist deklarative Programmierung?

Werbung
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 Programminterpretation.
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. Selektor-Funktionen (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 [SICP, vgl.
S. 120] 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 Anwendungs-Logik 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
65
Karsten Pietrzyk
Deklarativ, wenn möglich; imperativ, wenn nötig
wenigen Konzepten basiert (z.B. Pattern matching (S. 26) und einfach zu verwendende 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
Herunterladen