SICP 4.2.1 and 4.2.2
4.2.1 Normal Order and Applicative Order
Lazy evaluation is the technique of delaying the evaluation of procedure arguments until their values are needed. Roughly, languages that evaluate lazily are called ``normal-order'' languages and those that do not are called ``applicative-order'' languages.
Since Scheme is an applicative-order language, the following will give an error:
(define (try a b) (if (= a 0) 1 b)) (try 0 (/ 1 0)) ;; => error, division by 0
A procedure is said to "strict" in those arguments which are evaluated before the body of the procedure is entered, and it is said to be "non-strict" in those arguments which are not evaluated before the body of the procedure is entered.
Exercise 4.25
Exercise:
Suppose that (in ordinary applicative-order Scheme) we define
unless
as shown above and then definefactorial
in terms ofunless
as(define (factorial n) (unless (= n 1) (* n (factorial (- n 1))) 1))What happens if we attempt to evaluate
(factorial 5)
? Will our definitions work in a normal-order language?
Answer:
At some point, the chain of recursive calls ends up calling unless
where n
is bound to 1.
This means that the second argument passed to unless
, if evaluated,
would entail the application of factorial
to 0
.
An applicative order evaluation language would evaluate it; so it would initiate an infinite chain of applications (because the terminating condition will never be satisfied).
But there should be no problem in a normal-order language.
Exercise 4.26
Exercise:
Ben Bitdiddle and Alyssa P. Hacker disagree over the importance of lazy evaluation for implementing things such as
unless
. Ben points out that it's possible to implementunless
in applicative order as a special form. Alyssa counters that, if one did that,unless
would be merely syntax, not a procedure that could be used in conjunction with higher-order procedures. Fill in the details on both sides of the argument. Show how to implementunless
as a derived expression (likecond
orlet
), and give an example of a situation where it might be useful to haveunless
available as a procedure, rather than as a special form.
Answer:
Remember that Authors stated:
Some special forms in our language can be defined in terms of expressions involving other special forms, rather than being implemented directly (p. ?) […] Expressions (such as cond) that we choose to implement as syntactic transformations are called derived expressions.
That is the way Authors understand unless
to be a special form
(i.e., defined in terms of if
).
We can transform an unless
expression into an if
expression in the
following way:
(define (tagged-list? exp tag) (if (pair? exp) (eq? (car exp) tag) false)) (define (unless? exp) (tagged-list? exp 'unless)) (define (unless-condition exp) (cadr exp)) (define (unless-usual-value exp) (caddr exp)) (define (unless-exceptional-value exp) (cadddr exp)) (define (unless->if exp) (list 'if (unless-condition exp) (unless-exceptional-value exp) (unless-usual-value exp)))
Ben is right that we can implement unless
as a special form. Alyssa
is right in maintaining that, if implemented that way, it could not be
passed to higher-order procedures. The reason is that the arguments of
a procedures are evaluated; the evaluation of a symbol which is bound
to procedure will return a procedure object, but if we evaluate the
``name'' of special form, then we try to look it up as if it was a
variable name; but there is not such a binding in the environment.
It's kind of hard to think of a case where it would be useful to have
unless
as a procedure. The only thing I can think of is something
like BOH
, that is, a procedure that gives the user the choice of how
to deal with a certain condition.
(define BOH (lambda (cond-eval-strategy cond alt1 alt2) (cond-eval-strategy cond alt1 alt2)))
4.2.2 An Interpreter with Lazy Evaluation
Authors show the implementation of a (normal-order) Scheme in which compounde procedures are non-strict (in each argument) — but primitive procedures keep being strict.
When the evaluator applies a compound procedures, instead of evaluating the arguments, it turns them into object called ``thunks''. Thunks contain all the information that is need to evaluate the arguments as if it was evaluated at application time. The evalution of a thunk is called ``forcing''.
Thunks are to be forced when:
- they are passed to a primitive procedure;
- when they are the predicate of a conditional;
- when they are the operator of an application.
The evaluator memoizes the thunk. That is, if a thunk is forced, future forcirg can just retrieve their already-computed value.
Here is the new code:
(define (eval exp env) ... ((application? exp) (apply (actual-value (operator exp) env) (operands exp) env)) ...) (define (actual-value exp env) (force-it (eval exp env))) (define (apply procedure arguments env) (cond ((primitive-procedure? procedure) (apply-primitive-procedure procedure (list-of-arg-values arguments env))) ; changed ((compound-procedure? procedure) (eval-sequence (procedure-body procedure) (extend-environment (procedure-parameters procedure) (list-of-delayed-args arguments env) ; changed (procedure-environment procedure)))) (else (error "Unknown procedure type -- APPLY" procedure)))) (define (list-of-arg-values exps env) (if (no-operands? exps) '() (cons (actual-value (first-operand exps) env) (list-of-arg-values (rest-operands exps) env)))) (define (list-of-delayed-args exps env) (if (no-operands? exps) '() (cons (delay-it (first-operand exps) env) (list-of-delayed-args (rest-operands exps) env)))) (define (eval-if exp env) (if (true? (actual-value (if-predicate exp) env)) (eval (if-consequent exp) env) (eval (if-alternative exp) env))) (define input-prompt ";;; L-Eval input:") (define output-prompt ";;; L-Eval value:") (define (driver-loop) (prompt-for-input input-prompt) (let ((input (read))) (let ((output (actual-value input the-global-environment))) (announce-output output-prompt) (user-print output))) (driver-loop)) (define (delay-it exp env) (list 'thunk exp env)) (define (thunk? obj) (tagged-list? obj 'thunk)) (define (thunk-exp thunk) (cadr thunk)) (define (thunk-env thunk) (caddr thunk)) (define (evaluated-thunk? obj) (tagged-list? obj 'evaluated-thunk)) (define (thunk-value evaluated-thunk) (cadr evaluated-thunk)) (define (force-it obj) (cond ((thunk? obj) (let ((result (actual-value (thunk-exp obj) (thunk-env obj)))) (set-car! obj 'evaluated-thunk) (set-car! (cdr obj) result) ; replace `exp' with its value (set-cdr! (cdr obj) '()) ; forget unneeded `env' result)) ((evaluated-thunk? obj) (thunk-value obj)) (else obj)))
Exercise 4.27
Exercise:
Suppose we type in the following definitions to the lazy evaluator:
(define count 0) (define (id x) (set! count (+ count 1)) x)Give the missing values in the following sequence of interactions, and explain your answers.(5)
(define w (id (id 10))) ;;; L-Eval input: count ;;; L-Eval value: <RESPONSE> ;;; L-Eval input: w ;;; L-Eval value: <RESPONSE> ;;; L-Eval input: count ;;; L-Eval value: <RESPONSE>
Answer:
The first <RESPONSE> is 1 (I thought it was 0…). When we evaluate
(define w (id (id 10)))
, among other things, we apply eval
to the
``definition-value'', that is, to (id (id 10))
.
That's the application of a compound procedure (of the outer
id
). Regardless of lazy evaluation, that means we are going to
evaluate the expressions of the body of id
(in an environment in
which the parameters are set to delayed values).
This means, first, that the set!
in the body is evaluated, so
count
won't be 1 anymore. And, second, that the value of the final
expression — that value w
is set to — will be a thunk.
The second <RESPONSE> is 10: we are trying to print a thunk, so the thunk is forced (remember the modification we made to the driver loop).
The third <RESPONSE> is 2. To print the value of w
we nee to perform
the application of the inner id
(which entails increasing count
for the second time).
Exercise 4.28
Exercise:
Eval
usesactual-value
rather thaneval
to evaluate the operator before passing it toapply
, in order to force the value of the operator. Give an example that demonstrates the need for this forcing.
Answer:
Example: higher order functions. E.g., map
.
;; Unmodified evaluator (using `actual-value'): ;;; L-Eval input: (define (add1 x) (+ x 1)) ;;; L-Eval value: ok ;;; L-Eval input: (define (map proc items) (if (null? items) '() (cons (proc (car items)) (map proc (cdr items))))) ;;; L-Eval value: ok ;;; L-Eval input: (map add1 '(1 2 3)) ;;; L-Eval value: (2 3 4)
;; Using `eval' instead of `actual-value': ;;; L-Eval input: (define (add1 x) (+ x 1)) ;;; L-Eval value: ok ;;; L-Eval input: (add1 5) ;;; L-Eval value: 6 ;;; L-Eval input: (define (map proc items) (if (null? items) '() (cons (proc (car items)) (map proc (cdr items))))) ;;; L-Eval value: ok ;;; L-Eval input: (map add1 '(1 2 3)) . . Unknown procedure type -- APPLY (thunk add1 #0=(((map add1 try false true car cdr cons null? = + - * /) (procedure (proc items) ((if (null? items) '() (cons (proc (car items)) (map proc (cdr items))))) #0#) (procedure (x) ((+ x 1)) #0#) (procedure (a b) ((if (= a 0) 1 b)) #0#) #...
Exercise 4.29
Exercise:
Exhibit a program that you would expect to run much more slowly without memoization than with memoization. Also, consider the following interaction, where the
id
procedure is defined as in *Note 4.27 andcount
starts at 0:(define (square x) (* x x)) ;;; L-Eval input: (square (id 10)) ;;; L-Eval value: <RESPONSE> ;;; L-Eval input: count ;;; L-Eval value: <RESPONSE>Give the responses both when the evaluator memoizes and when it does not.
Answer:
The argument of (square (id 10))
is delayed (turned into a thunk),
In the body of square
, though, we have the application of a
primitive procedure ((* thunk thunk)
) so the thunks must be
forced. Given that the two thunks are the same thunk, if we memoize,
then the final <RESPONSE> is 1, otherwise is 2. The first <RESPONSE>
is 100, regardless of whether we memoize or not.
Clearly memoization will entail an improvement in performance in those cases in which we need the value for the same thunk many times and where forcing the thunk requires non-trivial computation.
The procedures fact
and fib
are two example where a difference can
be seen.
(eval '(define (fact n) (if (= n 1) 1 (* n (fact (- n 1))))) the-global-environment) (define (fib n) (cond ((= n 0) 0) ((= n 1) 1) (else (+ (fib (- n 1)) (fib (- n 2))))))
Let's test fact
:
(define start (runtime)) (eval '(define (fact n) (if (= n 1) 1 (* n (fact (- n 1))))) the-global-environment) (eval '(fact 140) the-global-environment) (display (- (runtime) start))
With memoization:
;; ok ;; 13462012475717524605876073858941615558355851148193967190051391468057460367090535696797920946629681836680869097041958983702264048370902871114013579941370766400374327741701139895604871545254810788060989321379840000000000000000000000000000000000 ;; 1556
Without memoization:
;; ok ;; 13462012475717524605876073858941615558355851148193967190051391468057460367090535696797920946629681836680869097041958983702264048370902871114013579941370766400374327741701139895604871545254810788060989321379840000000000000000000000000000000000 ;; 53581
When calling (fact <EXP>)
, <EXP>
is turned into a thunk. So the
body of fact
will be evaluated in an environment where n
(the
parameter name to which <EXP>
corresponds to) is bound to that
thunk. Given the body of fact
we need the value of n
twice (when
n
is not 1). Getting the value of n
requires looking up the value
of the variable in the environment. If we memoize, then we need to do
that once (the second time we already have the value in the thunk we
has become an evaluated-thunk
). If we don't memoize, then we need to
do it twice. Mutatis mutandis for fib
.
Exercise 4.30
Exercise:
Cy D. Fect, a reformed C programmer, is worried that some side effects may never take place, because the lazy evaluator doesn't force the expressions in a sequence. Since the value of an expression in a sequence other than the last one is not used (the expression is there only for its effect, such as assigning to a variable or printing), there can be no subsequent use of this value (e.g., as an argument to a primitive procedure) that will cause it to be forced. Cy thus thinks that when evaluating sequences, we must force all expressions in the sequence except the final one. He proposes to modify
eval-sequence
from section 4-1-1 to useactual-value
rather thaneval
:(define (eval-sequence exps env) (cond ((last-exp? exps) (eval (first-exp exps) env)) (else (actual-value (first-exp exps) env) (eval-sequence (rest-exps exps) env))))a. Ben Bitdiddle thinks Cy is wrong. He shows Cy the
for-each
procedure described in Exercise 2-23, which gives an important example of a sequence with side effects:(define (for-each proc items) (if (null? items) 'done (begin (proc (car items)) (for-each proc (cdr items)))))He claims that the evaluator in the text (with the original
eval-sequence
) handles this correctly:;;; L-Eval input: (for-each (lambda (x) (newline) (display x)) (list 57 321 88)) 57 321 88 ;;; L-Eval value: doneExplain why Ben is right about the behavior of
for-each
.b. Cy agrees that Ben is right about the
for-each
example, but says that that's not the kind of program he was thinking about when he proposed his change toeval-sequence
. He defines the following two procedures in the lazy evaluator:(define (p1 x) (set! x (cons x '(2))) x) (define (p2 x) (define (p e) e x) (p (set! x (cons x '(2)))))What are the values of
(p1 1)
and(p2 1)
with the originaleval-sequence
? What would the values be with Cy's proposed change toeval-sequence
?c. Cy also points out that changing
eval-sequence
as he proposes does not affect the behavior of the example in part a. Explain why this is true.d. How do you think sequences ought to be treated in the lazy evaluator? Do you like Cy's approach, the approach in the text, or some other approach?
Answer:
a.
There is nothing to force in the example. For the expressions in the sequences are not thunks.
When we evaluate something like
(begin (display "foo") ...)
we are going to, among other things, apply eval
to (display
"foo")
. And that is enough to carry out the complete application of
display
to "foo"
.
b.
When using the original eval-sequence
, the value of (p1 1)
is (1
2)
: the evaluation of p1
's definition creates a procedure object
whose environment is the global environment. The application of p1
to 1
, first, creates a new environment by extending the global
environment with a frame which binds x
to a thunk whose expression
is 1 and whose environment is the global evironment; and, second,
evaluates the body of p1
within that environment. This latter
evaluation entails the application of eval
to (set! x (cons x
'(2)))
and x
. The evaluation of (set! x (cons x '(2)))
entails
the forcing of x
, because primitive procedures must receive values
they can understand — not thunks. So the value of x
is set to (1
2)
.
When using Cy's eval-sequence
, the value of (p1 1)
should be (1
2)
as well.
When using the original eval-sequence
, the value of (p2 1)
is a
thunk. The evaluation of p2
's definition creates a procedure object
whose environment is the global environment. The application of p2
to 1
, first, creates a new environment by extending the global
environment with a frame which binds x
to a thunk whose expression
is 1 and whose environment is the global evironment; and, second,
evaluates the body of of p2
within that environment. This latter
evaluation entails the evaluation of the definition of p
and the
application of p
to (set! x (cons x '(2)))
. The evaluation of the
definition of p
creates a procedures object whose environment is the
environment p2
points to. The application of p
to (set! x (cons x
'(2)))
, first, creates a new environment by extending the environment
p
points to with a frame which binds e
to a thunk whose expression
is (set! x (cons x '(2)))
and whose environemnt is the environment
p
points to. This latter evaluation entails the evaluation of e
and x
(in the environemnt p
points to). e
evaluates to the thunk
described above and nothing is done with it. x
evaluates to the
other thunk described above and it is returned (it is the value of the
p
's application). So the final value is that thunk.
When using Cy's eval-sequence
, the value of (p2 1)
should be (1
2)
— that's because we are not applying eval
to e
; we are
instead applying actual-value
to it.
c.
The difference between the old eval-sequence
and the new one with
respect to the example in a. is that, when using the old procedure, we
apply eval
to (proc (car items))
, whereas, when using the new one,
we apply actual-value~
to it.
When we apply eval
to (proc (car items))
, items
have already
been forced, because null?
has been applied to them. proc
is
forced, because it is the operator of an application. So the value of
the application of eval
is the expression's actual value.
When we apply actual-value
to (proc (car items))
, there is an
extra step: we passed the evaluated value to force-it
. But since the
value is not a thunk, nothing changes.
d.
Cy's seems a better approach, since i) it ensures that side effects take place in both a-type cases and b-type cases, and ii) the difference with the other approach is minimal when it comes to a-type cases.
Exercise 4.31
Exercise:
The approach taken in this section is somewhat unpleasant, because it makes an incompatible change to Scheme. It might be nicer to implement lazy evaluation as an upward-compatible extension, that is, so that ordinary Scheme programs will work as before. We can do this by extending the syntax of procedure declarations to let the user control whether or not arguments are to be delayed. While we're at it, we may as well also give the user the choice between delaying with and without memoization. For example, the definition
(define (f a (b lazy) c (d lazy-memo)) ...)would define
f
to be a procedure of four arguments, where the first and third arguments are evaluated when the procedure is called, the second argument is delayed, and the fourth argument is both delayed and memoized. Thus, ordinary procedure definitions will produce the same behavior as ordinary Scheme, while adding the `lazy-memo' declaration to each parameter of every compound procedure will produce the behavior of the lazy evaluator defined in this section. Design and implement the changes required to produce such an extension to Scheme. You will have to implement new syntax procedures to handle the new syntax for `define'. You must also arrange for `eval' or `apply' to determine when arguments are to be delayed, and to force or delay arguments accordingly, and you must arrange for forcing to memoize or not, as appropriate.
Answer:
- Changes:
apply-evaluator
, instead of usinglist-of-delayed-args
, useslist-of-args
which can decide whether to delay an argument or not, since it receives that information which, thanks to the new syntax, is possibly included in the parameters (I'm packing that information into the arguments thatlist-of-args
receives).- When we delay an argument, we create a thunk which now contains
information about whether the forcing should use memoization or
not. More concretely, the second element of a thunk is now either
the symbol
memo
or the symbolno-memo
.
Here are new methods and modified methods:
(define (to-delay? arg-with-info) (cond ((not (pair? arg-with-info)) (error "arg-with-info is expected to be a pair")) (else (or (eq? (car arg-with-info) 'lazy) (eq? (car arg-with-info) 'lazy-memo))))) (define (arg arg-with-info) (cdr arg-with-info)) ;; apply either `actual-value` or `delay-it` depending on the symbol ;; attached (define (list-of-args exps env) (if (no-operands? exps) '() (cons (actual-value-or-delay (first-operand exps) env) (list-of-args (rest-operands exps) env)))) (define (actual-value-or-delay operand-with-info env) (if (to-delay? operand-with-info) (delay-it operand-with-info env) (actual-value (arg operand-with-info) env))) (define (make-args-with-info args params-with-info) (if (null? args) '() (if (pair? (car params-with-info)) (cons (cons (memo-info-symbol (car params-with-info)) (car args)) (make-args-with-info (cdr args) (cdr params-with-info))) (cons (cons 'non-lazy (car args)) (make-args-with-info (cdr args) (cdr params-with-info)))))) (define (memo-info-symbol param-with-info) (cadr param-with-info)) (define (force-it obj) (cond ((thunk? obj) (if (eq? (cadr obj) 'lazy) ;; no memo (actual-value (thunk-exp obj) (thunk-env obj)) (let ((result (actual-value ;; memo (thunk-exp obj) (thunk-env obj)))) (set-car! obj 'evaluated-thunk) (set-car! (cdr obj) result) (set-cdr! (cdr obj) '()) result))) ((evaluated-thunk? obj) (thunk-value obj)) (else obj))) ;; it expects the exp to be tagged with either 'lazy or 'lazy-memo (define (delay-it exp env) (if (to-memo? exp) (list 'thunk 'memo (cdr exp) env) (list 'thunk 'no-memo (cdr exp) env))) (define (to-memo? exp) (eq? (car exp) 'lazy-memo)) (define (thunk-exp thunk) (caddr thunk)) (define (thunk-env thunk) (cadddr thunk)) (define (apply-evaluator procedure arguments env) (cond ((primitive-procedure? procedure) (apply-primitive-procedure procedure (list-of-arg-values arguments env))) ((compound-procedure? procedure) (eval-sequence (procedure-body procedure) (extend-environment (procedure-parameters-names procedure) (list-of-args (make-args-with-info arguments (procedure-parameters procedure)) env) (procedure-environment procedure)))) (else (error "Unknown procedure type -- APPLY" procedure)))) (define (procedure-parameters-names proc) (param-names (cadr proc))) (define (param-names params-with-info) (cond ((null? params-with-info) '()) ((pair? (car params-with-info)) (cons (car (car params-with-info)) (param-names (cdr params-with-info)))) (else (cons (car params-with-info) (param-names (cdr params-with-info))))))
Now, for example, we could define try
(See 4.2.1) like this:
(define (try a (b lazy-memo)) (if (= a 0) 1 b))
and evaluating (try 0 (/ 1 0))
should return 1 instead of yielding
an error.