Sto scrivendo un interprete per una lingua piccola. Questo linguaggio supporta la mutazione, quindi il suo valutatore tiene traccia di uno Store
per tutte le variabili (dove type Store = Map.Map Address Value
, type Address = Int
e data Value
è un ADT specifico per la lingua).Come posso lavorare in monade annidate in modo pulito?
È anche possibile che i calcoli non vadano a buon fine (ad es., Dividendo per zero), quindi il risultato deve essere un Either String Value
.
Il tipo di mio interprete, poi, è
eval :: Environment -> Expression -> State Store (Either String Value)
dove type Environment = Map.Map Identifier Address
registra attacchi locali.
Per esempio, interpretando un letterale costante non ha bisogno di toccare il negozio, e il risultato riesce sempre, in modo da
eval _ (LiteralExpression v) = return $ Right v
Ma quando applichiamo un operatore binario, abbiamo bisogno di prendere in considerazione il negozio. Ad esempio, se l'utente valuta (+ (x <- (+ x 1)) (x <- (+ x 1)))
e x
è inizialmente 0
, il risultato finale deve essere 3
e 2
nel negozio risultante. Questo porta al caso
eval env (BinaryOperator op l r) = do
lval <- eval env l
rval <- eval env r
return $ join $ liftM2 (applyBinop op) lval rval
Si noti che il do
-notation sta lavorando all'interno della State Store
monade. Inoltre, l'uso di return
è monomorfo in State Store
, mentre gli usi di join
e liftM2
sono monomorfi nella monade Either String
. Cioè, qui usiamo
(return . join) :: Either String (Either String Value) -> State Store (Either String Value)
e return . join
non è un no-op.
(Come è evidente, applyBinop :: Identifier -> Value -> Value -> Either String Value
.)
Questo sembra confusa al massimo, e questo è un caso relativamente semplice. Il caso dell'applicazione della funzione, ad esempio, è notevolmente più complicato.
Quali utili best practice dovrei sapere per mantenere il mio codice leggibile e scrivibile?
MODIFICA: Ecco un esempio più tipico, che mostra meglio la bruttezza. La variante NewArrayC
ha parametri length :: Expression
e element :: Expression
(crea una matrice di una determinata lunghezza con tutti gli elementi inizializzati su una costante). Un semplice esempio è (newArray 3 "foo")
, che produce ["foo", "foo", "foo"]
, ma potremmo anche scrivere (newArray (+ 1 2) (concat "fo" "oo"))
, perché possiamo avere espressioni arbitrarie in un NewArrayC
. Ma quando effettivamente chiamiamo
allocateMany :: Int -> Value -> State Store Address,
che prende il numero di elementi per allocare e il valore per ogni slot, e restituisce l'indirizzo di partenza, dobbiamo disfare tali valori. Nella logica sottostante, puoi vedere che sto duplicando un po 'di logica che dovrebbe essere integrata nella monade Either
. Tutti i case
devono essere semplicemente associati.
eval env (NewArrayC len el) = do
lenVal <- eval env len
elVal <- eval env el
case lenVal of
Right (NumV lenNum) -> case elVal of
Right val -> do
addr <- allocateMany lenNum val
return $ Right $ ArrayV addr lenNum -- result data type
left -> return left
Right _ -> return $ Left "expected number in new-array length"
left -> return left
Questo è meraviglioso. Grazie. – wchargin
Hai ragione che io "voglio il bit dello stato più esterno" (in particolare, questo perché la ricerca dell'identificatore può fallire a seconda dello stato), ma non capisco come usare 'ExceptT String (State Store)' invece di ' StateT Store (o String) 'completa questo. In effetti, [questo post sembra suggerire diversamente] (http://stackoverflow.com/a/5076096/732016). Potresti spiegare per favore? – wchargin
Continua ad espandere i newtypes finché non ottieni qualcosa alla base. Vedrai la differenza abbastanza rapidamente: 'ExceptT String (State Store) a' si espande in' Store -> (Store, Either String a) 'mentre' StateT Store (tranne String) a' si espande in 'Store -> O String (Negozio, a) '. Che vuoi dipende da te, ovviamente. –