Do We Really Need All These Monad Transformers?
Since I first learned about them, I’ve been a fan of Monad Transformers. Just stick them all together and we can pretend we’re writing Java, who doesn’t want that? Mutable State, Logging, Configuration, Exceptions, they’re all there. Heck, stick some IO in that if you like. Apparently, there’s even a pre-built Reader/Writer/State Monad in there. However, lately I’ve been working on a fairly large Haskell project, and this project doesn’t use Monad transformers. And you know what? It’s working out for them just fine.
Lately, I’ve been wondering if all these transformers are really worth the effort? After you’ve chained together some massive
runFoo (runBar (runBaz (runMonadRun ))) foobarbaz function, and got it all working right and not returning some ridiculous nested tuple, what have you gained?
First up is
ReaderT let’s us carry around a read-only configuration. In an
ReaderT Monad, we can do the following:
foo :: (MonadReader c m) => a -> m b foo a = do -- stuff config <- ask -- more stuff, presumably using config
…without having to have a config parameter on our function. This is nice because it improves readability. Right? Because this is so bad:
foo :: c -> a -> b foo c a = -- stuff, presumably using config
ReaderT gets you
local!” you say:
foo :: (MonadReader c m) => a -> m b foo a = do -- stuff local modifyC bar where modifyC c = -- change the config bar = -- some monad
Nifty, I agree. Or we chould just do:
foo :: c -> a -> b foo c a = bar (modifyC c) where modifyC c = -- change the config bar c = -- some function of c
…which I believe is much clearer, because I didn’t have to go to Hackage to figure out what
Conceptually kind of a combination of
WriterT (I’m going to skip
WriterT for the sake of brevity),
StateT lets us use mutable state in a Monadic function:
foo :: (MonadState s m) => a -> m b foo a = do state <- get -- do stuff, presumably change the state put state' -- more stuff
So, what’s the non-monadic alternative? I imagine something like this:
foo :: s -> a -> (s, b) foo s a = (s', a') where s' = -- do something to change the state a' = -- do something using s'
I suppose that’d be workable, but now we have this tuple to deal with. We have a few options. We can do pattern matching:
bar :: a -> (s, b) bar a = (s', b') where s = -- some initial state (s', b) = foo s a b' = -- do something, using b but not s'
…or we can use something like
-- uncurry :: (a -> b -> c) -> (a, b) -> c baz :: s -> a -> (s, b) baz a = foo (uncurry $ bar a)
Both of these are much harder to understand than the Monadic version in my opinion. For both, we have to shimmy our data to fit the interface, and these are just contrived examples.
Finally, I’d like to talk about
ExceptT. This monad lets us simulate exceptions. Unlike Haskell’s normal exception system, where exceptions can only be caught in
IO, these exceptions can be caught within the
crud :: (MonadError String m) => a -> m b crud a = throwError "Oh crud!" -- doesn't catch foo :: (MonadError String m) => a -> m c foo a = do -- stuff res <- curd a -- doesn't make it this far -- catches the exception bar :: (MonadError String m) => a -> m c bar a = do -- stuff res <- catchError handler (crud a) -- still rolling where handler e = -- exception handler
Seems reasonable right? Or, we could just use
crud :: a -> Either String b crud a = Left "Oh crud!" -- must handle the error foo :: a -> c foo a = case (crud a) of Left e -> -- handle error Right c -> -- all good
Personally, I find these two to be a wash. Both are understandable. The Monadic version has the potential for the programmer to forget to handle the error, but the non-monadic version involved a noisy case statement.
Not As Clear As It Seemed Before
As you can see, these things are hit and miss. Having thought about it while typing this out, if I had some function that just needed to read a config and throw an error, I’d probably define it like this:
foo :: c -> a -> Either String b
…however, if I needed to throw some state in there:
foo :: (MonadReader c m, MonadState s m, MonadError e m) => a -> m b
StateT become more attractive; why not throw them on the stack? I suppose the point is that maybe using a huge transformer isn’t the best way, not that it certainly isn’t the best way. Just some food for thought.