2020-06-08
In today’s article I’ll be showing you a way to write pure functions that are Monad-friendly meaning that they are composable with Monadic contexts.
Let’s first get the imports and language extensions we’d be using for this article out of the way.
-- app.hs
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
import Control.Monad (forever)
import Control.Monad.Except (ExceptT, MonadError, catchError,
runExceptT, throwError)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad.State (MonadState, StateT, evalStateT, get,
modify)
import Numeric (readDec)
import System.IO (BufferMode (NoBuffering),
hSetBuffering, stdout)Cool. We can get started now!
Say you want to write a pure function that has a possibility of
failing. What type signature would you give it? One of the common ones
is Either String a. For example, let’s write a function
that given a list of integers returns their average.
-- app.hs
avg' :: [Int] -> Either String Double
avg' [] = Left "Cannot take average of empty list"
avg' xs = Right $ fromIntegral (sum xs) / fromIntegral (length xs)Functions with concrete return types such as
Either String a are not directly compatible with Monadic
contexts other than Either String. Suppose we have an
App monad for our application defined as follows.
-- app.hs
newtype App s m e a = App
{ unapp :: StateT s (ExceptT e m) a
} deriving (Functor, Applicative, Monad, MonadError e, MonadIO, MonadState s)Calling our avg' function from within App
context is not straightforward as the Either e monad is not
compatible with our App monad out of the box. We’ll need to
write a function that can lift a value of type Either e a
to App s m e a type. Although writing such a function is
possible, we can do better.
Instead of coding pure functions to concrete types, we can code them
to typeclasses. The typeclass that abstracts the functionality offered
by Either e type is MonadError e. Let’s
redefine our average function so that it operates within
MonadError context.
-- app.hs
avg :: MonadError String m => [Int] -> m Double
avg [] = throwError "Cannot take average of empty list"
avg xs = pure $ fromIntegral (sum xs) / fromIntegral (length xs)What did we change? Notice that we no longer return
Either String Double anymore. Instead, we return a
Double within a MonadError String context.
This MonadError String context could be any
MonadError String instance! Notice that our
App s m e monad is an instance of MonadError e
making it compatible with our new avg function when
e = String
We have also replaced Left with throwError
and Right with pure. For
Either e a monad the functionality is exactly the same as
before but now our function is more generic.
Let’s confirm that avg function does indeed work within
different MonadError String contexts.
-- ghci
λ> avg [1,2,3] :: Either String Double
Right 2.0
λ> let x = avg [1,2,3] :: Monad m => App s m String Double
λ> :t x
x :: Monad m => App s m String Double
Nice.
Let’s now write a simple application that would demonstrate the usefulness of Monad compatible pure functions. We will write a console app to print online averages of integers. The app will continuously keep asking the user for integer values and print the average of all values collected until now after each integer is read.
Enter an integer: 5
Current avg: 5.0
Enter an integer: 0
Current avg: 2.5
Enter an integer: abc
Could not parse "abc" to an int
Enter an integer: -2
Current avg: 1.0
We will use State [Int] monad to store the current state
of integers collected. So, our app will run in
App [Int] IO String monadic context. Let’s declare a type
alias for this type for convenience.
type MyApp = App [Int] IO String
To run our App s m e monad, we will need to unwrap and
run all its transformers one by one.
-- app.hs
runapp :: Monad m => s -> App s m e a -> m (Either e a)
runapp s = runExceptT . flip evalStateT s . unappNow let’s define a readInt function that will try to
parse an integer from a string while handling any errors. For this we
will use readDec function from Numeric module
with some modifications.
-- app.hs
readInt :: MonadError String m => String -> m Int
readInt [] = throwError "Cannot read int from empty string"
readInt ('-':xs) = negate <$> readInt xs -- Handle negative integers
readInt xs = case readDec xs of
[(v, "")] -> pure v
_ -> throwError $ "Could not parse " <> show xs <> " to an int"Note that we have defined readInt function within
MonadError String context just like the avg
function.
Next, we define a function getInt that will ask the user
to input an integer, try to parse the input, and ask the user to input
again if there was any error.
-- app.hs
getInt' :: MyApp Int
getInt' =
liftIO (putStr "Enter an integer: ")
*> liftIO getLine
>>= readInt
getInt :: MyApp Int
getInt = getInt' `catchError` \e -> (liftIO . putStrLn $ e) *> getIntNote how we are able to call our pure readInt function
from within MyApp monad without any lifting.
Alright, we have all the pieces we need. Now let’s define a loop that would ask the user for input and display the current averages.
-- app.hs
go :: MyApp ()
go = forever $ do
x <- getInt -- get int from user
modify (x:) -- prepend new int to our state
y <- avg =<< get -- compute the average of collected ints
liftIO $ putStrLn $ "Current avg: " <> show yOnce again, we are able to call pure avg function from
MyApp context without having to perform any lift
juggling.
And that’s it. We can now call go from our
main function to start the app.
-- app.hs
main :: IO ()
main = do
hSetBuffering stdout NoBuffering -- so that everything is printed right away
runapp [] go >>= either print (const $ pure ())We saw today how pure functions can be made more generic so that they may be called from monadic contexts without much trouble.
You might have noticed that MonadError String is not
completely generic as it assumes the error type to be
String. We cannot get rid of the concrete error type as we
need it to create the error value.
However, we can make our app monad an instance of
Bifunctor to easily convert a value of type
App s m e a to App s m e' a. The function that
does this is called
first.
I will cover
Bifunctor
in detail in a separate blog post.
Complete program is reproduced below if you want to copy it. ;)
-- app.hs
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
import Control.Monad (forever)
import Control.Monad.Except (ExceptT, MonadError, catchError,
runExceptT, throwError)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad.State (MonadState, StateT, evalStateT, get,
modify)
import Numeric (readDec)
import System.IO (BufferMode (NoBuffering),
hSetBuffering, stdout)
avg :: (MonadError String m) => [Int] -> m Double
avg [] = throwError "Cannot take average of empty list"
avg xs = pure $ fromIntegral (sum xs) / fromIntegral (length xs)
readInt :: MonadError String m => String -> m Int
readInt [] = throwError "Cannot read int from empty string"
readInt ('-':xs) = negate <$> readInt xs -- Handle negative integers
readInt xs = case readDec xs of
[(v, "")] -> pure v
_ -> throwError $ "Could not parse " <> show xs <> " to an int"
newtype App s m e a = App
{ unapp :: StateT s (ExceptT e m) a
} deriving (Functor, Applicative, Monad, MonadError e, MonadIO, MonadState s)
type MyApp = App [Int] IO String
runapp :: Monad m => s -> App s m e a -> m (Either e a)
runapp s = runExceptT . flip evalStateT s . unapp
getInt' :: MyApp Int
getInt' =
liftIO (putStr "Enter an integer: ")
*> liftIO getLine
>>= readInt
getInt :: MyApp Int
getInt = getInt' `catchError` \e -> printError e *> getInt
where printError e = liftIO $ putStrLn e
go :: MyApp ()
go = forever $ do
x <- getInt
modify (x:)
y <- avg =<< get
liftIO $ putStrLn $ "Current avg: " <> show y
main :: IO ()
main = do
hSetBuffering stdout NoBuffering
runapp [] go >>= either print (const $ pure ())