HYPERTHINGS

Multiton Classes with MOP

Continuing the practical exploration of the Metaobject Protocol, started in the previous post on Singletons, here Singletons are generalized to Multitons and a few use cases are discussed.

An example Multiton Class

The multiton pattern is generalization of the singleton pattern. Whereas a singleton is a class with at most one instance, a multiton is a class having at most some fixed finite number of instances.

For the implementation presented here, the number of instances is controlled by specifying a class option called :valid-keys which holds a list of keywords, one for each distinct instance

Consider the following definition of a class to model gemstones. Because each diamond is like every other diamond in its "diamondness", and each ruby in its "rubyness", there is really no reason to instantiate new instances for every diamond or ruby. Hence, you can use a multiton.

     ;; a class with at most three instances  
     (defclass gem ()  
       ((color  
         :reader gem-color  
         :initarg :color  
         :initform (error "must supply a color")  
        (hardness  
         :reader gem-hardness  
         :initarg :hardness  
         :initform (error "must supply a hardness"))  
       (:metaclass multiton)  
       (:valid-keys :diamond :ruby :emerald)  
       (:documentation "Gemstone Properties)  
     

As in the case with the singletons presentation, the above class definition uses a custom :metaclass, in this case multiton. Additionaly, the gem class is restricted to three distinct "gem instances" :diamond, :ruby, and :emerald.

When gem is instantiated for the first time with a particular key, the hardness and color slots must be supplied a value. On subsequent instantiations, however, only a key is required.

For example:

     ;; only the keys passed to :valid-keys are accepted  
     (make-instance 'gem :key :foobar)  
     ; error, bad key  
      
     ;; still need to pass in required args  
     (make-instance 'gem :key :ruby)  
     ; error :color required  
      
     ;; but only the first time  
     (make-instance 'gem :key :ruby :color :red :hardness 8)  
     ; #<GEM {10019C93A3}>  
      
     (make-instance 'gem :key :ruby)  
     ; #<GEM {10019C93A3}>  
      
     (eq (make-instance 'gem :key :ruby)  
         (make-instance 'gem :key :ruby))  
     ; T  
     

Keep reading to see how this works.

Defining The Metaclass

Every multiton class needs to keep track of two things: the keys used to reference its distinct instances, and the distinct instances themselves.

     ;; the multiton metaclass  
     (defclass multiton (standard-class)  
       ((instances  
         :initform (make-hash-table))  
        (valid-keys  
         :initarg :valid-keys  
         :initform (error "Must supply a list of valid instance keys")))  
       (:documentation "Metaclass for multiton classes."))  
     

Here, a hash table is used to to store the distinct instances. That table will be keyed by the members of the valid-keys list. In the case of the gem class, those are the values :diamond, :ruby, and :emerald.

An Aside: For small numbers of instances a hash table is probably too heavy-duty. If you are planning on using multitons with under 100 instances, a property list is probably an adequate data structure to store them in.

As with the singleton case from the previous post, the programmmer must manually validate the superclass relationships for the new metaclass:

     ;; validate superclas for multiton  
     (defmethod closer-mop:validate-superclass  
         ((sub multiton) (sup standard-class))  
       t)  
     

Supplying Default Superclasses

The astute reader may now be asking themselves, "Where is that :key argument coming from in the calls to initialize-instance above?"

Good question. The answer is: from a default superclass suppled to all instances of the multiton class.

Remember, multiton is a metaclass. Hence, instances of multiton are themselves new classes. Whenever a class is instantiated with the generic function initialize-instance, the specific initialize-instance method for that class's metaclass is looked up and applied. It is through the specialization of initialize-instance on a metaclass that you can supply a custom default superclass. For example, instances of the default metaclass standard-class are by default subclasses of standard-object.

For the multiton metaclass, new instance classes are by default sublcasses of has-instance-key, which looks like this:

     ;; superclass for instance classes of multiton  
     (defclass has-instance-key ()  
       ((key  
         :type keyword  
         :initarg :key  
         :initform (error "instance key required.")))  
       (:documentation  
        "Root superclass of instance classes of multiton.")) 

And here is the specialization of initialize-instance:

     ;; ensure superclass of 'has-instance-key  
     (defmethod initialize-instance :around  
         ((class multiton) &rest initargs &key direct-superclasses)  
       (if (loop :for super  
                   :in direct-superclasses  
                   :thereis (subtypep super 'has-instance-key))  
           (call-next-method)  
           (apply #'call-next-method  
                  class  
                  (list :direct-superclasses  
                        (cons (find-class 'has-instance-key)  
                              direct-superclasses)  
                        initargs))))  
     

It works by searching the list of direct-superclases supplied in defclass for a class that is a subtype of has-instance-key. If it finds one, then it proceeds as normal via call-next-method. Otherwise it still calls call-next-method, but first supplies the desired superclass.

Aside: If you were to be hacking on classes in a SLIME session or in the REPL, you would probably define a similar specialization of the generic function reinitialize-instance, which would be called when your classes are redefined.

Enforcing The Multiton Rules

The story so far: a metaclass for multitons has been defined and instances of that metaclass will fit in to the desired class hierarchy. One final piece is missing: ensuring that instances of the new classes will follow the multiton rules. What are those rules?

  1. That instances' key slot values belong to the prescribed valid-keys list.
  2. That two calls to make-instance with the same key are the same intstance.

As with the singleton case, a specialization of make-instance is called for:

     ;; enforcing multiton rules  
     (defmethod make-instance :around  
         ((class multiton) &key key &allow-other-keys)  
       (with-slots (instances valid-keys) class  
         (assert (member key valid-keys))  
         (or (gethash key instances)  
             (setf (gethash  key instances)  
                   (call-next-method)))))  
     

This implementation closly mirrors the specialization of make-instance on singletons from the last post. The gist is you get the instance already there or you make a new one, store it, and return it. There is the assertion that the key passed to make-instance is valid.

My Particular Use Case

Why might a person want to use multiton clases? I'm not sure about you, but I bumped into this pattern quite by accident while developing an ECS-like data model for a game I was working on. I only discovered the name "multiton" while researching the pattern for this post.

The gist of my problem was this: I needed to arrange for thousands of entities to share the same instances of a few component clases. Not only did I not want to waste memory by instantiating thousands of functionally equivalent components, but I also wanted to allow for a change in one instance to effect the behavior of every entity that shared it.

In a sense, one can think of of multitons as bundles of communication channels that are shared between objects. The bundle a channel belongs to is just a multiton class, and the particular channel whithin a bundle is an just an instance. Getting ahold of a channel becomes as simple as instantiating a class with the right keyword.

One final note: the only advantage to using multiton classes instead of, say, global hash tables, is that you can more naturally integrate them into your program's object lifecycles. In the case of my ad-hoc ECS system, a heap of code had already been written to deal with entities and their components. By making components that were also multitons, I extended the power of the system I'd already made with zero refactoring.

I hope have enjoyed this second installment of CLOS Encounters.

2021-05-24

next ⮫
2021-05-31
A Sketch of DYNAMIC-FLET
⮨ prev
2021-05-19
Singleton Classes with MOP