Object-oriented programming was established in the Smalltalk programming language by Dr. Alan Kay in the early 1980s, with heavy inspiration from Simula and LISP. Dr. Kay describes,
OOP to me means only messaging, local retention and protection and hiding of state-process, and extreme late-binding of all things. It can be done in Smalltalk and in LISP.
Let’s create an object from scratch to see what’s going on underneath.
This blog post is going to use a subset of the Scheme programming language. Scheme is a LISP with some properties that will prove useful:
- Block syntax for introducing lexical scoping (
let
). - Procedure objects that close over a surrounding environment (
lambda
). - Latent, dynamic typing.
One Minute Intro to Scheme
To create a symbol, use a single quote in front:
'hello
To assign a name for a value, use the define
form:
(define x 8)
To apply a function to values, use a prefix notation:
(+ 4 5 10 (- 20 3 10))
To create a procedure object, use the lambda
form:
(lambda (x) (display x))
To create a lexical block, use the let
form:
(let ((x 8)
(y 10)
(add100 (lambda (n) (+ n 100))))
(* x y (add100 30)))
To mutate a value, use the set!
form:
(define m 20)
(set! m (* m 2))
Here are example functions:
;; make-user : String Number -> (list-of any)
;; Create a user record with a given name and age.
(define make-user
(lambda (name age)
(cons 'user (cons name (cons age null)))))
;; display-name : (list-of any) -> String
;; Print the name of the user. Raises the error 'not-a-user if passed
;; something besides a user record.
(define display-name
(lambda (user)
(cond
((eq? (first user) 'user)
(display (first (rest user))))
(else
(error 'not-a-user)))))
Great, you’re now a Scheme expert.
Inverting It
Let’s see if we can get rid of that cond
that checks the type.
We’ll start with changing make-user
. This is how we used it:
(define mark (make-user "Mark" 18))
(display-name mark)
;; Mark
We’ll start with one small change:
(define mark (make-user "Mark" 18))
(display-name (mark))
;; Mark
That is, the make-user
function now produces a function instead of a list.
This function takes no arguments but when called it produces the desired list.
Functions like mark
are called thunks (as in, the past tense of “to think”).
;; make-user : String Number -> (-> list-of any)
;; Create a user record with a given name and age.
(define make-user
(lambda (name age)
(lambda ()
(cons 'user (cons name (cons age null))))))
For our next trick, we can pass an argument to mark
, our prior thunk:
(define mark (make-user "Mark" 18))
(mark 'display-name)
;; Mark
This treats mark
as an object that responds to messages, in this case the
'display-name
message.
;; make-user : String Number -> (Symbol -> any)
;; Create a user object with a given name and age. This object responds to
;; 'display-name. Raises the error 'message-not-understood for all other
;; messages.
(define make-user
(lambda (name age)
(lambda (message)
(cond
((eq? message 'display-name)
(display name))
(else
(error 'message-not-understood))))))
We’re going to walk through this function before moving on.
make-user
is a function that takes name
and age
. This function produces
another function, which we called mark
above.
mark
is a function that takes a message
and dispatches based on it. So far
it only knows one message: 'display-name
. When given that message it displays
the name
; for everything else it raises an error.
This is mark
, with one thing missing:
(lambda (message)
(cond
((eq? message 'display-name)
(display name))
(else
(error 'message-not-understood))))
The missing component is the closure. Note that name
is a free variable in
the above lambda
– it’s referenced but undefined. That’s because it’s
defined in the outer scope (in this case, the make-user
lambda
).
Set It
The rifle on the wall here is set!
. Let’s introduce a 'set-name!
message to
our object, which takes an argument:
(define mark (make-user "Mark" 18))
(mark 'display-name)
;; Mark
(mark 'set-name! "Mike")
(mark 'display-name)
;; Mike
First, here is another piece of syntax: the lambda
expression can take any
number of arguments if we separate the final argument with a .
(period). The
final argument is then passed as a list.
(define super-cons
(lambda (a b . others)
(cons a (cons b others))))
(super-cons 1 2 3 4 5) ;; => '(1 2 3 4 5)
So let’s change mark
to take not just a message but any number of arguments:
(lambda (message . args)
(cond
((eq? message 'display-name)
(display name))
(else
(error 'message-not-understood))))
And pass the first argument to set!
when the message 'set-name!
comes in:
(lambda (message . args)
(cond
((eq? message 'display-name)
(display name))
((eq? message 'set-name!)
(set! name (first args)))
(else
(error 'message-not-understood))))
As before, this is missing its closure:
(define make-user
(lambda (name age)
(lambda (message . args)
(cond
((eq? message 'display-name)
(display name))
((eq? message 'set-name!)
(set! name (first args)))
(else
(error 'message-not-understood))))))
Playing With Closures
All of these concepts are distinct, small components that can be composed. Let’s make a number incrementer using what we’ve learned.
First, another piece of syntax. To do multiple things in succession, discarding
all but the final return value, use the begin
form:
(begin
(display "hello")
(display "world"))
First, a number:
(define make-incr 1)
make-incr ;; => 1
Wrap it in a lambda
:
(define make-incr (lambda () 1))
(define gimme-an-n! make-incr)
(gimme-an-n!) ;; => 1
(gimme-an-n!) ;; => 1
Small refactoring: use a let
to hold the return value:
(define make-incr
(lambda ()
(let ((n 1))
n)))
(define gimme-an-n! make-incr)
(gimme-an-n!) ;; => 1
(gimme-an-n!) ;; => 1
Now we can use set!
to change it each time:
(define make-incr
(lambda ()
(let ((n 0))
(begin
(set! n (+ n 1))
n))))
(define gimme-an-n! make-incr)
(gimme-an-n!) ;; => 1
(gimme-an-n!) ;; => 1
… that’s not what we wanted. Let’s step through it:
(define gimme-an-n! make-inc)
gimme-an-n!
is the result of calling make-incr
. This is a function. Now we
call it:
(gimme-an-n!) ;; => 1
When we run this function, it first creates an environment with n
set to
0
. Then, it increments n
and returns it. When we call this function
again, it does it all over again. However, we only want to create the
environment once.
To fix this, we need our function to close over the environment so that we
re-use the same environment on every function call. Swap the let
and the
lambda
:
(define make-incr
(let ((n 0))
(lambda ()
(begin
(set! n (+ n 1))
n))))
(define gimme-an-n! make-incr)
(gimme-an-n!) ;; => 1
(gimme-an-n!) ;; => 2
When we run make-incr
, first it create an anonymous environment with n
set
to 0
. Then it returns a function that closes around this environment. This
function is assigned to gimme-an-n!
. The first time we call it, it increments
the n
in the environment and returns it. The second time it does the same,
re-using the existing environment. Therefore it increments the environment it
closes around.
Thus we have implemented messaging, local retention and protection of state-process, and extreme late-binding. This is the core of object oriented programming: closures and message passing.