The CL-REACTIVE
package provides support for reactive programming in
Common Lisp at the function level. In reactive programming, changing
the value of a variable causes other variables to be updated and other
functions to be invoked.
The common example of reactive programming is a spreadsheet where changing the value of one cell causes a variety of other cells to be recalculated.
Common Lisp already has Cells
which is reactive programming based on
CLOS slots. Here, reactive programming is handled at a function and
variable level instead of at the slot level.
This package provides signal variables and signal functions. Signal variables hold a single, typed value. Signal functions calculate their value from the values of signal variables and/or other signal functions. Signal variables and signal functions are collectively referred to as signals. In most instances, it is not necessary to distinguish between signal variables and signal functions. The only important distinction between the two are that one can explicitly set the signal value of a signal variable while one cannot do the same for a signal function.
Constants and functions can be used in place of signals in most places that expect a signal.
The fundamental building blocks for all signal operations are: querying signal values, defining new signal variables, defining new signal functions, and delaying eager recalculation of signal functions.
There is a predicate SIGNALP
to tell whether something is a signal.
(signalp obj) => t if signal and nil otherwise
Given a signal SIG
, one can query its value with the SIGNAL-VALUE
method.
(signal-value sig) => current value of the signal
Calling SIGNAL-VALUE
with SIG
calls the function with no arguments
and returns that value. Calling SIGNAL-VALUE
with SIG
a constant
(or anything other than a function or signal) returns that constant.
If SIG
is a signal variable, one can set the signal value with
SETF
:
(setf (signal-value sig) new-signal-value)
One can also use the convenience macro WITH-SIGNAL-VALUES
to work
directly with the value:
(defmacro with-signal-values (&rest decls) &body body)
The DECLS
here is a list of entries each of the form
(VARNAME SIGNAL)
. Each VARNAME
will be bound to the signal value
of the corresponding SIGNAL
. For example, given signals SIG1
and
SIG2
where SIG1
is a signal variable and SIG2
is any signal, one
might do something like:
(with-signal-values ((v1 sig1)
(v2 sig2)
(v3 5))
(incf v1)
(+ v1 v2 v3))
The WITH-SIGNAL-VALUES
macro here uses SYMBOL-MACROLET
to bind
V1
to the value of SIG1
, V2
to the value of SIG2
, and V3
to
the value of 5
.
One can define a signal variable with global scope using the macro
DEFSIGNAL-VARIABLE
:
(defmacro defsignal-variable (name value
&key (type t) documentation)
...)
This macro creates a global signal variable bound to the given NAME
.
The initial value of the signal variable is VALUE
. One can
optionally specify the TYPE
for this signal's values and a
DOCUMENTATION
string for this signal. For example, the following
snippet creates a signal variable for monitoring the current
temperature:
(defsignal-variable *current-temperature* 0
:type real
:documentation "Current temperature in degrees Celsius.")
One can also define signal variables in the local context using the macro
SIGNAL-LET
:
(defmacro signal-let ((&rest decls) &body body) ...)
The SIGNAL-LET
macro acts like LET
but binds signal variables to
the names. The DECLS
in SIGNAL-LET
can be either a symbol or a
list of the form (NAME VALUE &KEY (TYPE T) DOCUMENTATION)
. For
example, the following creates a list of three signal variables.
(signal-let ((sig-a 0 :type integer :documentation "First Signal")
(sig-b nil :documentation "Second Signal")
sig-c)
(list sig-a sig-b sig-c))
One can define a signal function (calculated signal) with global scope
using the macro DEFSIGNAL-FUNCTION
:
(defmacro defsignal-function (name (&rest depends) &body body)
...)
This macro creates a global signal function bound to the given NAME
.
The signal value is calculated from the BODY
using the signals listed
in DEPENDS
. The DEPENDS
here is a list of entries each of the
form (VARNAME SIGNAL)
. Each VARNAME
will be bound to the signal
value of the corresponding SIGNAL
for the BODY
. The BODY
will
be executed each time one of the DEPENDS
signals is updated.
The NAME
here can be either a symbol or a list of the form
(NAME &KEY (TYPE T) DOCUMENTATION)
. The results generated by BODY
must conform to the specified TYPE
. The DOCUMENTATION
here will
be used on the signal function itself as well as the wrapper function
#'NAME
. If DOCUMENTATION
is not specified and the BODY
begins
with a string, that string will be used as the DOCUMENTATION
.
For example, the following snippet creates a signal variable for monitoring the current temperature in Farenheit:
(defsignal-function (current-temperature-f :type real)
((celsius *current-temperature*))
"Current temperature in degrees Farenheit."
(+ (* 9/5 celsius) 32))
One can also define signal functions in the local context using the
macro SIGNAL-FLET
:
(defmacro signal-flet ((&rest fdecls) &body body) ...)
Each entry in the FDECLS
list is of the form
(NAME (&REST DEPENDS) &BODY SIG-BODY)
. This macro creates a local
signal function bound to the given NAME
. The signal value is
calculated from the SIG-BODY
using the signals listed in DEPENDS
.
The DEPENDS
here is a list of entries each of the form
(VARNAME SIGNAL)
. Each VARNAME
will be bound to the signal value
of the corresponding SIGNAL
for the SIG-BODY
. The SIG-BODY
will
be executed each time one of the DEPENDS
signals is updated.
The NAME
here can be either a symbol or a list of the form (NAME &KEY (TYPE T) DOCUMENTATION)
. The results generated by BODY
must
conform to the specified TYPE
. The DOCUMENTATION
here will be
used for the signal function.
The SIGNAL-FLET
macro acts like FLET
but binds signal functions to
the names. The signal functions depend on other signals. For example,
the following creates a list of two signal functions that both depend
on the signal SIG-X
.
(signal-flet ((sig-abs-x ((x sig-x)) (abs x))
((sig-sqr-x :type integer) ((x sig-x)) (* x x)))
(list sig-abs-x sig-sqr-x))
There are times when one will be updating multiple signal variables
which are closely related. Some signal functions may well depend upon
both of them. For these instances, it is convenient to delay
recalculating signals until all of the updates are done. One can use
the WITH-SIGNAL-UPDATES-DEFERRED
macro around a body of code where
signal functions will not eagerly update until the macro has
completed. Signal functions which are needed within the
WITH-SIGNAL-UPDATES-DEFERRED
section will be calculated as needed.
For example, in the following code, the
(signal-flet ((sig-min ((mx sig-mouse-x) (my sig-mouse-y))
(min mx my))
(sig-max ((mx sig-mouse-x) (my sig-mouse-y))
(max mx my)))
(with-signal-values ((mx sig-mouse-x)
(my sig-mouse-y)
(mouse-max sig-max))
(with-signal-updates-deferred ()
(setf mx new-x
my new-y)
(list (/ mx mouse-max) (/ my mouse-max)))))
Neither SIG-MAX
nor SIG-MIN
will be updated when MX
and MY
are
assigned. The SIG-MAX
signal will be updated when it is used to
create the LIST
. The SIG-MIN
signal will not be updated until the
WITH-SIGNAL-UPDATES-DEFERRED
section ends.
The fundamental signal operations above can be composed in a variety
of ways to make more powerful signal constructs. This section
describes the functions CL-REACTIVE
provides for composing signals.
The SIGNAL-COUNT
function creates a signal function that returns the
number of times the signal SIG has been updated.
(defun signal-count (sig &key documentation) ...)
So, for example, to count the number of updates to SIG-X
in a given
section of code, one might do:
(with-signal-values ((count-x (signal-count sig-x)))
... whatever code one wants here ...
count-x)
Note: This count actually reflects the number of times that a signal function dependent on SIG is updated so if SIG is updated multiple times within a single WITH-SIGNAL-UPDATES-DEFERRED section, this count would likely not reflect all of those updates.
The SIGNAL-IF
function takes three arguments: SIG-COND
, SIG-TRUE
, and SIG-FALSE
. It returns a signal function whose value is that of SIG-TRUE
if SIG-COND
is non-NIL
and the value of SIG-FALSE
otherwise.
(defun signal-if (sig-cond sig-true sig-false &key documentation) ...)
It is often convenient to use a constant value in place of either
SIG-TRUE
or SIG-FALSE
(or both):
(signal-if sig-cond :yes :no)
A signal function will be triggered each time one of the signals it
depends upon is changed. Sometimes, the signal function will
recalculate its value and come up with the same result it did the last
time. With the SIGNAL-ON-CHANGE
function, one can create a signal
that triggers its dependents only when the value of its input signal
changes.
(defun signal-on-change (sig &key (key #'identity)
(test #'equal)
documentation)
...)
The returned signal only triggers when SIG
changes value. The given
TEST
is used to tell when two values are the same. The KEY
function is invoked on the previous value and the current value then
the TEST
function is invoked with the results to determine whether
the two values are equal.
In the following example, the SIGNAL-MAX-CHANGED
signal will not
trigger its dependents even though the SIGNAL-MAX
signal will.
(signal-let ((sig-x 100 :type integer)
(sig-y 100 :type integer))
(signal-flet ((signal-max ((x sig-x) (y sig-y)) (max x y)))
(let ((signal-max-changed (signal-on-change signal-max)))
(with-signal-values ((x sig-x))
(setf x 50)))))
One can poll the SIGNAL-VALUE
at any time, but other signals which
depend upon this one will not be triggered.
The TEST
function is passed the KEY
of the previous value followed
by the KEY
of the new value of SIG
. One can use this to create
specialized signals. The following example triggers only on positive
zero-crossings:
(labels ((positive-crossing-p (a b)
(and (or (minusp a) (zerop a))
(plusp b)))
(not-a-positive-crossing-p (a b)
(not (positive-crossing-p a b))))
(signal-on-change sig :test #'not-a-positive-crossing-p))
Note: when the TEST
returns non-NIL
, there is no change. As such,
the test is the negation of when there is the desired change. Another
way one might create an equivalent signal is this:
(flet ((plusp-ish (a)
(if (plusp a) 1 0)))
(signal-on-change sig :key #'plusp-ish :test #'>=))
Again, the TEST
is used to tell when successive values are
equivalent. The TEST
of #'>=
is, thus, the opposite of when we
want to trigger the signal: #'<
.
The SIGNAL-APPLY
function creates a signal function whose value is
obtained by applying a given function FN
to the values of a list
SIGNALS
of signals.
(defun signal-apply (fn signals &key (type t) documentation) ...)
For example, if one needs a signal that tracks the sum of the signals
SIG-A
, SIG-B
, and SIG-C
, one can do:
(signal-apply #'+ (list sig-a sig-b sig-c))
One can also take advantage of constant signals here to do fancier
calculations. This following example creates a signal that returns
the position of the last character A
in the value of SIG-STRING
:
(signal-apply #'position (list #\A sig-string :from-end t))
Of course, one could also have written the previous example like this:
(signal-apply (lambda (s) (position #\A s :from-end t))
(list sig-string))
The SIGNAL-REDUCE
function creates a signal function of whose value
is that of CL:REDUCE
when run on a given list of signals.
(defun signal-reduce (fn signals &key
(from-end nil from-end-p)
(start nil startp)
(end nil endp)
(initial-value nil initial-value-p)
(key nil keyp)
(type t)
documentation) ...)
The TYPE
parameter gives the type specifier for the resulting
signal. The DOCUMENTATION
is used to document the resulting signal
function. The FROM-END
, START
, END
, INITIAL-VALUE
, and KEY
parameters are passed directly to CL:REDUCE
.
Note: The START
and END
do not make as much sense here as they do
in CL:REDUCE
. Here, if one uses START
and/or END
to specify a
(proper) sublist of SIGNALS
, the resulting signal function will
depend on signals that are not used in calculating its value. This
means that it will be triggered at times when there is no hope that
its value will change. It makes more sense to trim the SIGNALS
list
before calling SIGNAL-REDUCE
.
If one has some number of input signals in the list SIGNALS
and
wants a signal which is the product of those signals, then one might
do the following:
(signal-reduce #'* signals :initial-value 1)
It is perfectly possible to create a signal whose value is a LIST
:
(defsignal-variable *properties* (list :a 1 :b 2 :c 3)
:type list
:documentation "ABC properties.")
The behavior of the system is undefined if the signal value is destructively modified:
(nreverse (signal-value *properties*)) ; Bad!
Any number of other signals and functions may be looking at that list at any point in time. Additionally, such destructive changes do not trigger signals which depend on the value to be notified that it changed.
The CL-REACTIVE
package makes no attempt to enforce the immutability
of the signal values. It is incumbent on those using CL-REACTIVE
to
ensure that they do not mutate any values used as signal values.
The behavior of the system is also undefined if any signal variable is rebound. For example:
(defsignal-variable *x* 0)
...
(setf *x* (signal-let ((new-x 0)) new-x))
Thanks to Josh Cho for providing the fix
for SBCL compile issues with the SIGNAL-FUNCTION
class.