Quite some time ago, I set out to work through the course materials at MIT’s OpenCourseWare website for the introductory Computer Science course Structure and Interpretation of Computer Programs. I wanted to tackle the fundamental CS concepts that the course presented, but I also wanted to learn Scheme, the elegant Lisp dialect that SICP uses to illustrate its concepts. I was lucky enough to have some excellent help from a group of people who wanted to work through the course together. However, the going got tough, pressing business pressed, and soon I was stopped. Last month, I got started again. I worked through the environment model, which is itself worthy of a post, but what I’d like to discuss right now is the section on object-oriented programming, which surprised and then delighted me. Not only is OOP possible yet cumbersome in Scheme, but its very cumbersomeness has given me a deeper understanding of OOP than I’ve gotten from object-oriented languages.
Object-oriented programming in… Scheme?
There was a time when I thought object-oriented programming had developed straight out of procedural, imperative programming. I also didn’t really separate OOP mentally from the languages that implemented it. C++ and Java were OOP for me. My understanding grew a bit more sophisticated in time, as I came to understand that most languages could model classes, let you define objects, implement inheritance, and all that practical stuff. But I was still thinking about implementations, really. It was a surface understanding.
When I got to the lectures on OOP in SICP, and the project that involved implementing a simple game using OOP, I wasn’t sure what to expect. Scheme is a functional programming language, I thought. It wasn’t even natural to work with variables in Scheme the way I did in other languages: how would class definitions work? The answer was twofold. Implementation-wise, objects would be created out of procedures, using the concept of the closed procedure that had been illustrated earlier in the course in a curious reimplementation of the cons function. But more important than the intriguing “how” stuff was the way OOP was conceived in the course materialsâ€”as fundamentally concerned with state and message-passing.
OOP fundamentals: state and behavior
It’s pretty easy to get to thinking about OOP as all about class definitions and inheritance trees. But the state and behavior of objects are the essentials. The OOP System (OOPS, as the course materials’ jokily called it) maintains state with closed procedures. Here is an example:
(define (make-named-object name) (lambda (message) (cond ((eq? message 'name) (lambda (self) name)) (else (no-method name)))))
(All examples that I use are from a sample problem set publicly-available at the course’s site.)
This code snippet is a constructor: when executed it returns a procedure (marked by the internal
lambda statement) that is ready to respond to requests for specific behavior. There’s no
class or keyword or explicit method definition. But if we define a variable using
make-named-object, passing it a certain string for a name, and then we invoke the procedure pointed to by the variable with the argument ‘name, then we’ll get back another procedure that will return the name. We can define another variable with
make-named-object, with its own name, and we can then ask it for the name back. Both variables will have the same behavior, but return different information â€”they’re saving different states.
A more complicated example, the procedure
make-mobile-object, shows a simple inheritance setup and some more interesting behavior:
(define (make-mobile-object name location) (let ((named-obj (make-named-object name))) (lambda (message) (cond ((eq? message 'place) (lambda (self) location)) ... ((eq? message 'set-place) (lambda (self new-place) (set! location new-place) 'place-set)) (else (get-method named-obj message))))))
First, a variable
named-obj is defined by calling
make-named-object, the procedure we just examined. This becomes a reference to a named-object, a procedure that implements the named-object’s state and behavior. The name passed to the
make-mobile-object procedure is passed on to
make-named-object: that procedure will handle the state of the name. Notice the last line of the
make-mobile-object procedure: any message that our mobile-object does not know how to handle will be passed to the named-object. The message ‘name, for instance, will cause the named object to return a procedure that will in turn return the object’s name. This is how a mobile object inherits the state and behavior of a named object.
A mobile object will also respond to the messages ‘place and ‘set-place. The first returns a procedure for the object’s location and the second returns a procedure that will update the object with a new location. Setter and getter. This works because the location is closed by the
make-mobile-object procedure and the procedure that it returns. “location” points to a bit of computer memory, and
set! lets us change the data at that memory. The closure allows the returned procedure to remember this pointer from one execution to the next.
Asking objects to behave
Note how each object (which is just a procedure) responds to messages by returning other procedures. These innermost procedures are essentially methods. It’s important to understand that passing the message in this OOP system does not trigger the “methods”. They must still be evaluated, often with specific arguments, like the new location expected by the location-setter method.
You may have noticed that in the definitions of
make-mobile-object the returned procedure expects an argument “self”. Why? Because the authors of the OOPS wanted to make it easier to trigger the “methods” and to handle in a consistent way the case in which an object cannot respond to a message. So they created a general way for messages to be passed to objects that allows them to build in some robustness. They have written a procedure called
(define (ask object message . args) (let ((method (get-method object message))) (if (method? method) (apply method (cons object args)) (error "No method" message (cadr method))))) (define (get-method object message) (object message))
ask procedure first uses
get-method to ask the object for the “method” procedure that corresponds to the message. If the method is not found, a special “no-method” value (defined elsewhere) is returned, and ask will recognize the value and act accordingly. But if a valid method is returned, Scheme’s
apply procedure (hmm, or is it a macro?) triggers the method procedure, passing it first a reference to the object from which the method was extracted, and then any arguments passed along to
ask, we can do this:
(define book (make-mobile-thing 'SICP office) (ask book 'name) ==> SICP (ask book 'set-place bag) (ask (ask book 'place) 'name) ==> bag
Since bag is an object, we have to ask its name if we want the name of our book’s location.
You may be asking why this is so great. Coding this way is a lot of trouble compared with coding in an OOP-optimized language like Java or Ruby. Well, there are two reasons why I loved working with Scheme and OOP this way. First, having to wire up the state-management and message-passing mechanisms made me focus on these fundamentals in a way that ‘easier’ languages save you from. So I got a much deeper appreciation for them than I had before. I won’t be programming my next OOP application in Scheme, but I think I’ll have a better idea of what I’m doing no matter what language I’m using.
Second, there are complicated issues in OOP, like multiple inheritance or method inheritance, that you don’t really grasp until you’re actually implementing them. In the case of method inheritance, the lecture notes on the OCW site offer up as an example a system with a person “class” and several subclasses that inherit from person: students, professors, and arrogant professors. Any objects defined as people, students, professors, or arrogant professors can speak. Professors and arrogant professors can lecture. Arrogant professors preface anything they say with “Obviously”. It’s useful to base the implementation of the behavior “lecture” on the behavior “say”, which is defined in the superclass person, but it gets complicated when arrogant professors have this unique “say” behavior. It becomes clear that there’s a difference bewteen delegating to a superclass method and invoking that method on the object itself and then letting method inheritance play out. (The latter approach is the one that
ask implements). These concepts are not easy to get your head around if you’re using a language optimized for OOP in which these kinds of decisions are handled for you. Once you’ve taken a shot at setting them up yourself, you get a better idea of what’s going on out of your sight.
Epilogue: OOP in Lisp
Of course, I know better now about OOP being invented by C++ or Java. Not only was a little work done on OOP in Palo Alto once upon a time, but Lisp was an early language for OOP experimentation and development. CLOS is a sophisticated OOPS :-) for example. It only goes to show how right Abelson was to call Lisp a lousy language for solving most problems but an excellent one for building new languages to solve problems.