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:

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:

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:

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:

: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:

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:

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:

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 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).