Session 25
Implementing Objects with Closures
A Quick Review
... in which I open with a five-minute review of the four
"withdraw" functions that
close Session 24, using
00-withdraw.rkt
as a starting point. We end with a factory method for... an
object?
A Quick Warm-Up: Count On Me
We now know how to write functions that remember things.
counter
that gives us
the next integer every time it is called. For example:
> (counter) 1 > (counter) 2 > (counter) 3 > (for-each (lambda (n) (counter)) (range 10000)) > (counter) 10004Challenge 1: Modify your function so that we can create and use multiple counters at the same time.
Challenge 2: Modify your function so that its counter wraps around to 0 whenever it reaches an upper limit. The user provides the upper limit at creation time.
Some Counter Solutions
Here are
possible solutions
for all three versions of the exercise. The function
counter
is a simple example of
a closure,
a function that maintains state. We learned about this idea at the
end of our last session.
-
We can create a counter with memory simply by returning
a
lambda
from inside alet
. -
To enable the use of multiple counters at the same time, we
return
a
lambda
from within anotherlambda
, creating a counter "factory". -
To create bounded counters, we have the outer
lambda
take an argument and add a little logic to to theadd1
step so that the function knows when to wrap around.
Note: I used for-each
in my demo code above.
(for-each proc lst)
is a built-in Racket procedure
that applies proc
to every item in lst
,
in sequence, like map
. However, for-each
does this for the side effects and returns nothing. It is similar
to
the homegrown for-each
procedure
you saw in your reading a couple of sessions back.
"You Are Here"
We are now studying how programs can have state.
Last time,
we introduced the idea of mutable data and saw Racket's
set!
primitive for modifying the value of an existing
object. You also read about the distinction between
denoted and expressed values,
which connects how programs treats names with the underlying
machine model. Here is a simple glossary of terms:
- An identifier is a name used in the code.
- A binding is a connection to an actual value or function.
- A variable is an identifier plus a binding.
With mutable data, a program can represent values that change over time. However, it needs a way to remember data that is not recreated every time the program is executed. This is the idea of a closure, a function that is created in a local context where an identifier has a binding. The function is able to access the variable even after it is seemingly out of scope, because the closure stores both the function and the bindings that existed at the time the function was created.
In the case of counter
, the package consists of the
lambda
expression and the binding of n
to
its location.
Now we can see why the region of an identifier is not the same as its scope. In a closure, the region of an identifier is the body of the procedure. However, a closure outlives the local identifier, and the scope of the variable is the lifetime of the closure!
A closure creates a hole in space where interpretation is different from the space that surrounds it. We see this idea in the real world, too. For example:
The American embassy in Paris occupies a very nice building on the Place de la Concorde. Certainly, the embassy is physically within the boundaries of France. But when you step inside the embassy, what country are you in? You're no longer in France. You're in the United States, and US law applies.
In a similar way, a closure behaves like a sovereign state. Though the code travels to other locations in the program, the identifiers in that code retain the meaning they had in the code where they were created.
Consider our make-counter
function. The region of the
variable n
is the body of the let
expression that declares it. But then we call
make-counter
:
(let ((n 42)) (let ((clock-tick (make-counter))) (clock-tick)))
When clock-tick
set!s an n
, it is
the object inside its closure, not the one that exists when
clock-tick
is used. The n
created by the
let
expression is still alive and able to be
seen and changed. Its scope our new let
expression.
Changing the value of an object becomes meaningful only now that the object can exist over the course of multiple invocations of the procedure. Procedures that change the value of an object are called mutators, an addition to our vocabulary that refers to any procedure or special form that treats a data objects as variables for the purpose of changing the values, which are stored in particular locations.
Today, we continue with our discussion of programs that have state, including procedures that share data. By the end of the session, you will see how we can use closures of shared variables to implement many of the familiar concepts from object-oriented programming.
A Message Passing Syntax for Our Objects
The syntax for sending messages to our objects looks very Racket-y.
It reflects the fact that a bank account is a procedure
and that, when we send it a withdraw
or
deposit
message, the account returns a
procedure that we must call. Can you think of a more convenient
syntax, one that doesn't expose these implementation details? How
might we implement it?
Here is a possible solution:
(define send ; or even "←"
(lambda (object message . args)
(apply (look-up-method object message) args)))
(define look-up-method
(lambda (object selector)
(object selector)))
Now we can interact with our objects using a more convenient syntax, and with fewer parentheses:
> (send account-for-eugene 'withdraw 10) 160
Why do you think I defined a look-up-method
function?
Think back to
the apply-ff
function
we added to the finite function ADT...
Without the look-up-method
function, send
must assume that objects are implemented as functions. However,
we know that there is
an infinite variety of implementations
for any data abstraction. We are usually better off building
tools that do not assume a particular implementation.
Racket Reminder: Recall that the '.' in send
's
parameter list works just like the dot in dotted pair notation.
The rest of the parameter list is bound to the parameter
args
, no matter how many arguments are passed.
Exercise: An Alternate Implementation
Implementing a bank account as a function brings to mind an idea we encountered previously in our discussion of finite functions: the use of a Racket function as the concrete implementation of a datatype. As with previous ADTs, we have many alternative implementations available for implementing our little objects. Perhaps a data-based solution would be simpler?
make-account
that returns a
list of procedures that share the balance variable,
rather than a 'message selector' function. For example:
> (define eugene (make-account 100)) > (list? eugene) #tThe code for the withdraw and deposit procedures will be the same, so you don't have to write those
lambda
s again. Just
write WITHDRAW and DEPOSIT in their places.
Here is
a sample solution.
If we'd like, we can define functions named withdraw
and deposit
that access the desired function in the
list and calls it.
That is nice, but now the syntax for accessing the object and its methods has changed. That's a sign of that we have not designed a solid abstract interface for bank accounts. Different implementations of an ADT should never affect client code!
However, we did create syntactic sugar
to hide such details from our procedure-based implementation. Can
we adapt that idea here? You bet we can: scroll down in that file
for a new version of send
.
I noted last week that I think this idea of data-as-function is very cool, and closures show another reason why. Always keep in mind that data can be implemented as functions, and functions can be implemented as data. This sort of flexibility gives you more options when solving problems, including some the problems we face when writing a language interpreter.
Implementing More Object-Oriented Programming
Let's return to our bank account, implemented as
a different kind of message selector
and extended with a balance
method:
(define make-account ;; Creates a procedure that creates (lambda (acct-balance) ;; a closure around acct-balance. (let ((withdraw ;; These procedures share (lambda (amount) ;; the balance variable. (if (>= acct-balance amount) (begin (set! acct-balance (- acct-balance amount)) acct-balance) (error "Insufficient funds" acct-balance)))) (deposit (lambda (amount) (set! acct-balance (+ acct-balance amount)) acct-balance)) (balance (lambda () acct-balance)) (error (lambda args (error "Unknown request -- ACCOUNT")))) (lambda (transaction) (case transaction ('withdraw withdraw) ('deposit deposit) ('balance balance) (else error))))))
This one function implements many of object-oriented programming's basic principles:
-
make-account
is a constructor that returns a new instance of a bank account. - This object responds to deposit, withdraw, and balance messages by performing methods with the corresponding names. It ignores all other messages.
- The object encapsulates its instance variable, which is accessible only to this instance.
-
Each new account has its own identity and its own
copy of
balance
.
What else do we need to do object-oriented programming? We might
want a cleaner message-passing syntax. Our send
function moves us in that direction. But the cleaner syntax is
really just sugar.
We can use our message-sending procedure even more conveniently if we make our object a message responder rather than a message selector:
> (define ellen (make-account 100)) > (ellen 'withdraw 100) 0 > (ellen 'deposit 50) 50 > (ellen 'balance) 50 > (ellen 'deposit 20) 70
Quick Exercise: How do we modify send
to work
with this kind of object?
Going Deeper
Note: Read this section for the ideas only. The code gets a bit bigger, and perhaps harder to understand. You don't have to study the code in detail, but please do think about the ideas.
There are other semantic features of OOP that we might want to have:
- inheritance, the ability to create sub-classes that inherit behavior and state from an existing class
- dynamic polymorphism, in which two objects that are instances of the same different classes can be used by the same client code because they implement a common interface
Classes and Inhertiance
Inheritance is a challenge for this style of implementing OOP, because the idea of a class is not explicitly represented.
If your only exposure to objects is through Python or Java, you may be surprised to learn that not all object-oriented languages have classes! Consider Self, a language designed at Sun Microsystems (now owned by Oracle) beginning in the mid-1980s. In Self, there are no classes, only objects. You create a new class by "cloning" another object, called a prototype. You create a new kind of object by adding new state and behavior to an existing object. Self is quite cool and has influenced many languages since. You can do some things must more simply and elegantly in Self than in a class-based OO language.
The most popular and influential prototype-based language these days is Javascript. It is a language with broad application and remains a hotbed of new development, thanks to its ubiquity on the web.
In a dynamically-typed language such as Racket, classes might play a less important role than in a language such as Java. But how might we implement them?
If we think about classes in a different way, we can implement something simple that captures the idea. What if we think of a class as an object that can create instances for us? In that sense, we already know how to implement a class: use the same sort of closure that we use to implement objects!
If you'd like to see this idea in action, take a look at
this Racket file,
which defines make-account
as a function that responds
to new
messages by returning a constructor for a bank
account. It even has class variables (often called 'static
variables' in Java and C++) that we can interact with through the
class. Here is a sample interaction:
> (define eugene ((make-account 'new) 100)) 100 > ((eugene 'balance)) 100 > ((eugene 'deposit) 100) 200 > ((eugene 'balance)) 200 > ((bank-account 'count)) 1 > (define mary ((make-account 'new) 100)) > ((bank-account 'count)) 2
In this approach, a class is a function that returns a new object to us. The new object is... a function.
Of course, bank accounts are closures, too, so we don't have to use
the parenthesized syntax. Our original version of send
works with them:
> (define eugene (send make-account 'new 100)) > (send eugene 'balance) 100 > (send eugene 'deposit 100) 200 > (send eugene 'withdraw 50) 150 > (define mary (send make-account 'new 1000)) > (define alice (send make-account 'new 10000)) > (define bob (send make-account 'new 100000)) > (send make-account 'count) 4 > (send alice 'balance) 100000
We now have a way to implement multiple constructors, as we see
them in Java and C++. We can add another case to the selector
procedure returned by bank-account
!
In Java, classes aren't objects to which we send messages;
class
is a special construct. But in a language such
as Smalltalk, everything is an object. A Smalltalk class is an
object to which you send a message in order to create an instance
of the class. Today, languages like Ruby provide the same feature.
With one more level of closure wrapping our object closure, we are
able to implement a class with nothing new. As David Wheeler, a
programmer in the early 1950s (!) once said,
Any problem in computer science can be solved with another layer of indirection.
Programmers of that time were solving many of the problems that cause us to bang our heads against the wall, and they did so at a time when the tools available were a lot less powerful. Often, constraints make us more creative.
Dynamic Polymorphism
What, if anything, do we have to do to implement dynamic
polymorphism using our closure-based objects? Racket is
dynamically typed, so we already have the ability to treat any
object like some other. Any selector procedure that accepts
withdraw
and deposit
messages can act
as a bank account. Client code wouldn't know the difference.
This means that dynamic polymorphism is free in our
implementation of OOP.
If we create a class-based implementation of OOP that checks types, we will need to implement a mechanism to allow polymorphic objects.
Quick Trip Back to letrec
A few weeks back, we learned about
local recursive functions
and the challenge they pose for let
. We then saw
that Racket provides a special form letrec
for
creating local recursive functions. At the time, I
said:
Like thelet
expression,letrec
is a syntactic abstraction. We can implement the equivalent of aletrec
expression in "vanilla" Racket, using (1) a feature of the language we have not studied yet, but which you know well, and (2) a bit of a hack. Can you imagine how?
We have now studied the Racket feature we need: set!
.
We have now seen several times the hack we need, which is really the idea of a closure.
Take a peek at
this code,
which creates a function that refers to itself — without
using letrec
. It shows us a translational
semantics for letrec
.
letrec
really is a syntactic abstraction. It can be
defined as let
+ set!
!
Wrap Up
-
Reading
- Review the notes for this session and the last one, on functions with state. We didn't cover the final section of today's notes, Going Deeper, in class, so read it for a flavor of what is possible.
-
Homework
- This short assignment will help you prepare for our next session, which will be a review of one possible solution for Homework 9. The assignment is worth ten points, half the value of a regular assignment. There are right answers to some of the questions, but they are not the end goal of the assignment. The key point of the exercise is to study a a Huey interpreter and reflect on the choices you made when implementing your own code.
- Homework 10 will be available next time and due one week from then.
-
Credits
- I found the example using the American embassy in Paris in an early draft of Matthew Butterick's Beautiful Racket. The example did not survive into the final version of the book, but I like it and still use it, and Matthew deserves credit. Beautiful Racket is a beautiful book about language-oriented programming in Racket. If you are interested, check it out!