POODCL part 6: Inheritance
Chapter 6 of POODR discusses inheritance of class properties. You can define subclasses whose objects have all the properties of their superclasses plus whatever other properties we define for them. Subclasses are specialized, more concrete versions of their more general, more abstract superclasses.
In
the code for POODR chapter 6,
Metz approaches strategies for designing inheritable classes starting
with a concrete Bicycle
class. It begins as a class of road bikes
and progressively gets abstracted to provide an abstract class for
road, mountain, and recumbent bikes. I’ve translated the examples into
Common Lisp and discussed how to refactor them.
Here is a BICYCLE
class based on the Ruby version from
page 107.
(defclass bicycle ()
((size :reader size :initarg :size)
(tape-color :reader tape-color :initarg :tape-color)
(spares :reader spares)))
(defmethod initialize-instance :after ((b bicycle) &key)
(with-slots (spares tape-color) b
(setf spares
(list :chain "10-speed"
:tire-size "23" ;millimeters
:tape-color tape-color))))
I decided the best way to implement the Ruby spares
method for this
example as a slot which gets initialized with an
INITIALIZE-INSTANCE :AFTER
method. I feel this is the most
appropriate way to capture the value of the TAPE-COLOR
slot to make
sure it’s shared in the value for SPARES
. It works equivalently to
the Ruby examples.
CL-USER> (let ((bike (make-instance 'bicycle :tape-color "red" :size "M")))
(values
(size bike)
(spares bike)))
"M"
(:CHAIN "10-speed" :TIRE-SIZE "23" :TAPE-COLOR "red")
Every child class of bicycle is going to inherit these features. If we wanted to extend this class to cover mountain bikes and proceeded in the naivest way possible, we might end up with something like this:
(defclass bicycle ()
((style :reader style :initarg :style)
(size :reader size :initarg :size)
(tape-color :reader tape-color :initarg :tape-color)
(front-shock :reader front-shock :initarg :front-shock)
(rear-shock :reader rear-shock :initarg :rear-shock)
(spares :reader spares)))
(defmethod initialize-instance :after ((b bicycle) &key)
(with-slots (style spares tape-color rear-shock) b
(setf spares
(if (eq style 'road)
(list
:chain "10-speed"
:tire-size "23" ;millimeters
:tape-color (tape-color b))
(list
:chain "10-speed"
:tire-size "2.1" ;inches
:rear-shock (rear-shock b))))))
We shouldn’t mock this code. The constraints of the professional world
are sometimes cruel, so I’m sure there are many professionals who knew
better, but have done worse. I’ve added slots for mountain bike parts
and a style option to select on appropriate settings for the the
SPARES
slot.
However, the specifications recognize two kinds of bicycle with
disjoint properties. TAPE-COLOR
is just as irrelevant to the
MOUNTAIN
style as FRONT-SHOCK
and REAR-SHOCK
are to ROAD
style, but every object will have those slots regardless. STYLE
is
only useful for deciding what SPARES
will be.
It works.
CL-USER> (let ((bike (make-instance 'bicycle
:style 'mountain
:size "S"
:front-shock "Manitou"
:rear-shock "Fox")))
(spares bike))
(:CHAIN "10-speed" :TIRE-SIZE "2.1" :REAR-SHOCK "Fox")
But that doesn’t make it right.
We should proceed a little more reasonably, setting up a separate
MOUNTAIN-BIKE
class that inherits from bicycle.
(defclass mountain-bike (bicycle)
((front-shock :reader front-shock :initarg :front-shock)
(rear-shock :reader rear-shock :initarg :rear-shock)))
Now we have to look carefully at the remaining slots to separate what
would be specific to a ROAD-BIKE
class and what is common to both.
Metz discussed how to perform this analysis better than I could. But
we end up with code like this:
(defclass bicycle ()
((size :reader size :initarg :size)
(spares :reader spares)))
(defclass road-bike (bicycle)
((tape-color :reader tape-color :initarg :tape-color)))
Every bicycle class has SIZE
and SPARES
slots, but ROAD-BIKE
and
MOUNTAIN-BIKE
have additional slots specific and appropriate to
them. To decide spares for ROAD-BIKE
and MOUNTAIN-BIKE
we can
trivially adapt the original implementation of
INITIALIZE-INSTANCE :AFTER
for BICYCLE
.
(defmethod initialize-instance :after ((rb road-bike) &key)
(with-slots (spares tape-color) rb
(setf spares
(list :chain "10-speed"
:tire-size "23" ;millimeters
:tape-color tape-color))))
(defmethod initialize-instance :after ((mb mountain-bike) &key)
(with-slots (spares rear-shock) mb
(setf spares
(list
:chain "10-speed"
:tire-size "2.1" ;inches
:rear-shock rear-shock))))
If you have been evaluating this code as we go along, you will also
need to remove the INITIALIZE-INSTANCE :AFTER
method for BICYCLE
we defined earlier.
(remove-method #'initialize-instance
(find-method #'initialize-instance
(list :after)
(list (find-class 'bicycle))))
This implementation demonstrates a classic model of inheritance between a superclass and two subclasses. It also demonstrates an appropriate initialization method which sets up slot-values which depend on other slot values. We can expect it work but lets try it out:
CL-USER> (let ((rb (make-instance 'road-bike :size "M" :tape-color "red"))
(mb (make-instance 'mountain-bike :size "S"
:front-shock "Manitou"
:rear-shock "Fox")))
(values
(size rb)
(spares rb)
(size mb)
(spares mb)))
"M"
(:CHAIN "10-speed" :TIRE-SIZE "23" :TAPE-COLOR "red")
"S"
(:CHAIN "10-speed" :TIRE-SIZE "2.1" :REAR-SHOCK "Fox")
Careful readers of the INITITIALIZE-INSTANCE :AFTER
methods have
perhaps thought that our BICYCLE
superclass is missing some slots,
which means that our subclasses are also missing them. Lets redefine
BICYCLE
with CHAIN
and TIRE-SIZE
slots.
(defclass bicycle ()
((size :reader size :initarg :size)
(chain :reader chain :initarg :chain)
(tire-size :reader tire-size :initarg :tire-size)
(spares :reader spares)))
Several complications arise. We’re going to want to setup SPARES
with the appropriate values for the subclass of bikes. It would also
be nice to setup default values for the new slots too. Some defaults
are appropriate for all bike types, but some are appropriate to the
subclasses.
We have options. One is to setup our classes like this:
(defclass bicycle ()
((size :reader size :initarg :size)
(chain :reader chain
:initarg :chain
:initform "10-speed")
(tire-size :reader tire-size :initarg :tire-size)
(spares :reader spares)))
(defclass road-bike (bicycle)
((tire-size :reader tire-size
:initarg :tire-size
:initform "23" ;millimeters
)
(tape-color :reader tape-color :initarg :tape-color)))
(defclass mountain-bike (bicycle)
((tire-size :reader tire-size
:initarg :tire-size
:initform "2.1" ;inches
)
(front-shock :reader front-shock :initarg :front-shock)
(rear-shock :reader rear-shock :initarg :rear-shock)))
This roughly corresponds to the code on
page 126.
The :INITFORM
key gives a default value to the slot when an instance
is initialized. This kind of setup seems solid, but it leads to a
couple of problems. The first of which comes when Metz introduces a
new bike subclass, this one with a different default chain. This seems
easy enough to get around, by overiding the chain slot setup in the
new class.
(defclass recumbent-bike (bicycle)
((chain :reader chain
:initarg chain
:initform "9-speed")))
But this is graceless. We also have a problem in that this new class
doesn’t define a default value for TIRE-SIZE
. Metz recommends that
the Ruby Bicycle
class method default_tire_size
raise a
NotImplemented
error in this circumstance to alert future developers
adding classes and accessing new objects. Our Lisp implementation is
already going to raise an UNBOUND-SLOT
error if one proceeds with
calling TIRE-SIZE
on new RECUMBENT-BIKE
objects.
There can be other issues, particular to Common Lisp, with using
:INITFORM
key values like this. These are discussed
Chris Reisbeck’s notes on Graham’s ANSI Common Lisp chapter 11
but also at
this Lisp tips post.
The recommended way of dealing with these is with the
:DEFAULT-INITARGS
which results in cleaner code overall. While we’re
at it, we’ll implement RECUMBENT-BIKE
more completely.
(defclass bicycle ()
((size :reader size :initarg :size)
(chain :reader chain :initarg :chain)
(tire-size :reader tire-size :initarg :tire-size)
(spares :reader spares))
(:default-initargs
:chain "10-speed"))
(defclass road-bike (bicycle)
((tape-color :reader tape-color :initarg :tape-color))
(:default-initargs
:tire-size "23" ;millimeters
))
(defclass mountain-bike (bicycle)
((front-shock :reader front-shock :initarg :front-shock)
(rear-shock :reader rear-shock :initarg :rear-shock))
(:default-initargs
:tire-size "2.1" ;inches
))
(defclass recumbent-bike (bicycle)
((flag :reader flag :initarg :flag))
(:default-initargs
:chain "9-speed"
:tire-size "28"))
Not only is this implementation much cleaner, the default-values are
now pushed outwards, external to the slot definitions. They are
important only where they need to be important: in the initialization
parameters. Reading this code, I find the :DEFAULT-INITARGS
section
almost documentary. :INITFORM
values can have a place, but it’s a
very particular one.
We should now rexamine how the SPARES
slots of BICYCLE
objects are
initialized. We’ve been populating the SPARES
slot with a
pregenerated property list. It’s time to take that apart a bit.
(defmethod initialize-instance :after ((b bicycle) &key)
(with-slots (spares tire-size chain) b
(setf spares
(list
:chain chain
:tire-size tire-size))))
(defmethod initialize-instance :after ((rb road-bike) &key)
(with-slots (spares tape-color) rb
(setf (getf spares :tape-color) tape-color)))
(defmethod initialize-instance :after ((mb mountain-bike) &key)
(with-slots (spares rear-shock) mb
(setf (getf spares :rear-shock) rear-shock)))
(defmethod initialize-instance :after ((rb recumbent-bike) &key)
(with-slots (spares flag) rb
(setf (getf spares :flag) flag)))
We’ve added a new INITIALIZE-INSTANCE :AFTER
method to the BICYCLE
superclass and this gets inherited by the subclasses including the new
method for RECUMBENT-BIKE
objects. The handy thing about :BEFORE
and :AFTER
methods is that they all get called for every
superclass of your object. :AFTER
methods get called in
most-specific-last order, so the BICYCLE
class’s :AFTER
method is
called first to create the SPARES
property list. The :AFTER
methods for the subclass gets called next, to add properties to the
list.
CL-USER> (spares (make-instance 'road-bike :tape-color "red"))
(:TAPE-COLOR "red" :CHAIN "10-speed" :TIRE-SIZE "23")
CL-USER> (spares (make-instance 'mountain-bike :front-shock "Manitou" :rear-shock "Fox"))
(:REAR-SHOCK "Fox" :CHAIN "10-speed" :TIRE-SIZE "2.1")
CL-USER> (spares (make-instance 'recumbent-bike :flag "tall and orange"))
(:FLAG "tall and orange" :CHAIN "9-speed" :TIRE-SIZE "28")
If we hadn’t added the new method for RECUMBENT-BIKE
objects, it
would initialize, but it wouldn’t add the flag to the list of spares.
This is all pretty slick; we’ve implemented these classes and covered
most of the design concerns from this chapter using only features
built into the CLOS architecture: INITIALIZE-INSTANCE
methods,
:DEFAULT-INITARGS
, and inheritance. Our code is tight, focused, and
nearly self-explanatory. The technique that Metz introduces in this
chapter is of defining “template methods”, which subclasses can either
specialize or inherit. Metz discusses how to setup method hooks so
that implementors don’t have to rely on calling super
for
features. Although we don’t need to implement any of these to achieve
the same ends, it’s worth considering what we could do to make it
easier.
One outstanding concern is that the INITIALIZE-INSTANCE
methods have
a very significant dependence on knowing about the implementation
details of the class. The signs of this are in using the WITH-SLOTS
macro to access the slots directly, but also in the subclass :AFTER
methods knowing that SPARES
is a property list, they can add
properties to.
On the one hand, it’s reasonable to expect that implementors of
INITIALIZE-INSTANCE
methods should require this kind of inside
knowledge about the classes they create objects of. But this is not
true of most methods we might define for our classes. It would be
best, even for INITIALIZE-INSTANCE
methods, if we could limit how
much has to be known outside the class implementation. For this
iteration of demo code, I think we’ve done enough.