Reactive Classes and Computed Slot Networks
Another use for slot-value-using-class
In which we construct a small system for linking CLOS objects together, such that the value of an object slot is a function involving the slots of other objects. Changing the upstream objects automatically updates the downstream slots.
The Desired Effect
First an example
;; example
(defclass floater ()
((value :accessor floater-value :initform 1.0))
(:metaclass reactive))
(defclass square ()
((side :reader side)))
(defmethod print-object ((sq square) stream)
(format stream "#<SQUARE ~a>" (side sq)))
(defun mult (val mult)
(format t "Updating a slot (mult ~a ~a)~%" val mult)
(* val mult))
(defvar f1 (make-instance 'floater))
(defvar sq1 (make-instance 'square))
(set-slot-comp sq1 'side 'mult (dep f1 'value) 10)
(setf (floater-value f1) 20)
;; prints "Updating slot using (mult 20 10)
(print sq1) ;; #<SQUARE 200>
The floater
class uses a metaclass called reactive
. You will be exploring the details in a bit -- for now, look at how sq1
gets its slot updated.
That's right! Simply by setting the value
slot of the f1
object.
Of course, we could have easily linked additional objects to f1
, or we could have made the side
slot of sq1
depend on many other objects.
The example continues:
;; add another class
(defclass rect ()
((w :reader rect-width :initform 0)
(h :reader rect-height :initform 0)))
(defmethod print-object ((rect rect) stream)
(with-slots (w h) rect
(format stream "#<RECT W:~a H:~a>"
w h)))
(defvar r1 (make-instance 'rect))
;; depend on the same f1 value
(set-slot-comp r1 'w 'mult (dep f1 'value) 3)
(set-slot-comp r1 'h 'mult (dep f1 'value) 8)
(setf (floater-value f1) 6)
(print (list r1 sq1)) ;; (#<RECT W:18 H:48> #<SQUARE 60>)
So that's a brief demonstration of what this post will be building up. How does it work?
You can find the full code listing here should you wish to play along in your REPL.
High Level Overview
To build networks of objects, some depending on others, you need only make some of those objects' classes use the reactive
metaclass, and then set up dependencies via the set-slot-comp
function.
The set-slot-comp
function sets up a "downstream-upstream" relationship between slots. The lambda list for set-slot-comp
looks like this:
(object slot-name function &rest arguments)
The arguments
can be anything, but must include at least one "dependency" object. Dependency objects will be explained shortly. Looking again at the example
(set-slot-comp r1 'w 'mult (dep f1 'value) 3)
you see that the w
slot of the rectangle r1
is made to be the result of calling the mult
function. The arguments to mult are, effectively, (slot-value f1 'value)
and the number 3
. So here, r1
is "downstream" in the dependency network from f1
. Likewise f1
is "upstream" of r1
.
The ingredients of the secret sauce, like the previous post in this series, mostly come down defining some metaobjects and specializing the slot-value-using-class
method. The CLOS metaobject protocol calls slot-value-using-class
whenever a slot value is accessed, whether via the slot-value
form or through reader or accessor functions.
Apart from the reactive
metaclass, its related metaobjects, and slot-value-using-class
, there three helper objects that make the system work. It will be helpful to review them now:
- Dependency Objects are little more than a pairs that associate an object with one of its slots. These pairs are used to specify that something depends on that object-slot combination. Dependency objects are instances of a class called
dependency
, and are created in the above using thedep
function. - Value Computations encapsulate everything needed to update a slot of a particular object, including its dependencies and the function object used to calculate a new slot value. These are instances of
value-computation
, and are created usingset-slot-comp
. - Reactive Slot Tables are created for every slot definition object of a reactive class. These tables are keyed by objects, and their values are collections of value computation objects. Each value computation will be performed whenever the key object's slot value changes. These are weakly keyed hash tables, stored on the slot definitions for a class.
Now you're ready to see how it all fits together.
Actually Updating The Slots
Because slot-value-using-class
is a generic function, and because it also has a setf
defintion, we are free to specialize on (setf slot-value-using-class)
in our own code. In this case, just such a specialization is used to run our downstream slot update code whenever an upstream slot is modified.
Running Code when Slots are Updated
Whenever you modify a slot of an object whose class uses the reactive
metaclass, three things happen:
- A lookup is performed to retrieve the collection of
value-computation
instances associated with that object and that slot. - For each such
value-computation
, you check that the object targeted for automatic updates is still alive. - When it is, the
value-computation
is evaluated, which updates the downstream slot.
The above steps are implemented by specializing the :after
method combination to the setf slot-value-using-class
function:
;; this is what happens whenever a reactive's slots are modified
(defmethod (setf mop:slot-value-using-class) :after
(new (cl reactive) ob (sd reactive-effective-slot-definition))
;; 1. lookup a value computations collection
(a:when-let (entry (reactive-table-entry object sd))
(setf (valcomps entry)
(loop for valcomp in (valcomps entry)
for object = (tg:weak-pointer-value (valcomp-object valcomp))
;; 2. check that the value-computation refers to a live object
when object
;; 3. when it does, evaluate the valcomp and retain it.
do (evaluate-valcomp valcomp)
and collect valcomp))))
Note that method is specialized on the reactive-effective-slot-definition
class. It is on instances of this class that we store the tables for tracking dependencies. See the full code listing for details.
Evaluating VALUE-COMPUTATIONS
It is worth looking at the value computation class definition
;; instances are evaluated whenever dependencies change.
(defclass value-computation ()
((object
:reader valcomp-object
:initarg :object
:initform (error "object is required")
:documentation "A weak pointer to the object whose slot will be updated.")
(slot
:reader valcomp-slot
:initarg :slot
:initform (error "slot is required")
:documentation "The slot associated with this value computation")
(fn
:reader valcomp-fn
:initarg :fn
:initform (error "fn is required")
:documentation "The literal function to be called.")
(arguments
:reader valcomp-args
:initarg :args
:initform nil
:documentation "A list containing instances of DEPENDENCY and other values."))
(:documentation "A computation to be run for a given slot."))
When evaluate-valcomp
is called on an instance of value-computation
each dependency
instance that appears in the arguments
is retrieved. If any dependencies are dead, an error is signaled. Otherwise, an intermediate arguments list is formed from these retrieved dependencies. The new value the object
's slot
is the value of fn
applied to the intermediate arguments.
Some Notes & Conclusion
Weak Pointers
Whenever prudent, weak pointers to objects have been used when tracking the object interdependencies. A reference to an object in one of the dependency
or value-computation
class instances should not prevent that object from being garbage collected.
DAG Nab It
The system, being an educational tool, is deficient in a number of ways. For example, the system makes no effort to detect circularities in the dependency graph. The presence of a circularity will result in an infinite loop that is likely to quickly exhaust the call stack. So, as you play with the system, stick to Directed Acyclic Graphs for now.
Efficiency Considerations
The system currently creates a hash table for every slot of every class. If you have N
classes with M
slots, then that's N×M
hash tables. This situation could perhaps be improved by storing one table per class instead, but then you'd have to do more sophisticated lookup and probably more sophisticated memory managment. The one-per-slot approach was taken so that the hash tables could benefit form SBCL's :weakness :key
option.
Another option might be to use a association list of (weakpointer . valcomp)
pairs, and then use assoc
for lookup. This would be slower but friendler on memory consumption.
Yet another approach: you could delay the creation of a hash table until a slot actually has dependencies -- adding the table the first time it is required to exist and removing the table when no longer needed.
Poor Dynamism
The system makes no attempt to cope with the live recompilation of reactive classes. A starting point might be to specialize the reinitialize-instance
method on the class objects themselves. Something like:
(defmethod reinitialize-instance :before ((class reactive) &key)
;; do something useful here
)
Conclusion
And so ends another installment of CLOS Encounters. I hope you have enjoyed seeing an example of what is possible when you're free to augment the object system itself. I also hope you're inspired to improve upon the system sketched in this post.
Until next time!