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:

  1. 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 the dep function.
  2. 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 using set-slot-comp.
  3. 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:

  1. A lookup is performed to retrieve the collection of value-computation instances associated with that object and that slot.
  2. For each such value-computation, you check that the object targeted for automatic updates is still alive.
  3. 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.


It is worth looking at the value computation class definition

     ;; instances are evaluated whenever dependencies change.  
     (defclass value-computation ()  
         :reader valcomp-object  
         :initarg :object  
         :initform (error "object is required")  
         :documentation "A weak pointer to the object whose slot will be updated.")  
         :reader valcomp-slot  
         :initarg :slot  
         :initform (error "slot is required")  
         :documentation "The slot associated with this value computation")  
         :reader valcomp-fn  
         :initarg :fn  
         :initform (error "fn is required")  
         :documentation "The literal function to be called.")  
         :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  


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!