haskell - monadologia - ¿Cuál es la diferencia entre los diferentes ordenamientos de los mismos transformadores de mónada?
monadas filosofia (5)
Resumen: Diferentes órdenes de pila producen diferente lógica de negocios
Es decir, diferentes órdenes de transformador de mónada de la pila no solo afectan a los pedidos de evaluación, sino también a las funcionalidades de los programas.
Al demostrar el impacto de los pedidos, las personas usualmente usan los transformadores más simples como ReaderT
, WriterT
, StateT
, MaybeT
, ExceptT
. Diferentes órdenes de ellos no dan una lógica de negocios dramáticamente diferente, por lo que es difícil entender el impacto claramente. Además, algunos subconjuntos de ellos son conmutativos, es decir, no hay diferencias de funcionalidad.
Para fines de demostración, sugiero usar StateT
y ListT
, que revelan la dramática diferencia entre los pedidos de transformadores en las pilas de mónadas.
Antecedentes: StateT
y ListT
-
State
:State
mónadaState
está bien explicada en Para unas cuantas mónadas más .StateT
solo le da un poco más de poder: usar las operaciones monádicas de sum
subyacente. Es suficiente si conoceevalStateT
,put
,get
yevalStateT
, que se explican en muchos tutoriales de mónadasState
. -
ListT
:List
, aka,[]
, es una mónada (explicada en Un puñado de mónadas ).ListT ma
(en el paquetelist-t
) le ofrece algo similar a[a]
más todas las operaciones monádicas de la mónada subyacentem
. La parte difícil es la ejecución deListT
(algo comparable aevalStateT
): hay muchas formas de ejecución. Piense en los diferentes resultados que le interesan al utilizarevalStateT
,runStateT
yexecState
, el contexto de laList
de laList
tiene muchos consumidores potenciales, como simplementeexecState
, es decir,traverse_
, plegarlos , es decir,fold
y más.
Experimento: comprender el impacto de la orden de transformador de mónada
Construiremos una simple pila de StateT
mónadas de dos capas utilizando StateT
y ListT
encima de IO
para cumplir algunas funcionalidades de demostración.
Descripción de la tarea
Resumen de números en una secuencia
El flujo se ListT
como una lista de ListT
Integer
, por lo que nuestro ListT
. Para resumirlos, debemos mantener un estado de la suma mientras procesamos cada elemento en el flujo, donde viene nuestro StateT
.
Dos pilas
Tenemos un estado simple como Int
para mantener la suma.
-
ListT (StateT Int IO) a
-
StateT Int (ListT IO) a
Programa completo
#!/usr/bin/env stack
-- stack script --resolver lts-11.14 --package list-t --package transformers
import ListT (ListT, traverse_, fromFoldable)
import Control.Monad.Trans.Class (lift)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.State (StateT, evalStateT, get, modify)
main :: IO()
main = putStrLn "#### Task: summing up numbers in a stream"
>> putStrLn "#### stateful (StateT) stream (ListT) processing"
>> putStrLn "#### StateT at the base: expected result"
>> ltst
>> putStrLn "#### ListT at the base: broken states"
>> stlt
-- (ListT (StateT IO)) stack
ltst :: IO ()
ltst = evalStateT (traverse_ (/_ -> return ()) ltstOps) 10
ltstOps :: ListT (StateT Int IO) ()
ltstOps = genLTST >>= processLTST >>= printLTST
genLTST :: ListT (StateT Int IO) Int
genLTST = fromFoldable [6,7,8]
processLTST :: Int -> ListT (StateT Int IO) Int
processLTST x = do
liftIO $ putStrLn "process iteration LTST"
lift $ modify (+x)
lift get
printLTST :: Int -> ListT (StateT Int IO) ()
printLTST = liftIO . print
-- (StateT (ListT IO)) stack
stlt :: IO ()
stlt = traverse_ (/_ -> return ())
$ evalStateT (genSTLT >>= processSTLT >>= printSTLT) 10
genSTLT :: StateT Int (ListT IO) Int
genSTLT = lift $ fromFoldable [6,7,8]
processSTLT :: Int -> StateT Int (ListT IO) Int
processSTLT x = do
liftIO $ putStrLn "process iteration STLT"
modify (+x)
get
printSTLT :: Int -> StateT Int (ListT IO) ()
printSTLT = liftIO . print
Resultados y explicación
$ ./order.hs
#### Task: summing up numbers in a stream
#### stateful (StateT) stream (ListT) processing
#### StateT at the base: expected result
process iteration LTST
16
process iteration LTST
23
process iteration LTST
31
#### ListT at the base: broken states
process iteration STLT
16
process iteration STLT
17
process iteration STLT
18
La primera pila ListT (StateT Int IO) a
produce el resultado correcto, ya que StateT
se evalúa después de ListT
. Al evaluar StateT
, el sistema de tiempo de ejecución ya evaluó todas las operaciones de ListT
: alimentar a la pila con un flujo [6,7,8]
, pasando por ellas con traverse_
. La palabra que se evalúa aquí significa que los efectos de ListT
se han ido y ListT
es transparente para StateT
.
La segunda pila de StateT Int (ListT IO) a
no tiene el resultado correcto ya que StateT
tiene una vida demasiado corta. En cada iteración de la evaluación de ListT
, aka, traverse_
, el estado se crea, evalúa y desaparece. El StateT
en esta estructura de pila no logra su propósito de mantener los estados entre las operaciones de elementos de lista / flujo.
Estoy intentando definir una API para expresar un tipo particular de procedimiento en mi programa.
newtype Procedure a = { runProcedure :: ? }
Hay estado, que consiste en una asignación de ID a registros:
type ID = Int
data Record = { ... }
type ProcedureState = Map ID Record
Hay tres operaciones básicas:
-- Declare the current procedure invalid and bail (similar to some definitions of fail for class Monad)
abort :: Procedure ()
-- Get a record from the shared state; abort if the record does not exist.
retrieve :: ID -> Procedure Record
-- Store (or overwrite) a record in the shared state.
store :: ID -> Record -> Procedure ()
Tengo algunas metas con estas operaciones:
- Los procedimientos pueden hacer suposiciones (a diferencia de una llamada en bruto de
Map.lookup
) acerca de qué registros están disponibles, y si alguna de sus suposiciones es incorrecta, el Procedimiento en su totalidad devuelve un error. - Se puede encadenar una serie de Procedimientos usando
<|>
(de la clase Alternativa) para recurrir a Procedimientos que hacen diferentes suposiciones. (Similar a STM''s oorElse
)
Teniendo en cuenta estos objetivos, creo que quiero una combinación de las mónadas State
y Maybe
.
-- Which to choose?
type Procedure a = StateT ProcedureState Maybe a
type Procedure a = MaybeT (State ProcedureState) a
No puedo entender cómo los dos ordenamientos de Maybe
y State
se comportarán de manera diferente. ¿Alguien puede explicar la diferencia de comportamiento entre los dos ordenamientos?
Además, si ves un problema con mi pensamiento original (quizás estoy sobre ingeniería), siéntete libre de señalarlo.
Conclusión: las tres respuestas fueron útiles, pero había una idea común que me ayudó a decidir qué orden quería. Al observar el tipo de retorno de runMaybeT
/ runStateT
, fue fácil ver qué combinación tenía el comportamiento que estaba buscando. (En mi caso, quiero el tipo de devolución Maybe (ProcedureState, a)
).
Edit: originalmente obtuve los casos al revés. Arreglado ahora.
La diferencia entre los ordenamientos de las pilas de transformadores de mónada realmente solo importa cuando se están despegando capas de la pila.
type Procedure a = MaybeT (State ProcedureState) a
En este caso, primero ejecuta MaybeT, lo que da como resultado un cálculo con estado que devuelve un Maybe a
.
type Procedure a = StateT ProcedureState Maybe a
Aquí, StateT
es la mónada externa, lo que significa que después de ejecutar StateT con un estado inicial, se le otorgará un Maybe (a, ProcedureState)
. Es decir, el cálculo puede haber tenido éxito o puede no haberlo tenido.
Entonces, lo que elija dependerá de cómo quiera manejar los cálculos parciales. Con MaybeT
en el exterior, siempre obtendrá algún tipo de estado devuelto independientemente del éxito del cálculo, que puede o no ser útil. Con StateT
en el exterior, garantiza que todas las transacciones con estado son válidas. Por lo que describe, probablemente yo usaría la variante StateT
, pero espero que cualquiera de los dos pueda funcionar.
La única regla para el ordenamiento de transformadores de mónada es que si IO
(u otra mónada sin transformador) está involucrada, debe ser la parte inferior de la pila. Normalmente, las personas usarán ErrorT
como el siguiente nivel más bajo si es necesario.
Para complementar las otras respuestas, me gustaría describir cómo resolver esto en el caso general. Es decir, dados dos transformadores, ¿cuáles son las semánticas de sus dos combinaciones?
Tuve muchos problemas con esta pregunta cuando comencé a usar transformadores de mónada en un proyecto de análisis la semana pasada. Mi enfoque fue crear una tabla de tipos transformados que consulto cuando no estoy seguro. Así es como lo hice:
Paso 1 : crear una tabla de los tipos de mónada básicos y sus tipos de transformadores correspondientes:
transformer type base type (+ parameter order)
---------------------------------------------------------------
MaybeT m a m (Maybe a) b. Maybe b
StateT s m a s -> m (a, s) t b. t -> (b, t)
ListT m a m [a] b. [] b
ErrorT e m a m (Either e a) f b. Either f b
... etc. ...
Paso 2: aplique cada transformador de mónada a cada una de las mónadas base, sustituyendo el parámetro de tipo m
:
inner outer combined type
Maybe MaybeT Maybe (Maybe a)
Maybe StateT s -> Maybe (a, s) -- <== this !!
... etc. ...
State MaybeT t -> (Maybe a, t) -- <== and this !!
State StateT s -> t -> ((a, s), t)
... etc. ...
(Este paso es un poco doloroso, ya que hay un número cuadrático de combinaciones ... pero fue un buen ejercicio para mí, y solo tuve que hacerlo una vez). La clave para mí aquí es que escribí los tipos combinados sin envolver. - Sin todos esos molestos envoltorios MaybeT, StateT, etc. Es mucho más fácil para mí mirar y pensar en los tipos sin el repetitivo.
Para responder a su pregunta original, esta tabla muestra que:
MaybeT + State :: t -> (Maybe a, t)
un cálculo con estado donde puede que no haya un valor, pero siempre habrá una salida de estado (posiblemente modificada)StateT + Maybe :: s -> Maybe (a, s)
un cálculo en el que tanto el estado como el valor pueden estar ausentes
Podrá responder la pregunta usted mismo si intenta escribir funciones de "ejecución" para ambas versiones. No tengo instalados los transformadores MTL +, por lo que no puedo hacerlo yo mismo. Uno devolverá (Tal vez a, estado) El otro Tal vez (a, estado) .
Editar: he truncado mi respuesta, ya que agrega detalles que pueden ser confusos. La respuesta de John golpea el clavo en la cabeza.
Supongamos que en lugar de usar State
/ StateT
para almacenar el estado de sus procedimientos, estaba usando un IORef
en la mónada IO
.
A priori, hay dos formas en que puede querer que mzero
(o fail
) se comporte en una combinación de las mónadas IO
y Maybe
:
- o bien
mzero
borra todo el cálculo, de modo quemzero <|> x = x
; o -
mzero
hace que el cálculo actual no devuelva un valor, pero los efectos de tipoIO
se conservan.
Parece que desea el primero, de modo que el estado establecido por un procedimiento se "desenrolla" para el siguiente procedimiento en una cadena de <|>
s.
Por supuesto, esta semántica es imposible de implementar. No sabemos si un cálculo invocará mzero
hasta que lo mzero
, pero hacerlo puede tener efectos de IO
arbitrarios como launchTheMissiles
, que no podemos revertir.
Ahora, tratemos de construir dos pilas de transformadores de mónada diferentes de Maybe
y IO
:
-
IOT Maybe
- oops, esto no existe! -
MaybeT IO
El que existe ( MaybeT IO
) proporciona el comportamiento mzero
que es posible, y el inexistente IOT Maybe
corresponde al otro comportamiento.
Afortunadamente, está utilizando State ProcedureState
, cuyos efectos pueden revertirse, en lugar de IO
; la pila de transformadores de la mónada que desea es StateT ProcedureState Maybe
una.