Skip to content

nklein/cl-reactive

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

15 Commits
 
 
 
 
 
 
 
 

Repository files navigation

CL-REACTIVE: Reactive functions in Common Lisp

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.

Basic Signal Manipulations

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

Querying Signal Values

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.

Defining Signal Variables

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))

Defining Signal Functions

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))

Deferring Signal Updates

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.

Composing Signals

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.

Counting Signal Updates

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.

Selecting Between Signals

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)

Triggering Only On Changes

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: #'<.

Signal as a Function of Other Signals

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))

Reducing a List of Signals

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)

A Word About Mutability

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))

Credits

Thanks to Josh Cho for providing the fix for SBCL compile issues with the SIGNAL-FUNCTION class.

About

Reactive programming at the variable/function level for Common Lisp

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published