Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to create a limited version of the IO monad

I have written a number of functions using a monad transformer stack:

data Options
data Result
data Input
type Ingest a = EitherT String (ReaderT Options IO) a

foo :: Input -> Ingest Result

and so on. Now, most of these functions are fundamentally pure. I only need IO in one of these functions: this function reads a file, and logs (using log :: String -> IO ()) that it has done so. So the impurity of this one function "infects" my whole codebase, making all these functions capable of doing IO even though they don't need to, except to call this one function. This is distasteful for two reasons:

  • It doesn't make it clear what limited subset of IO these might perform
  • It doesn't make it clear which functions actually perform that IO

A colleague suggested parameterizing Ingest over a base monad type, which I like. Specifically, define a typeclass for reading the contents of a file, and provide an instance for IO as well as perhaps for some other monad to use in writing tests:

class Monad m => ReadFile m where
  readFile :: FilePath -> m Text

instance ReadFile IO where
  readFile = Data.Text.IO.readFile

A typeclass for logging already exists, so I could just use that existing typeclass. But then I'm not sure how to use this new class. Firstly, what do I replace my type alias with? I can't write

type Ingest m a = (Logging m, ReadFile m) => EitherT String (ReaderT Options m) a

because constraints are not permitted on type synonyms. Must I add that constraint to each of my functions? In principle this is nice because it flags the functions which may need to read a file, but in practice these functions are mutually recursive, and so all of them need that permission, making it a pain to write them all out.

I could define a newtype wrapper instead of a type synonym, but I don't think that makes things any better: I still have to add this new constraint to each of my functions.

Secondly, how do I actually call the functions of my new typeclass, from within the EitherT/ReaderT stack? I can't simply write

foo :: ReadFile m => FilePath -> Ingest m Text
foo = readFile

because this ignores the EitherT and ReaderT wrappers. Do I instead write this?

foo :: ReadFile m => FilePath -> Ingest m Text
foo = lift . lift $ readFile

Seems kinda a pain and quite fragile to the structure of the transformer stack. Do I write a number of instances like

instance ReadFile m => ReadFile (EitherT e m) where
  readFile = lift readFile

? That seems like a frustrating amount of boilerplate as well.

like image 269
amalloy Avatar asked Jan 09 '18 00:01

amalloy


1 Answers

Instead of using IO directly, wrap it in a abstract newtype e.g:

module IOLog(IOLog, logMsg) where 

newtype IOLog a = IOLog (IO a)

instance Functor IOLog where ...

instance Monad IOLog where ...

logMsg :: String -> IOLog ()
logMsg =  logIO . log

 -- local definitions --
 --
logIO :: IO a -> IOLog a
logIO =  IOLog

log :: String -> IO ()
         .
         .
         .

and use that to define Ingest:

type Ingest a = EitherT String (ReaderT Options IOLog) a

In this way you can control what subset of I/O the rest of the program can use, for the price of an extra module - no type-system extensions needed!

like image 154
atravers Avatar answered Sep 28 '22 02:09

atravers