Adding Inheritance and Tagged Structures
While this version of define-binary-class
will handle stand-alone structures, binary file formats often define on-disk structures that would be natural to model with subclasses and superclasses. So you might want to extend define-binary-class
to support inheritance.
A related technique used in many binary formats is to have several on-disk structures whose exact type can be determined only by reading some data that indicates how to parse the following bytes. For instance, the frames that make up the bulk of an ID3 tag all share a common header structure consisting of a string identifier and a length. To read a frame, you need to read the identifier and use its value to determine what kind of frame you’re looking at and thus how to parse the body of the frame.
The current define-binary-class
macro has no way to handle this kind of reading—you could use define-binary-class
to define a class to represent each kind of frame, but you’d have no way to know what type of frame to read without reading at least the identifier. And if other code reads the identifier in order to determine what type to pass to read-value
, then that will break read-value
since it’s expecting to read all the data that makes up the instance of the class it instantiates.
You can solve this problem by adding inheritance to define-binary-class
and then writing another macro, define-tagged-binary-class
, for defining “abstract” classes that aren’t instantiated directly but that can be specialized on by read-value
methods that know how to read enough data to determine what kind of class to create.
The first step to adding inheritance to define-binary-class
is to add a parameter to the macro to accept a list of superclasses.
(defmacro define-binary-class (name (&rest superclasses) slots) ...
Then, in the **DEFCLASS**
template, interpolate that value instead of the empty list.
(defclass ,name ,superclasses
...)
However, there’s a bit more to it than that. You also need to change the read-value
and write-value
methods so the methods generated when defining a superclass can be used by the methods generated as part of a subclass to read and write inherited slots.
The current way read-value
works is particularly problematic since it instantiates the object before filling it in—obviously, you can’t have the method responsible for reading the superclass’s fields instantiate one object while the subclass’s method instantiates and fills in a different object.
You can fix that problem by splitting read-value
into two parts—one responsible for instantiating the correct kind of object and another responsible for filling slots in an existing object. On the writing side it’s a bit simpler, but you can use the same technique.
So you’ll define two new generic functions, read-object
and write-object
, that will both take an existing object and a stream. Methods on these generic functions will be responsible for reading or writing the slots specific to the class of the object on which they’re specialized.
(defgeneric read-object (object stream)
(:method-combination progn :most-specific-last)
(:documentation "Fill in the slots of object from stream."))
(defgeneric write-object (object stream)
(:method-combination progn :most-specific-last)
(:documentation "Write out the slots of object to the stream."))
Defining these generic functions to use the **PROGN**
method combination with the option :most-specific-last
allows you to define methods that specialize object
on each binary class and have them deal only with the slots actually defined in that class; the **PROGN**
method combination will combine all the applicable methods so the method specialized on the least specific class in the hierarchy runs first, reading or writing the slots defined in that class, then the method specialized on next least specific subclass, and so on. And since all the heavy lifting for a specific class is now going to be done by read-object
and write-object
, you don’t even need to define specialized read-value
and write-value
methods; you can define default methods that assume the type argument is the name of a binary class.
(defmethod read-value ((type symbol) stream &key)
(let ((object (make-instance type)))
(read-object object stream)
object))
(defmethod write-value ((type symbol) stream value &key)
(assert (typep value type))
(write-object value stream))
Note how you can use **MAKE-INSTANCE**
as a generic object factory—while you normally call **MAKE-INSTANCE**
with a quoted symbol as the first argument because you normally know exactly what class you want to instantiate, you can use any expression that evaluates to a class name such as, in this case, the type
parameter in the read-value
method.
The actual changes to define-binary-class
to define methods on read-object
and write-object
rather than read-value
and write-value
are fairly minor.
(defmacro define-binary-class (name superclasses slots)
(with-gensyms (objectvar streamvar)
`(progn
(defclass ,name ,superclasses
,(mapcar #'slot->defclass-slot slots))
(defmethod read-object progn ((,objectvar ,name) ,streamvar)
(with-slots ,(mapcar #'first slots) ,objectvar
,@(mapcar #'(lambda (x) (slot->read-value x streamvar)) slots)))
(defmethod write-object progn ((,objectvar ,name) ,streamvar)
(with-slots ,(mapcar #'first slots) ,objectvar
,@(mapcar #'(lambda (x) (slot->write-value x streamvar)) slots))))))