If you’ve read previous posts about GUI programming in Haskell, you’ll know that reactive programming allows us to write simpler, less coupled controllers and to move much of our logic to the model of our applications. But how can we realise such stateful elements in Haskell? Meet Reactive Values.
Reactive values are typed, read/write mutable values whose changes we can listen to. Let’s break that into four parts:
- Typed: every reactive value has one, and only one type which corresponds to the type of elements it holds inside.
- Read-only/write-only/read-write: every reactive value can be read, written or both, but not all reactive values can be read, and not all can be written to.
- Mutable references: every reactive value behaves like a mutable reference. This is particularly important, because it breaks apart from FRP implementations. Reactive values are impure, stateful.
- Change notification: reactive values allow us to react to changes to them. This will allow us to build a declarative layer in which reactive values depend on one another (and change notification will be hidden from us).
In the UI world, examples of RVs abound. Think of a text entry, for example. Its text could be held in a reactive RW String value, and whether it’s enabled or disabled, in a reactive RW Boolean value.
If we move to more abstract structures, we can think of the data in our models, for instance. An integer in our model, holding our user’s year of birth, could be a reactive RW Int value, and we could easily hold the filename of the currently opened file in a ReadWrite (Maybe Filepath).
This model accommodates other kinds of interactive systems, for instance, network communication. Incoming messages could be presented in a read-only ByteString, whereas an operation that sends a message to an external server could be seen as a write-only reactive value (of some type).
Back to the functional world with Reactive Rules
At this point, you either think this is great, or an aberration. Let’s make everything a bit more functional by adding data dependencies and liftings functions.
The previous specification is, in essence, a set/get/notify layer, that gives us a uniform interface to every element. It partially solves the problems stated in the previous post, since different event listeners, which can be installed in the model or in the view, take care of uni-directional change propagation. Thus, our event handlers can focus on propagating changes in one direction, and one direction only.
But this is not good enough. If you remember correctly, we already saw that IO code is evil and wants to dominate the world and kill little kittens, so we need to improve this a bit more. Meet the data propagation combinators.
Data dependencies and data propagation
Data dependencies can be specified with out model provided that certain properties are fulfilled:
- The origin of the data is a readable value.
- The destination is a writeable value.
- Both types match.
That was easy! We can capture such information with the type signature of our combinators. It would be a bit pretentious to claim that data dependency does not provoke some sort of propagation, so we are going to use the symbols <:= and =:> for instance, as follows:
v :: ReactiveRW String -- a read/write string reactive value v = undefined -- Does not really matter for now v2 :: ReactiveWO String -- a write-only string reactive value v2 = undefined -- No matter (for now) rule1 = (v =:> v2) -- Whenever v changes, v2 will be 'updated'
That turns out to be a really declarative way of implementing data transfers (or data dependencies). Since in many cases we will want bi-directional synchronisation, let’s define a more handy combinator =:= that will update either side when the other changes:
rule2 = (v =:= v2) -- oh, oh, something's wrong
Well, we can’t do that to v2. It’s not fair, really. v2 is a read-only property, so it will never change. But we can do that if we have two read-write values:
v3 :: ReactiveRW String v3 = undefined rule2' = (v =:= v3) -- now it works
That is quite convenient. Now most normal changes to the UI will turn into simple data propagations, and bidirectionalities will require only one line of code! It’s it great?
Ok, the previous description is very idealistic. And it does not fulfil one of the important properties (and problems of FRP) described in this post. The problem is that the types of widget properties tend to be very simple (String, Int, Bool, etc.) but the types of our model can be arbitrarily complicated. So, we need a way to transform data before transmission.
We can achieve that with the lift combinators. Lifting a pure function onto a reactive value, we can transform it before data propagates from it or towards it. In order to match types correctly, we need to lifting operations: one to lift functions onto readable values, and another to apply transformations to writeable values.
liftR :: (a -> b) -> ReactiveR a -> ReactiveR b liftW :: (b -> a) -> ReactiveW a -> ReactiveW b
Let’s think about that for a second. If we want to transform a value that we just read, we need only apply a transformation to the value we read. It is as if we read the value, transformed it, and passed the result on. But when we lift a function onto a writeable value, then we need to transform it before we write it. We first transform the incoming value into something that matches our type, and then we write the result. (This may be summarised by saying that liftR is covariant and liftW is contravariant.)
And what about read-write values?, I hear you ask. Well, if we want read-write values to remain being read-write after a transformation, we need to lift two functions: one to transform values after reading, and another to transform values before writing them. This can be captured easily with another lifting operation:
lift :: (a -> b, b -> a) -> ReactiveRW a -> ReactiveRW b
(Our implementation defines the classes Readable and Writeable, to which read-write variables also belong, so we can apply liftR and liftW to read-write values, but we obtain read-only or write-only transformed values.)
By defining binary, ternary and so forth, we can accommodate for most of the transformations that we may need to specify in our reactive rules before data transmission. For instance:
vAge1 :: ReactiveRW Int vAge2 :: ReactiveRW Int vAddedAge :: ReactiveRW Int vAge1Text :: ReactiveRW String rule4 = (lift (show, read) vAge1 =:= vAge1Text) rule5 = (liftR2 (+) vAge1 vAge2 =:> rAddedAge)
And how about if the function that we pass is an endofunctor and an isomorphism? Well, then we don’t want to have to name it twice, so we can write the following:
vAge1TextReversed :: ReactiveRW String rule6 = (liftB reverse vAge1Text =:= vAge1TextReversed)
But of course, the compiler can’t tell whether reverse is indeed its own inverse, so it is up to us to prove it.
Reactive Application Architecture
Before moving on to the details, let us try to understand the impact that such a system could have in our applications. First of all, we have seen that the model takes care of change notification of those parts that might have changed. We do not know how this is done (read on to know more), but it greatly simplifies our controllers. Thanks to the reactive rules, we can now specify data dependencies in a very high-level, declarative way.
Another relevant aspect is that the same reactive value can appear in more than one rule. These need not be defined in the same module, or in the same function, which means that we can organise our rules in any way that pleases us.
The resulting architecture is what we call Model/View/Conditions or Model/View/Conditional-Controllers. The idea is that, with this new layer, our controller will become just a list of rules that describe very simple data dependencies, leaving all the work to external subsystems, or to the model (if it’s a purely abstract transformation). No logic whatsoever needs to be in the controller or in the view (although such a thing is possible), since reactive rules enable view-to-view communication and model-to-model communication.
In this post we have seen how we can create a reactive interface that allows us to present a uniform interface for both our model, our view, and other aspects of our programs. We have also seen that, even though such layer is likely monadic to a certain extent, we can hide those details from users and provide a much more declarative, almost pure, timeless interface. In a future post we will see how to make widgets and models reactive and the impact that will have in our applications.