This exploration of object-oriented programming languages starts from what we know already from PLAI, as well as our intuition about what objects are.
An object is meant to encapsulate in a coherent whole a piece of state (possibly, but not necessarily, mutable) together with some behavior that relies on that state. The state is usually called fields (or instance variables), and the behavior is provided as a set of methods. Calling a method is often considered as message passing: we send a message to an object, and if it understands it, it executes the associated method.
(define add (λ (n) (λ (m) (+ m n))))
> (define add2 (add 2)) > (add2 5)
(define counter (let ([count 0]) (λ () (begin (set! count (add1 count)) count))))
We can now effectively observe that the state of counter changes:
Now, what if we want a bi-directional counter? The function must be able to do either +1 or -1 on its state depending on... well, an argument!
(define counter (let ([count 0]) (λ (cmd) (case cmd [(dec) (begin (set! count (sub1 count)) count)] [(inc) (begin (set! count (add1 count)) count)]))))
> (counter 'inc)
> (counter 'dec)
This looks quite like an object with two methods and one instance variable, doesn’t it? Let’s look at another example, a stack.
(define stack (let ([vals '()]) (define (pop) (if (empty? vals) (error "cannot pop from an empty stack") (let ([val (car vals)]) (set! vals (cdr vals)) val))) (define (push val) (set! vals (cons val vals))) (define (peek) (if (empty? vals) (error "cannot peek from an empty stack") (car vals))) (λ (cmd . args) (case cmd [(pop) (pop)] [(push) (push (car args))] [(peek) (peek)] [else (error "invalid command")]))))
Here, instead of writing each method body in place in the lambda, we use internal defines. Also note that we use the dot notation for the arguments of the lambda: this enables the function to receive one argument (the cmd) as well as zero or more extra arguments (available in the body as a list bound to args).
Let’s try that:
> (stack 'push 1) > (stack 'push 2) > (stack 'pop)
> (stack 'peek)
> (stack 'pop)
> (stack 'pop)
cannot pop from an empty stack
We can clearly see a code pattern that can be used to define object-like abstractions. In the following we abstract the pattern more clearly:
(define point (let ([x 0]) (let ([methods (list (cons 'x? (λ () x)) (cons 'x! (λ (nx) (set! x nx))))]) (λ (msg . args) (apply (cdr (assoc msg methods)) args)))))
> (point 'x! 6) > (point 'x?)
Note that in this booklet, we use defmac to define macros. defmac is like define-syntax-rule, but it also supports the specification of keywords and captures of identifiers (using the #:keywords and #:captures optional parameters).
(defmac (OBJECT ([field fname init] ...) ([method mname args body] ...)) #:keywords field method (let ([fname init] ...) (let ([methods (list (cons 'mname (λ args body)) ...)]) (λ (msg . vals) (apply (cdr (assoc msg methods)) vals)))))
(defmac (-> o m arg ...) (o 'm arg ...))
(define p2D (OBJECT ([field x 0] [field y 0]) ([method x? () x] [method y? () y] [method x! (nx) (set! x nx)] [method y! (ny) (set! y ny)])))
> (-> p2D x! 15) > (-> p2D y! 20) > (-> p2D x?)
> (-> p2D y?)
Up to now, our objects have been created as unique specimen. What if we want more than one point object, possibly with different initial coordinates?
> (define add4 (add 4)) > (define add5 (add 5)) > (add4 1)
> (add5 1)
(define (make-point init-x init-y) (OBJECT ([field x init-x] [field y init-y]) ([method x? () x] [method y? () y] [method x! (new-x) (set! x new-x)] [method y! (new-y) (set! y new-y)])))
The make-point function takes the initial coordinates as parameter and returns a freshly created object, properly initialized.
> (let ([p1 (make-point 5 5)] [p2 (make-point 10 10)]) (-> p1 x! (-> p2 x?)) (-> p1 x?))
Our simple object system is sufficient to show the fundamental aspect of object-oriented programming: dynamic dispatch. Notice how, in the following, a node sends the sum message to each of its children without knowing whether it is a leaf or a node:
(define (make-node l r) (OBJECT ([field left l] [field right r]) ([method sum () (+ (-> left sum) (-> right sum))]))) (define (make-leaf v) (OBJECT ([field value v]) ([method sum () value])))
> (let ([tree (make-node (make-node (make-leaf 3) (make-node (make-leaf 10) (make-leaf 4))) (make-leaf 1))]) (-> tree sum))
As simple as it may seem, this object system is entirely enough to illustrate the fundamental abstraction mechanism that objects really are, as opposed to abstract data types. See the chapter on the benefits and limits of objects.
> (let ([l (make-leaf 2)]) (-> l print))
cdr: contract violation
(defmac (OBJECT ([field fname init] ...) ([method mname args body] ...)) #:keywords field method (let ([fname init] ...) (let ([methods (list (cons 'mname (λ args body)) ...)]) (λ (msg . vals) (let ([found (assoc msg methods)]) (if found (apply (cdr found) vals) (error "message not understood:" msg)))))))
Rather than assuming that the message will have an associated method in the method table of the object, we now first lookup and get the result as found, which will be #f if no method was found. In that case, we generate an informative error message.
> (let ([l (make-leaf 2)]) (-> l print))
message not understood: print
In this section, we have successfully embedded a simple object system in Scheme that shows the connection between lexically-scoped first-class functions and objects. However, we are far from done, because the object system we have is still incomplete and primitive.