In this section we will implement a normal-order language that is the same as JavaScript except that compound functions are non-strict in each argument. Primitive functions will still be strict. It is not difficult to modify the evaluator of section 4.1.1 so that the language it interprets behaves this way. Almost all the required changes center around function application.
The basic idea is that, when applying a function, the interpreter must determine which arguments are to be evaluated and which are to be delayed. The delayed arguments are not evaluated; instead, they are transformed into objects called thunks.[1] The thunk must contain the information required to produce the value of the argument when it is needed, as if it had been evaluated at the time of the application. Thus, the thunk must contain the argument expression and the environment in which the function application is being evaluated.
The process of evaluating the expression in a thunk is called forcing.[2] In general, a thunk will be forced only when its value is needed: when it is passed to a primitive function that will use the value of the thunk; when it is the value of a predicate of a conditional; and when it is the value of a function expression that is about to be applied as a function. One design choice we have available is whether or not to memoize thunks, similar to the optimization for streams in section 3.5.1. With memoization, the first time a thunk is forced, it stores the value that is computed. Subsequent forcings simply return the stored value without repeating the computation. We'll make our interpreter memoize, because this is more efficient for many applications. There are tricky considerations here, however.[3]
The main difference between the lazy evaluator and the one in section 4.1 is in the handling of function applications in evaluate and apply.
The is_application clause of evaluate becomes
: is_application(component)
? apply(actual_value(function_expression(component), env),
arg_expressions(component), env)
Whenever we need the actual value of an expression, we use
function actual_value(exp, env) {
return force_it(evaluate(exp, env));
}
Our new version of apply is also almost the same as the version in section 4.1.1. The difference is that evaluate has passed in unevaluated argument expressions: For primitive functions (which are strict), we evaluate all the arguments before applying the primitive; for compound functions (which are non-strict) we delay all the arguments before applying the function.
function apply(fun, args, env) {
if (is_primitive_function(fun)) {
return apply_primitive_function(
fun,
list_of_arg_values(args, env)); // changed
} else if (is_compound_function(fun)) {
const result = evaluate(
function_body(fun),
extend_environment(
function_parameters(fun),
list_of_delayed_args(args, env), // changed
function_environment(fun)));
return is_return_value(result)
? return_value_content(result)
: undefined;
} else {
error(fun, "unknown function type -- apply");
}
}
function list_of_arg_values(exps, env) {
return map(exp => actual_value(exp, env), exps);
}
function list_of_delayed_args(exps, env) {
return map(exp => delay_it(exp, env), exps);
}The other place we must change the evaluator is in the handling of conditionals, where we must use actual_value instead of evaluate to get the value of the predicate expression before testing whether it is true or false:
function eval_conditional(component, env) {
return is_truthy(actual_value(conditional_predicate(component), env))
? evaluate(conditional_consequent(component), env)
: evaluate(conditional_alternative(component), env);
}
Finally, we must change the driver_loop function (from section 4.1.4) to use actual_value instead of evaluate, so that if a delayed value is propagated back to the read-evaluate-print loop, it will be forced before being printed. We also change the prompts to indicate that this is the lazy evaluator:
const input_prompt = "L-evaluate input: ";
const output_prompt = "L-evaluate value: ";
function driver_loop(env) {
const input = user_read(input_prompt);
if (is_null(input)) {
display("evaluator terminated");
} else {
const program = parse(input);
const locals = scan_out_declarations(program);
const unassigneds = list_of_unassigned(locals);
const program_env = extend_environment(locals, unassigneds, env);
const output = actual_value(program, program_env);
user_print(output_prompt, output);
return driver_loop(program_env);
}
}
With these changes made, we can start the evaluator and test it. The successful evaluation of the try_me expression discussed in section 4.2.1 indicates that the interpreter is performing lazy evaluation:
const the_global_environment = setup_environment(); driver_loop(the_global_environment);
L-evaluate input:
function try_me(a, b) {
return a === 0 ? 1 : b;
}L-evaluate value: undefined
L-evaluate input:
try_me(0, head(null));
L-evaluate value: 1
Our evaluator must arrange to create thunks when functions are applied to arguments and to force these thunks later. A thunk must package an expression together with the environment, so that the argument can be produced later. To force the thunk, we simply extract the expression and environment from the thunk and evaluate the expression in the environment. We use actual_value rather than evaluate so that in case the value of the expression is itself a thunk, we will force that, and so on, until we reach something that is not a thunk:
function force_it(obj) {
return is_thunk(obj)
? actual_value(thunk_exp(obj), thunk_env(obj))
: obj;
}One easy way to package an expression with an environment is to make a list containing the expression and the environment. Thus, we create a thunk as follows:
function delay_it(exp, env) {
return list("thunk", exp, env);
}
function is_thunk(obj) {
return is_tagged_list(obj, "thunk");
}
function thunk_exp(thunk) { return head(tail(thunk)); }
function thunk_env(thunk) { return head(tail(tail(thunk))); }
Actually, what we want for our interpreter is not quite this, but rather thunks that have been memoized. When a thunk is forced, we will turn it into an evaluated thunk by replacing the stored expression with its value and changing the thunk tag so that it can be recognized as already evaluated.[4]
function is_evaluated_thunk(obj) {
return is_tagged_list(obj, "evaluated_thunk");
}
function thunk_value(evaluated_thunk) {
return head(tail(evaluated_thunk));
}
function force_it(obj) {
if (is_thunk(obj)) {
const result = actual_value(thunk_exp(obj), thunk_env(obj));
set_head(obj, "evaluated_thunk");
set_head(tail(obj), result); // replace exp with its value
set_tail(tail(obj), null); // forget unneeded env
return result;
} else if (is_evaluated_thunk(obj)) {
return thunk_value(obj);
} else {
return obj;
}
}
let count = 0;
function id(x) {
count = count + 1;
return x;
}
const w = id(id(10));
function square(x) {
return x * x;
}
function eval_sequence(stmts, env) {
if (is_empty_sequence(stmts)) {
return undefined;
} else if (is_last_statement(stmts)) {
return actual_value(first_statement(stmts), env);
} else {
const first_stmt_value =
actual_value(first_statement(stmts), env);
if (is_return_value(first_stmt_value)) {
return first_stmt_value;
} else {
return eval_sequence(rest_statements(stmts), env);
}
}
}function for_each(fun, items) {
if (is_null(items)){
return "done";
} else {
fun(head(items));
for_each(fun, tail(items));
}
}
L-evaluate input:
for_each(display, list(57, 321, 88));
57 321 88 L-evaluate value: "done"
function f1(x) {
x = pair(x, list(2));
return x;
}
function f2(x) {
function f(e) {
e;
return x;
}
return f(x = pair(x, list(2)));
}
thinking about) the expression could be done at compile time; thus, at run time, the expression would already have been
thunkabout (
Similarly, we could have allowed unneeded environments in the memoized delayed objects of section 3.5.1 to be garbage-collected, by having memo do something like fun = null; to discard the function fun (which includes the environment in which the lambda expression that makes up the tail of the stream was evaluated) after storing its value.