Scheme is a thoroughly multi-paradigm language, but sometimes it takes some insight to expose a particular paradigm. In particular, we will be looking at how to implement a basic object system in Scheme using closures.
What is an object?
Let's unlearn whatever you may know about object oriented programming and get back to basics. An object has:
- Attributes, which are data fields
- Methods, which are behaviors
Let's do a simple example in Python to illustrate what I mean.
class Person(): def __init__(self, name): self.name = name def say_hi(self): return "Hello, my name is " + self.name + "!" chuckie = Person('Chuckie') print(chuckie.say_hi())
This will print the string, "Hello, my name is Chuckie!". The attribute here is called name, and the method here is called say_hi.
Why are objects useful?
Polymorphism is the ability for different objects to choose the right way to respond to the same message. Building on our previous Python example:
class Dog(): def say_hi(self): return "Woof!" rocky = Dog() print(rocky.say_hi())
There are many different opinions about why objects are useful. My opinion is that polymorphism is the real substance behind objects, and the other features are sort of syntactic sugar. The reason behind my view has to do with Alan Kay's original vision for objects in Smalltalk, where objects would be like biological cells that can only communicate with messages.
Suppose I were writing a video game with many different kinds of creatures which greet the player when the player greets it.
# Assume player.command and player.target was defined earlier. if (player.command == 'greet'): print(player.target.say_hi())
Selecting the correct behavior would be much more challenging without polymorphism; I would need to write a control flow structure to decide what is the right response depending on the type of player.target.
Polymorphism with closures
It may be unsurprising that Scheme can support polymorphism well, because both Scheme and Smalltalk were designed as ways of understanding the actor model of concurrency. The actor model is basically what I just described: objects communicate using messages, but the way they respond to those messages is completely up to the recipient to decide.
Let's define a pair:
(define (kons a b) ;; This procedure says: ;; "Given a procedure called "selector", apply that ;; procedure to "a" and "b" (lambda (selector) (selector a b))) (define (kar p) ;; Given "a" and "b", select "a" (p (lambda (a b) a))) (define (kdr p) ;; Given "a" and "b", select "b" (p (lambda (a b) b)))
This behaves analogously to cons, car, and cdr. We have created a data structure using nothing but lambda! That's interesting in itself, but what does this have to do with polymorphism?
(define (iota size start) ;; (iota 0 start) => '() ;; Otherwise, return a list of integers ranging from ;; start to (start + size - 1) (if (= size 0) '() (lambda (selector) (selector start (iota (- size 1) (+ start 1)))))) (define l (iota 3 0))
Let's look at the contents of l like this:
(define (print . l) (for-each display l) (newline)) (print (kar l)) (print (kar (kdr l))) (print (kar (kdr (kdr l)))) (print (kdr (kdr (kdr l))))
Which will display 0 1 2 (), like the iota procedure in SRFI-1. Here, we exposed the exact same interface as kons. kar will select the first element of the list, and kdr will select everything else. However, we did something critical by performing a calculation without the second argument to selector. Namely, this part:
(iota (- size 1) (+ start 1))
This is very different than how kons works, but it does not matter: kar and kdr will treat both "iota" objects and "kons" objects the same. Hence, we have achieved a simple kind of polymorphism.
In principle, we could stop here. However, I want to take another step towards a more conventional object system.
Introducing control flow
We don't necessarily just want objects to contain and retrieve data, but mutating the internal state of the object is a common operation as well. Purely functional languages discourage mutation, but mutation is fairly natural in Scheme.
Another issue is that passing lambdas, as we did with kar and kdr, is rather clumsy and doesn't easily show what the code is doing in a more complex situation. To remedy that, we will be using control flow statements to select a method.
Let's go back to the Person/Dog example:
(define (person name) (lambda (message . args) (cond ((eq? message 'say-hi) (string-append "Hello, my name is " name "!")) ((eq? message 'say-bye) "See you later.") ((eq? message 'set-name!) (set! name (car args)))))) (define (dog) (lambda (message . args) (cond ((eq? message 'say-hi) "Woof!") ((eq? message 'say-bye) "Ruff...")))) (define chuckie (person "Chuckie")) (define rocky (dog)) (chuckie 'say-hi) (chuckie 'say-bye) (chuckie 'set-name! "Slim") (chuckie 'say-hi) (rocky 'say-hi) (rocky 'say-bye)
This gives us much more flexibility and clarity in the behavior of the objects we create, by changing the control flow based on what kind of message is passed to the object.
This gives you an idea of where to start if you were writing your own object system. Note that I have not covered inheritance at all. Inheritance is not very difficult at this point, but it introduces many design choices that may be better in its own post.
Rather than rolling your own objects, consider using an object system like YASOS or Prometheus. These libraries essentially do the same thing described in this post, but will allow others to extend your code. Personally, I prefer YASOS and that's the object system I use in my libraries.
Another important note is that many implementations like Guile and Gauche have their own object system which is heavily influenced by the Common Lisp Object System (CLOS). CLOS is a powerful but complex object system with a completely different object model than the one described here. Definitely look into that as well if the topic interests you.