Safe Haskell | None |
---|---|
Language | Haskell2010 |
This part of the tutorial will explain the basics behind simple-effects
and how to use the
effects provided by the library. To learn how to implement your own effects, check out the other
parts.
You'll need to enable some extensions to follow along: TypeApplications
, FlexibleContexts
,
OverloadedStrings
, DataKinds
.
State
Let's say we're writing a function that asks the user to name a fruit and adds their answer to a list of already known fruits. Here's what we want to do (in pseudocode)
addFruit = do fruit <- ask "Name a type of fruit please" knownFruits <- getCurrentlyKnownFruits setCurrentlyKnownFruits (fruit : knownFruits)
Our function needs to get input from the user, so we'll need a way to do IO. It also modifies a piece of state: a list of fruits. We state those requirements in the signature.
addFruit :: (MonadIO
m,MonadEffect
(State
[Text
]) m) => m ()
The MonadIO
constraint comes from Control.Monad.IO.Class of the transformers
package and
it lets us do IO without forcing our monad to be IO
.
The other constraint, MonadEffect
, is how you specify effects using this library. It says that
to run addFruit
you need to provide an implementation for a state holding a list of Text
s.
Here's how we might define the addFruit
function.
addFruit :: (MonadIO
m,MonadEffect
(State
[Text
]) m) => m () addFruit = doliftIO
(putStrLn
"Name a type of fruit please") fruit <-liftIO
getLine
knownFruits <-getState
setState
(fruit : knownFruits)
- Note
- It's possible to have more than one state available to use. Since
getState
andsetState
need to work with all of them you'll sometimes need to specify the type of state you want to get/set. In the above case we didn't need to since the compiler can infer that our state is[
. It infersText
]fruit ::
from the signature ofText
getLine
and it can infer the list part since we're using the list cons operator.To help the type checker in cases where it can't infer the types, it's convenient to use the
TypeApplications
extension. With it, we can writefruit <-
andgetState
@[Text
]
to be explicit about which state type we mean.setState
@[Text
] (fruit : knownFruits)
So lets use our function to ask for three types of fruit. After than we want to print the list.
-- this doesn't work yet main ::IO
() main = do addFruit addFruit addFruit fruits <-getState
@[Text
]liftIO
(
This almost works but we still need to provide an implementation of the state. The simplest way
to do that is the implementStateViaStateT
function. It takes an initial value, and a computation
that has a state requirement, and it satisfies that requirement. In this case, the initial
value will be an empty list, and the computation will be our whole do block.
main ::IO
() main =implementStateViaStateT
@[Text
] [] $ do addFruit addFruit addFruit fruits <-getState
@[Text
]liftIO
(
This should work. The reason we don't need to to anything about the MonadIO
constraint is
because the fact that the final result is in the IO
monad automatically satisfies it.
Another thing that we can do with the State
effect (and all the other ones provided by the
simple-effects
library) is provide a custom implementation that can depend on runtime values.
Lets imagine that we have a database and two functions:
getFruits ::MonadIO
m => Connection -> m [Text
] setFruits ::MonadIO
m => Connection -> [Text
] -> m ()
These should get a list of fruits from the database, and store a new list back into it. We can use the exact same code as above, just changing the part that implements the state:
main ::IO
() main = do conn <- connectToDb "my-connection-string"implement
(StateMethods
(getFruits conn) (setFruits conn)) $ do addFruit addFruit addFruit fruits <-getState
@[Text
]liftIO
(
And now suddenly our fruit list persists between sessions. We can do the same but instead talk to some remote API, or read/write from a file, or use a shared variable and run multiple computations at the same time...
Non-determinism
Now lets add an additional effect into the mix. For example, we an use the NonDeterminism
effect.
main ::IO
() main =evaluateAll
$implementStateViaStateT
@[Text
] [] $ do addFruit addFruit addFruit fruits <-getState
@[Text
] fruit <-choose
fruitsliftIO
(
The choose
function non-deterministically picks one fruit from the list and prints it. When
we use it, our do
block gets a new constraint. It's type is now
(MonadIO
m,MonadEffect
(State
[Text
]) m,MonadEffect
NonDeterminism
m) => m ()
Therefore we need to provide an implementation of the NonDeterminism
effect before we run the
whole thing. This is what the evaluateAll
function does. It runs the computation for each
non-deterministic possibility, meaning all the fruit will get printed.
- Note
- Instead of repeating
MonadEffect
for each effect, you can use theMonadEffects
type family and give it a list of effects instead. Like this(
MonadIO
m,MonadEffects
'[State
[Text
],NonDeterminism
] m) => m ()
Order
One thing to note is that the order in which you implement effects sometimes matters. For example, handling state first and then non-determinism after will result in the state being forked on each non-deterministic branch. Doing it in the reverse order will make the state shared between branches meaning that changes in one branch will affect the state when the next branch is taken. Here's an example
main :: IO () main = doevaluateAll
$implementStateViaStateT
@Int 0 $ dosetState
@Int 1choose
(replicate
3 ())setState
.succ
=<<getState
@IntliftIO
.getState
@IntputStrLn
""implementStateViaStateT
@Int 0 $evaluateAll
$ dosetState
@Int 1choose
(replicate
3 ())setState
.succ
=<<getState
@IntliftIO
.getState
@Int
The first run prints 2 2 2
because the state is handled first, while the second run prints
2 3 4
. A useful way to get an intuitive understanding of which order does what is to consider
that in the second case, after we handle non-determinism we can still get the state value because
we have yet to handle that effect. But if state was forked on each choice there could be many
possible state values after the whole computation finishes. Which one would getState
return?
The only way it makes sense is if the state is shared since it keeps things unambiguous.
On the other hand maybe we do want to see all the possible end states. The following example demonstrates how the two orderings let us do those two things.
main4 :: IO () main4 = do lst <-evaluateToList
$implementStateViaStateT
@Int 0 $ dosetState
@Int 1choose
(replicate
3 ())setState
.succ
=<<getState
@IntgetState
@IntimplementStateViaStateT
@Int 0 $ doevaluateAll
$ dosetState
@Int 1choose
(replicate
3 ())setState
.succ
=<<getState
@IntliftIO
.getState
@Int
The first run gets the state value at the end. This will be the end state for each fork. This
value becomes the result of the whole implementStateViaStateT
block but since we still need
to handle the non-determinism, this whole block is also ran once for each possible branch.
Finally, we collect all the results in a list using the evaluateToList
function. This handles
the non-determinism.
In the second run we get the state after handling non-determinism. This gives us the final value of the state shared between all the branches.
This concludes the first part of the tutorial. To see how other effects are used, check out the examples and the documentation of their respective modules.
If you want a more in-depth look into the inner workings of this library, continue to the second part of the tutorial: Tutorial.T2_Details. We'll look at implementing custom effects in part 3, Tutorial.T3_CustomEffects.