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 next
parametro 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 next
per la composizione, il tipo di ha p1
la stessa lunghezza del nostro programma (cioè 3 comandi):
p1 :: DSL (DSL (DSL next))
In questo esempio particolare, l'utilizzo in next
questo modo sembra un po 'strano, ma è importante se vogliamo che le nostre azioni abbiano variabili di tipo diverso. Potremmo volere un dattiloscritto get
e set
, per esempio.
Nota come il next
campo è diverso per ogni azione. Questo suggerisce che possiamo usarlo per creare DSL
un 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 deriving
per creare automaticamente l'istanza abilitando l' DeriveFunctor
estensione.
Il prossimo passo è il Free
tipo stesso. Questo è ciò che usiamo per rappresentare la nostra struttura AST , costruita sopra il DSL
tipo. 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 next
per 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 do
notazione per rendere più piacevoli le nostre espressioni DSL. L'unica domanda è: cosa mettere next
? Bene, l'idea è quella di utilizzare la Free
struttura per la composizione, quindi metteremo solo Return
per 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 Free
e Return
ovunque. Fortunatamente, c'è un modello possiamo sfruttare: il nostro modo di "lift" un'azione DSL in Free
è sempre la stessa, ci avvolgiamo in Free
e applichiamo Return
per 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 p4
sembra 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 follow
quale 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 follow
può essere utilizzato nei nostri programmi proprio come get
o 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 DSL
frammento, anche uno che non è finito end
. Fortunatamente, possiamo creare una versione "sicura" della funzione che accetta solo i programmi chiusi end
impostando la firma del tipo di input su (forall a. Free DSL a) -> IO ()
. Mentre la vecchia firma accetta un Free DSL a
per qualsiasi a
(mi piace Free DSL String
, Free DSL Int
e così via), questa versione accetta solo un Free DSL a
che 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 runIO
questo tipo perché non funzionerà correttamente per la nostra chiamata ricorsiva. Tuttavia, potremmo spostare la definizione runIO
in un where
blocco safeRunIO
e ottenere lo stesso effetto senza esporre entrambe le versioni della funzione.)
L'esecuzione del nostro codice IO
non è 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.