Wednesday, September 29, 2021

Adventures in Looping

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.