|
Allegro CL version 8.0 |
The following material excerpted (with permission) from ANSI Common Lisp by Paul Graham. Chapter 11 covers a review of CLOS concepts.
1. CLOS
2. Object-Oriented programming
3. Classes and Instances
4. Slot Properties
5. Superclasses
6. Precedence
7. Generic Functions
8. Auxiliary Methods
9. Method Combination
10. Encapsulation
11. Two Models
12. Summary
13. Exercises
The Common Lisp Object System, or CLOS, is a set of operators for doing object-oriented programming. Because of their common history it is conventional to treat these operators as a group. Technically, they are in no way distinguished from the rest of Common Lisp: defmethod is just as much (and just as little) an integral part of the language as defun.
Object-oriented programming means a change in the way programs are organized. This change is analogous to the one that has taken place in the distribution of processor power. In 1970, a multi-user computer system meant one or two big mainframes connected to a large number of dumb terminals. Now it is more likely to mean a large number of workstations connected to one another by a network. The processing power of the system is now distributed among individual users instead of centralized in one big computer.
Object-oriented programming breaks up traditional programs in much the same way. Instead of having a single program that operates on an inert mass of data, the data itself is told how to behave, and the program is implicit in the interactions of these new data "objects."
For example, suppose we want to write a program to find the areas of two-dimensional shapes. One way to do this would be to write a single function that looked at the type of its argument and behaved accordingly, as in Figure 11.1:
(defstruct rectangle height width) (defstruct circle radius) (defun area (x) (cond ((rectangle-p x) (* (rectangle-height x) (rectangle-width x))) ((circle-p x) (* pi (expt (circle-radius x) 2))))) > (let ((r (make-rectangle))) (setf (rectangle-height r) 2 (rectangle-width r) 3) (area r)) 6 Figure 11.1
Using CLOS we might write an equivalent program as in Figure 11.2. In the object-oriented model, our program gets broken up into several distinct methods, each one intended for certain kinds of arguments. The two methods in Figure 11.2 implicitly define an area function that works just like the one in Figure 11.1. When we call area, looks at the type of the argument and invokes the corresponding method.
(defclass rectangle () (height width)) (defclass circle () (radius)) (defmethod area ((x rectangle)) (* (slot-value x 'height) (slot-value x 'width))) (defmethod area ((x circle)) (* pi (expt (slot-value x 'radius) 2))) > (let ((r (make-instance 'rectangle))) (setf (slot-value r 'height) 2 (slot-value r 'width) 3) (area r)) 6 Figure 11.2
Together with this way of breaking up functions into distinct methods, object-oriented programming implies inheritance---both of slots and methods. The empty list given as the second argument in the two defclasses in Figure 11.2 is a list of superclasses. Suppose we define a new class of colored objects, and then a class of colored circles that has both colored and circle as superclasses:
(defclass colored () (color)) (defclass colored-circle (circle colored) ())
When we make instances of colored-circle, we will see two kinds of inheritance:
In practical terms, object-oriented programming means organizing a program in terms of methods, classes, instances, and inheritance. Why would you want to organize programs this way? One of the claims of the object-oriented approach is that it makes programs easier to change. If we want to change the way objects of class ob are displayed, we just change the display method of the ob class. If we want to make a new class of objects like obs but different in a few respects, we can create a subclass of ob; in the subclass, we change the properties we want, and all the rest will be inherited by default from the ob class. And if we just want to make a single ob that behaves differently from the rest, we can create a new child of ob and modify the child's properties directly. If the program was written carefully to begin with, we can make all these types of modifications without even looking at the rest of the code.
You go through two steps to create structures: you call defstruct to lay out the form of a structure, and a specific function like make-point to make them. Creating instances requires two analogous steps. First we define a class, using defclass:
(defclass circle () (radius center))
This definition says that instances of the circle class will have two slots (like fields in a structure), named radius and center respectively.
To make instances of this class, instead of calling a specific function, we call the general make-instance with the class name as the first argument:
> (setf c (make-instance 'circle)) #<Circle #XC27496>
To set the slots in this instance, we can use setf with slot-value:
> (setf (slot-value c 'radius) 1) 1
Like structure fields, the values of uninitialized slots are undefined.
The third argument to defclass must be a list of slot definitions. The simplest slot definition, as in the example above, is a symbol representing its name. In the general case, a slot definition can be a list of a name followed by one or more properties. Properties are specified like keyword arguments.
By defining an :accessor for a slot, we implicitly define a function that refers to the slot, making it unnecessary to call slot-value. If we update our definition of the circle class as follows,
(defclass circle () ((radius :accessor circle-radius) (center :accessor circle-center)))
then we will be able to refer to the slots as circle-radius and circle-center respectively:
> (setf c (make-instance 'circle)) #<Circle #XC5C726> > (setf (circle-radius c) 1) 1 > (circle-radius c) 1
By specifying a :writer or a :reader instead of an :accessor, we could get just the first half of this behavior, or just the second.
To specify a default value for a slot, we have to give an :initform argument. If we want to be able to initialize the slot in the call to make-instance, we define a parameter name as an :initarg. With both added, our class definition might become:
(defclass circle () ((radius :accessor circle-radius :initarg :radius :initform 1) (center :accessor circle-center :initarg :center :initform (cons 0 0))))
Now when we make an instance of a circle we can either pass a value for a slot using the keyword parameter defined as the slot's :initarg, or let the value default to that of the slot's :initform.
> (setf c (make-instance 'circle :radius 3)) #<Circle #XC2DE0E> > (circle-radius c) 3 > (circle-center c) (0 . 0)
We can specify that some slots are to be shared---that is, their value is the same for every instance. We do this by declaring the slot to have :allocation :class. (The alternative is for a slot to have :allocation :instance, but since this is the default there is no need to say so explicitly.) When we change the value of such a slot in one instance, that slot will get the same value in every other instance. So we would want to use shared slots to contain properties that all the instances would have in common.
For example, suppose we wanted to simulate the behavior of a flock of tabloids. In our simulation we want to be able to represent the fact that when one tabloid takes up a subject, they all do. We can do this by making all the instances share a slot. If the tabloid class is defined as follows,
(defclass tabloid () ((top-story :accessor tabloid-story :allocation :class)))
then if we make two instances of tabloids, whatever becomes front-page news to one instantly becomes front-page news to the other:
> (setf daily-blab (make-instance 'tabloid) unsolicited-mail (make-instance 'tabloid)) #<Tabloid #XC2AB16> > (setf (tabloid-story daily-blab) 'adultery-of-senator) ADULTERY-OF-SENATOR > (tabloid-story unsolicited-mail) ADULTERY-OF-SENATOR
The :documentation property, if given, should be a string to serve as the slot's documentation. By specifying a :type, you are promising that the slot will only contain elements of that type.
The second argument to defclass is a list of superclasses. A class inherits the union of the slots of its superclasses. So if we define the class screen-circle to be a subclass of both circle and graphic,
(defclass graphic () ((color :accessor graphic-color :initarg :color) (visible :accessor graphic-visible :initarg :visible :initform t))) (defclass screen-circle (circle graphic) ())
then instances of screen-circle will have four slots, two inherited from each superclass. A class does not have to create any new slots of its own; screen-circle exists just to provide something instantiable that inherits from both circle and graphic.
The accessors and initargs work for instances of screen-circle just as they would for instances of circle or graphic:
> (graphic-color (make-instance 'screen-circle :color 'red :radius 3)) RED
We can cause every screen-circle to have some default initial color by specifying an initform for this slot in the defclass:
(defclass screen-circle (circle graphic) ((color :initform 'purple)))
Now instances of screen-circle will be purple by default:
> (graphic-color (make-instance 'screen-circle)) PURPLE
We've seen how classes can have multiple superclasses. When there are methods defined for several of the classes to which an instance belongs, Lisp needs some way to decide which one to use. The point of precedence is to ensure that this happens in an intuitive way.
For every class there is a precedence list: an ordering of itself and its superclasses from most specific to least specific. In the examples so far, precedence has not been an issue, but it can become one in bigger programs. Here's a more complex class hierarchy:
(defclass sculpture () (height width depth)) (defclass statue (sculpture) (subject)) (defclass metalwork () (metal-type)) (defclass casting (metalwork) ()) (defclass cast-statue (statue casting) ())
Figure 11.3 contains a network representing cast-statue and its superclasses.
To build such a network for a class, start at the bottom with a node representing that
class. Draw links upward to nodes representing each of its immediate superclasses, laid
out from left to right as they appeared in the calls to defclass. Repeat the process for
each of those nodes, and so on, until you reach classes whose only immediate superclass is
standard-object---that is, classes for which the second argument to defclass
was (). Create links from those classes up to a node representing standard-object, and one
from that node up to another node representing the class t. The result will be a network
that comes to a point at both top and bottom, as in Figure 11.3.
The precedence list for a class can be computed by traversing the corresponding network as follows:
- If you are about to enter a node and you notice another path entering the same node from the right, then instead of entering the node, retrace your steps until you get to a node with an unexplored path leading upward. Go back to step 2.
- When you get to the node representing t, you're done. The order in which you first entered each node determines its place in the precedence list.
One of the consequences of this definition (in fact, of rule 3) is that no class appears in the precedence list before one of its subclasses.
The arrows in Figure 11.3 show how it would be traversed. The precedence list determined by this graph is: cast-statue, statue, sculpture, casting, metalwork, standard-object, t. Sometimes the word specific is used as shorthand to refer to the position of a class in a given precedence list. The preceding list runs from most specific to least specific.
The main point of precedence is to decide what method gets used when a generic function is invoked. This process is described in the next section. The other time precedence matters is when a slot with a given name is inherited from several superclasses.
A generic function is a function made up of one or more methods. Methods are defined with defmethod, which is similar in form to defun:
(defmethod combine (x y) (list x y))
Now combine has one method. If we call combine at this point, we will get the two arguments in a list:
> (combine 'a 'b) (A B)
So far we haven't done anything we could not have done with a normal function. The unusual thing about a generic function is that we can continue to add new methods for it.
First, we define some classes for the new methods to refer to:
(defclass stuff () ((name :accessor name :initarg :name))) (defclass ice-cream (stuff) ()) (defclass topping (stuff) ())
This defines three classes: stuff, which is just something with a name, and ice-cream and topping, which are subclasses of stuff.
Now here is a second method for combine:
(defmethod combine ((ic ice-cream) (top topping)) (format nil "~A ice-cream with ~A topping." (name ic) (name top)))
In this call to defmethod the parameters are specialized: each one appears in a list with the name of a class. The specializations of a method indicate the kinds of arguments to which it applies. The method we just defined will only be used if the arguments to combine are instances of ice-cream and topping respectively.
How does Lisp decide which method to use when a generic function is called? It will use the most specific method for which the classes of the arguments match the specializations of the parameters. Which means that if we call combine with an instance of ice-cream and an instance of topping, we'll get the method we just defined:
> (combine (make-instance 'ice-cream :name 'fig) (make-instance 'topping :name 'treacle)) "FIG ice-cream with TREACLE topping."
But with any other arguments, we'll get the first method we defined:
> (combine 23 'skiddoo) (23 SKIDDOO)
Because neither of the parameters of the first method is specialized, it will always get last priority, yet will always get called if no other method does. An unspecialized method acts as a safety net, like an otherwise clause in a case expression.
Any combination of the parameters in a method can be specialized. In this method only the first argument is:
(defmethod combine ((ic ice-cream) x) (format nil "~A ice-cream with ~A." (name ic) x))
If we call combine with an instance of ice-cream and an instance of topping, we'll still get the method that's looking for both, because it's more specific:
> (combine (make-instance 'ice-cream :name 'grape) (make-instance 'topping :name 'marshmallow)) "GRAPE ice-cream with MARSHMALLOW topping."
However, if the first argument is ice-cream and the second argument is anything but topping, we'll get the method we just defined above:
> (combine (make-instance 'ice-cream :name 'clam) 'reluctance) "CLAM ice-cream with RELUCTANCE."
When a generic function is called, the arguments determine a set of one or more applicable methods. A method is applicable if the arguments in the call come within the specializations of all its parameters.
If there are no applicable methods we get an error. If there is just one, it is called. If there is more than one, the most specific gets called. The most specific applicable method is determined based on the class precedence for the arguments in the call. The arguments are examined left to right. If the first parameter of one of the applicable methods is specialized on a more specific class than the first parameters of the other methods, then it is the most specific method. Ties are broken by looking at the second argument, and so on.
In the preceding examples, it is easy to see what the most specific applicable method would be, because all the objects have a single line of descent. An instance of ice-cream is, in order, itself, ice-cream, stuff, a standard-object, and a member of the class t.
Methods don't have to be specialized on classes defined by defclass. They can also be specialized on types (or more precisely, the classes that mirror types). Here is a method for combine that's specialized on numbers:
(defmethod combine ((x number) (y number)) (+ x y))
Methods can even be specialized on individual objects, as determined by eql:
(defmethod combine ((x (eql 'powder)) (y (eql 'spark))) 'boom)
Specializations on individual objects take precedence over class specializations.
Methods can have parameter lists as complex as ordinary Common Lisp functions, but the parameter lists of all the methods that compose a generic function must be congruent. They must have the same number of required parameters, the same number of optional parameters (if any), and must either all use &rest or &key, or all not use them. The following pairs of parameter lists are all congruent,
x) (a) (x &optional y) (a &optional b) (x y &rest z) (a b &key c) (x y &key z) (a b &key c d)
and the following pairs are not:
(x) (a b) (x &optional y) (a &optional b c) (x &optional y) (a &rest b) (x &key x y) (a)
Only required parameters can be specialized. Thus each method is uniquely identified by its name and the specializations of its required parameters. If we define another method with the same qualifiers and specializations, it overwrites the original one. So by saying
(defmethod combine ((x (eql 'powder)) (y (eql 'spark))) 'kaboom)
we redefine what combine does when its arguments are powder and spark.
Methods can be augmented by auxiliary methods, including before-, after-, and around-methods. Before-methods allow us to say, ``But first, do this.'' They are called, most specific first, as a prelude to the rest of the method call. After-methods allow us to say, ``P.S. Do this too.'' They are called, most specific last, as an epilogue to the method call. Between them, we run what has till now been considered just the method, but is more precisely known as the primary method. The value of this call is the one returned, even if after-methods are called later.
Before- and after-methods allow us to wrap new behavior around the call to the primary method. Around-methods provide a more drastic way of doing the same thing. If an around-method exists, it will be called instead of the primary method. Then, at its own discretion, the around-method may itself invoke the primary method (via the function call-next-method, which is provided just for this purpose).
This is called standard method combination. In standard method combination, calling a generic function invokes
- All before-methods, from most specific to least specific.
- The most specific primary method.
- All after-methods, from least specific to most specific.
The value returned is the value of the around-method (in case 1) or the value of the
most specific primary method (in case 2).
Auxiliary methods are defined by putting a qualifying keyword after the method name in the
call to defmethod. If we define a primary speak method
for the speaker class as
(defclass speaker () ()) (defmethod speak ((s speaker) string) (format t "~A" string))
then calling speak with an instance of speaker just prints the second argument:
> (speak (make-instance 'speaker) "I'm hungry") I'm hungry NIL
By defining a subclass intellectual, which wraps before- and after-methods around the primary speak method,
(defclass intellectual (speaker) ()) (defmethod speak :before ((i intellectual) string) (princ "Perhaps ")) (defmethod speak :after ((i intellectual) string) (princ " in some sense"))
we can create a subclass of speakers that always have the last (and the first) word:
> (speak (make-instance 'intellectual) "I'm hungry") Perhaps I'm hungry in some sense NIL
As the preceding outline of standard method combination noted, all before- and after-methods get called.
So if we define before- or after-methods for the speaker superclass,
(defmethod speak :before ((s speaker) string) (princ "I think "))
they will get called in the middle of the sandwich:
> (speak (make-instance 'intellectual) "I'm hungry") Perhaps I think I'm hungry in some sense NIL
Regardless of what before- or after-methods get called, the value returned by the generic function is the value of the most specific primary method---in this case, the nil returned by format.
This changes if there are around-methods. If there is an around-method specialized for
the arguments passed to the generic function, the around-method will get called first, and
the rest of the methods will only run if the around-method decides to let them. An around-
or primary method can invoke the next method by calling call-next-method.
Before doing so, it can use next-method-p to test whether there is a next
method to call.
With around-methods we can define another, more cautious, subclass of speaker:
(defclass courtier (speaker) ()) (defmethod speak :around ((c courtier) string) (format t "Does the King believe that ~A? " string) (if (eql (read) 'yes) (if (next-method-p) (call-next-method)) (format t "Indeed, it is a preposterous ~ idea.~%")) 'bow)
When the first argument to speak is an instance of the courtier class, the courtier's tongue is now guarded by the around-method:
> (speak (make-instance 'courtier) "kings will last") Does the King believe that kings will last? yes I think kings will last BOW > (speak (make-instance 'courtier) "the world is round") Does the King believe that the world is round? no Indeed, it is a preposterous idea. BOW
Note that, unlike before- and after-methods, the value returned by the around-method is returned as the value of the generic function.
In standard method combination the only primary method that gets called is the most specific (though it can call others via call-next-method). Instead we might like to be able to combine the results of all applicable primary methods.
It's possible to define methods that are combined in other ways---for example, for a generic function to return the sum of all the applicable primary methods. Operator method combination can be understood as if it resulted in the evaluation of a Lisp expression whose first element was some operator, and whose arguments were calls to the applicable primary methods, in order of specificity. If we defined the price generic function to combine values with +, and there were no applicable around-methods, it would behave as though it were defined:
(defun price (\&rest args) (+ (apply [most specific primary method] args) . . . (apply [least specific primary method] args)))
If there are applicable around-methods, they take precedence, just as in standard method combination. Under operator method combination, an around-method can still call the next method via call-next-method. However, primary methods can no longer use call-next-method.
We can specify the type of method combination to be used by a generic function with a :method-combination clause in a call to defgeneric:
(defgeneric price (x) (:method-combination +))
Now the price method will use + method combination; any defmethod for price must have + as the second argument. If we define some classes with prices,
(defclass jacket () ()) (defclass trousers () ()) (defclass suit (jacket trousers) ()) (defmethod price + ((jk jacket)) 350) (defmethod price + ((tr trousers)) 200)
then when we ask for the price of an instance of suit, we get the sum of the applicable price methods:
> (price (make-instance 'suit)) 550
The following symbols can be used as the second argument to defmethod or in the :method-combination option to defgeneric:
+ and append list max min nconc or progn
You can also use standard, which yields standard method combination.
Once you specify the method combination a generic function should use, all methods for that function must use the same kind. Now it would cause an error if we tried to use another operator (or :before or :after) as the second argument in a defmethod for price. If we want to change the method combination of price, we must remove the whole generic function by calling fmakunbound.
Object-oriented languages often provide some way of distinguishing between the actual representation of objects and the interface they present to the world. Hiding implementation details brings two advantages: you can change the implementation without affecting the object's outward appearance, and you prevent objects from being modified in potentially dangerous ways. Hidden details are sometimes said to be encapsulated.
Although encapsulation is often associated with object-oriented programming, the two ideas are really separate. You can have either one without the other.
In Common Lisp, packages are the standard way to distinguish between public and private information. To restrict access to something, we put it in a separate package, and only export the names that are part of the external interface.
We can encapsulate a slot by exporting the names of the methods that can modify it, but not the name of the slot itself. For example, we could define a counter class and associated increment and clear methods as follows:
(defpackage "CTR" (:use "COMMON-LISP") (:export "COUNTER" "INCREMENT" "CLEAR")) (in-package ctr) (defclass counter () ((state :initform 0))) (defmethod increment ((c counter)) (incf (slot-value c 'state))) (defmethod clear ((c counter)) (setf (slot-value c 'state) 0))
Under this definition, code outside the package would be able to make instances of counter and call increment and clear, but would not have legitimate access to the name state.
If you want to do more than just distinguish between the internal and external interface to a class, and actually make it impossible to reach the value stored in a slot, you can do that too. Simply unintern its name after you've defined the code that needs to refer to it:
(unintern 'state)
Then there is no way, legitimate or otherwise, to refer to the slot from any package.
Object-oriented programming is a confusing topic partly because there are two models of how to do it: the message-passing model and the generic function model. The message-passing model came first. Generic functions are a generalization of message-passing.
In the message-passing model, methods belong to objects, and are inherited in the same sense that slots are. To find the area of an object, we send it an area message,
tell obj area
and this invokes whatever area method obj has or inherits.
Sometimes we have to pass additional arguments. For example, a move method might take an argument specifying how far to move. If we wanted to tell obj to move 10, we might send it the following message:
tell obj move 10
If we put this another way,
(move obj 10)
the limitation of the message-passing model becomes clearer. In message-passing, we only specialize the first parameter. There is no provision for methods involving multiple objects---indeed, the model of objects responding to messages makes this hard even to conceive of.
In the message-passing model, methods are of objects, while in the generic function model, they are specialized for objects. If we only specialize the first parameter, they amount to exactly the same thing. But in the generic function model, we can go further and specialize as many parameters as we need to. This means that, functionally, the message-passing model is a subset of the generic function model. If you have generic functions, you can simulate message-passing by only specializing the first parameter.
(defclass a (c d) ...) (defclass e () ...) (defclass b (d c) ...) (defclass f (h) ...) (defclass c () ..) (defclass g (h) ...) (defclass d (e f g) ...) (defclass h () ...)
- Draw the network representing the ancestors of a, and list the classes an instance of a belongs to, from most to least specific.
- Do the same for b.
- precedence: takes an object and returns its precedence list, a list of classes ordered from most specific to least specific.
- methods: takes a generic function and returns a list of all its methods.
- specializations: takes a method and returns a list of the specializations of the parameters. Each element of the returned list will be either a class, or a list of the form (eql x), or t (indicating that the parameter is unspecialized).
Using these functions (and not compute-applicable-methods or find-method), define a function most-spec-app-meth that takes a generic function and a list of the arguments with which it has been called, and returns the most specific applicable method, if any.
Copyright © 1996 by Paul Graham, used with permission; modifications copyright © 2004, Franz Inc. Oakland, CA., USA. All rights reserved.
|
Allegro CL version 8.0 |