We have not yet explained how to load compiled code into the evaluator machine or how to run it. We will assume that the explicit-control-evaluator machine has been defined as in section 5.4.4, with the additional operations specified in footnote 5 (section 5.5.2). We will implement a function compile_and_go that compiles a JavaScript program, loads the resulting object code into the evaluator machine, and causes the machine to run the code in the evaluator global environment, print the result, and enter the evaluator's driver loop. We will also modify the evaluator so that interpreted components can call compiled functions as well as interpreted ones. We can then put a compiled function into the machine and use the evaluator to call it:
compile_and_go(parse(`
function factorial(n) {
return n === 1
? 1
: factorial(n - 1) * n;
}
`));
EC-evaluate value: undefined
EC-evaluate input:
factorial(5);
EC-evaluate value: 120
To allow the evaluator to handle compiled functions (for example, to evaluate the call to factorial above), we need to change the code at apply_dispatch (section 5.4.1) so that it recognizes compiled functions (as distinct from compound or primitive functions) and transfers control directly to the entry point of the compiled code:[1]
"apply_dispatch",
test(list(op("is_primitive_function"), reg("fun"))),
branch(label("primitive_apply")),
test(list(op("is_compound_function"), reg("fun"))),
branch(label("compound_apply")),
test(list(op("is_compiled_function"), reg("fun"))),
branch(label("compiled_apply")),
go_to(label("unknown_function_type")),
"compiled_apply",
push_marker_to_stack(),
assign("val", list(op("compiled_function_entry"), reg("fun"))),
go_to(reg("val")),To enable us to run some compiled code when we start the evaluator machine, we add a branch instruction at the beginning of the evaluator machine, which causes the machine to go to a new entry point if the flag register is set.[2] $\texttt{ }\texttt{ }$branch(label("external_entry")), // branches if flag is set "read_evaluate_print_loop", perform(list(op("initialize_stack"))), $\ldots$ The code at external_entry assumes that the machine is started with val containing the location of an instruction sequence that puts a result into val and ends with go_to(reg("continue")). Starting at this entry point jumps to the location designated by val, but first assigns continue so that execution will return to print_result, which prints the value in val and then goes to the beginning of the evaluator's read-evaluate-print loop.[3]
"external_entry",
perform(list(op("initialize_stack"))),
assign("env", list(op("get_current_environment"))),
assign("continue", label("print_result")),
go_to(reg("val")),Now we can use the following function to compile a function declaration, execute the compiled code, and run the read-evaluate-print loop so we can try the function. Because we want the compiled code to proceed to the location in continue with its result in val, we compile the program with a target of val and a linkage of "return". In order to transform the object code produced by the compiler into executable instructions for the evaluator register machine, we use the function assemble from the register-machine simulator (section 5.2.2). For the interpreted program to refer to the names that are declared at top level in the compiled program, we scan out the top-level names and extend the global environment by binding these names to "*unassigned*", knowing that the compiled code will assign them the correct values. We then initialize the val register to point to the list of instructions, set the flag so that the evaluator will go to external_entry, and start the evaluator.
function compile_and_go(program) {
const instrs = assemble(instructions(compile(program,
"val", "return")),
eceval);
const toplevel_names = scan_out_declarations(program);
const unassigneds = list_of_unassigned(toplevel_names);
set_current_environment(extend_environment(
toplevel_names,
unassigneds,
the_global_environment));
set_register_contents(eceval, "val", instrs);
set_register_contents(eceval, "flag", true);
return start(eceval);
}
If we have set up stack monitoring, as at the end of section 5.4.4, we can examine the stack usage of compiled code:
compile_and_go(parse(`
function factorial(n) {
return n === 1
? 1
: factorial(n - 1) * n;
}
`));total pushes = 0 maximum depth = 0 EC-evaluate value: undefined
EC-evaluate input:
factorial(5);
total pushes = 36 maximum depth = 14 EC-evaluate value: 120
With the programs in this section, we can now experiment with the alternative execution strategies of interpretation and compilation.[4] An interpreter raises the machine to the level of the user program; a compiler lowers the user program to the level of the machine language. We can regard the JavaScript language (or any programming language) as a coherent family of abstractions erected on the machine language. Interpreters are good for interactive program development and debugging because the steps of program execution are organized in terms of these abstractions, and are therefore more intelligible to the programmer. Compiled code can execute faster, because the steps of program execution are organized in terms of the machine language, and the compiler is free to make optimizations that cut across the higher-level abstractions.[5]
The alternatives of interpretation and compilation also lead to different strategies for porting languages to new computers. Suppose that we wish to implement JavaScript for a new machine. One strategy is to begin with the explicit-control evaluator of section 5.4 and translate its instructions to instructions for the new machine. A different strategy is to begin with the compiler and change the code generators so that they generate code for the new machine. The second strategy allows us to run any JavaScript program on the new machine by first compiling it with the compiler running on our original JavaScript system, and linking it with a compiled version of the runtime library.[6] Better yet, we can compile the compiler itself, and run this on the new machine to compile other JavaScript programs.[7] Or we can compile one of the interpreters of section 4.1 to produce an interpreter that runs on the new machine.
Take the ratio of the number of pushes in the compiled version to the number of pushes in the interpreted version, and do the same for the maximum stack depth. Since the number of operations and the stack depth used to compute $n!$ are linear in $n$, these ratios should approach constants as $n$ becomes large. What are these constants? Similarly, find the ratios of the stack usage in the special-purpose machine to the usage in the interpreted version.
Compare the ratios for special-purpose versus interpreted code to the ratios for compiled versus interpreted code. You should find that the special-purpose machine is much more efficient than the compiled code, since the hand-tailored controller code should be much better than what is produced by our rudimentary general-purpose compiler.
function fib(n) {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
EC-evaluate input:
compile_and_run(parse(`
function factorial(n) {
return n === 1
? 1
: factorial(n - 1) * n;
}
`));EC-evaluate value: undefined
EC-evaluate input:
factorial(5)
EC-Eval value: 120
register-machine operations.
compoundto mean interpreted (as opposed to compiled).
function start_eceval() {
set_register_contents(eceval, "flag", false);
return start(eceval);
}
function user_print(string, object) {
function prepare(object) {
return is_compound_function(object)
? "< compound function >"
: is_primitive_function(object)
? "< primitive function >"
: is_compiled_function(object)
? "< compiled function >"
: is_pair(object)
? pair(prepare(head(object)),
prepare(tail(object)))
: object;
}
display(string + " " + stringify(prepare(object)));
}Wormthat paralyzed the Internet in 1988 exploited the UNIX$^{\textrm{TM}}$ operating system's failure to check whether the input buffer has overflowed in the finger daemon. (See
primitivein our discussion of the evaluator and compiler. One strategy for minimizing work here is to write as many of these operations as possible in JavaScript and then compile them for the new machine. Ultimately, everything reduces to a small kernel (such as garbage collection and the mechanism for applying actual machine primitives) that is hand-coded for the new machine.