The let Special Form
let creates a new set of local variable bindings that exist only within its body: (let ((var1 val1) (var2 val2)) body). Critically, all the value expressions (val1, val2, ...) are evaluated using the bindings that existed before the let form — none of the new variables can see each other's values during initialization, since all bindings are established in parallel, not sequentially. This means (let ((x 1) (y x)) ...) will signal an error or use an outer x, not the x=1 just defined in the same let, because y's initializer is evaluated before x is bound.
Cricket analogy: let is like naming the entire playing XI on the team sheet simultaneously before the toss — every player's slot is decided from the pre-match squad list at once, so you can't reference 'the player just named at slot 3' to decide slot 4, since none of the new names exist yet during the naming process.
(let ((x 10)
(y 20))
(+ x y)) ; => 30
;; let* allows sequential access to earlier bindings
(let* ((x 10)
(y (* x 2)))
(+ x y)) ; => 30, y correctly sees x = 10
;; parallel let with the same names would fail to see each other:
;; (let ((x 10) (y x)) ...) would use the OUTER x, not 10let* for Sequential Binding
When you need each binding to see the ones defined before it in the same form, use let* instead of let. let* evaluates and binds each variable one at a time, in order, so later initializers can reference earlier variables in the same let* — exactly the behavior that plain let disallows. This makes let* the natural choice whenever you're computing a chain of intermediate values, such as (let* ((width 10) (height (* width 2)) (area (* width height))) area), where height depends on width and area depends on both.
Cricket analogy: let* is like setting a batting order where each subsequent slot can be decided based on who's already been placed — you pick the opener first, then decide the number three based on who the opener is, sequentially building the lineup one slot at a time.
let and let* both create a new lexical scope: bindings established inside the form are invisible outside it, and they shadow any outer variable of the same name for the duration of the body, restoring the outer binding once the let form completes.
Lexical Scope and Shadowing
Common LISP variables are lexically scoped by default, meaning a variable reference resolves to the nearest enclosing binding in the source code, not based on the dynamic call chain at runtime. When a let binding uses the same name as an outer variable, it shadows the outer one for the extent of the let's body — references to that name inside the body see the new local value, and the outer binding becomes visible again once the let form's body finishes executing. This is distinct from special (dynamically scoped) variables, typically declared with defvar or defparameter and conventionally named with asterisks like *count*, which resolve based on the most recent dynamic binding on the call stack rather than lexical nesting.
Cricket analogy: Lexical shadowing is like a substitute fielder temporarily taking a position during an injury break — inside that specific passage of play, the substitute is 'the fielder at that position', but once the original player returns, the original identity resolves again, purely based on the current passage of play, not who's on the broader squad list.
Using let to 'rebind' a special variable (one declared with defvar/defparameter) does not create a lexical shadow the way it does for ordinary variables — it dynamically rebinds the variable for the duration of the let, affecting every function called within that body, not just code lexically inside it. This dynamic-vs-lexical distinction is a frequent source of confusion for programmers coming from lexically-scoped-only languages.
- let creates local bindings whose initializer expressions are all evaluated using the prior (outer) environment, in parallel.
- let* binds variables sequentially, allowing later initializers to reference earlier ones in the same form.
- Both let and let* create a new lexical scope that ends when the form's body finishes executing.
- A let binding shadows an outer variable of the same name only for the duration of the let's body.
- Ordinary lexical variables resolve based on their position in the source code, not the runtime call chain.
- Special variables (declared with defvar/defparameter, conventionally named with *earmuffs*) are dynamically scoped and rebinding them with let affects all code called within the body, not just lexically nested code.
- Choose let* whenever a later binding's value depends on an earlier one defined in the same form.
Practice what you learned
1. In (let ((x 10) (y x)) y), assuming there is no outer x, what is the likely result?
2. What is the main advantage of let* over let?
3. What happens to a let-bound variable's shadow of an outer variable once the let form's body finishes?
4. How do special (dynamically scoped) variables behave differently when 'rebound' with let compared to ordinary lexical variables?
5. In (let* ((width 10) (height (* width 2)) (area (* width height))) area), what is the result?
Was this page helpful?
You May Also Like
Defining Functions in LISP
Learn how to define named functions in LISP using defun, understand parameter lists, return values, and documentation strings.
Lambda and Anonymous Functions
Learn how to create unnamed functions with lambda in LISP, and how they're used with higher-order functions like mapcar, remove-if, and reduce.
Conditionals in LISP
Master LISP's conditional forms — if, cond, when, unless, and case — for controlling program flow based on tested conditions.
Related Reading
Related Study Notes in Programming
Browse all study notesApache Spark Study Notes
Programming · 30 topics
ProgrammingApache Flink Study Notes
Programming · 30 topics
ProgrammingHadoop Study Notes
Programming · 30 topics
ProgrammingSnowflake Study Notes
Programming · 30 topics
ProgrammingApache Airflow Study Notes
Programming · 30 topics
Programmingdbt (Data Build Tool) Study Notes
Programming · 30 topics