When this matters
Use bindings to give a result a name, create a local scope, or deliberately keep state between calls. Most code needs only define and let. Reach for set! only when shared mutation is part of the model.
See it run
(define x 1)
(define get (lambda () x))
(set! x 2)
(get)Observed result
2Read the example
get closes over the global cell named x, not a frozen copy of the number 1. set! writes 2 into that same cell, so the later call observes 2. A new inner x would instead shadow the cell.
let* creates its names one after another from the top, so a later initializer can see the name just created.
(let* ((x 6)
(y (+ x 1)))
(* x y))Observed result
42letrec allocates every cell before it evaluates any initializer, which is what lets a name call itself.
(letrec ((f (lambda (n)
(if (= n 0)
1
(* n (f (- n 1)))))))
(f 5))Observed result
120How to reason about it
- Lookup chooses the nearest lexical cell, and shadowing does not mutate an outer binding.
letrecallocates cells before initializers and raises E321 when an uninitialized cell is read.- A duplicate top-level
defineupdates the existing global cell, preserving earlier closure references.
Choose quickly
| Form | Use it for | Visibility of initializers |
|---|---|---|
define | a top-level name or named procedure | current top-level environment |
let | independent local values | outer environment |
let* | locals that depend on earlier locals | each earlier binding |
letrec | mutually recursive local procedures | all allocated cells, though early reads fail |
set! | updating an existing cell | no new binding is created |
A common mistake
set! on an unbound name is E303. It never creates a binding.
Current boundaries
- The internal marker for an uninitialized cell cannot be represented as a guest value.
Keep going
Procedures shows how closures capture these cells. The closures guide turns that rule into a small stateful example.