Need for feedback: shoehorning a stateful computation into a state-less, IO-powered moandic stack

When I implemented my notification server using DBus, what I needed is to call makeMethod with a third argument being a stateful computation (because I needed to keep track of the most recently issued notification ID).

However, what makeMethod accepts as its 3rd argument is a MethodCall -> DBusR Reply, and the issue is that DBusR Reply is simply an alias for ReaderT Client IO Reply, whereas I wanted to have the power of StateT TheState (ReaderT Client m) Reply. What I ended up doing is writing this function:

state2IORef :: MonadIO m => (a -> StateT s m c) -> s -> IO (a -> m c)
state2IORef statefulComp s0 = do
  s0ref <- liftIO $ newIORef s0
  return $ \a -> do
    (r, s) <- runStateT (statefulComp a) =<< liftIO (readIORef s0ref)
    liftIO $ writeIORef s0ref s
    pure r

which accepts the StateT ... computation and the initial value of the state, and returns a computation of the underlying monadic type, but using IO and an IORef to take care of the state evolution.

At the call site, I can do

func <- state2IORef myStatefulComp initialState
-- func has the type that makeMethod requires

The “advantage” is that myStatefulComp doesn’t need to liftIO for doing state management, the disadvantage is that the above looks a bit clunky.

Is the above approach overkill? Does anybody know what the recommended/consolidated approach is in such scenarii?

Do you want to share the state between multiple invocations of func? If so then I think this is fine. If not then you can use

state2IORef2 :: MonadIO m => (a -> StateT s m c) -> s -> a -> m c
state2IORef2 statefulComp s0 a = do
  s0ref <- liftIO $ newIORef s0
  (r, s) <- runStateT (statefulComp a) =<< liftIO (readIORef s0ref)
  liftIO $ writeIORef s0ref s
  pure r

I’m not sure there is one single recommended approach, but I developed Bluefin to tackle these kinds of issues, so if I were working with dbus I’d make a Bluefin like this:

-- Abstract types: constructors not exposed
newtype DBusH (e :: Effects) = MkDBusH DBus.Client
newtype Method (e :: EFfects) = MkMethod (DBus.Method)

makeMethod
  :: MemberName
  -> Signature -- ^ Input parameter signature
  -> Signature -- ^ Output parameter signature
  -> (forall e. MethodCall -> DBusH e -> Eff (e :& es) Reply)
  -> Method es

(There are different ways to cook this, and some implementation details to take care of, but they all work out roughly the same. dbus is written in ReaderT of IO style, so it should be fairly easy to wrap.)

Then myStatefulComp would be something whose type contains a State argument, for example

myStatefulComp ::
  (e1 :> es, e2 :> es, e3 :> es) =>
  State s e1 ->
  Whatever e2 ->
  MethodCall ->
  DBusH e3 ->
  Eff es r

and you can use it like

let method =
      makeMethod
        memberName
        sig1
        sig2
        (myStatefulComp myState myWhatever

with no need to translate from StateT.

Do you want to share the state between multiple invocations of func ?

Yes, that’s what I do.

if I were working with dbus

Do I understand correctly that in what follows you’re suggesting an improvement for the dbus library (via your proposed code or another of those “different ways to cook this”)?

I think I’d say it’s a slightly different alternative API that puts the dbus library into a more “Bluefin style” (but since it’s written in ReaderT of IO style it’s already pretty close). The “different ways to cook this” refers to a few slightly different possible implementations of the Bluefin skin that could be put on top of dbus.

Yes, it’s overkill. The approach I would recommend here is to keep it simple: just use the IORef directly. There are of course other approaches like effect systems, but they’re also overkill if it’s just for this.

The “advantage” of your approach is nothing that can’t be achieved just by binding convenient local names, e.g. with

mkState :: MonadIO m => s -> IO (m s, s -> m ())
mkState s = newIORef s <&> \r ->
  ( liftIO $  readIORef r
  , liftIO . writeIORef r
  )

Apart from introducing unwarranted complexity, there are other issues with the approach if the context is concurrent; the read/write isn’t atomic so updates can get lost. In that case, you’ll want to use something like

atomic :: MonadIO m => IORef s -> State s a -> m a
atomic r update = liftIO $ atomicModifyIORef' r (swap . runState update)

or

locking :: MVar s -> StateT s (ReaderT r IO) a -> ReaderT r IO a
locking mv update = ReaderT \r ->
  modifyMVar mv \s ->
    swap <$> runReaderT (runStateT update s) r

instead.

4 Likes