You can implement a limited form of FRP using stream processors. For example, using the pipes
library, you might define a source of events:
mouseCoordinates :: (Proxy p) => () -> Producer p MouseCoord IO r
... and you might similarly define a graphical handler that takes mouse coordinates and updates a cursor on a canvas:
coordHandler :: (Proxy p) => () -> Consumer p MouseCoord IO r
Then you would hook up the mouse events to the handler using composition:
>>> runProxy $ mouseCoordinates >-> coordHandler
And it would run just the way you expect.
Like you said, this works well for a single chain of stages, but what about more arbitrary topologies? Well, it turns out that since the central Proxy
type of pipes
is a monad transformer, you can model any arbitrary topology just by nesting proxy monad transformers on top of themselves. For example, here is how you would zip two input streams:
zipD
:: (Monad m, Proxy p1, Proxy p2, Proxy p3)
=> () -> Consumer p1 a (Consumer p2 b (Producer p3 (a, b) m)) r
zipD () = runIdentityP $ hoist (runIdentityP . hoist runIdentityP) $ forever $ do
a <- request () -- Request from the outer Consumer
b <- lift $ request () -- Request from the inner consumer
lift $ lift $ respond (a, b) -- Respond to the Producer
This behaves like a curried function. You partially apply it to each input sequentially and you can then run it when it is fully applied.
-- 1st application
p1 = runProxyK $ zipD <-< fromListS [1..]
-- 2nd application
p2 = runProxyK $ p2 <-< fromListS [4..6]
-- 3rd application
p3 = runProxy $ printD <-< p3
It runs just the way you expect:
>>> p3
(1, 4)
(2, 5)
(3, 6)
This trick generalizes to any topology. You can find a lot more details about this in Control.Proxy.Tutorial in the "Branches, zips, and merges" section. In particular, you should check out the fork
combinator it uses as an example, which lets you split a stream into two outputs.