The beauty and the horror of Haskell monads
They are wonderful, but terrible
This post assumes some understanding of and familiarity with Haskell monads.
The beauty
There are a number of patterns that come up frequently in programming. Some of these are:
- Combining IO actions, making the result of each available to later ones: IO monad
- Building up, or reading from, a peusdo-mutable state or configuration: Reader, Writer, and State monads
- Running a sequence of functions where the later are skipped if the previous doesn't return a meaningful result: Maybe monad
- Running a function over all possible combined values of a number of lists: List monad
The beautiful thing is that problems that at face value are completely different actually share a common structure. They can be decomposed into their domain-specific components, and >>=
(bind) and return
, where, although different for all of the above examples, adhere to certain laws. Once you are familiar with any given instance of >>=
and return
, then it's really quite amazing how much can be achieved, and communicated, with just a few lines of code.
The horror
The "once you are familiar with any given instance of >>=
" hints at the horror. The >>=
and return
functions are (terribly?) overloaded. Each of the monads use the same notation, but they can all do radically different things. To know what any >>=
actually does, you have to infer its type from the surrounding code, and look to its definition. Similarly a do-block's behaviour depends radially on the definition of >>=
: and it might be even worse, because >>=
isn't even present in the code.
Taking and modifying some examples from What I Wish I Knew When Learning Haskell, we can see a use of the Maybe monad,
main :: IO ()
main = putStrLn . show $ example
example :: Maybe Int
example = do
a <- Just 3
b <- Nothing
return $ a + b
and an example of the Reader monad.
import Control.Monad.Reader
data MyContext = MyContext
{ foo :: String
, bar :: Int
} deriving (Show)
main :: IO ()
main = putStrLn . show $ runReader computation $ MyContext "hello" 1
computation :: Reader MyContext (Maybe String)
computation = do
n <- asks bar
x <- asks foo
if n > 0
then return (Just x)
else return Nothing
The monadic values example
and compution
definitions have similarities: do notation, <-, and return. However, these similarities are misleading. Take for example return
from the Maybe monad. Its definition is
return = Just
while the Reader monad's definition is
return a = Reader $ \_ -> a
Similarly, because of the different implementations of >>=
, line breaks in the do-notations result in different code. Hence how monads are sometimes described as programmable semicolons.
There is a lot of non-explicit behaviour going on. In code reviews, I often use the term "magical" if there is non-explicit behaviour, and I usually suggest it is to be avoided. Yes, >>=
and return
are likely to abide by the monad laws, and they certainly do in these examples, but I'm unsure if this is enough to understand what's going on.
As a parallel, Mike Acton, in his talk on data-oriented design, suggests only using C++ operator overloading that is "super obvious", such as adding vectors. Uncle Bob also teaches us to be more explicit.
The name of a variable, function, or class, should answer all the big questions. It should tell you why it exists, what it does, and how it is used.
Robert C. Martin
There are reasonable arguments that monads in Haskell, by overloading >>=
and return
, break these guidlines. Of course, perhaps I will feel differently once I have been working with Haskell for longer. We will see!