Continuation-based relative-time FRP
In a previous post I showed how it is possible to write asynchronous code in a direct style using the ContT
monad. Here, I’ll extend the idea further and present an implementation of a very simple FRP library based on continuations.
{-# LANGUAGE DoRec, BangPatterns #-} import Control.Applicative import Control.Monad import Control.Monad.IO.Class import Data.IORef import Data.Monoid import Data.Void
Monadic events
Let’s start by defining a callback-based Event
type:
newtype Event a = Event { on :: (a -> IO ()) -> IO Dispose }
A value of type Event a
represents a stream of values of type a
, each occurring some time in the future. The on
function connects a callback to an event, and returns an object of type Dispose
, which can be used to disconnect from the event:
newtype Dispose = Dispose { dispose :: IO () } instance Monoid Dispose where mempty = Dispose (return ()) mappend d1 d2 = Dispose $ do dispose d1 dispose d2
The interesting thing about this Event
type is that, like the simpler variant we defined in the above post, it forms a monad:
instance Monad Event where
First of all, given a value of type a
, we can create an event occurring “now” and never again:
return x = Event $ \k -> k x >> return mempty
Note that the notion of “time” for an Event
is relative.
All time-dependent notions about Events
are formulated in terms of a particular “zero” time, but this origin of times is not explicitly specified.
This makes sense, because, even though the definition of Event
uses the IO
monad, an Event
object, in itself, is an immutable value, and can be reused multiple times, possibly with different starting times.
e >>= f = Event $ \k -> do dref <- newIORef mempty addD dref e $ \x -> addD dref (f x) k return . Dispose $ readIORef dref >>= dispose addD :: IORef Dispose -> Event a -> (a -> IO ()) -> IO () addD d e act = do d' <- on e act modifyIORef d (`mappend` d')
The definition of >>=
is slightly more involved.
We call the function f
every time an event occurs, and we connect to the resulting event each time using the helper function addD
, accumulating the corresponding Dispose
object in an IORef
.
The resulting Dispose
object is a function that reads the IORef
accumulator and calls dispose
on that.
As the diagram shows, the resulting event e >>= f
includes occurrences of all the events originating from the occurrences of the initial event e
.
Event union
Classic FRP comes with a number of combinators to manipulate event streams. One of the most important is event union, which consists in merging two or more event streams into a single one.
In our case, event union can be implemented very easily as an Alternative
instance:
instance Functor Event where fmap = liftM instance Applicative Event where pure = return (<*>) = ap instance Alternative Event where empty = Event $ \_ -> return mempty e1 <|> e2 = Event $ \k -> do d1 <- on e1 k d2 <- on e2 k return $ d1 <> d2
An empty Event
never invokes its callback, and the union of two events is implemented by connecting a callback to both events simultaneously.
Other combinators
We need an extra primitive combinator in terms of which all other FRP combinators can be implemented using the Monad
and Alternative
instances.
once :: Event a -> Event a once e = Event $ \k -> do rec d <- on e $ \x -> do dispose d k x return d
The once
combinator truncates an event stream at its first occurrence. It can be used to implement a number of different combinators by recursion.
accumE :: a -> Event (a -> a) -> Event a accumE x e = do f <- once e let !x' = f x pure x' <|> accumE x' e takeE :: Int -> Event a -> Event a takeE 0 _ = empty takeE 1 e = once e takeE n e | n > 1 = do x <- once e pure x <|> takeE (n - 1) e takeE _ _ = error "takeE: n must be non-negative" dropE :: Int -> Event a -> Event a dropE n e = replicateM_ n (once e) >> e
Behaviors and side effects
We address behaviors and side effects the same way, using IO
actions, and a MonadIO
instance for Event
:
instance MonadIO Event where liftIO m = Event $ \k -> do m >>= k return mempty newtype Behavior a = Behavior { valueB :: IO a } getB :: Behavior a -> Event a getB = liftIO . valueB
Now we can implement something like the apply
combinator in reactive-banana:
apply :: Behavior (a -> b) -> Event a -> Event b apply b e = do x <- e f <- getB b return $ f x
Events can also perform arbitrary IO
actions, which is necessary to actually connect an Event
to user-visible effects:
log :: Show a => Event a -> Event () log e = e >>= liftIO . print
Executing event descriptions
An entire GUI program can be expressed as an Event
value, usually by combining a number of basic events using the Alternative
instance.
A complete program can be run with:
runEvent :: Event Void -> IO () runEvent e = void $ on e absurd runEvent_ :: Event a -> IO () runEvent_ = runEvent . (>> empty)
Underlying assumptions
For this simple system to work, events need to possess certain properties that guarantee that our implementations of the basic combinators make sense.
First of all, callbacks must be invoked sequentially, in the order of occurrence of their respective events.
Furthermore, we assume that callbacks for the same event (or simultaneous events) will be called in the order of connection.
Many event-driven frameworks provide those guarantees directly. For those that do not, a driver can be written converting underlying events to Event
values satisfying the required ordering properties.
Conclusion
It’s not immediately clear whether this approach can scale to real-world GUI applications.
Although the implementation presented here is quite simplistic, it could certainly be made more efficient by, for example, making Dispose
stricter, or adding more information to Event
to simplify some common special cases.
This continuation-based API is a lot more powerful than the usual FRP combinator set. The Event
type combines the functionalities of both the classic Event
and Behavior
types, and it offers a wider interface (Monad
rather than only Applicative
).
On the other hand, it is a lot less safe, in a way, since it allows to freely mix IO
actions with event descriptions, and doesn’t enforce a definite separation between the two. Libraries like reactive-banana do so by distinguishing beween “network descriptions” and events/behaviors.
Finally, there is really no sharing of intermediate events, so expensive computations occurring, say, inside an accumE
can end up being unnecessarily performed more than once.
This is not just an implementation issue, but a consequence of the strict equality model that this FRP formulation employs. Even if two events are identical, they might not actually behave the same when they are used, because they are going to be “activated” at different times.
Comments