Escher – funktionale und logische Programmierung

Werbung
Escher –
funktionale und logische Programmierung
Lars Hupel
3. Juni 2010
Zusammenfassung
Die Programmiersprache Escher“ wurde in den neunziger Jahren von
”
J. W. Lloyd spezifiziert. Konzeptuell vereinigt sie die Paradigmen der
funktionalen und der logischen Programmierung, wobei Haskell die syntaktische Basis bildet. Eine Erweiterung besteht z. B. darin, Pattern Matching nicht nur auf Datenkonstruktoren, sondern auch auf Funktionsaufrufen zuzulassen. In dieser Ausarbeitung sollen die Konzepte von Escher
vorgestellt werden und ein Vergleich zu einer weiteren funktional-logischen
Sprache – Curry“ – gezogen werden.
”
1
Einführung
Eschers grundlegendes Konzept ist die Vereinigung von zwei Programmierparadigmen. Auf der einen Seite ist das die funktionale Programmierung auf Basis
von Haskell, auf der anderen Seite logische Programmierung (mit Inspiration
durch Prolog).
Funktionales Paradigma Im Gegensatz zum imperativen Paradigma, bei
dem sogenannte Seiteneffekte auftreten, werden diese hierbei vermieden, denn
funktionale Programme basieren auf einer Reduktionssemantik, d. h. Terme
können unabhängig voneinander durch äquivalente Terme ersetzt werden. So
ist ein weiterer Hauptunterschied, dass Variablen nur einmal initialisiert werden
und anschließend immutable – also unveränderlich – sind. Seiteneffekte treten
jedoch trotzdem auf, wenn z. B. Benutzerinteraktion erfolgt und werden je nach
konkreter Sprache unterschiedlich modelliert.
Die wichtigsten Techniken beim funktionalen Programmieren sind die Rekursion und Pattern Matching. Während erstere auch aus imperativen Sprachen
bekannt ist, handelt es sich beim zweiten um einen mächtigen Mechanismus,
um Fallunterscheidungen über Variablen zu vereinfachen. Um beim Beispiel
Haskell zu bleiben: Jede Variable hat einen Typ, welcher aus mehreren Konstruktoren bestehen kann. Verkettete Listen werden so als leere Liste“ oder
”
Verkettung eines Werts mit einer Restliste“ definiert (nicht nur Algorithmen,
”
auch Datenstrukturen werden üblicherweise rekursiv definiert). Definiert man z.
1
B. eine Operation auf Listen, so wird per Pattern Matching unterschieden, welcher der beiden Fälle ( leere Liste“ oder Verkettung“) vorliegt. Ein Vorteil ist
”
”
daran, dass dieser Vorgang auch auf verschachtelte Typen angewandt werden
kann, um z. B. das zweite Element des fünften Tupels einer Liste zu extra”
hieren“, und zwar auf eine typsichere Weise, denn der Compiler prüft bereits
zur Übersetzung, ob das Ergebnis einer Operation mit dem Kontext verträglich
ist. Haskell bietet in der Regel die Möglichkeit, komplett auf die Notation von
Typen zu verzichten, da diese aus dem Kontext gefolgert – inferiert – werden
können.
Eine weitere Besonderheit von Haskell ist die sogenannte verzögerte oder Bedarfsauswertung, die dafür sorgt, dass man sogar mit unendlichen Listen umgehen kann. Ein Wert wird erst dann ausgerechnet, wenn er für die Berechnung
eines anderen benötigt wird. Hierzu wird immer die äußerste“ Anwendung ei”
ner Funktion ermittelt. Möchte man z. B. die n-te Fibonacci-Zahl ausrechnen,
kann man eine Funktion schreiben, die eine Liste aller Fibonacci-Zahlen ermittelt und dann auf das n-te Element zugreifen, was das System dazu veranlasst,
nur die ersten n Zahlen zu berechnen.
Allen funktionalen Sprachen sind die anonymen Funktionen oder Lambda-Ausdrücke in Verbindung mit Currying gemein. Funktionen werden dabei wie Werte
behandelt und können anderen Funktionen übergeben werden. Üblicherweise betrachtet man Funktionen mit n Parametern als Funktion mit einem Parameter,
welche eine Funktion mit n − 1 Parametern zurückgibt. Einen Aufruf kann man
sich dann als Binden“ von Parametern der Reihe nach vorstellen.
”
Logisches Paradigma Auch hier steht Pattern Matching an erster Stelle. Die
Auswertung eines Ausdrucks unterscheidet sich jedoch fundamental: Während
bei imperativen und funktionalen Programmen die Eingabeparameter immer
definiert sein müssen, versuchen logische Sprachen wie Prolog, fehlende Eingabeparameter so zu besetzen, dass eine wahre Aussage entsteht. Dazu bedienen
sie sich einer Datenbank aus Fakten und Folgerungsregeln, durch die wahre
Aussagen durch Backtracking erzeugt werden können.
Beim Backtracking, zu deutsch Rücksetzverfahren, handelt es sich um einen speziellen Typ von Algorithmen, bei welchem mehrere Möglichkeiten, ein Problem
zu lösen, der Reihe nach probiert werden. Stellt man einem Prolog-System also
eine Anfrage in Form eines Prädikats mit freien Variablen, so gibt es mehrere
mögliche Teillösungen, die sich aus der Anfrage durch einen einzelnen Reduktionsschritt ergeben. Zunächst wird eine Teillösung ausgewählt und mit dieser
rekursiv fortgefahren. Führt diese Teillösung jedoch zu keinem Ergebnis, geht
das System einen Schritt zurück und testet einen anderen Pfad. Dieses Vorgehen
entspricht einer einfachen Tiefensuche auf der Datenbasis.
Prolog bietet auch eine weitere Spezialität an, die Unifikation. Vermittels dieses
Mechanismus, welcher ein erweitertes Pattern Matching darstellt, kann nicht
nur eine Variable gegen einen Konstruktor abgeglichen werden, sondern beliebige Ausdrücke gegeneinander, wobei enthaltene Variablen automatisch ersetzt
werden. Vereinfacht gesagt, wird bei einer gegebenen Menge von Ausdrücken
2
eine Substitution von Variablen auf Werte ermittelt, die alle Ausdrücke isomorph aufeinander abbildet (sofern eine solche Substitution existiert). Beispiel:
Gegeben seien die beiden Ausdrücke t1 = f (X, [a, b]) und t2 = f (c, Y ) (Großbuchstaben stehen für Variablen, Kleinbuchstaben für Werte). Eine Unifikation
ist dann z. B. die Substitution σ = {X 7→ c, Y 7→ [a, b]}, denn σ(t1 ) ≡ σ(t2 ).
Rein logische Sprachen haben heute keine große Relevanz mehr, da sie in ihrem
Umfang doch recht eingeschränkt sind. Prolog z. B. unterstützt ausschließlich
Tiefensuche und kann daher nicht wesentlich optimiert werden. Für größere
Probleme sind jedoch alternative Suchstrategien erforderlich.
Die Programmiersprache Escher Escher wurde mit dem Ziel entwickelt,
eine Obermenge von Haskell darzustellen1 . Von Prolog sind lediglich gewisse
Ideen, nicht aber die Auswertungsstrategie übernommen. Um dies unter einen
Hut zu bringen, wird Pattern Matching stark erweitert: So kann nicht nur gegen
Konstruktoren abgeglichen werden, sondern auch gegen Funktionsaufrufe und
innerhalb von Lamdba-Ausdrücken. Beispielsweise kann folgende Definition geschrieben werden2 :
(ite u False w) ∨ t = (¬u ∧ w) ∨ t
Das System kann also, wenn die Funktion ∨ mit zwei Parametern a und t berechnet werden soll, nicht nur einfach strikt a und t auswerten, sondern, falls a
die Form ite u False w hat, dies durch (¬u ∧ w) ∨ t ersetzen.
2
Konzepte von Escher
Im folgenden soll die konkrete Ausgestaltung anhand des Escher-Interpreters
von Kee Siong Ng demonstriert werden. Tatsächlich unterscheidet sich diese
deutlich von der Spezifikation, die insbesondere im formalen Teil unvollständig
ist. Während die Spezifikation z. B. Infix-Notation vorsieht, setzt der Interpreter
vollständig auf Prefix, vermutlich um eine geringe Komplexität zu erreichen.
In der Implementation hingegen ist es nicht möglich, eigene Datentypen zu
definieren.
2.1
Auswertungsstrategie
Ein Programm besteht aus einer Menge von Definitionen. Beispiel einer Funktionsdefinition:
inList : a -> (List a) -> Bool ;
(inList x []) = False ;
(inList x (# y z)) = (ite (== x y) True (inList x z)) ;
1 mit Ausnahmen, z. B. von Haskells Typklassen, welche in Escher nicht existieren und
Unterschieden in der konkreten Syntax
2 ite steht für if – then – else“
”
3
Eine Anfrage kann nun wie folgt gestellt werden:
: (inList 1 [1,2]);
Die Notation [1, 2] ist dabei syntaktischer Zucker für den Listenkonstruktor
und wird vom Interpreter in (# 1 (# 2 [])) umgewandelt.
Zwecks Auswertung sucht der Interpreter nun nach einem Unterausdruck, der
reduziert werden kann, einem sogenannten Redex. Es muss also ein Subterm t der
Anfrage, eine Definition h = b und eine dazu passende Substitution θ gefunden
werden, so dass sich h mittels Substitution auf t abbilden lässt. Formal: θ(h)
(Substitution θ angewendet auf h) muss α-äquivalent zu t sein, d. h. sich nur
durch die Namen der Variablen von t zu unterscheiden.
Für den Fall, dass mehrere Redexe zur Verfügung stehen, wird derjenige genommen, der am weitesten links und am weitesten außen“ steht. In unserem
”
Beispiel (Redexe sind unterstrichen):
(inList 1 [1,2])
= (inList 1 (# 1 (# 2 [])))
→ (ite (== 1 1) True (inList 1 (# 2 [])))
→ (ite True True (inList 1 (# 2 [])))
→ True
Im ersten Schritt wird die Definition mit h = (inList x (# y z)) und der
Substitution θ = {x 7→ 1, y 7→ 1, z 7→ (# 2 [])} verwendet.
Der rekursive Aufruf von inList wird in den weiteren Schritten nicht ausgewertet, da sich das erste Argument zu True auswertet. Die Funktion ite ist dabei
in der Bibliothek implementiert und kein Sprachkonstrukt.
Eine interessanterer Fall ist jedoch die Anfrage
: (inList x [1,2]);
Answer: (((ite ((== x) 1)) True) (((ite ((== x) 2)) True) False)).
Die Antwort wird hier auf die gleiche Weise berechnet, mit dem Unterschied,
dass nicht alle Variablen instanziiert wurden. Tatsächlich wird eine vollständige Instanziierung durch die Substitution nicht benötigt, denn θ(h) darf sich,
wie oben definiert, durch die Namen von Variablen vom Redex t unterscheiden.
Folgende Berechnungsschritte werden daher ausgeführt:
(inList x [1,2])
= (inList x (# 1 (# 2 [])))
→ (ite (== x 1) True (inList x (# 2 [])))
→ (ite (== x 1) True (ite (== x 2) True (inList x [])))
→ (ite (== x 1) True (ite (== x 2) True False))
(Der Unterschied zur tatsächlichen Ausgabe kommt daher, dass der Interpreter
alle Werte in gecurryter“ Schreibweise ausgibt.)
”
4
2.2
Syntaxelemente
Mengen Wie bereits oben gesehen, werden Listenausdrücke vom Interpreter
in die Konstruktorschreibweise umgeformt. Eine weitere implizite Umformung
geschieht bei Mengen der Form {1,2,3}:
: ({1,2,3});
Answer: \pv.(ite (== pv 1) True (ite (== pv 2) True (ite (== pv 3) True False))) ;
Tatsächlich handelt es sich also um eine anonyme Funktion vom Typ number ->
Bool, die aus verketten ite-Aufrufen besteht. Nun könnte man meinen, dass diese Art der Darstellung von Mengen die Handhabung der üblichen Operationen
wie Schnitt und Vereinigung unnötig verkompliziere; allerdings schafft Pattern
Matching auf Funktionen Abhilfe. Folgendes Programm kann daher formuliert
werden:
set1: number -> Bool;
set1 = {1,2,3};
set2: number -> Bool;
set2 = {1,2,4};
Anfragen gestalten sich derart simpel, z. B. der Schnitt:
: (&& (set1 x) (set2 x));
Answer: (((ite ((== x) 1)) True) (((ite ((== x) 2)) True) False)) ;
Die Auswertung der letzten Anfrage ist nun aber nicht mehr selbstverständlich.
Grundlage sind folgende Definitionen für &&:
(&& (ite u v w) t) = (ite (&& u t) v (&& w t)) ;
(&& t (ite u v w)) = (ite (&& t u) v (&& t w)) ;
Auswertungsbeispiel (verkürzte Darstellung mit kleineren“ Parametern):
”
(&& ({1} x) ({1} x))
= (&& (\pv.(ite (== pv 1) True False) x) (\pv.(ite (== pv 1) True False) x))
→ && (ite (== x 1) True False) (ite (== x 1) True False)
→ ite (&& (== x 1) (ite (== x 1) True False)) True (&& False (ite (== x 1) True False))
→ ite (&& (== x 1) (ite (== x 1) True False)) True False
→ ite (ite (&& (== x 1) (== x 1)) True (&& (== x 1) False)) True False
→ ite (ite (&& (== x 1) (== x 1)) True False) True False
→ ite (&& (== x 1) (== x 1)) True False
→ ite (== x 1) True False
Auch hier gibt es einen Unterschied zur Spezifikation: Vorgesehen war, dass
Mengen nicht in Funktionen umgeformt werden, sondern Pattern Matching auf
Ausdrücken in mathematischer Mengennotation {x : p} (entspricht alle x für
”
die ein Prädikat p gilt“).
5
Quantoren
Der Interpreter bietet Existenz- und Allquantor an:
: \exists y. (&& ({1,2,4} y) (eq (mod y 2) 0));
Answer: True ;
: \forall y. (implies ({1,2,4} y) (eq (mod y 2) 0)) ;
Answer: False ;
In beiden Fällen handelt es sich um Funktionen mit dem Typ (a -> Bool) ->
Bool. Wiederum stehen in der Bibliothek zahlreiche Definitionen zur Verfügung,
um verschachtelte Aufrufe von Quantoren, Booleschen Operatoren und Mengenausdrücken zu vereinfachen.
Korrekterweise muss noch angemerkt werden, dass beim obigen Beispiel die Antwort erst über einen Zwischenschritt erfolgt (sprich: eine vollständige Evaluation
wird nicht auf Anhieb“ ausgeführt) – eine Unzulänglichkeit der vorliegenden
”
Implementation.
Quantoren sind in zweierlei Hinsicht praktisch: Zum einen kann der Existenzquantor als Ersatz für freie Variablen dienen (mehr dazu im folgenden Abschnitt) und so einen logischen Programmierstil ermöglichen. Zum anderen bietet das Zusammenspiel mit der Mengensyntax eine gänzlich andere Herangehensweise an Probleme. Allerdings muss man sich vor Augen führen, dass aufgrund der Komplexität der Umformungsregeln für (verschachtelte) Quantoren
nicht jeder Ausdruck ausgewertet werden kann.
2.3
Funktional-logische Programmierung
In Escher gibt es immer zwei Möglichkeiten, eine Funktion zu schreiben: funktional (Funktionen haben den Typ a -> b) und logisch (Funktionen haben den Typ
(a * b) -> Bool). Bei letzterem handelt es sich jedoch nicht um ein Prädikat
im Sinne des logischen Paradigmas, sondern um eine gewöhnliche Funktion,
die Nutzen von höheren Sprachkonstrukten von Escher macht. Tatsächlich existiert allerdings ein mechanisches Verfahren, um Prolog-Programme3 in EscherProgramme zu transformieren.
Man vergleiche z. B. folgende Funktionen, die das gleiche bewirken:
concat : ((List a) * (List a)) -> (List a) ;
(concat ([],x)) = x ;
(concat ((# u x), y)) = (# u (concat (x, y))) ;
append : ((List a) * (List a) * (List a)) -> Bool ;
(append (u,v,w)) =
(|| (&& (== u []) (== v w))
(\exists r.
(\exists x.
(\exists y.(&& (&& (== u (# r x)) (== w (# r y)))
(append (x,v,y))))))) ;
3 mit
der Einschränkung, dass reine Horn-Klauseln verwendet werden, also ohne Cuts
6
In einigen Fällen ist es unerheblich, welche Formulierung man verwendet. So
kann der Interpreter z. B. die Anfrage
: (== (concat ([1,2],x)) [1,2,3]);
Answer: (== x [3]) ;
auch im funktionalen Stil korrekt beantworten.
Der Unterschied zu Prolog tritt nun aber sehr deutlich an folgender Stelle hervor:
: (append (y,x,[1]));
Answer: (|| (&& (== y []) (== x [1])) (&& (== y [1]) (== x []))) ;
Es wird deutlich, dass der Interpreter immer eine gesammelte“ Antwort in Form
”
einer Disjunktion aller möglichen Ergebnisse liefert, wohingegen Prolog diese nur
bei Bedarf per Backtracking ermittelt. Sämtliche Umformungen basieren folglich
bei Escher auf definierten Umformungsregeln.
2.4
Erweitertes Pattern Matching
Wie bereits besprochen, unterscheidet Escher beim Pattern Matching nicht zwischen Konstruktoren und Funktionsaufrufen. Dies bringt folgende Vor- und
Nachteile:
• Umformungsregeln oder z. B. Quantoren können per Bibliothek implementiert werden, was größtmögliche Flexibilität erlaubt. Denkbar wäre es, dass
man für spezielle Zwecke Nicht-Standard-Logiken implementiert, z. B. eine
dreiwertige Logik. Natürlich muss dann aber beachtet werden, dass sich die
einzelnen Funktionen genügend unterscheiden und eine sinnvolle Berechnungskette ermöglichen. Dazu müssen die Funktionen alle Fälle abdecken
und dürfen sich dabei nicht überlappen und vor allem keine unendlichen
Umformungsschritte erlauben.
• Es gibt wesentlich mehr Möglichkeiten, mit Funktionen umzugehen. So ist
es in Haskell nicht möglich, Funktionen zu vergleichen. Escher unterstützt
diesen allgemeinen Fall zwar auch nicht, aber man kann mit LambdaAusdrücken in Mustern umgehen und in Folge dessen einen Einblick“ in
”
Funktionen erhalten.
• Das Ergebnis einer Berechnung kann auch durchaus eine Funktion sein,
die mögliche Substitutionen für freie Variablen in der Anfrage enthält.
Der Interpreter behandelt solch einen Fall wie einen Wert und gibt die
Definition aus.
Der größte Nachteil, der durch diese Technik entsteht, ist die mangelnde Effizienz. Laut Ng handelt es sich dabei um ein ungelöstes Problem, denn die meisten
Haskell-Optimierungen sind für Escher nicht anwendbar.
7
3
Vergleich mit Curry
Eine weitaus bekanntere funktional-logische Programmiersprache, Curry, verfolgt ähnliche Konzepte, geht aber in der Auswertung indes anders vor. Curry
unterstützt die beiden Auswertungsstrategien Narrowing und Residuation.
Ersteres verfolgt die Strategie, dass unbelegte Variablen stets instanziiert werden. Dafür probiert das System mögliche Werte, die sich durch den Kontext
ergeben, aus. Residuation hingegen verzögert die Auswertung solange, bis eine
Festlegung der Variablen anderweitig stattfindet, z. B. durch vorherige Auswertung anderer Terme. Manche Ausdrücke, darunter arithmetische Operationen (sogenannte starre Operationen oder rigid ), machen diese Art der Auswertung erforderlich. Selbst definierte Funktionen sind hingegen immer flexibel und
können per Narrowing ausgewertet werden.
Escher verwendet hingegen eine Art Zwischenweg: Im Gegensatz zum Narrowing
werden freie Variablen niemals geraten“, sondern einfach uninstanziiert gelas”
sen. Die Strategie entspricht folglich eher Residuation, da nur dann ein Redex
ausgewählt wird, wenn es eine passende Substitution von freien Variablen im
Muster zum Redex gibt. Eine gänzliche Entsprechung gibt es jedoch nicht, da
der Mechanismus der Unifikation fehlt. Folglich kann ein Subterm nicht später“
”
zum Redex werden, wenn nicht ein innerer Teil durch einen Auswertungsschritt
umgeformt wurde.
Die Unterschiede werden am folgenden Beispiel deutlich. Betrachtet sei eine
prefix-Funktion.
Curry
(verkürzte Ausgabe)
prefix xs list | xs ++ rest =:= list = xs where rest free
test> prefix xs [1,2] where xs free
Result: []
Result: [1]
Result: [1,2]
++ stellt die Listenkonkatenation und =:= den Gleichheitsoperator dar (im Sinne
der Unifizierbarkeit). Das System liefert nun alle möglichen Ergebnisse.
Escher (verkürzte Ausgabe)
prefix : (List a) -> (List a) -> Bool ;
(prefix s t) = \exists x. (append (s,x,t)) ;
: (prefix x [1,2]);
Answer: (|| (== x []) (|| (== x [1]) (== x [1,2]))) ;
4
Fazit und Ausblick
Vorgestellt wurde Escher und ein Vergleich ebendieser mit Curry, einer ähnlich
motivierten Sprache. Escher hat sich letztendlich nicht durchgesetzt, was wohl
8
durch mehrere Gründe bedingt ist: Zum einen wurde sie primär zu Lehrzwecken
entwickelt, was sich auch in der Spezifikation niederschlägt. Es werden dabei nur
Konzepte erläutert, aber keine konkreten Richtlinien definiert. Darüber hinaus
gibt es keine brauchbaren Interpreter für den kompletten Sprachumfang.
Andererseits setzte Escher einige bereits bekannte Techniken auf neue Art und
Weise um: Termersetzung, die z. B. vom Haskell-Compiler für diverse Optimierungen während des Übersetzens durchgeführt wird, kann Escher zur Laufzeit
durchführen. Dies führt natürlich zu einem höheren Maß an Komplexität, jedoch auch zu mehr Möglichkeiten; so kann ein formales Termersetzungssystem
ohne Konvertierung nach Escher übersetzt werden.
Ein anderer Diskussionspunkt ist das kombinierte funktional-logische Paradigma. Ersteres ist in der Praxis etabliert und wird von vielen populären Sprachen,
z. B. Haskell, ML, oder Lisp, auf unterschiedliche Weise implementiert. Logische
Sprachen sind hingegen, wie eingangs erwähnt, nur noch selten anzutreffen.
An dieser Stelle setzt Curry an, welche durchaus als Nachfolger“ von Escher
”
bezeichnet werden kann. Ein klarer Vorteil liegt darin, dass die Ähnlichkeit zu
Haskell recht groß ist und dadurch eine Vielzahl von Bibliotheken übernommen
werden kann. Allerdings führt auch Curry eine höhere Komplexität mit sich.
Während der Haskell-Compiler mögliche Programmierfehler wie mehrere Pattern, die die gleichen Fälle abdecken, anmahnt, erhält man bei Curry nicht selten
als Resultat No solutions“ oder gar mehrere Lösungen.
”
Abhilfe kann ein geschickter Einsatz von Nichtdeterminismen schaffen: Werden
nur Teilprobleme logisch formuliert, können diese determiniert werden4 und
durch geeignete Debugging- und Tracing-Tools inspiziert werden. Dennoch liegen bisher keine Erfahrungen bei größeren Projekten (bzw. deren Wartung) vor.
Eine vorsichtige Prognose kann daher lauten, dass die Größe des Programms
die Wahl des Paradigmas beeinflusst und eine Abwägung zwischen eleganten algorithmischen Lösungen (unterstützt durch logische Programmierung) und Robustheit durch Vermeidung von nichtdeterministischen Auswertungsstrategien
notwendig macht.
Quellenverzeichnis
[1] John W. Lloyd: Declarative Programming in Escher. Department of
Computer Science, University of Bristol. Juni 1995
http://www.cs.bris.ac.uk/Publications/Papers/1000073.pdf
[2] Kee Siong Ng: An Implementation of Escher. Computer Sciences
Laboratory, Australian National University. Februar 2006
http://rsise.anu.edu.au/~kee/Escher
[3] Kerstin I. Eder: EMA: Implementing the Rewriting Computational
Model of Escher. PhD thesis, Department of Computer Science,
University of Bristol. November 1998
http://www.cs.bris.ac.uk/Publications/Papers/1000307.pdf
4 vgl.
z. B. das Prolog-Prädikat findall
9
Herunterladen