Adventures in Looping
I was recently building a Slack bot in Haskell. The core of the Slack integration was a never-ending loop that read messages from a web socket and performed actions based on the message. But how should I go about looping forever in Haskell?
My first pass was to use the aptly-named forever function. My understanding of forever
was that it ran a provided IO
action over and over (this understanding was wrong, we’ll get to that). My initial code looked vaguely like this:
main :: IO ()
main = do
wsUrl <- fetchConnectionUrl
conn <- connectWebSocket wsUrl
forever $ do
message <- readMessage conn
case message of
MessageA val -> putStrLn "Message A"
MessageB val -> putStrLn "Message B"
Great, I’ve done it! But hold on a second, it turns out that Slack occasionally sends a Disconnect
message. In the case of a disconnect, I need to re-fetch a new connection URL, reconnect the web socket, and start looping again. Hmm, ok, let’s try something else:
main :: IO ()
main = forever $ do
wsUrl <- fetchConnectionUrl
conn <- connectWebSocket wsUrl
forever $ do
message <- readMessage conn
case message of
MessageA val -> putStrLn "Message A"
MessageB val -> putStrLn "Message B"
Disconnect -> undefined -- what to do here?
I’ve added an outer forever
to main
, so now any time the inner loop exits, I’ll reconnect and start running the message loop again. But how do I exit from a forever
loop? Ok, I guess we should use direct recursion.
loop :: Connection -> IO ()
loop conn = do
message <- readMessage conn
case message of
MessageA val -> do
putStrLn "Message A"
loop conn
MessageB val -> do
putStrLn "Message B"
loop conn
Disconnect -> pure ()
main :: IO ()
main = forever $ do
wsUrl <- fetchConnectionUrl
conn <- connectWebSocket wsUrl
loop conn
Well, this works, but I’m not thrilled. It’s frustrating that we have to remember to re-enter the loop in every case where we’re not exiting. If only there was some way to exit from a forever
loop. It turns out that there’s a post about this on “Haskell for all”! Ok, let’s just use this technique!
main :: IO ()
main = forever $ do
wsUrl <- fetchConnectionUrl
conn <- connectWebSocket wsUrl
void $ runMaybeT $ forever $ do
message <- liftIO $ readMessage conn
case message of
MessageA val -> liftIO $ putStrLn "Message A"
MessageB val -> liftIO $ putStrLn "Message B"
Disconnect -> mzero
Awesome! Wait, what? How does this work?
The forever
Function
First let’s look at the source of forever
:
forever :: (Applicative f) => f a -> f b
forever a = let a' = a *> a' in a'
There’s a lot going on in this small function. First, notice that we can use forever
with any Applicative, not just IO
and not just with monads. Next, let’s look at the *>
operator.
λ> :t (*>)
(*>) :: Applicative f => f a -> f b -> f b
The docs for *>
do a very good job of describing this function.
Sequence actions, discarding the value of the first argument.
'as *> bs' can be understood as the do expression
do as
bs
Finally, the clever let
expression takes advantage of laziness and self-reference to create an expression that basically looks like this:
a *> a *> a *> a *> -- ... and so on
For an IO ()
expression, this does what we expect – runs the first expression and then the second expression.
λ> putStrLn "hello world" *> putStrLn "some other thing"
hello world
some other thing
Introducing MaybeT
Before we get to MaybeT
, let’s look at how *>
works for plain-old Maybe
.
λ> Just 1 *> Just 2
Just 2
λ> Nothing *> Just 2
Nothing
The first example feels very similar to our IO ()
example, but the second is different. If we start with Nothing
, our sequence does not continue. It simply “short circuits” with Nothing
.
Alright, but what is MaybeT
? Fully explaining monad transformers is beyond the scope of this post, but for today we can think of it as being a little wrapper around IO
that gives us the capabilities of both IO
and Maybe
at the same time, albeit with a little extra boiler plate (liftIO
). Let’s try it out in the console.
λ> let part1 = do liftIO (putStrLn "hello"); pure 1
λ> let part2 = do liftIO (putStrLn "world"); pure 2
λ> runMaybeT $ part1 *> part2
hello
world
Just 2
The runMaybeT
function peels off the MaybeT
from our computation and returns an m (Maybe a)
(in this case, our m
is IO
).
λ> :t runMaybeT
runMaybeT :: MaybeT m a -> m (Maybe a)
Notice above that we run both of the IO
side effects but only return the second Maybe
value.
Let’s see if we can take advantage of Maybe
’s short-circuiting *>
behaviour with MaybeT
as well.
λ> let part1 = pure Nothing
λ> let part2 = do liftIO (putStrLn "world"); pure 2
λ> runMaybeT $ part1 *> part2
world
Just 2
That didn’t work. We need something that acts like Nothing
did for Maybe
, but in the context of MaybeT
. Hey, that’s what mzero is!
λ> let part2 = do liftIO (putStrLn "world"); pure 2
λ> runMaybeT $ mzero *> part2
Nothing
Look at that! Not only did we return Nothing
as expected, we did not run part2
at all – no side-effects were performed.
Putting it All Together
If forever
combines the same operation over and over with *>
, and we can run MaybeT
operations with forever
, then we can “exit” the forever
loop with mzero
! Now, our original example should make sense. I’ve included it below for convenience.
main :: IO ()
main = forever $ do
wsUrl <- fetchConnectionUrl
conn <- connectWebSocket wsUrl
void $ runMaybeT $ forever $ do
message <- liftIO $ readMessage conn
case message of
MessageA val -> liftIO $ putStrLn "Message A"
MessageB val -> liftIO $ putStrLn "Message B"
Disconnect -> mzero
Oh, one last thing – that void
function simply throws away the result inside of a Functor
and replaces it with ()
.
λ> :t void
void :: Functor f => f a -> f ()
Our main loop doesn’t care about return values, so we throw away the result after runMaybeT
. We’re only running this loop for side-effects.
from Hacker News https://ift.tt/39MvgwT
No comments:
Post a Comment
Note: Only a member of this blog may post a comment.