Although various implementations have been under development for some time, FRP has yet to reach a really satisfying breakthrough. The best semantic model has been that of Conal Elliot's Reactive, but unfortunately it lacks a Smart Enough Compiler and suffers from subtle space leaks. The most performant is probably Yampa, which is arrow-based. Arrows turn some people off; I personally find them too verbose and don't like the syntax for recursion.
Apfelmus has recently released a new FRP library, reactive-banana, which shows some early promise towards being a best-of-all-possible-worlds. In this post I'll examine some of the features of reactive-banana to see why I think this.
Introduction
Reactive-banana is very similar to the semantic model provided by Reactive, although much more minimal. One fundamental data type is the
Event
, which can be thought of as a list of values with their time of occurrence, i.e. Event a = [(Time, a)]
. The other fundamental type is the Behavior, which can be thought of as a value that changes with time, i.e. Behavior a = \Time -> a
. This is according to the reactive-banana documentation, which also notes that in this implementation Behavior
s aren't continuous but are actually step functions.So how does it look? Consider a simple system with a button and a toggle: when the button is pressed it generates a 0 if the toggle is off, and 1 when the toggle is on. We could model it like this:
togEvent :: Event Bool
buttonEvent :: Event ()
-- Upon receiving an Event (), mixEvents generates an Event Int
-- The value of the Int depends upon the most recent Event Bool status
mixEvents :: Event Bool -> Event () -> Event Int
The
so we just need to find a way to lift this into the FRP system. Behaviors have an Applicative instance, so let's try it:
and we've got it. If we prefer, we could define
So how does this work? The
Push vs Pull
One tension in the FRP design space relates to a push-driven (data-driven) vs pull-driven (demand-driven) architecture. Push-driven offer theoretically better performance, as data sources typically change relatively slowly (e.g. mouse clicks relative to screen refresh), so this minimizes recomputation. However, pull-driven seems to fit the functional paradigm better. Also, if a Behavior models a continuous function, pushing updates at the sample rate could be very wasteful.
The
Our implementation created a
Continuous Time
Another Behavior feature is that semantically (traditionally, but not in reactive-banana) they are continuous functions over time. There are a lot of reasons to like this feature, which is notably missing from the reactive-banana semantic model. Originally I was somewhat skeptical, however I've come to the conclusion that the reactive-banana model of Behaviors as step functions is more correct. Since a Behavior is an accumulator of Events, it makes sense that the Behavior would change only when new Events are received. As an Event is a datum at a discrete time, the Behavior must therefore be a discrete-valued function. In particular, there is a conflict inherent in overloading the meaning of a Behavior to both respond to Events and be continuous in time.
Furthermore, I don't think the programmer loses anything by the reactive-banana model. It's still possible to write functions that are continuous in time and lift them to Behaviors. This is because the reactive-banana framework has no notion of time. If you want a time value, you need to provide one yourself, perhaps via a hardware clock.
Let's examine how this works. Fundamentally, when the window system requests a screen redraw, we want to check the current time, generate the correct shape for the time, and finally update the display. Our system provides the redraw requests via an
Simultaneitys
One aspect that seems to cause a lot of difficulty is simultaneous events, multiple Events which occur at the same time. There are a few ways these can arise, such as long sampling periods, merging Event streams, and recursive definitions. reactive-banana specifies that the order of simultaneous events is undefined, providing the orderedDuplicate function to deal with certain cases where timing is important.
I don't like this solution. Semantically, it would be nice that an event stream were strictly increasing in time, disallowing simultaneous events entirely. I suspect that this could be made usable by providing the following two primitive functions:
The
Conclusion
So that's my initial reaction to reactive-banana. The library is quite minimal, but so far it's sufficiently expressive for every problem I've thought of. The new semantics of Behavior seem conducive to developing a correct model of how the implementation works, thereby making it simpler for users to reason about performance and operations. Requiring users to explicitly manage time may eventually become a burden, however it's a direction worth exploring. For many problems user code doesn't depend upon time anyway.
togEvent
and buttonEvent
sources are created by connecting to the window framework's event system (see reactive-banana-wx for an example), but mixEvents
is more complex. We could model it with the pure function
mixEventsF :: Bool -> () -> Int
mixEventsF True _ = 1
mixEventsF _ _ = 0
so we just need to find a way to lift this into the FRP system. Behaviors have an Applicative instance, so let's try it:
mixEvents = apply (mixEventsF <$> behavior False togEvent) buttonEvent
and we've got it. If we prefer, we could define
mixEventsF :: Bool -> Int
, but then we'd need to fmap const
onto the Behavior
to create a Behavior (() -> Int)
we could apply to an Event.So how does this work? The
Behavior
functions as an Event Accumulator. It stores every Bool
received from togEvent
, and when a button press event is received creates an event from applying the Bool
and ()
to the mixEventsF
function. One clue that this is the proper way to think of Behaviors is by the different ways they can be constructed:- lifting pure values, and
always
(these are semantically equivalent,pure
is defined asalways
) - the
behavior
smart constructor (only hold the most recentEvent
) - various
accumulate
functions onEvent
s
Push vs Pull
One tension in the FRP design space relates to a push-driven (data-driven) vs pull-driven (demand-driven) architecture. Push-driven offer theoretically better performance, as data sources typically change relatively slowly (e.g. mouse clicks relative to screen refresh), so this minimizes recomputation. However, pull-driven seems to fit the functional paradigm better. Also, if a Behavior models a continuous function, pushing updates at the sample rate could be very wasteful.
The
Behavior
type (at least in reactive and reactive-banana) moderates between these two models. We can see how this works by examining the semantics of our mixEvents
function.
-- Upon receiving an Event (), mixEvents generates an Event Int
-- The value of the Int depends upon the most recent Event Bool status
mixEvents :: Event Bool -> Event () -> Event Int
Our implementation created a
Behavior (() -> Int)
by combining a pure function with the Event Bool
stream. This behavior is then sampled whenever an Event ()
is received. By converting a discrete Event
into something which can be later sampled, the Behavior
localizes a change from push-driven to pull-driven. Thus Event Bool
s are stored and pulled from as necessary, while Event ()
s are pushed through and become Event Int
s, which are then pushed until they're also changed into Behaviors
(or reactimate
'd).Continuous Time
Another Behavior feature is that semantically (traditionally, but not in reactive-banana) they are continuous functions over time. There are a lot of reasons to like this feature, which is notably missing from the reactive-banana semantic model. Originally I was somewhat skeptical, however I've come to the conclusion that the reactive-banana model of Behaviors as step functions is more correct. Since a Behavior is an accumulator of Events, it makes sense that the Behavior would change only when new Events are received. As an Event is a datum at a discrete time, the Behavior must therefore be a discrete-valued function. In particular, there is a conflict inherent in overloading the meaning of a Behavior to both respond to Events and be continuous in time.
Furthermore, I don't think the programmer loses anything by the reactive-banana model. It's still possible to write functions that are continuous in time and lift them to Behaviors. This is because the reactive-banana framework has no notion of time. If you want a time value, you need to provide one yourself, perhaps via a hardware clock.
-- a pure, continuous function
-- using UTCTime to conveniently tie in with getTime.
createShape :: UTCTime -> Shape
-- an event source tied to the window toolkit's redraw event
redrawScreen :: Event ()
-- function to draw a shape to the screen
drawShape :: Shape -> IO ()
getTime :: IO UTCTime
getTime = Data.Time.Clock.getCurrentTime
reactiveSystem :: Prepare ()
reactiveSystem =
reactimate $ fmap drawShape
(apply (pure createShape) (mapIO getTime redrawScreen))
Let's examine how this works. Fundamentally, when the window system requests a screen redraw, we want to check the current time, generate the correct shape for the time, and finally update the display. Our system provides the redraw requests via an
Event ()
, which is mapped to the current time, creating an Event UTCTime
. The createShape
function is first lifted to a Behavior
then applied to the Event UTCTime
, creating an Event Shape
. Finally the drawShape
function is mapped over this, creating an Event (IO ()) which can be reactimate
'd, i.e. each IO ()
is performed as it becomes available. In my opinion, by removing time from the reactive-banana framework, both the described systems and the implementation are greatly simplified, with no loss of expressiveness.Simultaneitys
One aspect that seems to cause a lot of difficulty is simultaneous events, multiple Events which occur at the same time. There are a few ways these can arise, such as long sampling periods, merging Event streams, and recursive definitions. reactive-banana specifies that the order of simultaneous events is undefined, providing the orderedDuplicate function to deal with certain cases where timing is important.
I don't like this solution. Semantically, it would be nice that an event stream were strictly increasing in time, disallowing simultaneous events entirely. I suspect that this could be made usable by providing the following two primitive functions:
mergeCombine :: (a -> a -> a) -> Event a -> Event a -> Event a
-- When two events arrive simultaneously, create two output events with the right event delayed slightly
mergeOrdered :: Event a -> Event a -> Event a
The
mergeOrdered
function introduces a slight delay to preserve discrete events, whereas mergeCombine
has a function to combine two events to one. mergeCombine
handles the common cases of dropping one event via const
and combining with mappend
, as well as more specialized functions. I think these functions, together with the guarantee that an Event
stream only produces single events, are sufficient for a workable system.Conclusion
So that's my initial reaction to reactive-banana. The library is quite minimal, but so far it's sufficiently expressive for every problem I've thought of. The new semantics of Behavior seem conducive to developing a correct model of how the implementation works, thereby making it simpler for users to reason about performance and operations. Requiring users to explicitly manage time may eventually become a burden, however it's a direction worth exploring. For many problems user code doesn't depend upon time anyway.
This comment has been removed by the author.
ReplyDelete