[Haskell-cafe] How to write a polymorphic serializer?

Li-yao Xia lysxia at gmail.com
Fri Sep 15 14:12:43 UTC 2017


Hi Viktor,

This example is odd because it doesn't seem like the lock is doing anything.
Probably, the details that would make it more interesting have just been 
abstracted away, and I would guess that you do want a way to work with a 
single global lock.

A common pattern is that every MonadSomething type class (like 
Serializable here) comes with a SomethingT transformer providing the 
required environment.
In fact, this is a specialization of ReaderT/MonadReader.

     class Monad m => Serializable m where
       serially :: IO a -> m a

       default serially :: (m ~ t n, MonadTrans t, Serializable n) => IO 
a -> m a
       serially io = lift (serialize io)

     newtype SerializeT m a = SerializeT { runSerializeT :: MVar () -> m a }
     -- Monad instance

     instance MonadIO m => Serializable (SerializeT m) where
       serialize io = SerializeT (\lock -> liftIO (withMVar lock (const 
io)))

     instance Serializable m => Serializable (StateT s m)
     -- every other monad transformer where this makes sense

Keep foo and bar polymorphic, with MonadSomething constraints:

     -- No 'serially' argument
     foo :: (Serializable m, MonadIO m) => ... -> m ()
     foo ... = do
       ...
       serially $ ...

     bar :: (Serializable m, MonadState Int m) => ... -> m ()

You can compose foo and bar together so they are guaranteed to run under 
the same lock.

   baz :: (Serializable m, MonadState Int m, MonadIO m) => ... -> m ()
   baz ... ...  = do
     foo ...
     bar ...

To run an SerializeT action you still need to unwrap it explicitly, but 
just once, ensuring only one lock is used throughout the given action, 
and no user can mess with it as long as your types are abstract to them.

     serialize :: MonadIO m => SerializeT m a -> m a
     serialize m = do
       lock <- liftIO (newMVar ())
       runSerializeT m lock

     main :: IO ()
     main = evalStateT (serialize baz) 42

Now, if you are positive that you will only ever need a single lock, or 
you need synchronization even among distinct calls to `serialize` (which 
currently each generate a fresh lock), you can do this:

     -- Keep this out of the API.
-- Also notice this is *not* unsafeDupablePerformIO. We prevent a race 
condition on initialization.
     globalLock :: MVar ()
     globalLock = unsafePerformIO (newMVar ())

It is well known that unsafePerformIO requires extra care, but I believe 
that this situation is safe.

The main wart of unsafePerformIO is *unsoundness*: it allows you to 
derive unsafeCoerce :: forall a b. a -> b, and more generally it makes 
programs "go wrong".
However, as far as I can recall, this relies on an interaction between 
polymorphism and effects, the simplest example being to use 
unsafePerformIO to create a polymorphic MVar, put in a value of type a, 
take it out with type b.

Here it is being used at a single not-too-fancy ground type (MVar ()), 
so this doesn't seem to cause such problems.

     globalSerialize :: SerializeT m a -> m a
     globalSerialize m = runSerializeT m globalLock

Now that we have a global variable though, we might as well make a 
Serialize instance for IO.

     instance Serialize IO where
       serially io = withMVar globalLock (const io)

     main = flip evalStateT 42 $ do
       foo ...
       bar ... :: StateT Int IO ()

Regards,
Li-yao

On 09/15/2017 03:20 AM, Viktor Dukhovni wrote:
> I tried to implement a polymorphic serializer that uses a enclosed
> MVar () to serialize an action in an arbitrary IO monad, without
> a need for its user to resort to explicit liftIO calls.
>
> So, as a first step I create a typeclass that has a natural default
> implementation, and some instances for the Monads of interest that
> just use the natural default:
>
>      type Serially m = (MonadIO m) => forall a. IO a -> m a
>
>      class (MonadIO m) => Serializable m where
> 	serialize :: MVar () -> Serially m
> 	serialize lock = liftIO . withMVar lock . const
>
>      instance Serializable IO
>      instance Serializable (StateT Int IO)
>      ...
>
> With this, given:
>
> 	foo :: Serially IO -> ... -> IO ()
> 	foo serially ... = do
> 	    ...
> 	    serially $ ...
> 	    ...
>
> 	bar :: Serially (StateT Int IO) -> ... -> StateT Int IO ()
> 	bar serially ... = do
> 	    ...
> 	    serially $ ...
> 	    ...
>
> I can write:
>
> 	lock <- newMVar ()
> 	foo (serialize lock) ...
> 	bar (serialize lock)
>
> and the type system figures out the correct version of serialize
> for foo's and bar's actual monad.
>
> Is it possible to create a single "serialize lock" closure that
> works for both "foo" and "bar"?  Something that works along the
> lines of:
>
> 	let x = liftIO . withMVar lock . const :: ???
> 	foo x ...
> 	bar x ...
>
> If I leave out the "liftIO", then I can of course use:
>
>      x :: forall a. IO a -> IO a
>
> and the "liftIO" can be put explicitly into "foo" and "bar".
>
>      foo x ... = liftIO $ x $ ...
>      bar x ... = liftIO $ x $ ...
>
> but is it possible for "x" to both be polymorphic with respect to
> its user's monad and at the same time to be a *closure* around some
> MVar, and thus not directly a method of a type class.
>
> Of course needing to add an extra "liftI0" here and there is not
> onerous, I'm mostly just curious whether I'm missing something that
> can hide that boilerplate call in the serializer implementation.
>



More information about the Haskell-Cafe mailing list