Lisp in Small Pieces
Overview
This page contains notes on the book Lisp in Small Pieces by Christian Quiennec.
Ch. 1 The Basics of Interpretation
§ 1.3 Evaluating Atoms
Syntactic vs. Semantic Elements
(define (evaluate expr env)
(if (and (atom? expr) (symbol? expr))
(lookup expr env)
...
To be more precise, we could instead write:
(lookup (symbol->variable expr) env)
and likewise, taking the abstraction one step further:
(lookup (variable->key (symbol->variable expr)) env)
However, when writing a meta-circular evaluator we normally rely on the fact that we can represent the concept of a "variable" in our source language and "key" in our defining language both as ordinary lisp symbols.
Autoquote
When an expression is atomic … and when that expression is not a symbol, we have the habit of considering it as the representation of a constant that is its own value. This idempotence is known as the autoquote facility. An autoquoted object does not need to be quoted, and it is its own value.
§ 1.4 Evaluating Forms
§ 1.4.2 Alternatives
More on Syntactic vs. Semantic Elements
As above, the following snippet of code can be made more explicit by replacing:
... (case (car e) ...
((if (if (evaluate (cadr e) env)
(evaluate (caddr e) env)
(evaluate (cadddr e) env))))
...
with
(define the-false-value (cons "false" "boolean")
... (case (car e) ...
((if (if (not (eq? (evaluate (cadr e) env) the-false-value))
(evaluate (caddr e) env)
(evaluate (cadddr e) env))))
...
Again, such pedantry is not really necessary for a meta-circular eval, but it's useful to keep in mind that you're using a kind of shorthand when you implement it as in the first snippet.
§ 1.5 Representing the Environment
Fail Fast
The goal of a program is not so much to avoid committing errors but rather to fulfill its duty. … Errors and erroneous contexts need to be pointed out as early as possible so that their source can be corrected as soon as possible.
§ 1.6 Representing Functions
§ 1.6.1 Dynamic and Lexical Binding
In a lexical Lisp, a function evaluates its body in its own definition environment extended by its variables, whereas in a dynamic Lisp, a function extends the current environment, that is, the environment of the application.
Something to consider when designing a language: do you want to support
- only lexical bindings (à la Scheme)
- both lexical and dynamic bindings with the same syntax (Common Lisp)
- both, but with two separate, syntactically distinguished namespaces (EULisp, ISLisp).
McCarthy pondered the meaning of
(let ((a 1))
((let ((a 2))
(lambda (b) (list a b))) 3))
And realized he expected (2 3)
, not (1 3)
. So he introduced a
special form called function
in Lisp 1.0 for creating closures,
i.e. in (function (lambda () x))
, x
would be lexically bound in the
definition environment. But lexical environments offer a big advantage
in compiled code, namely that the compiler can generate efficient code
for variable access, rather than dynamically searching the environment
at runtime. So in Lisp 1.5, they made lexical scope the default and
introduced (declare (special x))
to indicate a variable that should be
dynamically scoped.
§ 1.6.2 Deep or Shallow Implementation
- Deep Binding
- doing a linear search through an alist (or similar) to find the dynamic binding.
- Shallow Binding
- associating the value of the variable directly with the symbol that represents it (i.e. store it on the symbol p-list). This avoids a linear search for the value at the cost of uglying up function calls, since functions must save/restore bindings, which interferes with TCO.
Ch. 2 Lisp, 1, 2, … ω
§ 2.2 Lisp2
Lisp2 allows the evaluator to take a shortcut. When evaluating the function position of an application, we can restrict it to a symbol and do a simple lookup rather than a full eval. That is
(define (evaluate e env)
(if (atom? e) ...
(case (car e) ...
((lambda) (make-function (cadr e) (cddr e) env))
(else (invoke (evaluate (car e) env)
(evlis (cdr e) env))))))
can be replaced with
...
(else (invoke (lookup (car e) env)
(evlis (cdr e) env)))...
or, if we want to allow lambda
in the function position as well as a
symbol, we can replace the lookup
with a call to
evaluate-application
, a mini eval that knows how to handle lambdas and
symbol lookup.
(define (evaluate-application fn args env fenv)
(cond ((symbol? fn) ((lookup fn fenv) args))
((and (pair? fn) (eq? (car fn) 'lambda))
(f.eprogn (cddr fn) (extend env (cadr fn) args) fenv))
(else (wrong "Incorrect functional term" fn))))
Lisp2 has the advantage of making function lookups faster, since we don't force another call to eval, but instead do the lookup directly, and also because the function environment will be more compact since it does not contain variable bindings. In addition, lambda forms in function position are greatly simplified. Consider:
(let ((state-tax 1.186))
((lambda (x) (* state-tax x)) (read)))
In the above case, the closure corresponding to the lambda will not be created, rather its body will be evaluated in the correct environment. We can also avoid checking that the symbol in the function position is indeed a function at application time if we perform the check before binding it.
However, many of these apparent advantages of Lisp2 can be overcome in Lisp1 if the function to apply can be determined statically, which is usually the case.
On the down side, with Lisp2 we lose the ability to calculate the
function to apply, as in ((if cond + -) 3 4)
.
§ 2.2.2 Duality of the Two Worlds
I.e., the two worlds of functions vs. evaluations.
To summarize these problems, we should say that there are calculations belonging to the parametric world that we want to carry out in the functional world, and vice versa. More precisely, we may want to pass a function as an argument or as a result, or we may even want the function that will be applied to be the result of a lengthy calculation.
The function
funcall
lets us take the result of a calculation coming from the parametric world and put it into the function world as a value. Conversely, the special formfunction
(a.k.a.#'
) lets us get the value of a variable from the function world.
§ 2.5 Name Spaces
§ 2.5.3 Dynamic Variables without a Special Form
One can have dynamic binding without special forms, so long as the
interpreter has been modified to pass the current dynamic environment to
functions. All that is needed then is to introduce functions bind/de
and assoc/de
to bind / reference dynamic vars.
(bind/de 'x 2
(lambda () (+ (assoc/de 'x error)
(let ((x (+ (assoc/de 'x error) (assoc/de 'x error))))
(+ x (assoc/de 'x error))))))
--> 8
This has the advantage of not requiring extra special forms, but the disadvantage of being hideous to read/type.
§ 2.6 Recursion
§ 2.6.1 Simple Recursion
(define (display-pi)
(display pi))
(define pi 2.71828)
(define (print-pi)
(display pi))
(define pi 3.14159)
What is the value of (display-pi)
? Of (print-pi)
?
§ 2.6.6 Recursion without Assignment
Paradoxical Combinator
\begin{align*} \exists \textbf{Y}, \forall F, \textbf{Y}F &= F(\textbf{Y}F) \\ \textbf{Y} &= \textbf{WW} \\ \textbf{W} &= \lambda \textbf{W} . \lambda F . F ((\textbf{W W}) F) \end{align*}or, in a normal-order lisp:
(let ((W (lambda (w)
(lambda (f)
(f ((w w) f)) ) )))
(W W) )
Scheme uses call-by-value, so we add an η-conversion to prevent
evaluation of the ((w w) f)
term.
(define fix
(let ((d (lambda (w)
(lambda (f)
(f (lambda (x) (((w w) f) x))) ) )))
(d d) ) )
We can then define factorial as:
(define fact
(fix (lambda (fact)
(lambda (n)
(if (= n 0)
1
(* n (fact (- n 1))) ) ) )) )
Ch. 3 Escape & Return: Continuations
The original escape form in Lisp 1.5 was prog
. prog
allows declaring
local variables, followed by a sequence of labels and instructions. The
instruction may contain go
forms, which cause unconditional jumps to
uncomputed labels, and a return
form, which specifies the return value
for the whole prog
form. In Lisp 1.5, go
and return
forms could
only appear in the first level of a prog
or in a cond
at the first
level.
(defun fact (n)
(prog (r)
(setq r 1)
loop (cond ((= n 1) (return r)))
(setq r (* n r))
(setq n (- n 1))
(go loop)))
§ 3.1 Forms for Handling Continuations
§ 3.1.1 The Pair catch
/ throw
(catch label forms ...)
(throw label form)
- Dynamic extent.
- Label bound in dynamic environment.
- Labels evaluated.
§ 3.1.2 The Pair block
/ return-from
(block label forms ...)
(return-from label form)
- Dynamic extent.
- Label bound in lexical environment.
- Labels not evaluated.
§ 3.1.3 Escapes with a Dynamic Extent
((block foo (lambda (x) (return-from foo x))) 33) --> error
(block foo
(let ((f1 (lambda (x) (return-from foo x))))
(* 2 (block foo (f1 1)))))
--> 1
(catch 'foo
(let ((f1 (lambda (x) (throw 'foo x))))
(* 2 (catch 'foo (f1 1)))))
--> 2
§ 3.1.5 Escapes with Indefinite Extent
A la Scheme's call/cc
.
(call/cc (lambda (k) (+ 1 (k 2)))) --> 2
The last choice due to Scheme is to not create a new type of object and thus to represent continuations by unary functions, indistinguishable from function created by
lambda
. The continuation k is thus reified, that is, turned into an object that becomes the value ofk
…Another solution would be to create another type of object, namely, continuations themselves. This type would be distinct from functions, and would necessitate a special invoker, namely,
continue
. The preceding example would then be rewritten like this:
(call/cc (lambda (k) (+ 1 (continue k 2)))) --> 2
I think I fall into this camp.
Some people like calls to continuations that are syntactically eye-opening because they make alteration of the thread of control more evident.
Here is fact
, implemented via call/cc
.
(define (fact n)
(let ((r 1))
(let ((k (call/cc (lambda (c) c))))
(set! r (* r n))
(set! n (- n 1))
(if (= n 1) r (k k)))))
§ 3.2 Actors in a Computation
§ 3.2.5 Sequence
A little implementation hack for begin
forms in the continuation-based
interpreter.
(define (evaluate-begin e* r k)
...(evaluate (car e*) r (make-begin-cont k e* r))...)
(define-method (resume (k begin-cont) v)
(evaluate-begin (cdr (begin-cont-e* k)) ...))
The cdr of e*
is taken in resume
rather than in evaluate-begin
, so
that one can tell which expression is in flight by examining the
continuation.
§ 3.3 Initializing the Interpreter
Is this sarcasm? I doubt it, but it got a chuckle out of me nonetheless. More likely something fell through the cracks in the translation from French.
Notice that the entire interpreter could easily be written in a real object-language, like Smalltalk, so we could take advantage of its famous browser and debugger. The only thing left to do is to add whatever is needed to open a lot of little windows everywhere.
§ 3.4 Implementing Control Forms
§ 3.4.4 Implementation of unwind-protect
Curious bit of trivia, CLtL2 introduced the restriction that an escape in a cleanup form couldn't unwind less far than the escape currently underway. In other words, the following was an error:
(catch 1
(catch 2
(unwind-protect (throw 1 'foo)
(throw 2 'bar))))
--> error
This restriction was apparently lifted in ANSI Common Lisp (the
hyperspec doesn't mention it, and SBCL happily returns BAR
).
§ 3.5 Comparing call/cc
to catch
In the world of Scheme, continuations can no longer be put on the stack because they can be kept in external data structures. Thus we have to adopt another model: a hierarchic model, sometimes called a cactus stack. The most naive approach is to leave the stack and allocate blocks for continuations directly in the heap… [but] the canonical implementation of
call/cc
copies the stack into the heap; the continuation is thus this very copy of the stack.
§ 3.7 Partial Continuations
This section describes a variant called partial continuations, which are continuations that act like function calls in so far as they return their value to the current continuation. With standard continuations:
(+ 1 (call/cc (lambda (k) (set! foo k) 2)))
--> 3
(foo (foo 4))
--> 5
If foo were a partial continuation, the result of (foo (foo 4))
would
be 6, not 5.
§ 3.9 Exercises
Exercise 3.10
Meditate on the following:
(define (make-box value)
(let ((box
(call/cc
(lambda (exit)
(letrec
((behavior
(call/cc
(lambda (store)
(exit (lambda (msg . new)
(call/cc
(lambda (caller)
(case msg
((get) (store (cons (car behavior)
caller)))
((set)
(store
(cons (car new)
caller))))))))))))
((cdr behavior) (car behavior)))))))
(box 'set value)
box))
Ch. 4 Assignment and Side Effects
§ 4.5 Semantics of Quotations
The following quote is essentially a continuation on the theme first presented in the sections Syntactic vs. Semantic Elements and More on Syntactic vs. Semantic Elements, above. That is, a distinction between objects and values in the definition language, as opposed to the defined language.
In the end, we can accept a compositional definition of quoting since, by transforming the program, we can revert to our usual habits. However, we might scrutinize these bad habits that depend on the fact that programs are often read with the function
read
and that thus the expression that appears in aquote
and that specifies the immediate data to return is coded with the same conventions: same dotted pairs, same symbols, etc. Natural laziness impinges on the interpreter to use this same value and thus to return it every time it's needed.
Ch. 5 Denotational Semantics
The introduction lists a variety of ways to specify the semantics of a language:
- Reference Implementation is probably the most common technique, in which a reference implemenation is the de facto standard to which any other implementation should conform. Think Python.
- Operational Semantics involves precisely specifying a virtual machine and its operations, and defining the meaning of a program or language in terms of how it's translated into instruction for the virtual machine. Think MMIX.
- Denotational Semantics "consists of defining (for a given language) a function, called the valuation, that associates each valid program of the language with a term in the denotation space," called its denotation. Usually, the λ-calculus is target language for the denotation. "The denotation of a program is the λ-term representing its meaning."
- Axiomatic Semantics were introduced by Floyd and Hoare. The idea is to define for each elementary form of the language a logical formula: \({P}form{Q}\) which indicates that if \(P\) is true before \(form\) executes, then \(Q\) is true after. Axiomatic semantics are thus useful for attempting to prove that program is correct, but not especially useful for constructing an implementation of a given language.
- Natural Semantics "favors relations (over functions) in a context derived from denotational semantics."
- Algebraic Semantics "reasons in terms of equivalent programs by means of rewrite rules."
§ 5.1 A Brief Review of λ-Calculus
The set of terms in the λ-calculus is given by Λ, which can be defined recursively as:
\begin{align*} \textrm{variable}&: \forall x \in \textbf{Variable}, & x \in \Lambda \\ \textrm{abstraction}&: \forall x \in \textbf{Variable}, \forall M \in \Lambda, & \lambda x.M \in \Lambda \\ \textrm{combination}&: \forall M, N \in \Lambda, & (M N) \in \Lambda \end{align*}β-reduction occurs during an application when the parameters of a combination are substituted for some value in the body of the combination. E.g.
\[ \beta\textrm{-reduction}: (\lambda x. M \;\; N) \overset{\beta}{\to} M[x \to N] \]
A redex is a red-ucible ex-pression, i.e. an application in which the first term is an abstraction. A β-reduction supresses a redex.
When a term contains no redex…, we say that the term is in normal form. Terms in λ-calculus do no necessarily have a normal form, but when they have one, it is unique because of the Church-Rosser property. When a term has a normal form, there exists a finite series of β-reductions that convert the original term into the normal form. An evaluation rule is a procedure that indicates which redex (if there is more than one) ought to be β-reduced.
Common examples of evaluation rules are normal order (aka call by name), call by need, and call by value. Scheme uses the latter for efficiency reasons. Normal order is so named becaues it's guaranteed to find the normal form, if it exists, whereas call by value is not. Consider, for example:
\[ ((\lambda x.\lambda y.y \; (\omega \, \omega)) \: z) \\ \textrm{where} \ \omega = \lambda x.(x \, x) \]
In a normal-order language, the above β-reduces to \(z\), whereas in Scheme, the \((\omega \, \omega)\) term causes an infinite loop.
§ 5.2 Semantics of Scheme
The following define various domains of objects handled by denotations.
\begin{align*} \textbf{Environment} &= \textbf{Identifier} \to \textbf{Address} \\ \textbf{Memory} &= \textbf{Address} \to \textbf{Value} \\ \textbf{Value} &= \textbf{Function} + \textbf{Boolean} + \textbf{Integer} + \textbf{Pair} + \ldots \\ \textbf{Continuation} &= \textbf{Value} \times \textbf{Memory} \to \textbf{Value} \\ \textbf{Function} &= \textbf{Value}* \times \textbf{Continuation} \times \textbf{Memory} \to \textbf{Value} \end{align*}And some greek letters that customarily represent the domains.
\begin{array}{r l | r l} \pi & \textbf{Program} & \rho & \textbf{Environment} \\ \nu & \textbf{Identifier} & \alpha & \textbf{Address} \\ \sigma & \textbf{Memory} & \epsilon & \textbf{Value} \\ \kappa & \textbf{Continuation} & \varphi & \textbf{Function} \end{array}Extensionality is the property that \((\forall x, f(x) = g(x)) \implies (f = g)\). It is linked to the η-conversion…
\[ \eta\textrm{-conversion}: \lambda x.(M \;\; x) \overset{\eta}{\to} M \;\; \textrm{with x not free in M} \]
The denotation of a tiny scheme (missing variable airity, etc.). The
denotation of call/cc
is included in the book. I've left it out here
because it's too tedious to typeset.
For comparison, here is the denotation given for the λ-calculus.
\begin{align*} \mathcal{L}[\nu]\rho &= (\rho \: \nu)\\ \mathcal{L}[(\textbf{lambda} \: (\nu) \: \pi)]\rho &= \lambda \epsilon.(\mathcal{L}[\pi] \rho[\nu \to \epsilon])\\ \mathcal{L}[(\pi \: \pi^\prime)]\rho &= ((\mathcal{L} \: \rho)(\mathcal{L}[\pi^\prime] \: \rho)) \end{align*}Ch. 6 Fast Interpretation
§ 6.1 A Fast Interpreter
6.1.1 Migration of Denotations
λ-hoisting or λ-drifting means migrating code out of the body of a lambda, to precompute as much as possible and capture those values in the closed environment, to avoid having to recompute the values every time the function is called. Not to be confused with λ-lifting.
Ch. 10 Compiling into C
§ 10.4 Eliminating Nested Functions
As a language, C does not support functions inside other functions. In other words, a
lambda
inside anotherlambda
cannot be translated directly. Consequently, we must eliminate these cases in a way that turns the program to compile into a simple set of closed functions, that is, funcitons without free variables. Once again, we're lucky to find such a natural transformation. We'll call itlambda
-lifting because it makeslambda
forms migrate towards the exterior in such a way that there are no remaininglambda
forms in the interior.