Was ist das Muster "Free Monad + Interpreter"?

95

Ich habe Leute gesehen, die mit Interpreter über Free Monad gesprochen haben , insbesondere im Zusammenhang mit dem Datenzugriff. Was ist das für ein Muster? Wann könnte ich es benutzen wollen? Wie funktioniert es und wie würde ich es implementieren?

Ich verstehe (von Posts wie diesem ), dass es darum geht, das Modell vom Datenzugriff zu trennen. Wie unterscheidet es sich vom bekannten Repository-Muster? Sie scheinen die gleiche Motivation zu haben.

Benjamin Hodgson
quelle

Antworten:

138

Das tatsächliche Muster ist wesentlich allgemeiner als nur der Datenzugriff. Es ist eine einfache Möglichkeit, eine domänenspezifische Sprache zu erstellen, mit der Sie einen AST erhalten, und dann einen oder mehrere Interpreter zu haben, um den AST auszuführen, wie Sie möchten.

Der kostenlose Monad-Teil ist nur ein praktischer Weg, um einen AST zu erhalten, den Sie mit Haskells Standard-Monad-Funktionen (wie Do-Notation) zusammenstellen können, ohne viel benutzerdefinierten Code schreiben zu müssen. Damit ist auch sichergestellt , dass Ihr DSL ist zusammensetzbare : Sie können es in Teilen definieren und setzen dann die Teile zusammen in einer strukturierten Art und Weise, so dass Sie die Vorteile von Haskell normalen Abstraktionen wie Funktionen übernehmen.

Wenn Sie eine freie Monade verwenden, erhalten Sie die Struktur einer zusammensetzbaren DSL. Sie müssen nur die Teile angeben. Sie schreiben einfach einen Datentyp, der alle Aktionen in Ihrem DSL umfasst. Diese Aktionen können alles tun, nicht nur den Datenzugriff. Wenn Sie jedoch alle Datenzugriffe als Aktionen angegeben haben, erhalten Sie einen AST, der alle Abfragen und Befehle für den Datenspeicher angibt. Sie können dies dann so interpretieren, wie Sie möchten: Führen Sie es in einer Live-Datenbank aus, führen Sie es in einem Schein aus, protokollieren Sie einfach die Befehle zum Debuggen oder optimieren Sie die Abfragen.

Schauen wir uns ein sehr einfaches Beispiel für einen Schlüsselwertspeicher an. Im Moment werden sowohl Schlüssel als auch Werte nur als Zeichenfolgen behandelt, aber Sie können mit ein wenig Aufwand Typen hinzufügen.

data DSL next = Get String (String -> next)
              | Set String String next
              | End

Mit dem nextParameter können wir Aktionen kombinieren. Wir können dies benutzen, um ein Programm zu schreiben, das "foo" bekommt und "bar" mit diesem Wert setzt:

p1 = Get "foo" $ \ foo -> Set "bar" foo End

Leider reicht dies für ein sinnvolles DSL nicht aus. Da wir nextfür die Komposition verwendet haben, hat der Typ p1dieselbe Länge wie unser Programm (dh 3 Befehle):

p1 :: DSL (DSL (DSL next))

In diesem Beispiel ist die Verwendung nextdieser Methode etwas ungewöhnlich, aber es ist wichtig, dass unsere Aktionen unterschiedliche Typvariablen haben sollen. Wir möchten vielleicht eine getippte getund setzum Beispiel.

Beachten Sie, wie das nextFeld für jede Aktion unterschiedlich ist. Dies deutet darauf hin, dass wir damit DSLeinen Funktor erstellen können:

instance Functor DSL where
  fmap f (Get name k)          = Get name (f . k)
  fmap f (Set name value next) = Set name value (f next)
  fmap f End                   = End

Tatsächlich ist dies die einzig gültige Möglichkeit, einen Functor daraus zu machen, sodass wir derivingdie Instanz automatisch erstellen können, indem wir die DeriveFunctorErweiterung aktivieren .

Der nächste Schritt ist der FreeTyp selbst. Das ist es, was wir verwenden, um unsere AST- Struktur darzustellen , die auf dem DSLTyp aufbaut. Sie können sich das wie eine Liste auf Typebene vorstellen, in der "cons" nur einen Funktor wie den DSLfolgenden verschachtelt :

-- compare the two types:
data Free f a = Free (f (Free f a)) | Return a
data List a   = Cons a (List a)     | Nil

Damit können wir Free DSL nextProgrammen unterschiedlicher Größe den gleichen Typ geben:

p2 = Free (Get "foo" $ \ foo -> Free (Set "bar" foo (Free End)))

Welches hat den viel schöneren Typ:

p2 :: Free DSL a

Der eigentliche Ausdruck mit all seinen Konstruktoren ist jedoch immer noch sehr umständlich zu verwenden! Hier kommt der Monadenteil ins Spiel. Wie der Name "freie Monade" andeutet, Freeist dies eine Monade - solange f(in diesem Fall DSL) ein Funktor ist:

instance Functor f => Monad (Free f) where
  return         = Return
  Free a >>= f   = Free (fmap (>>= f) a)
  Return a >>= f = f a

Jetzt kommen wir voran: Wir können die doNotation verwenden, um unsere DSL-Ausdrücke zu verbessern. Die Frage ist nur, worauf es ankommt next. Nun, die Idee ist, die FreeStruktur für die Komposition zu verwenden, also werden wir einfach Returnfür jedes nächste Feld setzen und die Do-Notation die ganze Installation erledigen lassen:

p3 = do foo <- Free (Get "foo" Return)
        Free (Set "bar" foo (Return ()))
        Free End

Das ist besser, aber immer noch etwas umständlich. Wir haben Freeund Returnüberall. Glücklicherweise gibt es ein Muster , das wir ausnutzen können: die Art , wie wir „Lift“ ein DSL - Aktion in Freeimmer der gleichen wir es in wickeln Freeund gelten Returnfür next:

liftFree :: Functor f => f a -> Free f a
liftFree action = Free (fmap Return action)

Auf diese Weise können wir nun nette Versionen von jedem unserer Befehle schreiben und haben eine vollständige DSL:

get key       = liftFree (Get key id)
set key value = liftFree (Set key value ())
end           = liftFree End

Auf diese Weise können wir unser Programm schreiben:

p4 :: Free DSL a
p4 = do foo <- get "foo"
        set "bar" foo
        end

Der nette Trick ist, dass es zwar p4wie ein kleines Imperativ-Programm aussieht, aber tatsächlich ein Ausdruck ist, der den Wert hat

Free (Get "foo" $ \ foo -> Free (Set "bar" foo (Free End)))

Der freie Monadenteil des Musters hat uns also ein DSL beschert, das Syntaxbäume mit netter Syntax erzeugt. Wir können auch zusammensetzbare Teilbäume schreiben, indem wir nicht verwenden End; Zum Beispiel könnten wir haben, followwas einen Schlüssel nimmt, seinen Wert erhält und diesen dann selbst als Schlüssel verwendet:

follow :: String -> Free DSL String
follow key = do key' <- get key
                get key'

Jetzt followkann in unseren Programmen genauso verwendet werden wie getoder set:

p5 = do foo <- follow "foo"
        set "bar" foo
        end

So bekommen wir auch eine schöne Komposition und Abstraktion für unser DSL.

Jetzt, wo wir einen Baum haben, kommen wir zur zweiten Hälfte des Musters: dem Interpreter. Wir können den Baum interpretieren, wie wir möchten, indem wir ihn mit Mustern abgleichen. Auf diese Weise können wir unter anderem Code für einen realen Datenspeicher schreiben IO. Hier ist ein Beispiel für einen hypothetischen Datenspeicher:

runIO :: Free DSL a -> IO ()
runIO (Free (Get key k)) =
  do res <- getKey key
     runIO $ k res
runIO (Free (Set key value next)) =
  do setKey key value
     runIO next
runIO (Free End) = close
runIO (Return _) = return ()

Dies wertet gerne jedes DSLFragment aus, auch eines, mit dem es nicht endet end. Glücklicherweise können wir eine "sichere" Version der Funktion erstellen, die nur Programme akzeptiert, die mit geschlossen endwerden, indem die Signatur des Eingabetyps auf gesetzt wird (forall a. Free DSL a) -> IO (). Während die alte Unterschrift akzeptiert einen Free DSL afür jeden a (wie Free DSL String, Free DSL Intund so weiter), ist diese Version akzeptiert nur eine , Free DSL adie für Arbeiten jeden möglichen a-Welche wir mit nur schaffen können end. Dies garantiert, dass wir nicht vergessen, die Verbindung zu schließen, wenn wir fertig sind.

safeRunIO :: (forall a. Free DSL a) -> IO ()
safeRunIO = runIO

(Wir können nicht einfach mit runIOdiesem Typ beginnen, da er für unseren rekursiven Aufruf nicht richtig funktioniert. Wir könnten jedoch die Definition von runIOin einen whereBlock verschieben safeRunIOund den gleichen Effekt erzielen, ohne beide Versionen der Funktion verfügbar zu machen.)

Das Einspielen unseres Codes IOist nicht das Einzige, was wir tun können. Zum Testen möchten wir es möglicherweise State Mapstattdessen mit einem reinen ausführen . Das Ausschreiben dieses Codes ist eine gute Übung.

Das ist also das freie Muster von Monade + Interpreter. Wir stellen ein DSL her und nutzen die freie Monadenstruktur, um die gesamte Installation zu erledigen. Mit unserem DSL können wir die Do-Notation und die Standard-Monadenfunktionen nutzen. Um es dann tatsächlich zu benutzen, müssen wir es irgendwie interpretieren; da der baum letztendlich nur eine datenstruktur ist, können wir ihn für verschiedene zwecke beliebig interpretieren.

Wenn wir dies verwenden, um Zugriffe auf einen externen Datenspeicher zu verwalten, ähnelt es in der Tat dem Repository-Muster. Es vermittelt zwischen unserem Datenspeicher und unserem Code und trennt die beiden. In mancher Hinsicht ist es jedoch spezifischer: Das "Repository" ist immer ein DSL mit einem expliziten AST, den wir dann verwenden können, wie wir möchten.

Das Muster selbst ist jedoch allgemeiner. Es kann für viele Dinge verwendet werden, die nicht unbedingt externe Datenbanken oder Speicher erfordern. Es ist überall dort sinnvoll, wo Sie die Feinsteuerung von Effekten oder mehreren Zielen für eine DSL wünschen.

Tikhon Jelvis
quelle
6
Warum heißt es eine "freie" Monade?
Benjamin Hodgson
14
Der "freie" Name stammt aus der Kategorietheorie: ncatlab.org/nlab/show/free+object , bedeutet jedoch, dass es sich um eine "minimale" Monade handelt. vergessen "alles andere ist es Struktur.
Boyd Stephen Smith Jr.
3
@BenjaminHodgson: Boyd hat vollkommen recht. Ich würde mir keine allzu großen Sorgen machen, wenn Sie nicht einfach nur neugierig sind. Dan Piponi hielt einen tollen Vortrag darüber, was "kostenlos" bei BayHac bedeutet, was einen Blick wert ist. Versuchen Sie, seinen Folien zu folgen, da das Bild im Video völlig unbrauchbar ist.
Tikhon Jelvis
3
Ein Nitpick: "Der kostenlose Monad-Teil ist nur ein praktischer Weg, um einen AST zu erhalten, den Sie mit Haskells Standard-Monad-Funktionen (wie Do-Notation) zusammenbauen können, ohne viel benutzerdefinierten Code schreiben zu müssen." Es ist mehr als "nur" das (wie Sie sicher wissen). Freie Monaden sind auch eine normalisierte Programmdarstellung, die es dem Interpreter unmöglich macht, zwischen Programmen zu unterscheiden, deren do-Notation unterschiedlich ist, aber tatsächlich "gleich" bedeutet.
Sacundim
5
@sacundim: Könnten Sie Ihren Kommentar näher erläutern? Insbesondere der Satz "Freie Monaden sind auch eine normalisierte Programmdarstellung, die es dem Interpreten unmöglich macht, zwischen Programmen zu unterscheiden, deren Schreibweise unterschiedlich ist, die aber tatsächlich" dasselbe bedeuten "."
Giorgio
15

Eine freie Monade ist im Grunde eine Monade, die eine Datenstruktur in derselben "Form" wie die Berechnung erstellt, anstatt etwas Komplizierteres zu tun. ( Es gibt Beispiele online gefunden werden. ) Diese Datenstruktur wird dann auf ein Stück Code übergibt , die es verbraucht und die Operationen ausführen. * Ich bin nicht ganz vertraut mit dem Repository - Muster, aber von was ich gelesen habe es erscheint um eine übergeordnete Architektur zu sein, könnte ein kostenloser Monad + -Interpreter verwendet werden, um sie zu implementieren. Andererseits könnte der freie Interpreter monad + auch dazu verwendet werden, ganz andere Dinge wie Parser zu implementieren.

* Es ist erwähnenswert, dass dieses Muster nicht nur für Monaden gilt und in der Tat effizienteren Code mit kostenlosen Anwendern oder kostenlosen Pfeilen erzeugen kann . ( Parser sind ein weiteres Beispiel dafür. )

Dan
quelle
Entschuldigung, ich hätte klarer über Repository sein sollen. (Ich habe vergessen, dass nicht jeder einen Business Systems / OO / DDD-Hintergrund hat!) Ein Repository kapselt im Grunde den Datenzugriff und rehydriert Domänenobjekte für Sie. Es wird häufig zusammen mit Dependency Inversion verwendet - Sie können verschiedene Implementierungen des Repo "einstecken" (nützlich zum Testen oder wenn Sie die Datenbank oder ORM wechseln müssen). Der Domänencode ruft nur repository.Get()an, ohne zu wissen, woher das Domänenobjekt stammt.
Benjamin Hodgson