Saurabh Nanda
Saurabh Nanda

Reputation: 6793

How to build a Monad with non-IO "exterior", but IO "interior"?

I'm trying to write a Monad which renders some HTML, while tracking (and caching) a few specific function calls. Here's what I tried:

data TemplateM a = TemplateM
  { templateCache :: ![(Text, Text)]
  , templateResult :: !(IO a)
  }

Here's how I plan to use this:

renderCached :: Text -> TemplateM Text
renderCached k = 
  -- lookup templateCache from the monadic context, if it lacks the key, 
  -- then fetch the key from an external data source (which is where the
  -- "IO interior" comes from, and store it in templateCache (monadic context)

Notably, I do not want arbitrary IO actions to be executed in TemplateM via lift, liftIO, and suchlike. The only IO that should happen in TemplateM is to fetch something from the cache via the renderCached function.

I was able to define the Functor and Applicative instances for this, but got completely stuck with the Monad instance. Here's how far I got:

instance Functor TemplateM where
  {-# INLINE fmap #-}
  fmap fn tmpl = tmpl{templateResult=fmap fn (templateResult tmpl)}

instance Applicative TemplateM where
  {-# INLINE pure #-}
  pure x = TemplateM
    { templateCache = []
    , templateResult = pure x
    }

  {-# INLINE (<*>) #-}
  fn <*> f =
    let fnCache = templateCache fn
        fnFunction = templateResult fn
        fCache = templateCache f
        fResult = templateResult f
    in TemplateM { templateCache = fnCache <> fCache
                 , templateResult = fnFunction <*> fResult
                 }

Is there any way to write the Monad instance for this without exposing the IO internals to the outside world?

Upvotes: 3

Views: 112

Answers (2)

dfeuer
dfeuer

Reputation: 48631

As others have suggested, the elementary solution here is to use StateT. Since you don't need to store your IORef in a data structure or share it between threads, you can eliminate it altogether. (Of course, if that changes and you do end up wanting to share state across multiple concurrent threads, you'll have to revisit this choice.)

import Control.Monad.State.Strict
import Data.Text (Text)
import Data.Tuple (swap)

newtype TemplateM a = TemplateM {unTemplateM :: StateT [(Text, Text)] IO a}
  deriving (Functor, Applicative, Monad)

renderCached :: Text -> TemplateM Text
renderCached k = TemplateM $ do
  v <- pure $ "rendered template for " <> k
  modify ((k, v) :)
  pure v

runTemplateM :: [(Text, Text)]
             -> TemplateM a
             -> IO ([(Text, Text)], a)
runTemplateM initialCache x = fmap swap $ flip runStateT initialCache (unTemplateM x)

It goes without saying that a cache like this should almost certainly be stored as a structure that is not a list. One promising option is to use text-trie, a data structure Wren Romano designed specially for this purpose. You might also consider a HashMap or even a Map.

Upvotes: 2

Saurabh Nanda
Saurabh Nanda

Reputation: 6793

I've worked out a solution sitting on top of ReaderT, but I really want to get my original idea to work:

newtype TemplateM a = TemplateM { unTemplateM :: ReaderT (IORef [(Text, Text)]) IO a } deriving (Functor, Applicative, Monad)

renderCached :: Text -> TemplateM Text
renderCached k = TemplateM $ do
  -- this is just dummy code. The actual cache lookup has not
  -- been implemented, but the types align
  v <- pure $ "rendered template for " <> k
  cacheRef <- ask
  atomicModifyIORef' cacheRef (\x -> ((k, v):x, ()))
  pure v

runTemplateM :: [(Text, Text)] 
             -> TemplateM a 
             -> IO ([(Text, Text)], a)
runTemplateM initialCache x = do
  initialCacheRef <- newIORef initialCache
  (flip runReaderT) initialCacheRef $ do
    res <- unTemplateM x
    ref <- ask
    finalCache <- readIORef ref
    pure (finalCache, res)

Upvotes: 2

Related Questions