Store-passing style
Store-passing style (SPS) makes changing state explicit. An SPS procedure receives the current store and returns both its ordinary value and the store that the next computation must use.
That one rule lets us describe memoization, allocation, assignment, and sequencing without mutating a Racket data structure. It also exposes a crucial interpreter distinction:
Environment : Variable -> Location
Store : Location -> Value
The environment says where a variable’s value lives. The store says what value is currently there.
Learning objectives
After working through this note, you should be able to:
- state the input-output contract of a store-passing procedure;
- use
values,let-values, andlet*-valuesto thread a store; - explain why each later computation must receive the preceding computation’s output store;
- distinguish an environment from a store;
- explain why assignment changes the store rather than the environment;
- trace allocation, variable lookup, assignment, and sequencing in an explicit-store interpreter; and
- distinguish object-language mutation from host-language mutation.
The store-passing contract
We will write the contract of an SPS procedure schematically as
(f-sps input store0) -> (values answer store1)
answer is the ordinary result of the computation. store1 is the state
after that computation. If another computation happens next, it must receive
store1, not store0.
Racket’s values form produces multiple values. let-values receives them:
(define (quotient+remainder m n)
(values (quotient m n) (remainder m n)))
(let-values ([(q r) (quotient+remainder 17 5)])
(list q r))
;; => '(3 2)
These are two Racket values, not one two-element list. Their positions are part of the interface. Throughout this note, the ordinary answer comes first and the successor store comes second.
When several store-passing computations occur in order, let*-values makes
the dependency visible:
(let*-values ([(value1 store1) (first-sps input store0)]
[(value2 store2) (second-sps value1 store1)])
(values value2 store2))
The second call sees store1. Reusing store0 there would discard every
change made by the first call.
A first example: memoized Fibonacci
The ordinary recursive Fibonacci program repeats subcomputations. A cache can
record answers already found. In the SPS version, the cache plays the role of
the store. Here n is assumed to be an exact nonnegative integer:
(define (fib-sps n cache)
(cond
[(hash-has-key? cache n)
(values (hash-ref cache n) cache)]
[(< n 2)
(values n (hash-set cache n n))]
[else
(let*-values ([(v1 cache1) (fib-sps (sub1 n) cache)]
[(v2 cache2) (fib-sps (- n 2) cache1)])
(define answer (+ v1 v2))
(values answer (hash-set cache2 n answer)))]))
(define (fib n)
(unless (exact-nonnegative-integer? n)
(raise-argument-error 'fib "exact-nonnegative-integer?" n))
(let-values ([(answer final-cache) (fib-sps n (hash))])
answer))
The important feature is not merely that fib-sps has an extra parameter.
Every recursive call returns a possibly larger cache, and the next recursive
call receives that cache.
For (fib-sps 4 (hash)), the significant flow is:
| Computation | Ordinary value | Relevant cache fact afterward |
|---|---|---|
fib-sps 1 |
1 | 1 -> 1 |
fib-sps 0 |
0 | 0 -> 0 |
fib-sps 2 |
1 | 2 -> 1 |
second request for fib-sps 1 |
1 | cache hit; no recomputation |
fib-sps 3 |
2 | 3 -> 2 |
second request for fib-sps 2 |
1 | cache hit; no recomputation |
fib-sps 4 |
3 | 4 -> 3 |
hash-set does not mutate its input hash. It produces a new immutable hash
whose mapping differs at one key. The program models changing state by passing
successive values.
Memoization is a useful warm-up, but an interpreter store has a more specific job: it models the memory of the language being interpreted.
Why an interpreter separates environment and store
In an interpreter without assignment, an environment can map a variable directly to its value:
x -> 5
Once the language has assignment, a binding must keep its identity while its value changes. We insert a location between the name and the value:
Environment Store
x -> location 0 location 0 -> 5
After (set! x 4), the environment is unchanged:
Environment New store
x -> location 0 location 0 -> 4
This indirection also explains how a closure can observe an update. The
closure saves an environment containing x -> location 0; it does not freeze
the value 5. Looking up x later follows the same location into the current
store.
A small persistent store
Our teaching store carries the next unused location and an immutable hash of allocated cells:
(struct store (next cells) #:transparent)
(define (empty-store)
(store 0 (hash)))
(define (store-allocate st value)
(define location (store-next st))
(values location
(store (add1 location)
(hash-set (store-cells st) location value))))
(define (store-ref st location)
(hash-ref (store-cells st)
location
(lambda ()
(error 'store-ref "unallocated location: ~s" location))))
(define (store-set st location value)
(unless (hash-has-key? (store-cells st) location)
(error 'store-set "unallocated location: ~s" location))
(store (store-next st)
(hash-set (store-cells st) location value)))
The three operations have different contracts:
| Operation | Result |
|---|---|
store-allocate |
a fresh location and a successor store |
store-ref |
the value at an existing location |
store-set |
a successor store with one location updated |
Reading does not change the store. Allocation and update return new stores. The old stores remain valid Racket values, which makes traces and tests easy to inspect.
The object language
The interpreter below supports this small language:
Expression ::= Number
| Variable
| (sub1 Expression)
| (zero? Expression)
| (if Expression Expression Expression)
| (lambda (Variable) Expression)
| (Expression Expression)
| (set! Variable Expression)
| (begin Expression Expression)
begin has exactly two subexpressions in this teaching language. The first is
evaluated for its effect; its value is discarded. The second provides the
value of the whole begin expression.
An environment is an association list from symbols to locations. A closure saves a parameter, a body, and the environment from its definition site:
(struct closure (parameter body environment) #:transparent)
(define empty-env '())
(define (extend-env name location env)
(cons (cons name location) env))
(define (apply-env env name)
(cond
[(assq name env) => cdr]
[else (error 'apply-env "unbound variable: ~s" name)]))
Notice that a closure saves locations indirectly through its environment. It does not save a private copy of the store.
The evaluator invariant
Every evaluator call obeys the same invariant:
(value-of expression environment store0)
-> (values value store1)
value is the meaning of expression in environment, beginning with
store0. store1 contains exactly the effects of evaluating that expression
in the interpreter’s documented order.
The clauses with no effects return their input store unchanged:
[(? number? n)
(values n st)]
[(? symbol? name)
(values (store-ref st (apply-env env name)) st)]
[`(lambda (,parameter) ,body)
(values (closure parameter body env) st)]
The sub1 clause evaluates its subexpression first and returns the resulting
store:
[`(sub1 ,operand)
(let-values ([(value st1) (value-of operand env st)])
(values (sub1 value) st1))]
The set! clause evaluates its right-hand side, finds the existing location of
the variable, and updates that location in the resulting store:
[`(set! ,name ,rhs)
(let-values ([(value st1) (value-of rhs env st)])
(values (void)
(store-set st1 (apply-env env name) value)))]
Assignment does not extend the environment and does not allocate a new location. It changes the value associated with an existing binding.
Sequencing passes the first expression’s output store to the second:
[`(begin ,first ,second)
(let-values ([(_ st1) (value-of first env st)])
(value-of second env st1))]
At function application, this interpreter evaluates the operator first and the operand second. It then allocates a fresh location for the argument and evaluates the body in the closure’s saved environment:
[`(,operator ,operand)
(let*-values ([(procedure st1) (value-of operator env st)]
[(argument st2) (value-of operand env st1)])
(apply-closure procedure argument st2))]
The order written here is semantic. Once effects exist, reversing the two subcomputations can change the result.
A complete executable interpreter
The following module collects the examples and tests the store-passing invariants.
#lang racket
(require rackunit)
;; A warm-up store: a persistent Fibonacci cache.
(define (fib-sps n cache)
(cond
[(hash-has-key? cache n)
(values (hash-ref cache n) cache)]
[(< n 2)
(values n (hash-set cache n n))]
[else
(let*-values ([(v1 cache1) (fib-sps (sub1 n) cache)]
[(v2 cache2) (fib-sps (- n 2) cache1)])
(define answer (+ v1 v2))
(values answer (hash-set cache2 n answer)))]))
(define (fib n)
(unless (exact-nonnegative-integer? n)
(raise-argument-error 'fib "exact-nonnegative-integer?" n))
(let-values ([(answer final-cache) (fib-sps n (hash))])
answer))
;; Store = Location -> Value, plus the next fresh location.
(struct store (next cells) #:transparent)
(define (empty-store)
(store 0 (hash)))
(define (store-allocate st value)
(define location (store-next st))
(values location
(store (add1 location)
(hash-set (store-cells st) location value))))
(define (store-ref st location)
(hash-ref (store-cells st)
location
(lambda ()
(error 'store-ref "unallocated location: ~s" location))))
(define (store-set st location value)
(unless (hash-has-key? (store-cells st) location)
(error 'store-set "unallocated location: ~s" location))
(store (store-next st)
(hash-set (store-cells st) location value)))
;; Env = Variable -> Location.
(define empty-env '())
(define (extend-env name location env)
(cons (cons name location) env))
(define (apply-env env name)
(cond
[(assq name env) => cdr]
[else (error 'apply-env "unbound variable: ~s" name)]))
(struct closure (parameter body environment) #:transparent)
(define (apply-closure procedure argument st)
(match procedure
[(closure parameter body saved-env)
(let-values ([(location st1) (store-allocate st argument)])
(value-of body
(extend-env parameter location saved-env)
st1))]
[_ (error 'apply-closure "not a closure: ~s" procedure)]))
(define (value-of expression env st)
(match expression
[(? number? n)
(values n st)]
[(? symbol? name)
(values (store-ref st (apply-env env name)) st)]
[`(sub1 ,operand)
(let-values ([(value st1) (value-of operand env st)])
(values (sub1 value) st1))]
[`(zero? ,operand)
(let-values ([(value st1) (value-of operand env st)])
(values (zero? value) st1))]
[`(if ,test ,consequent ,alternative)
(let-values ([(test-value st1) (value-of test env st)])
(if test-value
(value-of consequent env st1)
(value-of alternative env st1)))]
[`(lambda (,parameter) ,body)
(values (closure parameter body env) st)]
[`(set! ,name ,rhs)
(let-values ([(value st1) (value-of rhs env st)])
(values (void)
(store-set st1 (apply-env env name) value)))]
[`(begin ,first ,second)
(let-values ([(_ st1) (value-of first env st)])
(value-of second env st1))]
[`(,operator ,operand)
(let*-values ([(procedure st1) (value-of operator env st)]
[(argument st2) (value-of operand env st1)])
(apply-closure procedure argument st2))]
[_ (error 'value-of "bad expression: ~s" expression)]))
(define (run expression)
(let-values ([(answer final-store)
(value-of expression empty-env (empty-store))])
answer))
(module+ test
;; The Fibonacci result and its returned cache agree.
(let-values ([(answer cache) (fib-sps 10 (hash))])
(check-equal? answer 55)
(check-equal? (hash-ref cache 10) 55)
(check-equal? (hash-ref cache 9) 34))
(check-equal? (fib 0) 0)
(check-equal? (fib 1) 1)
(check-equal? (fib 12) 144)
;; Persistent store updates leave the preceding store unchanged.
(let*-values ([(location st1) (store-allocate (empty-store) 5)])
(define st2 (store-set st1 location 4))
(check-equal? location 0)
(check-equal? (store-ref st1 location) 5)
(check-equal? (store-ref st2 location) 4))
;; Pure expressions and ordinary application still work.
(check-equal? (run 5) 5)
(check-equal? (run '((lambda (x) x) 5)) 5)
(check-equal? (run '((lambda (x) (sub1 x)) 5)) 4)
(check-equal? (run '(if (zero? 0) 7 8)) 7)
;; Assignment changes the cell observed by the following expression.
(check-equal?
(run '((lambda (x)
(begin
(set! x (sub1 x))
x))
5))
4)
;; A closure and its enclosing computation share x's location.
(check-equal?
(run '((lambda (x)
((lambda (change)
(begin
(change 0)
x))
(lambda (ignored)
(set! x (sub1 x)))))
5))
4)
;; A closure uses the store at application time, not a saved snapshot.
(check-equal?
(run '((lambda (x)
((lambda (f)
(begin
(set! x 4)
(f 0)))
(lambda (ignored) x)))
5))
4)
;; Effects expose the documented operator-before-operand order.
(check-equal?
(run '((lambda (x)
((begin
(set! x 1)
(lambda (ignored) x))
(begin
(set! x 2)
0)))
0))
2)
;; A shadowing parameter receives a different location.
(check-equal?
(run '((lambda (x)
(begin
((lambda (x) (set! x 0)) 7)
x))
5))
5))
Worked trace: assignment and sequencing
Consider:
((lambda (x)
(begin
(set! x (sub1 x))
x))
5)
The essential states are:
| Step | Environment fact | Store fact | Produced value |
|---|---|---|---|
| evaluate the lambda | empty | empty | closure |
| evaluate the operand | empty | empty | 5 |
bind x |
x -> 0 |
0 -> 5 |
begin body starts |
read x in (sub1 x) |
x -> 0 |
0 -> 5 |
5, then 4 |
execute set! |
x -> 0 |
0 -> 4 |
void |
evaluate the second part of begin |
x -> 0 |
0 -> 4 |
4 |
The binding of x never changes. The store cell at its location does.
A closure observes the current store
In the larger test from the complete module, the procedure bound to change
was created while x referred to location 0. Calling change later updates
location 0. When the enclosing body subsequently evaluates x, lookup follows
the same saved location into the new store and obtains 4.
That is the point of the location indirection. If the closure had copied the
value 5 instead, it would fail to observe the assignment.
The next test is sharper still: it creates f while x contains 5, changes
x to 4, and only then calls f. The answer must be 4. An implementation
that accidentally saves the creation-time store inside the closure would
return the stale value 5.
SPS and CPS are different transformations
Both styles add an explicit parameter, but they expose different hidden structure:
| Style | Extra input describes | How a computation finishes |
|---|---|---|
| direct style | neither explicitly | returns one ordinary value |
| continuation-passing style | what to do with the value next | invokes a continuation |
| store-passing style | the state before the computation | returns a value and successor state |
A program can use both styles simultaneously. An evaluator in CPS and SPS would receive both a continuation and a store; each clause would eventually send both the value and updated store onward according to its chosen interface. Neither transformation is merely a renaming of the other.
Supervised practice
Use the evaluator invariant, not trial execution, to work through these.
- Starting with
x -> 3and store3 -> 10, show the environment and store after evaluating(set! x (sub1 x)). - In the application clause, circle the store consumed by each subcomputation and draw arrows showing where that store came from.
- Predict the result of the shadowing test before running it. Identify the two
distinct locations allocated for the two bindings named
x. - Write a clause for
(begin e1 e2)that mistakenly evaluatese2with the original store. Give the smallest program that exposes the mistake. - Extend the language with
(+ e1 e2). State the intended order, then write a clause that threads both stores correctly. - Explain why
store-setreturns a new store while object-languageset!still counts as mutation.
Common mistakes
- Reusing an old store. Each subsequent subcomputation must receive the most recently returned store.
- Mapping variables directly to values. With assignment, an environment maps variables to stable locations; changing values live in the store.
- Changing the environment for
set!. Assignment updates an existing cell. Binding introduction is what extends an environment. - Allocating from stale state. Freshness is determined by the current store, after all earlier effects.
- Saving a whole store in a closure. A closure saves its lexical environment. It is applied using the current store.
- Treating evaluation order as irrelevant. Once expressions can change state, operator-first and operand-first evaluation can differ observably.
- Confusing explicit state with host mutation. Successive immutable Racket store values can model a language whose programs mutate variables.
- Confusing SPS with CPS. A store represents current memory; a continuation represents pending control.
Summary
- An SPS procedure receives a store and returns an ordinary value plus a successor store.
let*-valuesrecords the data dependency between successive stores.- Memoized Fibonacci provides a small example of passing an evolving cache.
- An explicit-store interpreter separates
Variable -> LocationfromLocation -> Value. - Variable lookup follows both mappings; assignment updates the second.
- Function application allocates a fresh location for its parameter.
- Closures retain lexical locations and therefore observe later updates through the current store.
- Persistent host-language data can model object-language mutation precisely.
Self-check questions
- What are the two results of an SPS computation?
- Why must the second recursive call in
fib-spsreceivecache1? - What does an environment map a variable to once assignment is present?
- What changes during
set!: the environment, the store, or both? - Why does a closure save an environment but not a creation-time store?
- Where does the interpreter allocate a fresh location?
- What evaluation order does the application clause implement?
- How can immutable Racket hashes model mutation in the interpreted language?
- What hidden structure does SPS expose that CPS does not?
Acknowledgment
This note synthesizes store-passing developments from earlier C311 and CSAS 3113 course materials, including the memoized-Fibonacci and explicit-store interpreter examples used in class from 2012 through 2024. The examples here have been rewritten and tested for this course.