Reactive Variables
By Bjarno Oeyen | Posted on July 27, 2024
Note from the editor (which is me): This was and old draft that I had ready for almost more than a year, but forgot to publish. But I think it is still relevant, so I'm publishing it today.
I've been pretty interested in Reactive Programming for a while now, to put it lightly. More specifically, with the Reactive Programming paradigm. The basic abstraction found here is that of signals that are automatically updated whenever one of the signals that they depend on changes. I was wondering what the easiest way was to implement such abstraction, without making it a full-blown programming language with all bells and whistles. Just a simple library, in plain old R5RS.
I made that thing, and I will now talk about the thing.
Walkthrough
First, a small example (if you wish to play along, you can find a link further down below):
(define %time ($source 0))
(define %fast-time ($derived (* (%time) 2)))
(display (%fast-time)) ;; Prints 0
($! %time 10)
(display (%fast-time)) ;; Prints 20
On line 1, a new source signal is defined whose initial value is 0. On line 2, a derived signal is defined that multiplies the source signal's value by two. Displaying the current value of the signal shows 0 (as 0 times 2 is, last time I checked, 0). If afterwards the value of the source signal is modified to 10 (Line 4) then displaying the value of the derived signal shows 20 (10 times 2 is still 20). As a convention, I prefix the operators of the library with $
and the signals that I create them with %
. To get a signal's value, it has to be applied (as if it would be a procedure): this works in both lifted code (e.g., code in the $derived
expression) and regular Scheme code.
This simple example shows all the functionality of the library — like I said, no bells and whistles. There's no stateful accumulation of data. There's no higher-order operators (although signals are first-class). Nor is there any cycle detection (see below for details). There's no difference between continuous and discrete signals. Every signal has (at all times) a value, which is updated at discrete intervals.
The library avoids glitches, and does this simply by updating the signals in the same order as they were defined in. We assume that a signal can only depend on signals that were created earlier (and hence that there are no cyclic dependencies, by construction). This mechanism is not new and has been used earlier. Its the easiest way to avoid glitches without needing any complex data structures or a compiler. Furthermore, it easily supports incremental definitions of programs and is thus compatible with Scheme's built-in REPL, making it convenient to give it a test drive.
Download
The library can be found via this link:
Implementation
The library is implemented as ±50 lines of Scheme code. Signals (both source signals and derived signals) are implemented as dispatch procedure objects. Sending a message causes it to return a value or to be updated. These messages are not intended to be sent by user applications: the API implementation internally uses them. There are 4 types of messages: one is used to get the signal's value (which is cached, and not computed every time it is needed), one is used to destructively assign a new value (and only works for source signals), one is used to update it (with the stored lifted procedure), and one is used to get a signal's so-called chain.
A chain is a single-linked list of cons cells which contains the order in which signals are created.
In other words, a signal's chain is the list of signals that were creater after it. To keep track of this chain, the implementation keeps a so-called next-pair
in scope, which is the starting point in the chain of the last-created signal. Creating a new signal means creating a new cons cell, and updating the previous cons cell to point to the newly-created signal and the pair to use for the next signal's chain.
Future Work
As discussed earlier, this library is pretty bare bones. However, it seems (at first sight) as a good starting point to start explore the paradigm, without too much complexity. If one has a basic understanding of Scheme, one can explore Reactive Programming, I think — this still has to be tested.
In the future, I might add additional features, and do so with the least amount of complexity as possible. The featured discussed earlier (state accumulation, higher-order switching...) are the obvious candidates for future extensions.