Object-oriented Programming in Common Lisp
The Common Model of OOP
Object-oriented programming (OOP) has a rich history, beginning with Smalltalk and Simula, two languages most students don't see any more. There were many ideas about how to do OOP but most modern languages have settled on something like this:
class Rectangle { constructor(height, width) { this.height = height; this.width = width; } area() { return this.height * this.width; } perimeter() { return (this.height + this.width) * 2; } }
This defines a Rectangle class. A class has fields or member variables, like height and width, and methods like area and perimeter. In addition, there is a special constructor method that can be used to create instances
The other key concept is instances. Classes define code. Instances hold data. You typically create instances with a new operator.
const rect1 = new Rectangle(5, 12);
Given an instance, you call its methods to with a dot operator, like this
rect1.area() // returns 60
rect1.perimeter() // returns 34
Note that the method definitions in the class referred to the magic variable this. this refers to the instance that was used to call the method. It's critical because the instance is not passed as a parameter to the method.
Classes are organized into hierarchies with superclasses, and inheritance, very much like frame systems.
The Common Lisp Model of OOP
OOP in Common Lisp is done using the Common Lisp Object System (CLOS). CLOS was one of handful of proposed OOP extensions to Common Lisp that were implemented, tested, and proposed to the Common Lisp community. Though not as mature as the Flavors OOP system, nor as simple as Object Lisp, CLOS was selected because it was the most general of the choices. Much of its complexity comes from a deliberate attempt to cover all bases.
CLOS has a very different, more general, more uniform model of OOP than the one described above. CLOS generalizes simple structures into classes in a hieararchy. It generalizes functions into methods that can have different code for different argument types. It doesn't put methods inside of classes. It has no magic variable this. It has no special dot operator.
CLOS has the following basic elements:
- Classes, defined with
defclass
. - Instances of classes, created with
make-instance
. - Methods, defined with
defmethod
.
CLOS methods are
called like functions. In fact, they are functions and
defmethod
is an extension of defun
.
Classes and Methods
Suppose we were implementing a cooking system. Among other things, we want to define classes of food, and methods for preparing them for eating. (Note: the complete code for these examples is here.)
First, some classes. defclass
has the form:
(defclass name (superclasses) (slots) options)
We'll discuss slots and options later.
(defclass food () ()) (defclass fruit (food) ()) (defclass apple (fruit) ()) (defclass orange (fruit) ()) (defclass seafood (food) ()) (defclass shrimp (seafood) ()) (defclass prawn (shrimp) ())
Next we define methods. defmethod
has the
form:
(defmethod name (specialized-parameters) expressions)
A specialized parameter is either a normal variable name, or a
list of the form (variable-name
class-name)
.
(defmethod prepare ((item apple)) (core item)) (defmethod prepare ((item orange)) (peel item)) (defmethod prepare ((item shrimp)) (peel item) (devein item))
core
, peel
, and devein
could be methods or functions.
Now, let's "prepare a prawn:"
> (prepare (make-instance 'prawn)) ... peel called with prawn instance ... devein called with prawn instance
This looks like a normal function call, but what actually happens is this:
- The built-in method
make-instance
, when passed a class name, creates an instance of that class. In this case, an instance ofprawn
is created. prepare
is called withprawn
. The only class containingprawn
that has aprepare
method is shrimp, so that method is used.
Methods are associated with classes. We do the same things to everything in a certain class. If we want to treat something differently, we make a new class or subclass.
Slots and instances
Instances represent particular objects of a class. Clearly, objects differ. You and I are both people, but we have different names, ages, weights, and so on. Two instances of a rectangle in a window will probably differ in location and size. To capture these details, CLOS uses slots. Slots are attached to instances to hold local data about that instance.
Slots in CLOS are very similar to slots in the MOP frame system. Each slot consists of a name and a value. When an instance is created, its slots are given initial values. These values can be read and modified later.
The kinds of slots an instance can have, their initial values, and how they are read and modified, is specified when the class is defined. The syntax is a little complex, because CLOS offers a great deal of control over how slot behave.
For example, suppose we want all fruit to have two properties: color and price. Both can be specified when an object is created. Price can be changed later, but not color.
(defclass fruit (food) ((color :initarg :color :reader fruit-color) (price :initarg :price :accessor fruit-price)))
The above has two slot specifications. The purpose of a slot specification is to define how to functionally access and modify the slot.
A slot specification has the form
(slot-name option1 value1 option2 value2 ...)
A slot name can be any symbol (but not a keyword). All options are, of course, optional.
The option :initarg keyword
gives the
keyword to use with make-instance
to specify an
initial value for the slot. Thus, to make a green fruit that
costs 50 cents, we'd write
(setq item (make-instance 'fruit :price 50 :color 'green))
The option :accessor method-name
gives
the method to call to read and modify the slot. If you only want
to allow the slot to be read, but not modified, as in the case of
color, use the :reader method-name
option
instead. Thus, the above says that the following are possible
operations on item
, an instance of
fruit
:
(fruit-color item) -- get color (fruit-price item) -- get price (setf (fruit-price item) 100) -- set price (setf (fruit-color item) 'red) -- illegal!
Now, suppose we want to say that apples are normally red. That
is, we want to give a default value for the :color
initarg. Default values for initargs are specified as a class
option, not a slot option. In particular, assuming that
apple
has no slots beyond what it inherits from
fruit
, then we'd write:
(defclass apple (fruit) () (:default-initargs :color 'red))
The following would make a 25 cent red apple and an unpriced green apple:
(make-instance 'apple :price 25) (make-instance 'apple :color 'green)
Overriding methods
When you define a new class, it automatically inherits the
methods of its superclasses. For example, the
prepare
method for prawn
was inherited
from shrimp
without change.
If we want to, we can override the inherited method by simply
defining a new method for the subclass, using
defmethod
.
Often, however, we want to "augment" the inherited method, but leave as much of the work as possible to the superclasses. There are two ways to do this in CLOS:
- define a method for the class that uses
call-next-method
to call on the superclasses, or - define a "before" or "after" method
call-next-method
When a method is passed a set of arguments, CLOS calculates a list of all the methods that could apply to those arguments. They are collected from the methods on the superclasses of the arguments and sorted so that the methods on most specific classes come first. Normally, just the first method is used.
That method, however, while executing, can call
call-next-method
to call the next method in line.
Thus, a more specific method can "pass the buck" to more general
methods.
For example, suppose we wanted prepare
for
prawn
to include a "wash" step before any other
steps:
(defmethod prepare ((item prawn)) (wash item) (call-next-method))
call-next-method, if called with no arguments, passes the
original arguments on to the next method in line. If you need to
pass modified arguments, then they need to be given to
call-next-method
explicitly.
Before and after methods
Because it is so common to want to simply add steps before or
after some other actions, CLOS has before and after methods. You
define them with defmethod
, using the keywords
:before
and :after
:
(defmethod prepare :before ((item prawn)) (wash item))
This does the same thing (roughly) as the previous code. The
difference is that the previous code defined what is called the
primary method for prepare
for
prawn
, whereas the latter code defies a before
method.
CLOS has the following rules about before, after, and primary methods:
- All the before methods applicable to a given set of arguments are executed first, from the most specific to the most general.
- The most specific primary method is called.
- All the after methods applicable to a given set of arguments are executed first, from the most general to most specific.
- The value of the primary method is returned.
:initform versus :default-initarg
There are actually two ways to initialize slots in CLOS, initforms and default initargs. Default initargs are more commonly used.
For example, here's a definition for the class circle
,
using default initargs:
(defclass circle () ((radius :accessor circle-radius :initarg :radius) (center :accessor circle-center :initarg :center)) (:default-initargs :radius 1 :center (cons 0 0)))
Here's the same class, using initforms:
(defclass circle () ((radius :accessor circle-radius :initarg :radius :initform 1) (center :accessor circle-center :initarg :center :initform (cons 0 0)))))
The initform version looks simpler. What's the difference between default initarg's and initforms, and why are default initargs preferred?
A default initarg is a default value for an initarg. An initform is a default value for a slot. So what difference does that make? Here's how it works. When you say
(make-instance 'circle ...)
make-instance
(in conjunction with
initialize-instance
) creates an instance of a circle as
follows:
make-instance
creates an empty instance.make-instance
passes the instance and any arguments it was given toinitialize-instance
.initialize-instance
first uses any explicit initargs that you gave tomake-instance
.- For slots still uninitialized, it then uses any default initargs that have been defined.
- Finally, for slots still uninitialized, it uses any default initforms that have been defined.
So one difference is that default initargs take priority over initforms.
Here's another difference. Suppose we add an area slot to circle:
(defclass circle () ((radius :accessor circle-radius :initarg :radius) (center :accessor circle-center :initarg :center) (area :accessor circle-area)) (:default-initargs :radius 1 :center (cons 0 0)))
Note that there is neither an initform nor an initarg for
area
. Instead, we are going to calculate the area from
the radius when the instance is created. Suppose we do this by
defining an after method on initialize-instance
:
(defmethod initialize-instance :after ((c circle) &key radius &allow-other-keys) (setf (circle-area c) (* pi radius radius)))
Now suppose we make the following call:
(make-instance 'circle)
This works fine with our definition of circle
. But if
we replaced the default initargs with initforms, it would cause an
error, because radius
would be nil
. No
:radius
argument was given and there was no default
value for that argument.
Note that we could define the after method on
initialize-instance
to work with either class definition
as follows:
(defmethod initialize-instance :after ((c circle) &rest args) (setf (circle-area c) (* pi (circle-radius c) (circle-radius c))))
This definition has two disadvantages:
- It's slightly more costly to access a slot than a keyword value.
- It doesn't generalize to before methods, where the slots haven't been created yet.
For this reason, many programmers use default initargs in their class definitions, rather than initforms.
A little more on parameter specializers
Class names are the most common parameter specializer.
Parameters don't have to be specialized, however, specializers
can also be one of the built-in types, and CLOS has something
called eql
specializers, that allows methods to be
defined for specific objects, rather than classes of objects.
Unspecialized parameters
As an example of a method with no specializers, consider
(defmethod wash (item) (format t "~&Washing ~S~%" item))
This looks just like a function definition (except for the
defmethod
) but it actually means something
different. A variable with no specializer is the same as a
variable with the specializer t
. t
is
the name of the class of all classes. That is, all classes
(except t
) have t
as a superclass. So
the above gives a method that applies to everyone.
How does this differ from simply defining the equivalent function? The answer is simple: methods can be overridden, functions can't be. The above says what to do if no more specific method can be found. The function version says what to do, no matter what.
Type specializers
Suppose we have two cook
methods, one for cooking
for a particular number of seconds, and the other for cooking
until some goal state is reached, e.g., "cook until done." The
first method would take a food item and an integer, the second a
food item and a symbol. They could be defined thus:
(defmethod cook ((item food) (time integer)) (format t "~&Cooking ~S ~S seconds~%" item time)) (defmethod cook ((item food) (goal symbol)) (format t "~&Cooking ~S until ~S%" item goal))
CLOS predefines class names for all the built-in Common Lisp types, so that they can be used as parameter specializers.
eql specializers
Now suppose we want to treat cooking zero seconds specially. Even though zero is neither a class nor a type, but an object, we can still specialize on it:
(defmethod cook ((item food) (time (eql 0))) (format t "~&Leaving ~S raw~%" item))
This is for illustration only and not good code, since it's so
easy to check for zero in the primary method, and someone reading
the primary method above won't know that the zero case is handled
elsewhere. Better examples are given in Keene's book. Also, we
use eql
specializers for modular GBS's.
Classes versus Frames
CLOS classes clearly have a lot in common with frames in the MOP system. Both have abstractions and instances and slots. This is not accidental. First, such mechanisms are just inherently good ways for representing concepts. Second, many of the developers of CLOS were AI programmers.
As a result, many people wonder if it's possible to represent frames directly as CLOS classes, and some people actually do it. That is, something like
(defmop m-apple (m-fruit) :color m-red)
is expanded into something like
(defclass m-apple (m-fruit) ((color :initarg :color)) (:default-initargs :color 'm-red))
This is a big mistake! Frames and CLOS have very different goals, and hence support very different operations.
The goal of CLOS classes is to support programming, in particular, the generation of reusable, very modular, efficient code. The goal of frames is support knowledge representation.
A big problem in implementing CLOS is getting method calls to be as efficient as possible (not too much worse than straight function calls). Techniques for doing this typically involve building tables of precomputed calling patterns for various kinds of arguments, and organizing slots into predefined arrays for quick access. This involves a fair amount of computation when classes and methods are defined, in order to minimize computation when methods are called.
As a result, basic CLOS does not provide several facilities that a knowledge representation system should:
- a way to get a list of all the slots of a class or instance, which frame matching needs
- a functional way to create a new class, i.e., something
besides
(eval `(defclass ...))
, which learning algorithms need - a way to get some or all of the subclasses or instances of a class, which case-based reasoning needs
The moral is simple.
Use classes for programming, frames for knowledge representation.
In practice, this means that
Just as domain knowledge doesn't belong in Lisp code, it doesn't belong in classes or methods either. Classes and methods are Lisp code.
Our examples of classes of food and methods for cooking are OK if we're trying to write a program to cook. They're not OK if we're trying to write a program to reason about cooking.
Note that while it is not a good idea to implement
defmop
as defclass
, it's not
unreasonable to implement defmop
with code that
creates instances of a MOP class object.
Code Maintenance and CLOS
The effect of CLOS (and object-oriented programming) in general on the maintainability of code is not really well known yet.
The goals of OOP are to enable code that
- better models the tasks involved, and
- can be reused across projects
Better modelling is quite compatible with the goal of maintainability. Code that refers to actions and objects "in the world" is easier to read and debug than code that refers to generic data structures such as numbers and arrays.
Reusability focuses on a different issue, namely code development. If code can be reused, then development can be faster. Good class and method libraries, like good code libraries in general, should enable more robust, efficient systems to be developed, but maintainability will depend on how well-designed those libraries are, how readable code using those libraries is, and so on.
The class and method approach to programming is a two-edged sword. On the one hand, it fits well with the Cardinal Rule of Functions. Methods in well-designed OOP code are usually quite short and to the point.
On the other hand, large OOP systems can be quite hard to
trace and debug. For example, the method draw
no
longer sits in one place. It's fragmented across all the objects
that can be drawn. Removing a method is not as simple as just
"undefining" a function.
Working in large OOP systems usually requires tools for "browsing" class libraries and locating the specific methods that would be used for any particular instance.
Tell Me More
All the gory details of basic CLOS are in the Common Lisp manual. A good introduction to proper programming using CLOS is Sonya Keene's Object-Oriented Programming in Common Lisp (Addison-Wesley).