Qual è il modello "Monade + interprete gratis"?


95

Ho visto persone parlare di Free Monad con interprete , in particolare nel contesto dell'accesso ai dati. Cos'è questo modello? Quando potrei volerlo usare? Come funziona e come lo implementerei?

Capisco (da post come questo ) che si tratta di separare il modello dall'accesso ai dati. In che cosa differisce dal noto modello di repository? Sembrano avere la stessa motivazione.

Risposte:


138

Il modello attuale è in realtà significativamente più generale del semplice accesso ai dati. È un modo leggero di creare un linguaggio specifico del dominio che ti dà un AST e quindi avere uno o più interpreti per "eseguire" l'AST come preferisci.

La parte della monade gratuita è solo un modo pratico per ottenere un AST che puoi assemblare usando le strutture standard della monade di Haskell (come la notazione) senza dover scrivere molto codice personalizzato. Questo assicura anche che la tua DSL sia compostabile : puoi definirla in parti e quindi unire le parti in modo strutturato, permettendoti di sfruttare le normali astrazioni di Haskell come le funzioni.

L'uso di una monade libera ti dà la struttura di un DSL componibile; tutto quello che devi fare è specificare i pezzi. Devi solo scrivere un tipo di dati che racchiuda tutte le azioni nel tuo DSL. Queste azioni potrebbero fare qualsiasi cosa, non solo l'accesso ai dati. Tuttavia, se si specificavano tutti gli accessi ai dati come azioni, si otterrebbe un AST che specifica tutte le query e i comandi nell'archivio dati. Potresti quindi interpretarlo come preferisci: eseguilo su un database live, eseguilo su un finto, registra i comandi per il debug o prova a ottimizzare le query.

Vediamo un esempio molto semplice per, diciamo, un archivio di valori chiave. Per ora, tratteremo sia le chiavi che i valori come stringhe, ma potresti aggiungere tipi con un po 'di sforzo.

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

Il nextparametro ci consente di combinare azioni. Possiamo usarlo per scrivere un programma che ottiene "pippo" e imposta "bar" con quel valore:

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

Sfortunatamente, questo non è abbastanza per un DSL significativo. Dal momento che abbiamo usato nextper la composizione, il tipo di ha p1la stessa lunghezza del nostro programma (cioè 3 comandi):

p1 :: DSL (DSL (DSL next))

In questo esempio particolare, l'utilizzo in nextquesto modo sembra un po 'strano, ma è importante se vogliamo che le nostre azioni abbiano variabili di tipo diverso. Potremmo volere un dattiloscritto gete set, per esempio.

Nota come il nextcampo è diverso per ogni azione. Questo suggerisce che possiamo usarlo per creare DSLun funzione:

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

In realtà, questo è l' unico modo valido per renderlo un Functor, quindi possiamo usare derivingper creare automaticamente l'istanza abilitando l' DeriveFunctorestensione.

Il prossimo passo è il Freetipo stesso. Questo è ciò che usiamo per rappresentare la nostra struttura AST , costruita sopra il DSLtipo. Puoi pensarlo come un elenco a livello di tipo , in cui "contro" sta semplicemente annidando un funzione come DSL:

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

Quindi possiamo usare Free DSL nextper dare a programmi di dimensioni diverse gli stessi tipi:

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

Che ha il tipo molto più bello:

p2 :: Free DSL a

Tuttavia, l'espressione reale con tutti i suoi costruttori è ancora molto imbarazzante da usare! È qui che entra in gioco la parte della monade. Come implica il nome "monade libera", Freeè una monade, purché f(in questo caso DSL) sia un funzione:

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

Ora stiamo arrivando da qualche parte: possiamo usare la donotazione per rendere più piacevoli le nostre espressioni DSL. L'unica domanda è: cosa mettere next? Bene, l'idea è quella di utilizzare la Freestruttura per la composizione, quindi metteremo solo Returnper ogni campo successivo e permetteremo alla notazione di fare tutto l'impianto idraulico:

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

Questo è meglio, ma è ancora un po 'imbarazzante. Abbiamo Freee Returnovunque. Fortunatamente, c'è un modello possiamo sfruttare: il nostro modo di "lift" un'azione DSL in Freeè sempre la stessa, ci avvolgiamo in Freee applichiamo Returnper next:

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

Ora, usando questo, possiamo scrivere belle versioni di ciascuno dei nostri comandi e avere un DSL completo:

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

Usando questo, ecco come possiamo scrivere il nostro programma:

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

Il trucco è che mentre p4sembra un piccolo programma imperativo, in realtà è un'espressione che ha il valore

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

Quindi, la parte di monade libera del modello ci ha ottenuto un DSL che produce alberi di sintassi con una bella sintassi. Possiamo anche scrivere sotto-alberi componibili non usando End; per esempio, potremmo avere followquale prende una chiave, ne ottiene il valore e quindi la usa come chiave stessa:

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

Ora followpuò essere utilizzato nei nostri programmi proprio come geto set:

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

Quindi otteniamo anche una bella composizione e astrazione per il nostro DSL.

Ora che abbiamo un albero, arriviamo alla seconda metà del modello: l'interprete. Siamo in grado di interpretare l'albero come ci piace semplicemente abbinandoli su di esso. Questo ci consentirebbe di scrivere codice su un vero archivio di dati in IO, così come altre cose. Ecco un esempio contro un ipotetico archivio dati:

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 ()

Questo valuterà felicemente qualsiasi DSLframmento, anche uno che non è finito end. Fortunatamente, possiamo creare una versione "sicura" della funzione che accetta solo i programmi chiusi endimpostando la firma del tipo di input su (forall a. Free DSL a) -> IO (). Mentre la vecchia firma accetta un Free DSL aper qualsiasi a (mi piace Free DSL String, Free DSL Inte così via), questa versione accetta solo un Free DSL ache funziona per ogni possibile a- che possiamo solo creare end. Questo garantisce che non dimenticheremo di chiudere la connessione quando avremo finito.

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

(Non possiamo semplicemente dare runIOquesto tipo perché non funzionerà correttamente per la nostra chiamata ricorsiva. Tuttavia, potremmo spostare la definizione runIOin un whereblocco safeRunIOe ottenere lo stesso effetto senza esporre entrambe le versioni della funzione.)

L'esecuzione del nostro codice IOnon è l'unica cosa che potremmo fare. Per i test, potremmo invece volerlo eseguire contro un puro State Map. Scrivere quel codice è un buon esercizio.

Quindi questo è il modello monade + interprete gratuito. Facciamo un DSL, sfruttando la struttura della monade libera per fare tutto l'impianto idraulico. Possiamo usare la notazione e le funzioni monade standard con il nostro DSL. Quindi, per usarlo effettivamente, dobbiamo interpretarlo in qualche modo; poiché l'albero è in definitiva solo una struttura di dati, possiamo interpretarlo come ci piace per scopi diversi.

Quando lo utilizziamo per gestire gli accessi a un archivio dati esterno, è effettivamente simile al modello di repository. Intermedia tra il nostro archivio di dati e il nostro codice, separando i due. In un certo senso, però, è più specifico: il "repository" è sempre un DSL con un AST esplicito che possiamo quindi usare come preferiamo.

Tuttavia, il modello stesso è più generale di quello. Può essere utilizzato per molte cose che non implicano necessariamente database o archiviazione esterni. Ha senso ovunque desideri un controllo accurato degli effetti o di più target per un DSL.


6
Perché si chiama monade "libera"?
Benjamin Hodgson,

14
Il nome "libero" deriva dalla teoria delle categorie: ncatlab.org/nlab/show/free+object ma in qualche modo significa che è monade "minima" - che solo le operazioni valide su di essa sono le operazioni monade, come ha " dimenticato "tutto è altra struttura.
Boyd Stephen Smith Jr.

3
@BenjaminHodgson: Boyd ha perfettamente ragione. Non me ne preoccuperei troppo se non sei solo curioso. Dan Piponi ha parlato molto del significato di "libero" in BayHac, che vale la pena dare un'occhiata. Prova a seguire insieme alle sue diapositive perché l'immagine nel video è completamente inutile.
Tikhon Jelvis,

3
A nitpick: "La parte della monade libera è solo [la mia enfasi] un modo pratico per ottenere un AST che puoi assemblare usando le strutture standard della monade di Haskell (come la notazione) senza dover scrivere un sacco di codice personalizzato." È molto più di "solo" (come sono certo che lo sai). Le monadi libere sono anche una rappresentazione di programma normalizzata che rende impossibile all'interprete distinguere tra programmi la cui donotazione è diversa ma in realtà "significano lo stesso".
Sacundim,

5
@sacundim: potresti approfondire il tuo commento? Soprattutto la frase "Le monadi libere sono anche una rappresentazione di programma normalizzata che rende impossibile per l'interprete distinguere tra programmi la cui notazione è diversa ma in realtà" significano lo stesso "."
Giorgio,

15

Una monade libera è fondamentalmente una monade che costruisce una struttura di dati nella stessa "forma" del calcolo piuttosto che fare qualcosa di più complicato. ( Ci sono esempi che si possono trovare online. ) Questa struttura di dati viene quindi passata a un pezzo di codice che lo consuma e svolge le operazioni. * Non ho la completa familiarità con il modello di repository, ma da quello che ho letto appare per essere un'architettura di livello superiore e un monade + interprete gratuito potrebbe essere utilizzato per implementarla. D'altra parte, la monade + interprete gratuita potrebbe anche essere usata per implementare cose completamente diverse, come i parser.

* Vale la pena notare che questo modello non è esclusivo delle monadi e in effetti può produrre un codice più efficiente con applicativi gratuiti o frecce libere . (I parser ne sono un altro esempio. )


Mi scuso, avrei dovuto essere più chiaro riguardo al repository. (Ho dimenticato che non tutti hanno un sistema aziendale / OO / background DDD!) Un repository fondamentalmente incapsula l'accesso ai dati e reidrata gli oggetti di dominio per te. Viene spesso utilizzato insieme a Dependency Inversion: è possibile "collegare" diverse implementazioni di Repo (utile per i test o se è necessario cambiare database o ORM). Il codice di dominio chiama semplicemente repository.Get()senza sapere da dove sta ottenendo l'oggetto di dominio.
Benjamin Hodgson,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.