Multiton Classes with MOP
Continuing an Introduction to the Metaobject Protocol
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 make-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?
- That instances'
key
slot values belong to the prescribedvalid-keys
list. - That two calls to
make-instance
with the samekey
are the sameintstance
.
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.