Initialising structure objects modularly
I use defstruct
a lot, even when execution speed or space usage isn’t an issue:
they’re better suited to static analysis than standard-object
s and that makes code
easier to reason about, both for me and SBCL. In particular, I try to exploit
read-only and typed slots as much as possible.
Structures also allow single-inheritance – and Christophe has a branch to allow
multiple subclassing – but don’t come with a default protocol like CLOS’s
make-instance
/initialize-instance
. Instead, we can only provide default value
forms for each slot (we can also define custom constructors, but they don’t carry over
to subtypes).
What should we do when we want to allow inheritance but need complex initialisation which would usually be hidden in a hand-written constructor function?
I’ll use the following as a completely artificial example. The key parts are that I have typed and read-only slots, that the initialisation values depend on arguments, and that we also have some post-processing that needs a reference to the newly-constructed structure (finalization is a classic).
(defstruct (foo (:constructor %make-foo)) (x (error "Missing arg") :type cons :read-only t) (y (error "Missing arg") :read-only t)) (defun make-foo (x y) ;; non-trivial initial values (multiple-value-bind (x y) (%frobnicate x y) (let ((foo (%make-foo :x x :y y))) ;; post-processing (%quuxify foo) foo)))
The hand-written constructor is a good, well-known, way to hide the complexity, as long as we don’t want to allow derived structure types. But what if we do want inheritance?
One way to work around the issue is to instead have an additional slot for arbitrary extension data. I’m not a fan.
Another way is to move the complexity from make-foo
into initialize-foo
,
which mutates an already-allocated instance of foo
(or a subtype). I’m even less
satisfied by this approach than by the previous one. It means that I lose read-only
slots, and, when I don’t have sane default values, typed slots as well. I also have to
track whether or not each object is fully initialised, adding yet more state to take
into account.
For now, I’ve settled on an approach that parameterises allocation. Instead of
calling %make-foo
directly, an allocation function is received as an argument. The
hand-written constructor becomes:
(defun make-foo (x y &optional (allocator ’%make-foo) &rest arguments) ;; the &rest list avoids having to build (a chain of) ;; closures in the common case (multiple-value-bind (x y) (%frobnicate x y) ;; allocation is parameterised (let ((foo (apply allocator :x x :y y arguments))) (%quuxify foo) foo)))
This way, I can define a subtype and still easily initialize it:
(defstruct (bar (:constructor %make-bar) (:include foo)) z) (defun make-bar (x y z) (make-foo x y ’%make-bar :z z))
The pattern is nicely applied recursively as well:
(defun make-bar (x y z &optional (allocator ’%make-bar) &rest arguments) (apply ’make-foo x y allocator :z z arguments))
Consing-averse people will note that the &rest
arguments are only used for
apply
. SBCL (and many other implementations, most likely) handles this case
specially and doesn’t even allocate a list: the arguments are used directly on the call
stack.
I’m sure others have encountered this issue. What other solutions are there? How can the pattern of parameterising allocation be improved or generalised?