Search code examples
haskellfunctional-programmingstate-monadio-monad

Can I use StateT/MaybeT/forever to eliminate explicit recursion from this IO action?


I have a program like this,

start :: [Q] -> R -> IO R
start qs = fix $ \recurse r -> do
  q <- select qs
  (r', exit) <- askQ q r
  (if exit
    then return
    else recurse) r'

that takes a list of Questions, a Report, and returns a new Report, in the IO monad because select needs it to pick a question at random (and also because askQ will wait for user keyboard input); however, the user did not choose to exit while executing askQ, start will recursively call itself. (fix $ \recurse is the trick to write a recursive lambda.)

The above code smells a lot like a few things:

But can't really tell if any or more of those abstractions can be used to write the above code in a more idiomatic way, most importantly avoiding the explicit recursion.


This is just some experiment I've done in GHCi to gather a better understanding of the accepted answer.

Here's some utils for the following,

type M = MaybeT (StateT [String] IO) Int)
printAndRet rs@(r, s) = putStrLn ("result: " ++ show r ++ ", state: " ++ show s)
                        >> return rs

Here's 4 computations happening in the MaybeT-StateT-composed monad defined there,

c1 = ((MaybeT $ StateT $ \s -> printAndRet (Just 1, "again":s) ) :: M
c2 = ((MaybeT $ StateT $ \s -> printAndRet (Just 2, "once more":s) ) :: M
c3 = ((MaybeT $ StateT $ \s -> printAndRet (Nothing, "a final time":s) ) :: M
c4 = ((MaybeT $ StateT $ \s -> printAndRet (Just 10, "and never again":s) ) :: M

and here's what happens when we chain them together with >> and run the resulting action:

flip runStateT ["some initial state"] $ runMaybeT $ (c1 >> c2 >> c3 >> c4)
result: Just 1, state: ["again","some initial state"]
result: Just 2, state: ["once more","again","some initial state"]
result: Nothing, state: ["a final time","once more","again","some initial state"]
(Nothing,["a final time","once more","again","some initial state"])

Solution

  • Well, the result would be something like:

    type M = MaybeT (StateT R IO)
    
    start :: [Q] -> M ()
    start qs = forever (select qs >>= askQ)
    

    This presumes that select and askQ will be rewritten to run in the M monad instead of IO:

    select :: [Q] -> M Q
    askQ :: Q -> M ()
    

    The result is very... succinct.

    A standalone example, for posterity...

    import Data.Coerce
    import Control.Applicative
    import Control.Monad
    import Control.Monad.State
    import Control.Monad.Trans.Maybe
    import System.Random
    
    newtype Q = Q String deriving (Show)
    newtype R = R [String] deriving (Show)
    
    type M = MaybeT (StateT R IO)
    
    runM :: M a -> IO (Maybe a, R)
    runM = flip runStateT (R []) . runMaybeT
    
    select :: [Q] -> M Q
    select qs = (qs !!) <$> randomRIO (0, length qs - 1)
    
    askQ :: Q -> M ()
    askQ (Q q) = do
      liftIO $ putStrLn q
      r <- liftIO getLine
      if r == "exit" then mzero
        else modify (coerce (r:))
    
    start :: [Q] -> M ()
    start qs = forever (select qs >>= askQ)
    
    main :: IO ()
    main = do
      result <- runM $ start [ Q "Is this idiomatic?"
                             , Q "Seriously, what's wrong with recursion?"
                             ]
      print result
    

    It seems to work:

    λ> main
    Seriously, what's wrong with recursion?
    nothing
    Is this idiomatic?
    yes
    Is this idiomatic?
    exit
    (Nothing,R ["yes","nothing"])