Object-oriented Programming in Common Lisp
The Standard Model of OOP
Most people summarize object-oriented programming (OOP) as "you define objects and send them messages and code attached to the objects respond to the messages sent." The code that responds to a particular message is called a method. Often, the intuition is that the method is attached or "inside" the object. This intuition does not apply to Common Lisp, as we shall see.
For example, you define a window object, define a rectange object, put the rectangle in the window, and send a "show yourself" message to the window. The window's "show yourself" method draws borders and so on, and then sends "show yourself" messages to each of the objects it contains. The methods on those objects do the appropriate things, e.g., draw a rectangle or circle or whatever.
Part of the power of OOP is the ability to have standard messages such as "show yourself" handled in different ways by different objects.
Common (but not universal) is the use of classes to organize objects. That is, you'd have a window class and a rectangle class and make instances of those classes to represent particular windows and rectangles.
Classes are themselves organized into taxonomic hierarchies. There might be "basic window," under which is "window with scroll bars," "window with no scroll bars," and so on.
In this approach, methods are attached to classes, not instances. When a message is sent to some instance, you look at all the classes that the instance belongs to, and use the method attached to the most specific class possible.
For example, the "show yourself" method for "window with scroll bars" might send "show yourself" to "basic window" first, to get the borders and other common items taken care of, then it would draw the scroll bars afterwards.
Most messages are sent to instances, but there are messages that go to classes. In particular, "make instance" is a message sent to classes.
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 the following basic elements:
- Classes, defined with
- Instances of classes, created with
- Methods, defined with
Rather than having a "send message" command, CLOS methods are
called like functions. In fact, they are functions and
defmethod is an extension of
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
(defmethod name (specialized-parameters) expressions)
A specialized parameter is either a normal variable name, or a
list of the form
(defmethod prepare ((item apple)) (core item)) (defmethod prepare ((item orange)) (peel item)) (defmethod prepare ((item shrimp)) (peel item) (devein item))
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 of
prepareis called with
prawn. The only class containing
prawnthat has a
preparemethod 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.
: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))
: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
item, an instance of
(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
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)
When you define a new class, it automatically inherits the
methods of its superclasses. For example, the
prepare method for
prawn was inherited
shrimp without change.
If we want to, we can override the inherited method by simply
defining a new method for the subclass, using
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-methodto call on the superclasses, or
- define a "before" or "after" 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
For example, suppose we wanted
prawn to include a "wash" step before any other
(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
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
(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
prawn, whereas the latter code defies a before
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
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
make-instancecreates an empty instance.
make-instancepasses the instance and any arguments it was given to
initialize-instancefirst uses any explicit initargs that you gave to
- 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
(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:
This works fine with our definition of
circle. But if
we replaced the default initargs with initforms, it would cause an
radius would be
: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
(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
eql specializers, that allows methods to be
defined for specific objects, rather than classes of objects.
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
the name of the class of all classes. That is, all classes
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.
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.
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
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
(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
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
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).