Session 25
Implementing Objects with Closures
Download this zip file to use as you work through Session 25.
A Quick Review
A Quick Warm-Up: You 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)
10004
Challenge 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
lambdafrom inside alet. -
To enable the use of multiple counters at the same
time, we return
a
lambdafrom within anotherlambda, creating a counter "factory". -
To create bounded counters, we have the outer
lambdatake an argument and add a little logic to theadd1step 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, values
that change over time. Last time,
we introduced the idea of mutable data and learned about
Racket's set! primitive, which modifies the value of
an existing object.
In reading for today, you learned 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.
Source
I found the example using the American embassy in Paris
in an early draft of Matthew Butterick's
Beautiful Racket,
which we encountered in
Session 21
on creating new languages within 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!
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 what if we
call make-counter in a world where n
already exists?
(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 is the body of the let expression that
creates it.
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.
Toward Object-Oriented Programming
As we introduced mutation, sequences, side effects, and closures over the last two sessions, we began to move from the realm of functional programming into the realm of imperative programming. With the idea of a selector procedure, we have begun to move toward a particular imperative form: object-oriented programming. While we can write OO programs in a functional style, with no side effects, one of the powerful features of OOP is the ability to create objects that manage their own state.
The closure returned by make-account behaves like
a simple object:
-
We can send it a
depositmessage or awithdrawmessage. - In response to such a message, the account object selects the appropriate member function to perform and returns it as an answer.
- The message sender can then invoke the procedure with the necessary arguments.
The syntax we use to send messages to an object implemented as a selector function is not as convenient as we might like:
> ((account-for-eugene 'withdraw) 10) 90
What can we do to improve that?
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 function. When we send it a
withdraw or deposit message, the
account returns a procedure that we must call. That
both requires the Racket-y syntax and exposes the underlying
implementation of the object.
Can you think of a more convenient syntax, one that doesn't expose the 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)))
Why define 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 first argument is bound to
object. The second is bound to
message. The rest of the arguments are bound
as a list to the parameter args, no matter how
many are passed.
Now we can interact with our objects using a more convenient syntax, and with fewer parentheses:
> (send account-for-eugene 'withdraw 10) 160
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.
For example:
> (define eugene (make-account 100)) > (list? eugene) #t
How will we withdraw and deposit money from the account now? Try it out.
Can we modify send to work with objects
implemented as lists?
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
the code 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.
Closures as Objects in Object-Oriented Programming
Let's return to our bank account, implemented as
a different kind of message selector
and extended with a balance operation:
(define make-account ;; Creates a procedure that creates a
(lambda (balance) ;; closure around balance. All of the
(let ((withdraw ;; procedures share the balance variable.
(lambda (amount)
(if (>= balance amount)
(begin
(set! balance (- balance amount))
balance)
(error "Insufficient funds" balance))))
(deposit
(lambda (amount)
(set! balance (+ balance amount))
balance))
(get-balance
(lambda ()
balance))
(error
(lambda args
(error "Unknown request -- ACCOUNT"))))
(lambda (transaction)
(case transaction
('withdraw withdraw)
('deposit deposit)
('balance get-balance)
(else error))))))
Source: Wikipedia
This one function implements many of object-oriented programming's basic ideas:
-
make-accountis a constructor that returns a new instance of a bank account. - This object responds to deposit, withdraw, and balance messages by performing methods with similar 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.
One way to make the object more convenient to use is to convert it into 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 could we modify send
to work with this kind of object?
But we can do better than that. Your reading for next session will show you an even fuller implementation of OOP.
A Nostalgic 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 theletexpression,letrecis a syntactic abstraction. We can implement the equivalent of aletrecexpression 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 the "hack" we need several times in the last two sessions, 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
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.
- Read a short section, Going Deeper with Objects, for a flavor of what is possible if we want to develop a more complete version of object-oriented programming from our closures.
-
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 primary goal of the assignment. The primary goal is to study a a Boom 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.