In Lisp CLOS, how can a class setter automatically update another slot?

167 views Asked by At

I am new to CLOS. Here is my example:

   (defclass box ()
    ((length :accessor box-length :initform 0 :initarg :bxl)
     (breath :accessor box-breadth :initform 0 :initarg :bxb)
     (height :accessor box-height :initform 0 :initarg :bxh)
     (volume :reader   volume     :initform 0 :initarg :v)))

And the constructor is:

    (defun make-box (l b h)
     (make-instance 'box :bxl l :bxb b :bxh h :v (* l b h)))

So when I make an instance of the 'box' like this:

    ; make a box, 4 x 3 x 2
    (defparameter my-box (make-box 4 3 2))`

It works as I expected. I can 'describe' my-box and get:

    (describe my-box)
      #<BOX {100363F493}>
        [standard-object]

    Slots with :INSTANCE allocation:
      LENGTH                         = 4
      BREATH                         = 3
      HEIGHT                         = 2
      VOLUME                         = 24

Now, the question. If I update the 'height' like this:

    (setf (box-height my-box) 5)

How can I make this 'setf' automatically update the 'volume' slot?

So that VOLUME would change to (* 4 3 5) = 60?

2

There are 2 answers

1
ignis volens On BEST ANSWER

One way to do this is an after method on the setf method of the various accessors. So:

(defmethod (setf box-length) :after (length (b box))
  (with-slots (breadth height volume) b
    (setf volume (* length breadth height))))

This could be done by a before method as well, but if you use a general 'update-the-volume' function you want to use an after method to avoid storing the slots twice, or define the setf side of the accessor completely yourself.

Another approach which is certainly simpler is to not have a volume slot at all but compute it:

(defclass box ()
  ((length :accessor box-length :initform 0 :initarg :bxl)
   (breath :accessor box-breadth :initform 0 :initarg :bxb)
   (height :accessor box-height :initform 0 :initarg :bxh)))

(defgeneric volume (object))

(defmethod volume ((b box))
  (* (box-length b) (box-breadth b) (box-height b)))

Obviously other classes can still have a volume slot and methods on the volume generic function can access that slot: the protocol is the same.

You can even make describe report the volume, either by defining a method on describe-object for boxes, or just defining an after method. In the latter case in particular you probably have to fiddle to get the formatting to agree with whatever your implementation's describe does. Here is a method which coincidentally is OK for my usual implementation (LispWorks):

(defmethod describe-object :after ((b box) stream)
  (format stream "~& and volume ~D~%" (volume b)))

Now

> (describe (make-instance 'box))

#<box 801001147B> is a box
length      0
breath      0
height      0
 and volume 0
0
coredump On

Ad-hoc

Using CLOS only, you can write a compute-volume function that performs the computation, and have a slot in your object that is used as a cache.

This is possible because slots in CLOS can be unbound, so anytime a slot changes, it can invalidate the cache by making the volume slot unbound.

The reader function for volume, however, fills the slot if it is unbound. This ensures that the slot is computed only when necessary.

(defclass box ()
  ((length :accessor box-length  :initarg :bxl)
   (breath :accessor box-breadth  :initarg :bxb)
   (height :accessor box-height  :initarg :bxh)
   (volume :accessor volume)))

(defun compute-volume (l b h)
  (* l b h))

You can define an :around method for volume:

(defmethod volume :around (box)
  (if (slot-boundp box 'volume)
      (call-next-method)
      (setf (volume box)
            (compute-volume (box-length box)
                            (box-breadth box)
                            (box-height box)))))

The above means that when the slot is bound, you call the next available method, the standard one which accesses the slot. Otherwise, the slot is set to the value being computed, and that value is returned by setf, so you compute the volume and cache it.

Then, each slot needs to invalidate the cache. In theory you can also check if the value actually changed from its past value to be less aggressive, but the volume computation is not very worth avoiding.

(defmethod (setf box-length) :after (value box)
  (declare (ignore value))
  (slot-makunbound box 'volume))

This can be done for multiple slots with a macro:

(macrolet ((def-invalidate-method (accessor)
             (let ((value (gensym)) (box (gensym)))
               `(defmethod (setf ,accessor) :after (,value ,box)
                  (declare (ignore ,value))
                  (slot-makunbound ,box 'volume)))))
  (def-invalidate-method box-length)
  (def-invalidate-method box-breath)
  (def-invalidate-method box-height))

Cells

This might be a bit early if you are a beginner but it is worth reading about the Cells library at some point, it is interesting to see how CLOS can be used to implement functional reactive programming, ie. slots that recomputes automatically one of its dependency changes (like spreadsheet cells).

(ql:quickload :cells)

Let's define a temporary package:

(defpackage :socells (:use :cl :cells))
(in-package :socells)

With Cells, you can define a model, which is like a class but with some slots that can be recomputed automatically.

(defmodel box ()
  ((length :accessor box-length :initarg :bxl)
   (breath :accessor box-breadth :initarg :bxb)
   (height :accessor box-height  :initarg :bxh)
   (volume :reader   volume     
           :initform (c? (* (box-length self)
                            (box-breadth self)
                            (box-height self))))))

Here, the initform for the volum is an expression (c? ...), which defines a cell expression that is computed. Inside this expression, self is bound implicitly to the model, and the formula is stored in a way can be used to recompute the actual slot value whenever any of the dependencies change.

In the constructor, you need to wrap the values in (c-in ...) forms, to inform the Cells system that these values are inputs:

(defun make-box (l b h)
  (make-instance 'box
                 :bxl (c-in l)
                 :bxb (c-in b)
                 :bxh (c-in h)))

Then you can change the values and the volume is recomputed:

(let ((box (make-box 4 3 2)))
  (print (volume box))
  (incf (box-length box))
  (print (volume box))
  (incf (box-height box) 10)
  (print (volume box))
  (incf (box-breadth box) 20)
  (print (volume box)))

This prints:

24 
30 
180 
1380

You can choose to recompute the volume as soon as an input change, or only when its value is requested. You can also add observer functions that react when some cell slots changes its value (this can be used to update the UI, or to log things).