I think optimally, with a set of typeclasses, one could define instances for IO and just use things with no interpretation or build a datastructure with free monads to perform whatever other inspections or manipulations you want. There could be some incompatability between the two notions that I'm missing, though...
I think I've seen the typeclass route as well before. It may have just been a blog post and not a library implementation, though. The two methods have different epistemological ideas, but could be combined. For instance:
{-# LANGUAGE DeriveFunctor, GeneralizedNewtypeDeriving, FlexibleInstances #-}
module CrWr where
import Control.Monad.Free
import Data.IORef
data RWRefF t a = NewRef t (IORef t -> a) | PeekRef (IORef t) (t -> a) | PutRef (IORef t) t a
deriving Functor
newtype RWRef t a = RWRef (Free (RWRefF t) a) deriving Monad
newRef :: t -> RWRef t (IORef t)
newRef t = RWRef $ liftF $ NewRef t id
peekRef :: IORef t -> RWRef t t
peekRef r = RWRef $ liftF $ PeekRef r id
putRef :: IORef t -> t -> RWRef t ()
putRef r t = RWRef $ liftF $ PutRef r t ()
interpret :: RWRef t a -> IO a
interpret (RWRef (Pure a)) = return a
interpret (RWRef (Free (NewRef t f))) = newIORef t >>= interpret . RWRef . f
interpret (RWRef (Free (PeekRef r f))) = readIORef r >>= interpret . RWRef . f
interpret (RWRef (Free (PutRef r t next))) = writeIORef r t >> interpret (RWRef next)
-- Highly specialized class of IO monads
class Monad m => RWIntRefMonad m where
new :: Int -> m (IORef Int)
peek :: IORef Int -> m Int
put :: IORef Int -> Int -> m ()
toIO :: m a -> IO a
instance RWIntRefMonad (RWRef Int) where
new = newRef
peek = peekRef
put = putRef
toIO = interpret
incr :: RWIntRefMonad m => IORef Int -> m ()
incr r = peek r >>= put r . (+1)
main = do r <- newIORef 0
-- We need to specialize before the m gets erased
toIO (incr r :: RWRef Int ())
readIORef r >>= print