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!