Summary
- Reminder from OOP: concepts of encapsulation and state
- Using
set!to change state - Higher-level functions as “objects”
- Passing messages to make multiple commands
- Using variable argument lists to enable more kinds of method calls
Closures as (basic) objects
In the last two classes, we established two key concepts:
- Conceptual: A closure is a “bundle” of a function and its captured referencing environment. We compared this to a Java object: the captured variables (like
tax-rate) are likeprivatefields, and the function code is like a single method. - Implementation: We saw how this works using the Environment Model. Calling a function creates a new frame. If that function returns a
lambda, the resulting closure gets a pointer to that new frame, keeping its local variables (e.g.,x = 5inmake-adder) “alive” even after the outer function returns.
Our make-adder function was a “factory” that created objects, but these objects could only do one thing:
(define add-5 (make-adder 5))
(add-5 10) ; The *only* thing we can do is call it.
A Java object obj can do many things: obj.method1(), obj.method2(arg), etc. How can we replicate this?
Preliminaries: set! and cond
First we need to introduce two new Scheme commands.
-
set!changes the value of an existing binding:(define x 10) ; x is declared and set to 10 (set! x 20) ; x is reassigned to 20This does go against the principles of pure functional programming, because it’s mutating an existing value. So you don’t often need or want to use
set!in Scheme code, but sometimes it’s helpful to do things (like create stateful objects). -
condis an if/else if/else if/… construct in Scheme, to avoid having to do lots of nested if’s. Here’s the syntax:; check the state of water give its temp in kelvins (cond ((< temp 273.15) 'ice) ((> temp 373.15) 'steam) (else 'water)) ; equivalent nested if/else (if (< temp 273.15) 'ice (if (> temp 373.15) 'steam 'water))You can also have more than one S-espression inside each condition, which can be useful when you want to call
set!and then return something.
Message passing
Instead of returning a closure that is the method, we will return a closure that is the object itself — like a central dispatcher. To “call a method,” we will pass this dispatcher function a message (like a symbol) specifying the operation we want.
The object’s “instance variables” will be local bindings created by internal defines. The “methods” will be the logic inside the dispatcher’s cond that share and (if needed) mutate this local state.
Example 1: A Simple Counter
Let’s build a counter object that understands three “messages”: 'increment, 'get, and 'reset.
;; `make-counter` is our "class" or "factory"
(define make-counter
(lambda () ;; the "constructor" takes no arguments
;; `count` is the "private instance variable"
(define count 0)
;; This lambda is the "object" we return
;; It's a dispatcher that takes a message.
(lambda (message)
(cond
((eq? message 'increment)
(set! count (+ count 1))) ; Mutates the captured state
((eq? message 'get)
count) ; Returns the captured state
((eq? message 'reset)
(set! count 0))
(else
(error "Unknown message:" message))))))
Using the Counter Object
This is how we interact with our new “object.” Notice the syntactic similarity to OOP:
;; 1. Create two "instances"
(define c1 (make-counter))
(define c2 (make-counter))
;; 2. "Call methods" on c1
(c1 'increment)
(c1 'increment)
(display (c1 'get)) ; Prints 2
;; 3. "Call methods" on c2 (shows encapsulation)
(c2 'increment)
(display (c2 'get)) ; Prints 1
;; 4. c1's state is independent of c2's
(c1 'reset)
(display (c1 'get)) ; Prints 0
(display (c2 'get)) ; Prints 1 (unchanged)
How this works (Environment Model):
- Each call to
(make-counter)creates a new frame. Inside that frame, the inner(define count 0)creates a binding:count = 0. - It then returns a closure (the
lambda (message) ...). This closure bundles its code with a pointer to its defining environment—that new frame. c1andc2are two different closures. They point to the same block of code but to different environment frames (one wherecountis 0, one where it’s 1, etc.).- When we call
(c1 'increment), theset!command walks the environment chain, findscountin its enclosing frame, and mutates it there. The state is perfectly encapsulated.
Example 2: A Bank Account (with arguments)
That’s great, but methods often take arguments (e.g., deposit(100)). How do we handle that?
We can make our dispatcher lambda accept variable arguments using the (lambda (message . args) ...) syntax. The args will be a list of all other arguments.
;; `make-account` is our "class" factory
(define make-account
(lambda (initial-balance)
;; `balance` is the private "instance variable"
(define balance initial-balance)
;; The returned lambda is the "object"
(lambda (message . args) ;; args is a list of all remaining arguments
(cond
((eq? message 'deposit)
(set! balance (+ balance (car args)))
balance)
((eq? message 'withdraw)
(cond ((>= balance (car args))
(set! balance (- balance (car args)))
balance)
(else
(error "Insufficient funds!"))))
((eq? message 'get-balance)
balance)
(else
(error "Unknown message:" message))))))
Using the Bank Account Object
(define acct1 (make-account 100))
(acct1 'deposit 50) ; Returns 150
(acct1 'withdraw 20) ; Returns 130
(acct1 'get-balance) ; Returns 130
(acct1 'withdraw 200) ; Raises Error: "Insufficient funds"
(acct1 'get-balance) ; Returns 130 (state was unchanged)