Seminar Programmiersprachen und Programmiersysteme SS 2010 Seminarausarbeitung Thema: Funktionale Programmierung mit Constraints von Jan Rasmus Tikovsky Betreuer: Prof. Dr. Michael Hanus Literatur: Tom Schrijvers, Peter Stuckey and Phil Wadler: Monadic Constraint Programming 1 Inhaltsverzeichnis 1 Einleitung 3 2 Grundlagen 4 2.1 2.2 3 4 Monaden . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 4 2.1.1 Formale Denition . . . . . . . . . . . . . . . . . . . . . . . . . 4 2.1.2 Beispiel für die Anwendung von Monaden . . . . . . . . . . . . 5 Continuations . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 6 MCP Framework Monadische Constraint Programmierung 7 3.1 Modellierung des Constraint-Problems . . . . . . . . . . . . . . . . . . 7 3.1.1 Konjunktive Constraint-Modelle 7 3.1.2 Erweiterung zu Disjunktiven Constraint-Modellen . . . . . . . 9 3.1.3 Dynamische Modellierung . . . . . . . . . . . . . . . . . . . . . 10 3.2 Constraint Solver . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . 11 3.3 Suchstrategien und -Transformer . . . . . . . . . . . . . . . . . . . . . 14 . . . . . . . . . . . . . . . . . 3.3.1 Primitive Suchalgorithmen . . . . . . . . . . . . . . . . . . . . 14 3.3.2 Einfache Such-Transformer . . . . . . . . . . . . . . . . . . . . 15 3.3.3 Kombinierbare Such-Transformer . . . . . . . . . . . . . . . . . 18 Zusammenfassung und Ausblick 21 Literatur 22 Anhang 23 2 1 Einleitung Constraint Programming beschäftigt sich allgemein gesprochen mit der Lösung von Problemen durch die Beschreibung von Bedingungen oder Eigenschaften (Constraints), die die Lösung dieses Problems erfüllen muss. Constraints können also auch als Fakten einer partiellen Lösung des Problems betrachtet werden, die die Eigenschaften unbekannter Objekte und deren Beziehungen untereinander beschreiben. Diese Eigenschaften und Beziehungen werden durch Variablen modelliert, die ihrerseits einen endlichen oder unendlichen Wertebereich haben können. Bei endlichem Wertebereich spricht man auch von Finite Domain Constraint Problems. Constraint Programming besteht üblicherweise aus zwei Teilen: einer Modellierungskomponente und einer Komponente zur Lösung des modellierten Problems (Solver). Die Modellierung der Problemstellung und ihrer Constraints erfolgt auf deklarative Weise mit Hilfe von Regeln. Die Lösung des Problems durch die Solver-Komponente geschieht durch Speicherung, Kombination und Vereinfachung der Constraints durch eektive Algorithmen. Dabei werden im Laufe der Bearbeitung Variablenbelegungen ausprobiert und propagiert (Constraint Propagation), so dass der Wertebereich der Constraint-Variablen eingeschränkt wird. Diese Vorgehensweise wird iterativ angewendet, bis eine Lösung gefunden wird oder ein Widerspruch auftritt. In diesem Fall wird mit einer anderen Variablenbelegung neu begonnen (Backtracking). Anwendungsgebiete des Constraint Programming sind neben der Lösung von Rätseln wie Sudoku oder n-Damen-Problem auch vermehrt im kommerziellen Bereich zu nden, z. B. für die Erstellung von Fahr- und Stundenplänen sowie in der Produktionsplanung und Personaleinsatzplanung. Einerseits kommen im Constraint Programming logische Programmiersprachen wie PROLOG zum Einsatz, andererseits gibt es spezielle Softwaresysteme zur Modellierung und Lösung von Constraint-Problemen wie z.B. ECLiPSe oder Gecode (Generic Constraint Development Environment). Der dieser Ausarbeitung zugrundeliegende Artikel nutzt Abstraktionen und Mechanismen (Monaden, Funktionen höherer Ordnung, Continuations und lazy Evaluation) funktionaler Programmiersprachen, um ein Constraint Programming System in Haskell zu implementieren. Dazu wurde ein Framework entwickelt bestehend aus • einer in Haskell eingebetteten Modellierungssprache (EDSL) zur Beschreibung von Constraint-Problemen mittels einer Baumstruktur, • einer Solver-Einheit zur Auswertung des Modells mittels grundlegender Suchalgorithmen wie Breiten- und Tiefensuche und • einer Bibliothek aus kombinierbaren Such-Transformern, die die Implementierung komplexerer Suchalgorithmen ermöglichen. Beispielhaft wird die Funktionalität des Frameworks in dieser Ausarbeitung am n-Damen-Problem erläutert: Dabei soll ein n x n Felder groÿes Schachbrett so mit n Damen besetzt werden, dass keine Dame eine andere schlagen kann. Das bedeutet, dass in jeder Reihe, jeder Spalte sowie Diagonale darf jeweils nur eine Dame platziert werden darf. Eine mögliche Lösung des 8-Damen-Problems zeigt das Bild links. Abbildung che Lösung 1: eine des mögli- 8-Damen- Problems 3 2 Grundlagen 2.1 Monaden Programme geschrieben in einer rein funktionalen Sprache wie Haskell sind im Grunde genommen nur Mengen von Funktionen, deren Ergebnis nur von den jeweiligen Eingabeparametern abhängt. Ein Vorteil solcher Programme ist, dass die Reihenfolge der Auswertung keine Rolle spielt. Da es auch keine Seiteneekte gibt, lässt sich die Fehlerfreiheit solcher Programme leichter nachweisen und ermöglicht die sogenannte lazy Auswertung von Funktionen. Diese Seiteneektfreiheit kann aber auch zu einem Nachteil werden: Es ist nämlich erheblicher Aufwand nötig, um Daten vom Punkt ihrer Erzeugung zu ihrer letztendlichen Nutzung durchzureichen. Dadurch ist eventuell der ursprüngliche Sinn der Funktion nur noch schwer erkennbar. Dieser Nachteil kann durch den Einsatz von Monaden umgangen werden. Sie erlauben, erwünschte Seiteneekte (z.B. Zustandsänderungen, Fehlerbehandlung usw.) ohne den beschriebenen (Durchreich-)Aufwand zu integrieren bzw. die Auswertungsreihenfolge zu bestimmen. 2.1.1 Formale Denition Formal lassen sich Monaden als Tripel (m, return, (>>=)) denieren bestehend aus einem einstelligen Typkonstruktor m und den zwei Operationen: (1) return :: a −> m a Diese Funktion macht aus einem einfachen Wert eine monadische Berechnung, die nur diesen Wert zurückgibt und sonst nichts tut. (2) (>>=) :: m a −> (a −> m b) −> m b Dieser Operator - auch Bindeoperator genannt - dient dazu, die Auswertungsreihenfolge zu kontrollieren. Er führt zunächst eine monadische Berechnung aus und übergibt deren Ergebnis an eine weitere monadische Berechnung. Die Monaden-Typklasse in Haskell sieht wie folgt aus: c l a s s Monad m where (>>=) :: m a −> (a −> −> m b) m b return : : a −> m a fail : : String −> m a f a i l s = error s (>>) :: m a −> m b m >> k = m >>= \_ −> −> m b k Neben den bereits eingeführten Operationen gibt es noch eine Funktion zur Erzeugung von monadischen Fehlermeldungen fail sowie den Sequenzoperator (>>). Dieser ist durch den Bindeoperator deniert und verhält sich genauso bis auf die Tatsache, dass das Ergebnis der ersten monadischen Berechnung nicht weitergereicht wird. Für den Sequenzoperator gelten die folgenden Gesetze: return ( ) >> m = m return ( ) = m m >> m >> ( n >> o ) Das heiÿt, (>>) = (m >> n ) >> o return () wirkt als eine Art neutrales Element und der Sequenzoperator ist assoziativ. Beide Operationen bilden zusammen im mathematischen Sinne einen Monoid. Anstelle der Operatoren (>>) und (>>=) kann man auch Haskells do- Notation verwenden. Auf diese Weise sind Sequenzen besser lesbar. Die do-Notation wird eingeleitet durch das Schlüsselwort do und die Länge der Sequenz wird durch die O-Side-Rule bestimmt. Beispiel: main = getChar >>= \ c −> putChar 4 c Entsprechend in do-Notation: do getChar putChar c main = c <− 2.1.2 Beispiel für die Anwendung von Monaden Der Vorteil der Verwendung von Monaden soll am Beispiel eines einfachen Interpreters verdeutlicht werden. Er interpretiert Terme, bei denen es sich entweder um Integerkonstanten oder Quotienten einer ganzzahligen Division zweier Integerwerte handelt: data Term = Con Int | Div Term Term Deniert wird dieser simple Interpreter durch die Funktion: Term −> Int eval :: eval ( Con a ) = a eval ( Div t1 t2 ) = eval ` div ` t1 eval t2 Ist der Dividend Null, so wirft der GHCi erst bei der Auswertung des Quotienten eine divide by zero -Exception. Will man erreichen, dass der Interpreter schon frühzeitig solch einen Fehlerfall abfängt, muss in jedem rekursiven Aufruf der eval-Funktion eine Fehlersuche und -behandlung hinzufügt werden. Dazu muss sowohl die Datenstruktur als auch die Interpreterfunktion angepasst werden: data M a = R a i s e E x c e p t i o n type E x c e p t i o n = String eval :: eval ( Con a ) Term eval ( Div t1 Raise e Return a −> M Return Return a Int = Return t2 ) = a case e v a l −> R a i s e e −> case e v a l e −> R a i s e e b −> Raise | t2 t1 of of i f b == 0 then R a i s e " d i v i d e by z e r o " e l s e R e t u r n ( a ` div ` b ) Bei jedem Aufruf der eval-Funktion muss das Ergebnis geprüft werden: Wurde eine Exception geworfen, so wird diese durchgereicht, sonst wird mit dem erhaltenen Wert weitergerechnet. Durch den Einsatz von Monaden lässt sich dieser Aufwand jedoch reduzieren, indem man einen monadischen Term-Interpreter inklusive Fehlerbehandlung verwendet. Dazu deniert man die Monadenoperationen (a) sowie die Interpreterfunktion (b) folgendermaÿen: (a) return a = R e t u r n a = case m o f R a i s e m >>= k e Return raise :: raise e = Raise Exception −> a −> −> Raise k e a M a e Durch diese Denition ist implizit gewährleistet, dass bei der Verknüpfung von TermAuswertungen mit dem monadischen Bindeoperator eventuelle Exceptions durchgereicht werden. (b) eval eval eval Int return a ( Div t 1 t 2 ) = do :: Term −> ( Con a ) a <− e v a l M = t1 5 b <− e v a l t2 i f b == 0 then r a i s e " d i v i d e by z e r o " e l s e return ( a ` div ` b ) ) ) Wird ein Quotient (Div t1 t2) ausgewertet, so wird zunächst die monadische Be- rechnung (die Auswertung des Terms t1) ausgeführt und deren Ergebnis dann an die zweite monadische Berechnung (die Auswertung des Terms t2) weitergereicht. Dabei wird das Ergebnis von t2 auf Null überprüft und dementsprechend entweder eine Exception geworfen oder der Quotient berechnet. Mehr zu Monaden und weitere Beispiele ndet man in [3]. 2.2 Continuations Normalerweise sind Programme im sogenannten direkten Stil geschrieben. Das heiÿt, nach der erfolgreichen Ausführung einer Funktion wird der berechnete Wert über den Stack zurückgegeben und über die Rücksprungadresse zur aufrufenden Funktion verzweigt, um die Berechnung dort fortzusetzen. Mit Hilfe von Continuations - im sogenannten Continuation-Passing-Style (CPS) denierten Funktionen - kann der Kontrolluss eines Programms abweichend von diesem normalen Ablauf explizit gesteuert werden. Dazu erhält eine Funktion eine Nachfolgerfunktion als zusätzliches Argument. Nach Ausführung der aufgerufenen Funktion wird nun nicht über die Rücksprungadresse zurückverzweigt, sondern diese Nachfolgerfunktion aufgerufen und zwar mit dem berechneten Ergebnis der Ursprungsfunktion. Mit diesen Nachfolgerfunktionen, die auch als Fortführungen bzw. im Englischen als Continuations bezeichnet werden, kann man nun beliebig lange Funktionsketten bilden, die garantiert unmittelbar hintereinander ausgeführt werden. Bei ausschlieÿlicher Verwendung von Funktionen im CPS gilt: Weder Funk- tionsergebnisse noch Rücksprungadressen werden auf dem Stack abgelegt, da keine Funktion im traditionellen Sinne zur aufrufenden Funktion zurückspringt . Durch Speicherung der aktuellen Continuation (Parameter und Folgefunktion) wird quasi ein snapshot des aktuellen Programmzustands gespeichert. Mit Hilfe dieses Mechanismus kann das Programm zu einem späteren Zeitpunkt wieder an der Stelle und in exakt dem festgehaltenen Zustand fortgesetzt werden, an der der snapshot erzeugt wurde. Beispielprogramm einmal im direkten Stil (1) und einmal im Continuation-PassingStyle (2): (1) (2) Int −> Int | n == 0 = 1 | otherwise = n fac :: fac n fac ' cps :: fac ' cps n k ∗ Main> Int −> fac ( Int ( n − 1) −> r) −> r | n == 0 | otherwise = f a c ' c p s ( n − 1) ( \ facnm1 fac ' cps = k ∗ 5 1 ( ` div ` −> k (n 2) 60 Weiterführende Informationen zu Continuations sind in [4] zu nden. 6 ∗ facnm1 ) ) 3 MCP Framework Monadische Constraint Programmierung Das MCP Framework ist ein in der funktionalen Sprache Haskell geschriebenes, generisches Framework zur Modellierung und Lösung von Constraint-Problemen. Durch die Verwendung verschiedener Abstraktionen erlaubt es dem Benutzer, ConstraintProbleme in einer baumartigen Struktur zu modellieren und eigene Constraint Solver sowie Suchalgorithmen für diese Probleme zu implementieren. 3.1 Modellierung des Constraint-Problems Zum Lösen eines Constraint-Problems muss man zunächst eine geeignete Darstellung nden, mit der das Problem modelliert werden kann. Üblicherweise verwendet man dazu eine sogenannte Modellierungssprache. Das MCP Framework ist implementiert als eine Embedded Domain Specic Language (EDSL). Es bettet eine Sprache zur Modellierung von Constraint-Problemen direkt in die Programmiersprache Haskell ein. 3.1.1 Konjunktive Constraint-Modelle Ein Modell für ein Constraint-Problem wird im MCP Framework mit Hilfe einer baumartigen Datenstruktur repräsentiert, die wie folgt deniert ist: . data T r e e solver = Return | NewVar | Add a a ( Term solver ( Constraint −> Tree solver ) solver ( Tree a) solver a) Der generische Datentyp Tree erhält zwei Typparameter: Durch die Typvariable solver wird das Modell an einen speziellen Constraint Solver und damit auch an einen konkreten Constraint-Wertebereich gebunden. Die Typvariable a ist ein im Baummodell gespeicherter Typ, durch den die Tree-Datenstruktur zu einer Monade wird. Durch diese beiden Typparameter ist die Typsicherheit des Modells für beliebige Solver- und Ergebnistypen gewährleistet. Diese Datenstruktur bietet zunächst nur die folgenden drei Konstruktoren: • Return ist der Basiskonstruktor. Er stellt ein triviales, erfüllbares Modell dar und weist daher auf das Ende einer Modellierung hin. • Der Konstruktor NewVar, der eine Funktion f als Parameter erhält, repräsentiert ein um eine neue Constraint-Variable erweitertes Modell. Das Modell wird durch die Funktion • Mit dem f erzeugt. Add-Konstruktor c erweitert. wird ein bestehendes Modell t um ein zusätzliches Constraint Wie bereits erwähnt, soll Tree solver zu einer Instanz von Haskells Monad-Typklasse return und (>>=) werden dann wie folgt imple- gemacht werden. Deren Funktionen mentiert: instance Monad ( T r e e return = R e t u r n (>>=) solver ) where = extendTree ( Return x) ` extendTree ` k = k ( NewVar f) ` extendTree ` k = NewVar ` extendTree ` k = Add c ( Add c t) x 7 (\ v (t −> f v ` extendTree ` ` extendTree ` k) k) Durch den Aufruf von return wird ein triviales Modell erzeugt, das bei Auswertung durch den Solver (siehe 3.2) den übergebenen Parameter zurückgibt. Der monadische Bindeoperator (>>=) wird durch eine Funktion extendtree imple- mentiert, die ein bestehendes Baummodell erweitert. Diese Erweiterung wird durch das bestehende Modell bis zu einem Return-Knoten durchgereicht, der dann durch die Erweiterung ersetzt wird. Die Verwendung von monadischen Modellen hat den Vorteil, dass bei der Berechnung eines Werts als Seiteneekt ein Modell erzeugt wird. Auÿerdem kann auf die neu denierten Operatoren (>>) und (>>=) zurückgegrien und Haskells do-Notation verwendet werden (siehe unten). Jetzt kann man das n-Damen-Problem mit der kennengelernten Baumstruktur modellieren: nqueens n = e x i s t n $ \ queens −> model queens n model queens n = queens ` a l l i n ` ( 1 , n ) /\ a l l d i f f e r e n t queens /\ d i a g o n a l s queens a l l i n queens r a n g e = c o n j [ q ` in_domain ` r a n g e | q <− queens ] a l l d i f f e r e n t queens = c o n j [ q i @/= q j | q i : q j s <− queens , q j <− q j s ] d i a g o n a l s queens = c o n j [ q i @/= ( q j @+ d ) /\ q j @/= ( q i @+ d ) | q i : q j s <− queens , ( qj , d ) <− qjs [ 1 . . ] ] conj = (/\) true tails tails zip foldl Aus Platzgründen wird hier nur eine kurze textuelle Beschreibung der Hilfsfunktionen angegeben. Ihre genaue Denition kann man im Anhang A.2 nachlesen. exist n erzeugt eine Liste von n neuen Constraint-Variablen mit Hilfe NewVar-Konstruktors, um die n Damen auf dem Schachbrett zu modellieren. Dann ruft sie die Funktion model auf, die die nötigen Beschränkungen für die Variablen in der Liste generiert. Dies geschieht durch Erzeugung eines Add-Knotens für jedes Die Funktion des Constraint. model queens ein allin Beispielsweise fügt die von aufgerufene Funktion Variable aus der Liste Constraint hinzu, das diese auf den Wertebereich für jede Constraint- alldifferent und diagonals erzeugen zusätzqueens Constraints, die diese derart beschränken, {1,...,n} beschränkt. Die Funktionen lich für alle Variablen aus der Liste dass je zwei durch die Variablen modellierten Damen nicht in der gleichen Reihe und nicht auf der gleichen Diagonalen stehen. Dazu wird der Operator (@/=) verwendet, mit dem man Ungleichheit zwischen zwei Variablen ausdrücken kann. Die Beschränkung, dass in jeder Spalte des Schachbretts auch nur genau eine Dame steht, wird durch die Constraint-Variablen selbst modelliert. Somit repräsentiert jede Variable auch jeweils eine Spalte des Bretts. Der Operator (/\) verknüpft die einzelnen Constraints zu einer Konjunktion von Constraints. Er ist nur syntaktischer Zucker für den monadischen Sequenzoperator und damit für die Funktion extendTree. Er sorgt also dafür, dass die einzelnen geneReturn (), (Newvar f) und (Add c t)- zu rierten Knoten des Modell-Datentyps - einem (einzigen) Modell zusammengesetzt werden. Man spricht daher auch von einem konjunktiven Constraint-Modell. Für das 2-Damen-Problem erzeugt die obige Funktion dann beispielsweise das folgende Modell (Unter Verwendung der Constraint- und Term-Typen aus dem Anhang A.1): NewVar $ \ q1 NewVar $ Add −> −> \ q2 (Dom q1 Add 1 (Dom q2 Add ( Diff Add 2) 1 q1 ( Diff Add $ 2) q1 ( Diff Return $ q2 ) $ ( Plus q2 q2 ( Plus ( Const q1 () 8 1))) ( Const $ 1))) $ 3.1.2 Erweiterung zu Disjunktiven Constraint-Modellen Bislang kann man mit der in Haskell eingebetteten Sprache zur Modellierung von Constraint-Problemen nur Konjunktionen von Constraints beschreiben. Aber oftmals sind solche Konjunktionen nicht ausreichend, um über ihre Erfüllbarkeit zu entscheiden. Um das Problem der Unvollständigkeit zu beheben, müssen solange weitere Constraints hinzugefügt werden, bis eine Lösung gefunden oder Inkonsistenz erreicht wird. Da nicht bekannt ist, welche Constraints hinzugefügt werden müssen, muss man verschiedene Alternativen ausprobieren. Dazu erweitert man die oben vorgestellte Modell-Datenstruktur um zwei weitere Konstruktoren: data T r e e = ... | Try | Fail Mit dem solver ( Tree a solver Try-Konstruktor a) ( Tree solver a) kann man Verzweigungen in einem Modell erzeugen. Da- mit wird auch verständlich, warum das Modell stets als baumartige (Daten-)Struktur bezeichnet wurde. Durch Einführung des Try-Konstruktors werden Modelle zu Bi- närbäumen. Verwendet man solche Verzweigungen, so kann man mit dem linken und rechten Teilbaum zwei disjunkte Belegungen der Constraint-Variablen formulieren. Man kann unter Verwendung der Try-Knoten also verschiedene Variablenbelegungen und damit mögliche Lösungen ausprobieren. Modelle, die diesen Konstruktor nutzen, werden auch als disjunktive Modelle bezeichnet. Der zweite neue Konstruktor Fail kommt in Zweigen des Modells vor, die zu keiner Lösung führen. Auch für die neuen Konstruktoren ist die Funktion zur Erweiterung des Modells deniert: Fail ( Try l r) ` extendTree ` k = Fail ` extendTree ` k = Try ( l ` extendTree ` k) (r ` extendTree ` k) Fail-Knoten eine Sackgasse im Modell darstellt, wird er nicht erweitert. Try-Knoten erweitert, wird diese Erweiterung auf seine beiden Zweige Da ein Falls man einen angewandt. Mit den neuen Konstruktoren ist es jetzt möglich, die verschiedenen Belegungen der Variablen im Modell des n-Damen-Problems als disjunkte Alternativen aufzuzählen. nqueens n = exist n $ \ queens −> model queens enumerate enumerate enum var queens values = conj values = disj = disj foldl (\/) /\ [ enum | q u e e n <− q u e e n s ] [ v a r @= v a l u e queen n queens [1..n] values v a l u e <− v a l u e s ] | false Zunächst werden durch den Aufruf der Funktion model wie im obigen Beispiel n neue Constraint-Variablen und ein konjunktives Modell erzeugt. Die Variablen werden dabei den Bedingungen des n-Damen-Problems entsprechend beschränkt. Die enumerate-Funktion sorgt dann mit ihrer Hilfsfunktion enum dafür, dass für jede Variable alle möglichen Belegungen aufgezählt werden. Dazu wird mit der enum-Funktion zunächst für jede Variable eine Liste von n Add- Knoten erzeugt, wobei jeder dieser Knoten ein anderes Gleichheits-Constraint hinzufügt. Jedes dieser Gleichheits-Constraints steht wiederum für jeweils eine mögliche Belegung dieser Variablen mit einem Wert aus der Menge {1,...,n}. Aus dieser Liste von Add-Knoten (\/) Try-Konstruktor ist - für jede Va- wird dann durch Aualtung mit dem Disjunktionsoperator der nichts weiter als syntaktischer Zucker für den riable ein einzelner Binärbaum mit Verzweigungen für alle alternativen Belegungen erzeugt. 9 Die enumerate-Funktion faltet dann mit dem Konjunktionsoperator (/\) die Liste dieser Binärbäume zu einem einzigen binären Modell auf. Das heiÿt, die Binärbäume werden jetzt so zusammengesetzt, dass in dem Ergebnismodell jede Kombination von möglichen Belegungen der Constraint-Variablen als ein Pfad vorkommt. Zur Verdeutlichung wird hier der erhaltene Binärbaum mit den Aufzählungen aller Variablenbelegungen für das 2-Damen-Problem angegeben: Try ( Add ( Same ( Try q1 ( Const ( Add 1)) ( Same q2 Return ( Try ( Add ( Const 1)) ()) ( Same q2 Return ( Const 2)) ()) Fail ))) ( Try ( Add ( Same ( Try q1 ( Add ( Const 2)) ( Same q2 Return ( Try ( Add ( Const 1)) ()) ( Same q2 Return ( Const 2)) ()) Fail ))) Fail ) 3.1.3 Dynamische Modellierung Das Aufzählen aller möglichen Variablenbelegungen in einem disjunktiven Modell ist sehr aufwendig: Ein solches Modell des n-Damen-Problems hat auch die Anzahl der Try-Verzweigungen nn Blattknoten und wächst mit der Problemgröÿe. Auÿerdem sind viele Zweige des Modells einfach überüssig, weil die Variablenbelegungen dort sofort zu Inkonsistenzen führen. Auch wenn durch Haskells Prinzip der lazy Auswertung nicht der gesamte Binärbaum erzeugt wird, wäre es ezienter den Baum unter Einbeziehung von Informationen des Constraint Solvers dynamisch zu konstruieren. Dazu nimmt man an, dass der hier verwendete Solver für nite domain-ConstraintProbleme zusätzlich zu den Funktionen der Solver-Typklasse (siehe Abschnitt 3.2) die beiden folgenden Funktionen implementiert: domain :: FDTerm value :: FDTerm Mit der Funktion −> −> domain FD FD [ Int ] Int kann man den Wertebereich einer Constraint-Variable be- stimmen und zwar in Abhängigkeit des aktuellen Zustands des Solvers. Das bedeutet, dass es sich nicht mehr um den statischen Wertebereich {1,...,n} für das n-DamenProblem handeln muss, sondern dass dieser Wertebereich durch Propagierung von hinzugefügten Constraints gegebenenfalls weiter eingeschränkt wurde. Die Funktion value gibt den Wert einer Constraint-Variable zurück, der ihr im ak- tuellen Zustand des Solvers zugewiesen wurde. Damit man anstelle der statischen Wertebereiche auf die dynamischen, durch die Constraint-Propagierung möglicherweise kleineren Wertebereiche der Variablen zugreifen kann, muss man einen weiteren Konstruktor zur Modell-Datenstruktur hinzufügen: data T r e e = | solver a ... Dynamic Dieser ( solver ( Tree Dynamic-Konstruktor solver a )) ermöglicht es, die Aufzählungen aller möglichen Varia- blenbelegungen in einem disjunktiven Modell des n-Damen-Problems dynamisch zu erzeugen und zwar abhängig vom Solver-Zustand: 10 nqueens n = exist n $ \ queens −> model queens enumerate enumerate label label Die = Dynamic . n /\ queens label return ( ) ( v : v s ) = do d <− domain v return $ enum v d /\ e n u m e r a t e v s [] = enumerate-Funktion entspricht jetzt einem Aufruf des Dynamic-Konstruktors, label erhält. Die- der als Parameter das Ergebnis einer neuen Hilfsfunktion namens se bestimmt für jede Variable aus der Liste der Constraint-Variablen zunächst deren dynamischen Wertebereich und gibt diesen dann an die Funktion Wie im obigen Beispiel erzeugt die enum-Funktion enum weiter. zu jeder Variablen einen Binär- baum mit Verzweigungen für alle möglichen Belegungen. Diese hängen jetzt allerdings nicht mehr vom statischen sondern vom dynamischen Wertebereich der Variablen ab. Da der Dynamic-Konstruktor kein Modell als Parameter erwartet, sondern eine Solver-Berechnung, die ein Modell einkapselt, ruft return-Funktion label noch die monadische des Solvers auf. Bislang wurden die Constraint-Variablen zur Modellierung des n-Damen-Problems immer in ihrer natürlichen Reihenfolge mit Werten belegt. Die Wahl der Reihenfolge der Variablenbelegung hat keinen Einuss auf die Form der Lösungen, jedoch auf die Gröÿe des Modells. Daher ist es durchaus sinnvoll, die label-Funktion derart anzupassen, dass die Strategien zur Auswahl der Variablenreihenfolge aber auch zur Wahl der Reihenfolge ihrer Belegung mit Werten dynamisch gewechselt werden kann. Beispielsweise erhält man ein deutlich kleineres Modell, wenn man die Variable mit dem am meisten eingeschränkten Wertebereich zuerst auswählt. Das heiÿt, man ordnet die Variablen aufsteigend nach der Gröÿe ihres (dynamischen) Wertebereichs an. Durch die Bevorzugung der am meisten eingeschränkten Variable verhindert man das frühzeitige Auftreten von Inkonsistenzen. Man nennt diese Strategie auch Heuristik der maximal eingeschränkten Variablen bzw. im Englischen rst-fail-Prinzip. Speziell für das n-Damen-Problem ist es zudem günstig bei der Belegung der Variablen zunächst Positionen aus der Mitte des Schachbretts auszuwählen und sich dann langsam zu den Rändern des Bretts vorzuarbeiten. Dies führt zwar nicht zu einer weiteren Verkleinerung des erzeugten Modells, kann aber Lösungen in den linken Teil des Modells verschieben. Falls man das Modell von links nach rechts auswertet, kann dies zu einem schnelleren Finden der Lösungen beitragen. Diese Strategie zur Auswahl der Reihenfolge der Werte für die Variablenbelegungen bezeichnet man im Englischen auch als middleout value ordering. Die Implementierung dieser Strategien für die Auswahl der Variablen bzw. der Werte aus dem Wertebereich ndet man im Anhang A.3. 3.2 Constraint Solver Bislang wurde nur gezeigt, wie mit Hilfe der MCP-Modellierungssprache ConstraintProbleme durch eine monadische baumartige Datenstruktur repräsentiert werden können. Der zweite zentrale Bestandteil eines Constraint-Frameworks, nämlich die Einheit zum Lösen eines Constraint-Problems (Constraint Solver bzw. Constraint-Löser) soll in diesem Abschnitt vorgestellt werden. Allgemein ist es das Ziel eines Constraint Solvers, den Wertebereich der ConstraintVariablen zu verkleinern oder die Anzahl der betrachteten Constraints zu verringern. Gleichzeitig muss er weiterhin in einem konsistenten Zustand bleiben. Dazu wird das Prinzip der Constraint-Propagierung angewandt. Dabei werden die Wertebereiche der Constraint-Variablen iterativ durch eine lokale Betrachtung der gültigen Constraints einschränkt, bis eine eindeutige Belegung aller Variablen gefunden wurde. Der Zustand eines Constraint Solvers kann entweder konsistent, inkonsistent oder unbekannt sein. Er ergibt sich aus den im Constraint-Speicher vorhandenen Constraints 11 und deren Interpretation durch den Solver. Solange der Zustand nicht inkonsistent ist, kann er durch Hinzufügen neuer Constraints erweitert werden. Ein Constraint Solver im MCP Framework ist im Grunde genommen nur ein Interpreter für die bereits kennengelernten baumförmigen Modelle. Ein generisches Interface für einen solchen Solver ist durch die folgende Typklasse gegeben: c l a s s Monad s o l v e r => S o l v e r s o l v e r where type C o n s t r a i n t s o l v e r : : ∗ type Term s o l v e r : : ∗ type L a b e l s o l v e r : : ∗ newvar :: solver add :: Constraint ( Term mark :: solver goto :: Label run :: solver solver ) solver ( Label solver −> a −> Bool solver solver ) −> solver () a In dieser Denition der Typklasse wird verlangt, dass auch ein Constraint Solver eine Monade ist. Denn man setzt im Allgemeinen voraus, dass der Solver eine zustandsorientierte Berechnung einkapselt, um seinen internen Zustand, den Constraint-Speicher, vor dem Benutzer zu verbergen. Des Weiteren muss ein Constraint-Typ angegeben werden, der vorgibt, welche Arten von Constraints - z.B. Gleichheits-Constraint oder (Werte-)Bereichs-Constraint - verwendet werden (Beispiel siehe Anhang A.1). Der Term-Typ bestimmt, welche Form die Terme haben, über die sich die Constraints erstrecken. Beispiele für Terme sind Variablen, Konstanten, Summenterme etc. Schlieÿlich benötigt man auch noch einen Label-Typ, der den internen Zustand eines Solvers repräsentiert. Ein Label ist entweder einfach eine Kopie des Zustands (copying) oder es ist ein Trace aller Operationen, die zum Erreichen dieses Zustands geführt haben (trailing). Auÿerdem verlangt das Interface, dass folgende Funktionen implementiert werden: • Die Funktion newvar ist das Solver-Gegenstück zum NewVar-Konstruktor des Modells. Sie erzeugt als Berechnung des Solvers eine neue Constraint-Variable. • In der gleichen Weise ist die add-Funktion das Solver-Pendant zum Add-Konstruk- tor. Sie fügt ein neues Constraint zum Constraint-Speicher des Solvers hinzu und gibt durch ihren booleschen Rückgabewert an, ob dessen Zustand danach noch konsistent (True) ist oder nicht (False). • Die Funktion mark liefert das Label (Zustandsmarke) des aktuellen Zustands eines Constraint Solvers. • Mit der goto-Funktion kann man den zum übergebenen Label zugehörigen Zu- stand des Solvers wiederherstellen. Diese und die mark-Funktion benötigt man für das Backtracking in disjunkten Modellen. • Die Funktion run führt eine Aktion in der Monade aus und entnimmt das Er- gebnis aus der Berechnung des Solvers. Um den vorgestellten Solver auf ein konkretes Modell eines Constraint-Problems anzusetzen und eine Lösung zu berechnen, verwendet man die folgenden Funktionen: solve :: Solver s o l v e = run eval :: eval model . Solver eval ' ( Return eval ' ( Add c => T r e e solver solver a −> [a] eval solver => T r e e = eval ' x) t) solver model a −> solver [] do x s <− c o n t i n u e w l return ( x : x s ) w l = do b <− add c i f b then e v a l ' t wl e l s e c o n t i n u e wl wl = 12 [a] f) wl = do v <− newvar wl = do now <− mark eval ' ( NewVar eval ' ( Try eval ' Fail wl = c o n t i n u e eval ' ( Dynamic m) wl = eval ' l r) (f eval ' l v) wl ( ( now , r ) : w l ) wl do t <− m eval ' t wl Wie bereits erwähnt wurde, ist der Solver einfach nur ein Interpreter für Baummodelle, der abhängig vom aktuell interpretierten Knoten spezielle Solver-Aktionen durchführt. Verwendet wird dafür die Hilfsfunktion eval', die in jedem Schritt eine Arbeitsliste von Tupeln mitführt. Jedes dieser Tupel speichert eine Zustandsmarke (label) des Solvers zusammen mit dem in diesem Zustand noch zu interpretierenden Zweig des Baummodells. Anfangs ist diese Liste - im pattern matching durch die Variable wl gematcht - leer. Mit Hilfe der Funktion continue (siehe unten) wird die Interpretation des Modells in einem anderen Zweig als dem aktuellen - gespeichert in dieser Liste - fortgesetzt. Wird beispielsweise ein Return-Knoten ausgewertet, so bedeutet dies, dass das Ende des gerade interpretierten Zweigs im Baummodell erreicht wurde. Die Auswertung wird dann per continue im nächsten Zweig gemäÿ der Liste fortgesetzt und das Ergebnis des aktuellen Zweigs in die spätere Ergebnisliste eingetragen. Interpretiert der Solver dagegen einen per add-Funktion Add-Knoten, so trägt er das neue Constraint in seinen Constraint-Speicher ein. Abhängig von dem sich dadurch ergebenden neuen Zustand entscheidet er, wo die Auswertung fortgesetzt wird: bei Konsistenz im aktuellen Zweig, bei Inkonsistenz in einem anderen Zweig. NewVar-Knoten auswertet, erzeugt er mit seiner newvar-Funktion zunächst eine neue Constraint-Variable, die der Funktion f des NewVar-Knotens übergeben wird. Dann wird die Interpretation auf dem durch die Ausführung von f erzeugten Baummodell fortgesetzt. Falls der Constraint Solver einen Bei der Interpretation eines Try-Knotens ermittelt der Solver zunächst das Label sei- nes aktuellen Zustands und trägt dieses zusammen mit dem rechten Zweig (r) dieses Knotens als Tupel am Anfang der Arbeitsliste ein. Auf diese Weise kann er später seinen Zustand zurücksetzen und dann auch noch den rechten Zweig interpretieren (Backtracking). Zunächst setzt er aber die Auswertung im linken Zweig (l) der Verzweigung fort. Erreicht der Solver bei der Auswertung einen Fail-Knoten, so bedeutet dies, dass der continue wird aktuelle Zweig des Modells zu keiner Lösung führt. Durch Aufruf von in diesem Fall in einem anderen Zweig weiter nach Lösungen gesucht. Für die Interpretation eines Dynamic-Knotens wird das in der Solver-Berechnung ein- gebettete Baummodell entnommen und weiter interpretiert. continue [] continue ( ( p a s t , t ) : wl ) = = return [ ] do g o t o p a s t eval ' Wird die continue-Funktion t wl über einer leeren Arbeitsliste ausgeführt, so ist die In- terpretation abgeschlossen und die eingekapselte Ergebnisliste wird zurückgegeben. Das Ergebnis wird durch den Aufruf der Ist diese Liste nicht leer, so wird per run-Funktion goto-Funktion entnommen. der dort im ersten Tupel ge- speicherte frühere Zustand des Solvers wiederhergestellt und der im gleichen Tupel gespeicherte Zweig als nächstes interpretiert. 13 3.3 Suchstrategien und -Transformer Es wurde bereits vorgestellt, wie man Constraint-Probleme mit Hilfe des MCP Frameworks modelliert und das erzeugte Modell dann mit Hilfe des vorgestellten Constraint Solvers interpretiert und löst. Dabei wurde allerdings nicht darauf eingegangen, welche Suchstrategien der Solver bei der Auswertung des Modells verwenden kann. Das folgende Kapitel behandelt die Möglichkeiten, die das MCP Framework bietet, um durch Such-Algorithmen und -Transformationen die Auswertungsreihenfolge zu beeinussen und zu steuern. 3.3.1 Primitive Suchalgorithmen Die primitiven Suchalgorithmen bestimmen im Kontext des MCP Frameworks, in welcher Reihenfolge die Zweige und Knoten eines Baummodells durch den Constraint Solver interpretiert werden. Die Realisierung der Interpreterfunktion in Abschnitt 3.2 nutzt eine Arbeitsliste für die noch auszuwertenden Zweige, die dem LIFO-Prinzip genügt und somit einem Stack entspricht. Es wird also ein Zweig des Modells so lange ausgewertet, bis eine Inkonsistenz auftritt (Fail) bzw. der Zweig zu Ende ist (Return ()). Dann wird der nächste Zweig nach dem gleichen Prinzip ausgewertet. Dies entspricht einer Tiefensuche. Diese Suchstrategie muss aber nicht grundsätzlich die beste Wahl für die Interpreterfunktion des Constraint Solvers sein. Um auch andere Suchalgorithmen wie die Breitensuche oder eine heuristisch über Prioritäten gesteuerte Suche (im Englischen als best-rst search bezeichnet) zu realisieren, nutzt das MCP Framework das folgende Interface eines generischen Queue-Datentyps als Ersatz für die oben erwähnte Arbeitsliste. c l a s s Queue q where type Elem q : : ∗ emptyQ :: isEmptyQ :: popQ :: pushQ :: Elem q −> q −> Bool q −> ( Elem q , q ) Elem q −> q −> q q q ist dabei der Typ der in der Queue gespeicherten Elemente. Ansonsten stellt das Interface noch Funktionen zur Erzeugung einer leeren Queue, zum Prüfen auf Leerheit, zum Entfernen eines Elements aus der Queue sowie zum Hinzufügen eines Elements zur Queue zur Implementierung bereit. Damit die Auswertungsfunktion des Constraint Solvers auf das Queue-Interface zurückgreifen kann, ändert man ihre Typsignatur wie folgt: eval ' :: ( Solver Elem => T r e e solver , q ~ Queue q , ( Label solver a −> s o l v e r , Tree q −> solver solver a )) [a] Der Typ der in der Queue gespeicherten Elemente entspricht also einem Tupel bestehend aus einer Zustandsmarke des Constraint Solvers und dem in diesem Zustand noch nicht ausgewerteten Zweig des Modells. An den Implementierungen der eval'- und continue-Funktion ändert sich kaum etwas: ... eval ' ( Try l r) wl = do now <− mark continue $ pushQ ( now , l ) $ pushQ ( now , r ) wl ... continue wl | isEmptyQ | otherwise wl = = return [ ] l e t ( ( p a s t , t ) , wl ' ) = popQ wl in do g o t o p a s t eval ' 14 t wl ' Zum Eintragen bzw. Entfernen eines Elements in die bzw. aus der Queue muss jetzt pushQ- bzw. popQ-Funktion zurückgegrien werden. Bei der InterpreTry-Knotens werden jetzt beide Zweige, erst der rechte, dann der linke, Queue eingetragen und anschlieÿend die continue-Funktion aufgerufen. Auf nur auf deren tation eines in die diese Weise kann man unterschiedliche Implementierungen der Queue-Typklasse nun auch andere Suchalgorithmen als die Tiefensuche realisieren. Der Vorteil dieser Implementierung ist, dass man neue primitive Suchalgorithmen jetzt einfach denieren kann, indem man eine neue Instanz der Queue-Typklasse deniert. Der Code für die Interpreterfunktion des Constraint Solvers muss nicht umgeschrieben werden, da er nur auf die dann neu denierten Funktionen des Queue-Interfaces zugreift. Um die Tiefen-, die Breiten- und die best-rst Suche zu realisieren, implementiert man die Queue-Typklasse jetzt einfach als Stack, FIFO-Queue bzw. Priority Queue. 3.3.2 Einfache Such-Transformer Aufbauend auf den kennengelernten primitiven Algorithmen wie der Tiefensuche können weitere komplexere Suchalgorithmen implementiert werden. Dazu führt das MCP Framework sogenannte Such-Transformer ein, die Transformationen des zugrundeliegenden Baummodells darstellen. Beispiele für solche Transformationen sind Kürzungen des Baumes wie das Abschneiden ab einer bestimmten Knotentiefe (tiefenbeschränkte Suche) oder das zufallsgesteuerte Vertauschen von Zweigen im Modell. Vorstellung des Transformer-Interface Das Verhalten eines solcher Transformers wird durch die folgende Typklasse beschrieben, die er implementieren muss: c l a s s T r a n s f o r m e r t where type E v a l S t a t e t : : ∗ type T r e e S t a t e t : : ∗ leftT , rightT leftT _ :: t −> TreeState t −> TreeState t id = rightT _ = l e f t T nextT :: SearchSig solver q t a nextT = e v a l ' initT :: t −> Die beiden Typen ( EvalState t , TreeState t) EvalState (Auswertungsstatus) und TreeState (Baumstatus) be- stimmen den internen Zustand eines Such-Transformers anhand dessen er entscheidet, wie die Suche weiter verlaufen soll. Während der EvalState von einem durch die Interpreterfunktion auszuwertenden Knoten zum nächsten weitergereicht wird, wird der TreeState nur bei einem Übergang von einem Vater- zu einem Kindknoten im EvalState wird bei jedem Schritt TreeState wird jeweils nur in einem Zweig Modell durchgereicht. Anders formuliert: Der der Auswertung weitergegeben und der des Modells durchgereicht. Der Sinn und Zweck dieser beiden Zustandstypen wird in den konkreten Beispielen weiter unten deutlich. Mit den Funktionen Übergang von einem leftT und rightT kann man denieren, wie der TreeState beim Try-Knoten auf dessen linken bzw. rechten Kindknoten weiterge- geben werden soll, also die Vererbung des Baumstatus vom Vater- an die Kindknoten. In der Default-Implementierung verhält sich rightT wie leftT leftTTreeState und die Funktion kopiert durch die Verwendung der Identitätsfunktion einfach den des Vaterknotens. Wie bereits erwähnt, werden Such-Transformer auf primitive Suchstrategien aufgesetzt. Sie entscheiden vor der Auswertung eines jeden Knotens, wie bzw. an welcher Stelle im Modell die Suche fortgeführt werden soll, und reichen dann den entsprechenden Knoten an den zugrunde liegenden Suchalgorithmus weiter. Konkret wird dafür die nextT-Funktion verwendet, die eval' aufruft. Die beiden funktion per Default einfach die angepasste AuswertungsFunktionen haben die folgende Typsignatur: 15 type S e a r c h S i g ( Solver Elem q ~ => T r e e −> solver solver , ( Label t a = Transformer s o l v e r , Tree solver −> a solver q Queue q , q −> t solver −> t , a , TreeState EvalState t −> t )) TreeState t [a] Die Elemente der Queue sind jetzt Tripel. Sie enthalten eine Zustandsmarke des Solvers sowie den in diesem Zustand noch nicht ausgewerteten Zweig des Modells und den TreeState. Des Weiteren erhalten die Funktionen nextT und eval' den verwendeten Transformer sowie dessen Zustand gegeben durch EvalState und TreeState als zusätzliche Parameter (im Vergleich zur eval'-Funktion aus Abzum Zustand gehörigen schnitt 3.3.1). Die Implementierung der eval'-Funktion und der continue-Funktion passt man fol- gendermaÿen an: eval ' ( Try l r) wl t es ts = do now <− mark l e t wl ' = pushQ ( now , l , l e f t T pushQ continue continue wl t wl ' t ( now , r , r i g h t T t ts ) t $ ts ) wl es es | isEmptyQ | otherwise wl = = return [ ] l e t ( ( p a s t , t r e e , t s ) , wl ' ) = popQ wl in do g o t o p a s t nextT Bei der Interpretation eines Try-Knotens tree wl ' t es ts TreeState leftT bzw. rightT wird der TreeState wird jetzt zusätzlich der neue für den linken bzw. rechten Kindknoten mit Hilfe der Funktionen bestimmt und in die Arbeitsliste (Queue) eingetragen. Dadurch wie oben beschrieben stets vom Vater- an die Kindknoten durchgereicht EvalState in analoger Weise von einem auszuwertenden Knoten zum nächscontinue-Funktion neben dem verwendeten Transformer auch dessen EvalState als zusätzlichen Parameter. Anstelle der eval'-Funktion ruft sie nun die nextT-Funktion auf. So entscheidet der Transformer, ob und wenn ja Um den ten weiterzureichen, erhält die an welcher Stelle die Auswertung des Modells fortgesetzt wird. Das Verhalten eines Transformers hängt dabei von der konkreten Denition seiner Funktionen ab (siehe Beispiele). Die Funktion initT der Transformer-Typklasse dient, wie der Name vermuten lässt, dazu den Zustand eines Transformers zu initialisieren, bevor mit der Interpretation eines Modells begonnen wird. Implementierungsbeispiele für Such-Transformer Nun kann man das vorgestellte Interface nutzen, um konkrete Transformationen zu implementieren. Beispielsweise kann man einen Transformer denieren, der dafür sorgt, dass nur Knoten bis zu einer bestimmten Tiefe im Baummodell ausgewertet werden und alle darunterliegenden Knoten quasi abgeschnitten werden. Ein solcher Transformer mit Tiefenbeschränkung lässt sich folgendermaÿen implementieren: newtype DepthBoundedST = DBST Int instance T r a n s f o r m e r DepthBoundedST where type E v a l S t a t e DepthBoundedST = Bool type T r e e S t a t e DepthBoundedST = Int i n i t T (DBST n ) = ( False , n ) leftT _ ts nextT tree = ts q t es − 1 ts | t s == 0 | otherwise = e v a l ' q t True tree q t = continue es ts Die Tiefe n, ab der das Modell abgeschnitten werden soll, wird im Wert eingebettet. Der EvalState DepthBoundedST- gibt durch einen booleschen Wert an, ob das aus- zuwertende Modell bereits abgeschnitten wurde (True) oder nicht (False). Mit dem 16 TreeState wird gezählt, bis zu welcher Tiefe - relativ zur Tiefe des aktuellen Kno- tens - der Baum in diesem Zweig noch ausgewertet werden darf. Initialisiert werden beide Zustände daher mit False bzw. dem Wert für die Tiefenbeschränkung n. Die leftT-Funktion (und damit gemäÿ Interface-Denition auch die der rightT-Funktion) sorgt dafür, dass jedes Mal wenn der linke bzw. rechte Zweig eines Try-Knotens zur weiteren Auswertung ausgewählt wird, der TreeState und damit der Wert für die relative Tiefe dekrementiert wird. Die nextT-Funktion beschreibt Denition der schlieÿlich das eigentliche Verhalten dieses Transformers: Wurde die maximal zulässige Tiefe im aktuellen Zweig erreicht, so wird der EvalState auf True gesetzt. Die Auswertung wird dann in einem anderen Zweig des Modells fortgesetzt, der durch den zugrunde liegenden Suchalgorithmus bestimmt wird. Ansonsten wird die Interpretation des Modells an der aktuellen Position fortgeführt und der gegenwärtige Zustand des Transformers durchgereicht. In ähnlicher Weise kann man weitere Transformer denieren, die das Modell an bestimmten Stellen verkleinern, indem sie Zweige abschneiden. Beispielsweise kann man die Auswertung auf eine feste Anzahl von Knoten beschränken (Transformer mit Knotenbeschränkung). Eine weiteres Beispiel sind Transformer mit beschränkter Abweichung (Englisch: Limited Discrepancy Transformer), die eine maximale Anzahl dafür festlegen, wieviele rechte Verzweigungen bei der Auswertung eines Pfades durch den Baum verfolgt werden dürfen. In diesen Beispielen wirkt der Transformer immer dann auf den Interpreter ein, wenn dieser auf die Queue zugreift. Das MCP Framework ermöglicht es aber auch Transformer zu denieren, die an anderen Stellen auf den Ablauf der Auswertung einwirken. So wäre beispielsweise ein Transformer denkbar, der die Auswertungsreihenfolge des linken und rechten Zweigs eines eines solchen Try-Knotens zufällig festlegt. Die Implementierung Zufallsgesteuerten Transformers sieht wie folgt aus: newtype RandomizeST = RDST Int instance T r a n s f o r m e r RandomizeST where type E v a l S t a t e RandomizeST = [ Bool ] type T r e e S t a t e RandomizeST = ( ) i n i t T (RDST s e e d ) = ( randoms $ mkStdGen s e e d , ( ) ) tryT ( Try l r) q t i f b then e v a l ' else eval ' Für den EvalState ( b : bs ) ts = ( Try r l ) q t bs ts ( Try l r) q t bs ts verwendet man eine zufällig erzeugte Liste von booleschen Wer- ten. Initialisiert wird diese durch den Aufruf eines Zufallswert-Generators, der eine unendliche lazy Liste von Zufallswerten (in diesem Fall vom Typ Bool) erzeugt. Der TreeState () initialisiert. leftT-, rightT- und nextT-Funktion. Stattdessen deniert dieser Transformer eine Funktion namens tryT, die abhängig von den zufällig erzeugten booleschen Werten in der Liste entscheidet, ob die Zweige eines Try-Knotens wird für diesen Transformer nicht benötigt und daher mit Ebenfalls nicht benötigt werden die vor der Auswertung vertauscht werden. Falls der oberste Wert aus dieser Liste True ist, so werden die beiden Zweige vertauscht, anderenfalls bleibt die ursprüngliche Reihenfolge erhalten. In beiden Fällen wird die Interpreterfunktion auf den (gegebenfalls modizierten) Try-Knoten angesetzt, wobei der aktualisierte EvalState (= Restliste der booleschen Werte) weitergegeben wird. Schlieÿlich bestimmt der primitive Suchalgorithmus, in welchem Zweig die Auswertung fortgesetzt wird. Mit dem vorgegebenen Transformer-Interface des MCP Frameworks ist die Umsetzung vieler weiterer Transformationen denkbar, beispielsweise • Suche nur der ersten k Lösungen eines Problems, • Wechsel der Strategie für die Auswahl der Constraint-Variablen ab einer bestimmten Tiefe oder • Variation der Reihenfolge für die Variablenbelegung ab einer bestimmten Tiefe. 17 3.3.3 Kombinierbare Such-Transformer Bisher wurden nur einfache Transformationen des Modells und damit auch der Suche nach Lösungen präsentiert. Die Kombination mehrerer solcher Transformationen führt in der Regel zu einem komplexeren jedoch auch ezienteren Transformer. Kombiniert man beispielsweise einen Transformer mit Knotenbeschränkung mit einem mit Tiefenbeschränkung, so wird das Modell an einer anderen Stelle gekürzt als bei unabhängiger Anwendung der jeweiligen einfachen Transformer. Da das im Abschnitt 3.3.2 vorgestellte Transformer-Interface keine Unterstützung für das Zusammensetzen mehrerer Transformer bietet, führt das MCP Framework ein neues Interface ein. Dieses erlaubt es dem Benutzer, auf unkomplizierte Weise die bereits vorgestellten einfachen Transformer zu beliebig komplexen Transformern zusammenzubauen. Dazu stellt das Framework die CTransformer-Typklasse (für Composable Transformer, also zusammensetzbare Transformer) zur Verfügung. Dieses Interface deniert Typen und Funktionen, die bis auf ihre Benennung im Grunde genommen denen des Transformer-Interfaces entsprechen. Zum Beispiel wird der Zustand eines CEvalState kombinierbaren Transformers durch die beiden Typen und CTreeState nextCT- bestimmt. Der einzige wirkliche Unterschied steckt in der Typsignatur der Funktion: type C S e a r c h S i g ( Solver => T r e e −> solver solver , solver c a −> c) −> C E v a l S t a t e a ) −> (CONTINUE c (EVAL solver c −> solver [a] Im Unterschied zur a = CTransformer nextT-Funktion c −> CTreeState solver c c a) der Transformer-Typklasse greift diese Funktion nicht mehr auf die Queue-Datenstruktur zurück. Dafür erwartet sie zwei zusätzliche Parameter, deren Typen folgendermaÿen deniert sind: type EVAL s o l v e r c a = ( Tree −> type CONTINUE s o l v e r c solver solver a = ( CEvalState a −> CEvalState c [a]) −> c solver [a]) Bei diesen beiden Parametern handelt es sich um Continuations, also um Funktionen, die nach Beendigung der nextCT-Funktion ausgeführt werden. Sie erhalten den Rückgabewert dieser Funktion, um damit weiterzurechnen. Ein Transformer wird nun nicht mehr notwendigerweise aufbauend auf einem primitiven Suchalgorithmus wie der Tiefensuche ausgeführt, sondern eventuell aufgesetzt auf einen Stack von anderen Transformern. Deshalb darf er nicht mehr direkt die Funktionen eval' und continue zur Steuerung des Ablaufs der weiteren Auswertung aufrufen, denn damit würde er (EVAL solver c a) bzw. (CONTINUE solver c a) der eval'- bzw. continue-Funktion sordie anderen Transformer aus der Kombination übergehen. Die Abstraktionen gen dafür, dass die anderen Transformer auf dem Stack berücksichtigt werden, bevor die eigentlichen Funktionen, also eval' bzw. Zusätzlich führt man eine Datenstruktur continue, aufgerufen werden. Composition ein, mit der man zwei CTrans- former zu einem zusammenfassen kann: data C o m p o s i t i o n e s (: −) :: ts ( CTransformer => a −> −> where a, CTransformer b) b Composition ( CEvalState a , CEvalState b) ( CTreeState a , CTreeState b) Dazu verwendet man den Kompositionskonstruktor (:-). Eine Komposition speichert nur die internen Zustände der kombinierten Transformer in Form zweier Tupel, eines für die Auswertungsstatus und eines für die Baumstatus. Alle anderen Bestandteile der Transformer sind nicht sichtbar. Die Idee ist, dass eine solche Komposition sich nun selbst wieder wie ein CTransformer, also ein kombinierbarer Transformer, verhält. Dies wird gewährleistet, indem man die Composition-Datenstruktur zu einer Instanz der CTransformer-Typklasse macht: 18 instance CTransformer ( Composition e s t s ) where type CEvalState ( Composition e s t s ) = e s type CTreeState ( Composition e s t s ) = t s initCT ( c1 : − c2 ) = let ( es1 , t s 1 ) = initCT c1 ( es2 , t s 2 ) = initCT c2 in ( ( es1 , e s 2 ) , ( t s 1 , t s 2 ) ) l e f t C T ( c1 : − c2 ) ( t s 1 , t s 2 ) = ( l e f t C T c1 t s 1 , l e f t C T c2 t s 2 ) rightCT ( c1 : − c2 ) ( t s 1 , t s 2 ) = ( rightCT c1 t s 1 , rightCT c2 t s 2 ) nextCT t r e e ( c1 : − c2 ) ( es1 , e s 2 ) ( t s 1 , t s 2 ) e v a l ' c o n t i n u e = nextCT t r e e c1 e s 1 t s 1 ( \ t r e e ' es1 ' −> nextCT t r e e ' c2 e s 2 t s 2 ( \ t r e e ' ' es2 ' −> e v a l ' t r e e ' ' ( es1 ' , es2 ' ) ) ( \ es2 ' −> c o n t i n u e ( es1 ' , es2 ' ) ) ) ( \ es1 ' −> c o n t i n u e ( es1 ' , e s 2 ) ) EvalinitCT-Funktion zuder des anderen (c2) in- Um den Zustand einer solchen Komposition gegeben durch die Tupel für den und den TreeState zu initialisieren, werden durch Aufruf der nächst der Zustand des einen Transformers (c1) und dann itialisiert. leftCT und rightCT rightCT-Funktionen der an Nach dem gleichen Prinzip deniert man die Funktionen auf Kompositionen. Dazu wendet man die der leftCT- und Komposition beteiligten Transformer komponentenweise an, das heiÿt für den jeweiligen TreeState die zugehörige Funktion. nextCT-Funktion auf Kompositionen von Transformern ist im CPS (Continuationeval' und continue. Zunächst wird die nextCT-Funktion des ersten Transformers aus der Die Passing-Style) deniert. Als Continuation-Parameter erhält sie die Funktion Komposition aufgerufen, die ihrerseits zwei lambda-Funktionen als ContinuationParameter erhält. Falls der erste Transformer entscheidet die Auswertung an der aktuellen Stelle im Modell fortzusetzen, so wird die Komponente aufgerufen (erste lambda-Funktion bzw. Transformers c1). nextCT-Funktion der zweiten EVAL-Continuation des ersten Bestimmt der erste Transformer dagegen mit der Auswertung in einem anderen Zweig des Modells fortzufahren, so wird der zweite Transformer aus continue-Funktion einfach übersprungen CONTINUE-Continuation des ersten Transformers c1). der Komposition durch direkten Aufruf der (zweite lambda-Funktion bzw. Die nextCT-Funktion der zweiten Komponente verwendet ebenfalls zwei lambda- Funktionen als Continuation-Parameter. Für den Fall, dass der zweite Transformer festlegt, die Auswertung im aktuellen Zweig des Modells fortzusetzen, so wird die eval'-Funktion aufgerufen (EVAL-Continuation des zweiten Transformers c2). An- sonsten sorgt der zweite Transformer aus der Komposition für die Ausführung der continue-Funktion (CONTINUE-Continuation des zweiten Transformers c2). Um nun zu erreichen, dass sich ein kombinierbarer Transformer - hierbei kann es sich auch um mehrere in einer Komposition zusammengefasste Transformer handeln - wieder wie ein gewöhnlicher Transformer verhält, führt das MCP Framework den TStack-Transformer ein: data TStack e s TStack :: where ts CTransformer => c −> TStack c ( CEvalState c) ( CTreeState c) Der TStack-Transformer speichert den Zustand eines übergebenen, kombinierbaren Transformers. Damit sich ein solcher Transformer wieder wie ein gewöhnlicher Transformer verhält, macht man ihn zu einer Instanz der Transformer-Typklasse, die im Abschnitt 3.3.2 vorgestellt wurde: instance T r a n s f o r m e r ( TStack e s t s ) where type E v a l S t a t e ( TStack e s t s ) = e s type T r e e S t a t e ( TStack e s t s ) = t s initT ( TStack c) = initCT c leftT ( TStack c) = leftCT c rightT ( TStack c ) = rightCT c 19 nextT tree nextCT q t@ ( TStack tree (\ tree ' c es es ' −> −> (\ es ' c) es ts = ts eval ' tree ' continue q q t t es ' ts ) es ' ) Die Funktionen der Transformer-Typklasse werden durch ihr jeweiliges Pendant aus dem kombinierbaren Transformer implementiert, den man an den TStack-Transformer übergibt. Die nextT-Funktion wird durch die nextCT-Funktion deniert. Diese vereval' als EVAL-Continuation und die Funktion continue als wendet die Funktion CONTINUE-Continuation. Mit den vorgestellten Werkzeugen des MCP Frameworks kann man jetzt einen Transformer mit einer Tiefenbeschränkung von 5 und einen mit einer Knotenbeschränkung von 12 folgendermaÿen zu einem einfachen Transformer zusammensetzen: ( TStack (CDBST 5 : − CNBST 1 2 ) ) Dazu muss man nur das Interface für kombinierbare Transformer entsprechend für die beiden Transformer-Varianten implementieren, die implementierten CTransformer mit Hilfe des Kompositionskonstruktors (:-) zu einem neuen CTransformer zu- sammensetzen und diesen dann an den TStack-Transformer übergeben. Das folgende Bild veranschaulicht die Wirkung zweier zusammengesetzter Transformer anhand des obigen Beispiels. Abbildung 2: Tiefensuche (oben) und Breitensuche (unten) mit Tiefenbeschränkung 5, Knotenbeschränkung 12 - nur schwarze Knoten werden besucht Das MCP Framework stellt in einer Bibliothek eine ganze Reihe von solchen kombinierbaren Transformern zur Verfügung, die der Benutzer beliebig zusammensetzen kann, um neue, komplexere Transformationen für die Auswertung seines Modells durch den Constraint Solver zu erzeugen. Darunter sind auch bekannte Optimierungstechniken wie Branch-and-Bound. Hierbei handelt es sich um ein Verfahren zum Finden optimaler Lösungen. Nachdem eine Lösung gefunden wurde, wird versucht, eine bessere zu nden. Weitere Verfahren, die sich mit Hilfe des MCP Frameworks implementieren lassen, sind Iterative Deepening (iterative Tiefensuche) oder Restart Optimization. 20 4 Zusammenfassung und Ausblick Das MCP Framework ist ein generisches Framework zur Modellierung und Lösung von Constraint-Problemen. Es stellt eine in Haskell eingebettete Modellierungssprache zur Repräsentation von Constraint-Problemen als eine monadische, baumartige Struktur zur Verfügung. Die Verwendung von Monaden hat den Vorteil, dass Modelle als Seiteneekte von Berechnungen erzeugt werden, und ermöglicht es, Haskells imperative do-Notation zu nutzen. Weiterhin bietet das Framework einen monadischen Constraint Solver, der als Interpreter für die erzeugten Modelle dient. Abhängig von der Art des aktuell interpretierten Knotens des Baummodells führt er verschiedene Aktionen wie das Hinzufügen neuer Constraints in seinen Constraint-Speicher oder das Erzeugen neuer ConstraintVariablen durch. Primitive Suchalgorithmen wie Breiten- oder Tiefensuche werden durch die Implementierung eines generischen Queue-Interfaces bereitgestellt. Auf diese Weise wird die Auswertungsreihenfolge bei der Interpretation eines Modells durch den Solver gesteuert. Des Weiteren ermöglicht das MCP Framework während der Auswertung Transformationen am Modell vorzunehmen. Diese sind auf die grundlegenden Suchstrategien aufgesetzt und modizieren das Baummodell und damit die Suche nach Lösungen (beispielweise durch Abschneiden des Baums an bestimmten Stellen). Dazu stellt das Framework ein Transformer-Interface zur Verfügung, das für einige konkrete Transformationen wie die Tiefenbeschränkung implementiert wurde. Schlieÿlich bietet es noch die Möglichkeit, durch Implementierung eines Interface für zusammensetzbare Transformer und Verwendung eines speziellen TStack-Transformers beliebig viele gewöhnliche Transformer zu einer einzigen, komplexeren Transformation zu kombinieren. Aufbauend auf dem bisherigen MCP Framework haben die Entwickler mittlerweile ein weiteres Framework speziell für Constraint-Probleme mit endlichen Wertebereichen (Finite Domain) implementiert, das FD-MCP Framework (siehe [2]). Mit dieser Erweiterung ist es einerseits möglich, die auch in dieser Ausarbeitung vorgestellten Haskellbasierten Constraint Solver für die Lösung eines FD-Constraint-Problems zu nutzen. Andererseits kann man auch einen in C++ implementierten Solver namens Gecode (siehe [5]) als Backend für das mit der FD-MCP-Modellierungssprache beschriebene Modell verwenden. Dazu stellt das Framework zum Einen eine in Haskell geschriebene Abstraktion des Gecode-Solvers mit entsprechenden Term- und Constraint-Typen zur Verfügung, sowie eine Funktion, mit der sich generische FD-Modelle in GecodeModelle übersetzen lassen. Zum Anderen gibt es eine Funktion, die ein in Haskell erzeugtes Gecode-Modell in entsprechenden C++ Code übersetzt. Mit einem Benchmark haben die Entwickler festgestellt, dass die mit dem MCP Framework geschriebenen Programme zur Modellierung eines Constraint-Problems nur halb so lang sind, wie ihr Gecode C++ Pendant. Der in C++ übersetzte Code eines solchen Programms ist zwar deutlich länger als die direkt in Gecode modellierte Variante, erreicht aber die gleiche Performance. Folgende Forschungsschwerpunkte sind für die Zukunft geplant: • Generalisierung des Frameworks für beliebige Suchalgorithmen • Weitergehende Erforschung der Performance-Charakteristik des Frameworks wie z.B. den durch Transformer erzeugten Overhead • Erzeugung ezienter Suchalgorithmen für Gecode aus High Level Spezikationen mit Hilfe der kombinierbaren Such-Transformer des MCP Frameworks • Entwicklung einer zusätzlichen Instanz des FD-MCP Frameworks als LaufzeitFrontend für Gecode zur Vermeidung unnötiger Übersetzungsschritte 21 Literatur [1] Tom Schrijvers, Peter Stuckey and Phil Wadler. Monadic Constraint Programming. Journal of Functional Programming, 2009. [2] Pieter Wuille and Tom Schrijvers: Monadic Constraint Programming with Gecode. In Proceedings of the 8th International Workshop on Constraint Modelling and Reformulation, pages 171185, 2009 [3] Philip Wadler. Monads for Functional Programming. In Advanced Functional Programming, First International Spring School on Advanced Functional Programming Techniques-Tutorial Text, pages24-52, London, UK, 1995. [4] defmacro.org. Functional Programming for the Rest of Us - Abschnitt Continuations, Link: http://www.defmacro.org/ramblings/fp.html#part_9 [5] GecodeTeam. Available from Gecode:Generic Constraint http://www.gecode.org Development Environment, 2006. [6] Frank Huch. Script zur Vorlesung Funktionale Programmierung - Institut für Informatik, Arbeitsgruppe für Programmiersprachen und Übersetzerkonstruktion, CAU Kiel 22 Anhang A.1 Unterstützte Constraints und Terme für einen einfachen Finite DomainSolver: data F D C o n s t r a i n t s = Less ( FDExpr s) ( FDExpr s) Diff ( FDExpr s) ( FDExpr s) | Same ( FDExpr ( FDExpr s) | Dom ( FDExpr | | s) Int Int s) −− −− −− −− [ Less x y ] [ Diff x y ] [ Same x y ] [Dom x y z ] = = = = [x] [x] [x] [x] −− −− −− −− −− [ Var v ] [ Const n ] [ Plus x y ] [ Minus x y ] [ Mult x y ] = = = = = [v] n [x] + [y] [x] − [y] [x] ∗ [y] ... data FDExpr s = Var ( FDTerm s ) | C o n s t Int | Plus | Minus ( FDExpr | Mult | ... s) ( FDExpr ( FDExpr ( FDExpr s) s) s) ( FDExpr ( FDExpr s) s) < [y] /= [ y ] == [ y ] in {y , . . . , z} A.2 Syntaktischer Zucker für die Modellierung von Constraint-Problemen: exist n k = f n [] where f 0 a c c = k a c c f v e n a c c = NewVar $ ` in_domain ` ( l , u) @= n \v −> ( n − 1) f = Add (Dom v l = Add ( Same e e 1 @/= e 2 = Add ( Diff e1 e 1 @+ e 2 = ( Plus true = Return false = Fail (/\) = (>>) (\/) = Try e1 u) ( v : acc ) true ( Const e2 ) n)) true true e2 ) () A.3 Implementierung der rst-fail-Strategie zur Variablenauswahl und der middleout-Reihenfolge für die Belegung mit Werten: enumerate label v s = Dynamic varsel valsel . vs = ( label firstfail do v s ' <− v a r s e l label ' where l a b e l ' label ' [] middleout vs ) vs ' return ( ) = do d <− v a l s e l $ domain v return $ enum v d /\ = ( v : vs ) Dynamic firstfail middleout vs . ( label varsel valsel do d s <− mapM domain v s return [ v | ( d , v ) <− z ip d s v s , then s o r t W i t h by ( length d ) l = l e t n = ( length l ) ` div ` 2 in i n t e r l e a v e ( drop n l ) ( reverse $ take n l ) vs ) vs = interleave [] ys = ys interleave ( x : xs ) ys = x : i n t e r l e a v e 23 ys xs ] A.4 Modellierung eines minuplu-Rätsels mit der MCP-Modellierungssprache: 9x 3- a1 2- a2 a3 a4 b2 b3 b4 8x 2- c1 c2 c3 c4 d3 d4 Die Feldbezeichnungen a1...a4,...,d1,...d4 entsprechen den Constraint-Variablen im Modell. Zusätzlich müssen die jeweils angegebenen Rechenoperationen und Ergebnisse in den jeweiligen Teilkästchen durch die Einträge erfüllt werden, z.B. bedeutet 9x, dass in 12x d1 doku - in jeder Spalte / Zeile jede Zahl aus dem Wertebereich (hier 1 ... 4) nur einmal vorkommen. 5+ b1 Bei einem minuplu-Rätsel darf - wie bei einem Su- d2 dem Teilkästchen oben links folgendes gelten muss: a1 * b1 * b2 = 9. Abbildung 3: 4 x 4 minupluRätsel Die Modellierung dieses Rätsels mit dem MCP Framework zeigt der folgende Code: −− b e i d e Berechnungen n ö t i g , da S u b t r a k t i o n n i c h t kommutativ m i n u s C o n s t r a i n t v1 v2 e = v1 − v2 @= e \/ v2 − v1 @= e minuplu exist exist exist exist = 4 $ \as@ [ a1 , a2 , a3 , a4 ] −> 4 $ \bs@ [ b1 , b2 , b3 , b4 ] −> 4 $ \cs@ [ c1 , c2 , c3 , c4 ] −> 4 $ \ds@ [ d1 , d2 , d3 , d4 ] −> v a r s = a s ++ bs ++ c s ++ ds rows = a s : bs : c s : ds : [ ] cols = rows v a r s ` a l l i n ` ( 1 , 4 ) /\ conj ( a l l D i f f rows ) /\ conj ( a l l D i f f c o l s ) /\ a1 ∗ b1 ∗ b2 @= 9 /\ m i n u s C o n s t r a i n t a2 a3 3 /\ m i n u s C o n s t r a i n t a4 b4 2 /\ b3 + c3 + c2 @= 5 /\ c1 ∗ d1 @= 8 /\ d2 ∗ d3 @= 12 /\ m i n u s C o n s t r a i n t c4 d4 2 /\ vars let transpose in map map return −− −− −− −− Erzeugung d e r 16 C o n s t r a i n t −V a r i a b l e n je 4 für jede Zeile des R ä t s e l s −− −− −− −− −− −− −− −− L i s t e der Z e i l e n L i s t e der Spalten W e r t e b e r e i c h s −C o n s t r a i n t C o n s t r a i n t s : Z e i l e n − und Spalteneinträge verschieden Constraints für die im " minuplu " v o r g e g e b e n e n Berechnungen −− Rückgabe d e r Lösung Ergebnisausgabe: C:\monadiccp-0.6.1\examples> minuplu overton_run (107,[[3,1,4,2,1,3,2,4,4,2,1,3,2,4,3,1]]) A.5 Selbstgeschriebener Transformer (sorgt dafür, dass nur die ersten k Lösungen ausgegeben werden): newtype CKSolutionsST ( s o l v e r : : ∗ −> ∗ ) a = CKSST Int instance S o l v e r s o l v e r => CTransformer ( CKSolutionsST s o l v e r type CEvalState ( CKSolutionsST s o l v e r a ) = Int type CTreeState ( CKSolutionsST s o l v e r a ) = ( ) type CForSolver ( CKSolutionsST s o l v e r a ) = s o l v e r type CForResult ( CKSolutionsST s o l v e r a ) = a initCT (CKSST returnCT _ e s | e s <= 1 | otherwise k) = (k , ( ) ) continue exit = exit es = c o n t i n u e ( es − 1) 24 a) where