Extending Monads for Debugging in Haskell

Posted July 2, 2012 ‐ 3 min read

One of the nice things about Haskell is the ability to extend the class of Monads.

One of the original purposes of Monads was to describe flow while leaving the implementation of the flow to a later stage. This allows to define what happens as a side effect of the computational steps.

For example, let's say we have a computation that we would like to debug. If we formulate it algebraically, it would be harder to checks the step of the computation in run-time. So naturally we break the computation into statement-like stages, introducing code to trace the intermediate results in between.

However, sometimes we would also like to keep the performance of the 'untraced' computation as it was. In C++ we can can use templates in order to instantiate two implementations of the computation. In C we would probably use macros trickery of some sort along with static inline functions that define to nothing. In Python we would probably use a global debug variable, the __debug__ builtin, or a dedicated logging library.

In Haskell, this can come naturally as an extension of the Monad class, with the advantage of multiple instantiation. To illustrate, let's extend Monad with MonadDebug:

import Control.Monad.Identity (runIdentity, Identity)

class Monad m => MonadDebug m where
  logDebug :: String -> m ()

instance MonadDebug IO where
  logDebug s = putStrLn s

instance MonadDebug Identity where
  logDebug _ = return ()

Those instances make it possible to use class function logDebug directly under IO or under pure computations with the Identity Monad.

Let's define a sample computation function:

computation :: MonadDebug m => Integer -> Integer -> m Integer
computation x y = do
  let t1 = x * 2 + y
  logDebug $ "Here, t1=" ++ (show t1)
  let t2 = t1 * 3 + x
  logDebug $ "Here, t2=" ++ (show t2)
  let t3 = t2 * 7 - x * x
  logDebug $ "Here, t3=" ++ (show t3)
  return t3

The type signature for computation is optional, and can be inferred by the compiler, simply because we referenced logDebug under our Monad.

Now, let's try to use it under the two environments. Here's the code:

main :: IO ()
main = do
  putStrLn "Run computation as pure:"
  let t = runIdentity $ computation 1 2
  putStr "Result: "
  print t
  putStrLn ""

  putStrLn "Run computation with impure IO logging stages: "
  t' <- computation 1 2
  putStr "Result: "
  print t'

Let's try to run it:

# runghc test.hs
Run computation as pure:
Result: 90

Run computation with impure IO logging stages:
Here, t1=4
Here, t2=13
Here, t3=90
Result: 90

The advantage is that the compiler can optimize the pure Identity Monad computation much better compared to the non-pure computation, and we achieve this without using any Haskell constructs that are much sophisticated.

p.s. A novice reader might also be able to devise MonadDebug instances for the various Monad transformer classes under the cases where MonadIO is the underlying Monad.


Share this post
Follow author