5.4.3 块、赋值和声明
块
块的主体是在当前环境的基础上进行求值的,该环境通过将所有本地名称绑定到值"unassigned"的帧进行扩展。我们暂时利用val寄存器来保存块中声明的所有变量的列表,该列表是通过第 4.1.1 节中的scan_out_declarations获得的。假定scan_out_declarations和list_of_unassigned函数作为机器操作可用。
"ev_block",
assign("comp", list(op("block_body"), reg("comp"))),
assign("val", list(op("scan_out_declarations"), reg("comp"))),
save("comp"), // so we can use it to temporarily hold unassigned values
assign("comp", list(op("list_of_unassigned"), reg("val"))),
assign("env", list(op("extend_environment"),
reg("val"), reg("comp"), reg("env"))),
restore("comp"), // the block body
go_to(label("eval_dispatch")),
赋值和声明
赋值由ev_assignment处理,通过eval_dispatch到达,其中包含在comp中的赋值表达式。ev_assignment中的代码首先求值表达式的值部分,然后将新值安装到环境中。假定assign_symbol_value函数作为机器操作可用。
"ev_assignment",
assign("unev", list(op("assignment_symbol"), reg("comp"))),
save("unev"), // save variable for later
assign("comp", list(op("assignment_value_expression"), reg("comp"))),
save("env"),
save("continue"),
assign("continue", label("ev_assignment_install")),
go_to(label("eval_dispatch")), // evaluate assignment value
"ev_assignment_install",
restore("continue"),
restore("env"),
restore("unev"),
perform(list(op("assign_symbol_value"),
reg("unev"), reg("val"), reg("env"))),
go_to(reg("continue")),
变量和常量的声明都是以类似的方式处理的。请注意,赋值的值是被赋的值,声明的值是undefined。这是通过在继续之前将val设置为undefined来处理的。与元循环求值器一样,我们将函数声明转换为一个值表达式为 lambda 表达式的常量声明。这发生在ev_function_declaration,它在comp中进行转换并且继续到ev_declaration。
"ev_function_declaration",
assign("comp",
list(op("function_decl_to_constant_decl"), reg("comp"))),
"ev_declaration",
assign("unev", list(op("declaration_symbol"), reg("comp"))),
save("unev"), // save declared name
assign("comp",
list(op("declaration_value_expression"), reg("comp"))),
save("env"),
save("continue"),
assign("continue", label("ev_declaration_assign")),
go_to(label("eval_dispatch")), // evaluate declaration value
"ev_declaration_assign",
restore("continue"),
restore("env"),
restore("unev"),
perform(list(op("assign_symbol_value"),
reg("unev"), reg("val"), reg("env"))),
assign("val", constant(undefined)),
go_to(reg("continue")),
练习 5.25
扩展求值器以处理while循环,将其转换为while_loop函数的应用,如练习 4.7 所示。您可以将while_loop函数的声明粘贴到用户程序的前面。您可以通过假设语法转换器while_to_application作为机器操作来“作弊”。参考练习 4.7,讨论如果允许在while循环中使用return、break和continue语句,这种方法是否有效。如果不行,您如何修改显式控制求值器以运行包含这些语句的while循环程序?
练习 5.26
修改求值器,使其使用基于第 4.2 节的惰性求值的正常顺序求值。
5.4.4 运行求值器
随着显式控制求值者的实施,我们完成了从第 1 章开始的一个发展,其中我们已经探索了逐渐更精确的求值过程模型。 我们从相对不正式的替换模型开始,然后在第 3 章将其扩展为环境模型,这使我们能够处理状态和变化。 在第 4 章的元循环求值者中,我们使用 JavaScript 本身作为一种语言,以使在求值组件期间构建的环境结构更加明确。 现在,通过寄存器机器,我们仔细研究了求值者的存储管理、参数传递和控制机制。 在每个新的描述级别上,我们都不得不提出问题并解决在以前不太精确的求值处理中不明显的模糊问题。 要理解显式控制求值者的行为,我们可以模拟它并监视其性能。
我们将在我们的求值机器中安装一个驱动循环。这起到了第 4.1.4 节中driver_loop函数的作用。 求值者将重复打印提示,读取程序,通过转到eval_dispatch来求值程序,并打印结果。 如果在提示处没有输入任何内容,我们将跳转到标签evaluator_done,这是控制器中的最后一个入口点。 以下说明构成了显式控制求值器的控制器序列的开头:³²
"read_evaluate_print_loop",
perform(list(op("initialize_stack"))),
assign("comp", list(op("user_read"),
constant("EC-evaluate input:"))),
assign("comp", list(op("parse"), reg("comp"))),
test(list(op("is_null"), reg("comp"))),
branch(label("evaluator_done")),
assign("env", list(op("get_current_environment"))),
assign("val", list(op("scan_out_declarations"), reg("comp"))),
save("comp"), // so we can use it to temporarily hold unassigned values
assign("comp", list(op("list_of_unassigned"), reg("val"))),
assign("env", list(op("extend_environment"),
reg("val"), reg("comp"), reg("env"))),
perform(list(op("set_current_environment"), reg("env"))),
restore("comp"), // the program
assign("continue", label("print_result")),
go_to(label("eval_dispatch")),
"print_result",
perform(list(op("user_print"),
constant("EC-evaluate value:"), reg("val"))),
go_to(label("read_evaluate_print_loop")),
我们将当前环境(最初是全局环境)存储在变量current_environment中,并在每次循环时更新它以记住过去的声明。 操作get_current_environment和set_current_ environment只是获取和设置这个变量。
let current_environment = the_global_environment;
function get_current_environment() {
return current_environment;
}
function set_current_environment(env) {
current_environment = env;
}
当我们在函数中遇到错误(例如在apply_dispatch处指示的“未知函数类型”错误)时,我们会打印错误消息并返回到驱动循环。³³
"unknown_component_type",
assign("val", constant("unknown syntax")),
go_to(label("signal_error")),
"unknown_function_type",
restore("continue"), // clean up stack (from apply_dispatch)
assign("val", constant("unknown function type")),
go_to(label("signal_error")),
"signal_error",
perform(list(op("user_print"),
constant("EC-evaluator error:"), reg("val"))),
go_to(label("read_evaluate_print_loop")),
为了模拟的目的,我们在每次通过驱动循环时初始化堆栈,因为在求值中断后(例如未声明的名称)可能不为空。³⁴
如果我们将在第 5.4.1–5.4.4 节中呈现的所有代码片段组合起来,我们可以创建一个求值者机器模型,可以使用第 5.2 节的寄存器机器模拟器运行。
const eceval = make_machine(list("comp", "env", "val", "fun",
"argl", "continue", "unev"),
eceval_operations,
list("read_evaluate_print_loop",
〈entire machine controller as given above〉
"evaluator_done"));
我们必须定义 JavaScript 函数来模拟求值者作为原语使用的操作。 这些是我们在第 4.1 节中用于元循环求值者的相同函数,以及在第 5.4 节中的脚注中定义的少数额外函数。
const eceval_operations = list(list("is_literal", is_literal),
〈complete list of operations for eceval machine〉);
最后,我们可以初始化全局环境并运行求值者:
const the_global_environment = setup_environment();
start(eceval);
EC-求值输入:
function append(x, y) {
return is_null(x)
? y
: pair(head(x), append(tail(x), y));
}
EC-求值值:
undefined
EC-求值输入:
append(list("a", "b", "c"), list("d", "e", "f"));
EC-求值值:
["a", ["b", ["c", ["d", ["e", ["f", null]]]]]]
当然,以这种方式求值程序将比直接在 JavaScript 中输入它们要花费更长的时间,因为涉及多个级别的模拟。 我们的程序由显式控制求值者机器求值,该机器由 JavaScript 程序模拟,JavaScript 解释器本身也在求值。
监控求值者的性能
模拟可以是指导求值者实施的强大工具。 模拟不仅使探索寄存器机器设计的变化变得容易,还可以监视模拟求值者的性能。 例如,性能中的一个重要因素是求值者如何有效地使用堆栈。 我们可以通过使用收集堆栈使用统计信息的模拟器版本(第 5.2.4 节)定义求值者寄存器机器,并在求值者的print_result入口点添加指令来打印统计信息,观察求值各种程序所需的堆栈操作数量:
"print_result",
perform(list(op("print_stack_statistics"))), // added instruction
// rest is same as before
perform(list(op("user_print"),
constant("EC-evaluate value:"), reg("val"))),
go_to(label("read_evaluate_print_loop")),
现在与求值者的交互看起来像这样:
EC-求值输入:
function factorial (n) {
return n === 1
? 1
: factorial(n - 1) * n;
}
总推送= 4
最大深度= 3
EC-求值值:
undefined
EC-求值输入:
factorial(5);
总推送次数 = 151
最大深度 = 28
EC-求值值:
120
请注意,求值器的驱动循环在每次交互开始时重新初始化堆栈,因此打印的统计数据将仅涉及用于求值上一个程序的堆栈操作。
练习 5.27
使用监控堆栈来探索求值器的尾递归属性(第 5.4.2 节)。启动求值器并定义迭代factorial函数,该函数来自第 1.2.1 节:
function factorial(n) {
function iter(product, counter) {
return counter > n
? product
: iter(counter * product,
counter + 1);
}
return iter(1, 1);
}
运行该函数,使用一些小的n值。记录计算n!所需的最大堆栈深度和推送次数。
-
a. 您会发现求值
n!所需的最大深度与n无关。那个深度是多少? -
b. 根据您的数据,确定用于求值
n!的总推送操作次数的n的公式,其中n >= 1。请注意,使用的操作次数是n的线性函数,因此由两个常数确定。
练习 5.28
与练习 5.27 进行比较,探索以下函数的行为,用于递归计算阶乘:
function factorial(n) {
return n === 1
? 1
: factorial(n - 1) * n;
}
通过使用监控堆栈运行此函数,确定作为n的函数的堆栈的最大深度和用于求值n!的总推送次数。(同样,这些函数将是线性的。)通过使用适当的n表达式填写以下表格来总结您的实验:
| 最大深度 | 推送次数 | |
|---|---|---|
| 递归阶乘 | ||
| 迭代阶乘 |
最大深度是求值器在执行计算时使用的空间量的度量,推送次数与所需时间相关。
练习 5.29
修改求值器的定义,通过更改ev_return,如第 5.4.2 节所述,使求值器不再是尾递归。重新运行练习 5.27 和 5.28 中的实验,以证明factorial函数的两个版本现在都需要随着输入增长而线性增长的空间。
练习 5.30
监视树递归 Fibonacci 计算中的堆栈操作:
function fib(n) {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
-
a. 给出一个关于计算
Fib(n)所需的堆栈的最大深度的n的公式,其中n ≥ 2。提示:在第 1.2.2 节中,我们认为该过程使用的空间随n呈线性增长。 -
b. 给出一个公式,用于计算
Fib(n)的总推送次数,其中n ≥ 2。您应该发现推送次数(与使用的时间相关)随着n呈指数增长。提示:让S(n)表示计算Fib(n)时使用的推送次数。您应该能够证明存在一个公式,用S(n – 1)、S(n – 2)和一些固定的“开销”常数k来表示S(n)。给出公式,并说明k是多少。然后证明S(n)可以表示为aFib(n + 1) + b,并给出a和b的值。
练习 5.31
我们的求值器目前只捕获并信号两种错误——未知组件类型和未知函数类型。其他错误将使我们退出求值器读取-求值-打印循环。当我们使用寄存器机模拟器运行求值器时,这些错误会被底层 JavaScript 系统捕获。这类似于当用户程序出错时计算机崩溃。³⁵制作一个真正的错误系统是一个大项目,但值得努力去理解这里涉及的内容。
-
a. 在求值过程中发生的错误,例如尝试访问未绑定的名称,可以通过更改查找操作来捕获,使其返回一个不同的条件代码,这个代码不可能是任何用户名称的可能值。求值器可以测试这个条件代码,然后执行必要的操作转到
signal_error。找到求值器中需要进行这种更改的所有地方并修复它们。这是很多工作。 -
b. 处理错误的问题更糟糕,这些错误是通过应用原始函数来发出的,比如尝试除以零或尝试提取字符串的
head。在专业编写的高质量系统中,每个原始应用都会作为原始的一部分进行安全检查。例如,对head的每次调用都可以首先检查参数是否是一对。如果参数不是一对,应用将返回一个特殊的条件代码给求值器,然后报告失败。我们可以通过使每个原始函数检查适用性并在失败时返回适当的特殊条件代码来安排在我们的寄存器机器模拟器中。然后,求值器中的primitive_apply代码可以检查条件代码,并在必要时转到signal_error。构建这个结构并使其工作。这是一个重大项目。
5.5 编译
第 5.4 节的显式控制求值器是一个寄存器机器,其控制器解释 JavaScript 程序。在本节中,我们将看到如何在控制器不是 JavaScript 解释器的寄存器机器上运行 JavaScript 程序。
显式控制求值器机器是通用的——它可以执行任何可以用 JavaScript 描述的计算过程。求值器的控制器编排其数据路径的使用,以执行所需的计算。因此,求值器的数据路径是通用的:它们足以执行我们想要的任何计算,只要有适当的控制器。³⁶
商用通用计算机是围绕一组寄存器和操作组织的寄存器机器,构成了一组高效和方便的通用数据路径。通用机器的控制器是一个解释器,用于解释我们一直在使用的寄存器机器语言。这种语言被称为机器的本地语言,或者简称为机器语言。用机器语言编写的程序是使用机器的数据路径的指令序列。例如,显式控制求值器的指令序列可以被视为通用计算机的机器语言程序,而不是专用解释器机器的控制器。
在高级语言和寄存器机器语言之间弥合差距的常见策略有两种。显式控制求值器说明了解释的策略。用机器的本地语言编写的解释器配置机器以执行用于求值的语言(称为源语言)编写的程序。源语言的原始函数被实现为用给定机器的本地语言编写的子例程库。要解释的程序(称为源程序)表示为数据结构。解释器遍历这个数据结构,分析源程序。在这样做的同时,它通过从库中调用适当的原始子例程来模拟源程序的预期行为。
在这一部分,我们探讨了编译的替代策略。给定源语言和机器的编译器将源程序翻译成等效的程序(称为目标程序),用机器的本地语言编写。我们在这一部分实现的编译器将 JavaScript 编写的程序翻译成要使用显式控制求值器机器数据路径执行的指令序列。³⁷
与解释相比,编译可以大大提高程序执行的效率,我们将在编译器概述中解释。另一方面,解释器提供了一个更强大的交互式程序开发和调试环境,因为正在执行的源程序可以在运行时进行检查和修改。此外,由于整个原语库都存在,因此在调试期间可以构建新程序并将其添加到系统中。
考虑到编译和解释的互补优势,现代程序开发环境采用混合策略。这些系统通常组织得很好,以便解释函数和编译函数可以相互调用。这使程序员可以编译那些假定已经调试的程序部分,从而获得编译的效率优势,同时保留解释执行模式,用于程序中处于交互式开发和调试状态的部分。
编译器概述
我们的编译器在结构和功能上都与我们的解释器非常相似。因此,编译器用于分析组件的机制将类似于解释器使用的机制。此外,为了方便地接口编译和解释的代码,我们将设计编译器生成遵循与解释器相同的寄存器使用约定的代码:环境将保留在env寄存器中,参数列表将累积在argl中,要应用的函数将在fun中,函数将在val中返回它们的答案,并且函数应返回的位置将保留在continue中。通常,编译器将源程序转换为执行与解释器在求值相同源程序时执行的基本相同的寄存器操作的目标程序。
这种描述提出了实现一个基本编译器的策略:我们以与解释器相同的方式遍历组件。当我们遇到解释器在求值组件时执行的寄存器指令时,我们不执行该指令,而是将其累积到一个序列中。生成的指令序列将成为目标代码。观察编译相对于解释的效率优势。每次解释器求值一个组件时,例如f(96, 22),它都会执行对组件进行分类的工作(发现这是一个函数应用),并测试参数表达式列表的结束(发现有两个参数表达式)。使用编译器,组件只在编译时分析一次。编译器生成的目标代码仅包含求值函数表达式和两个参数表达式、组装参数列表以及将函数(在fun中)应用于参数(在argl中)的指令。
这与我们在第 4.1.7 节的分析求值器中实现的优化相同。但是在编译代码中还有进一步提高效率的机会。解释器运行时,遵循一个必须适用于语言中的任何组件的过程。相比之下,编译代码的一个特定段意味着执行某个特定的组件。这可能会有很大的不同,例如在使用堆栈保存寄存器时。当解释器求值一个组件时,必须为任何可能发生的情况做好准备。在求值子组件之前,解释器保存所有以后可能需要的寄存器,因为子组件可能需要任意求值。另一方面,编译器可以利用它正在处理的特定组件的结构来生成避免不必要的堆栈操作的代码。
以应用程序f(96, 22)为例。在解释器求值应用程序的函数表达式之前,它通过保存包含参数表达式和环境的寄存器来为此求值做好准备,这些值稍后将被需要。然后解释器求值函数表达式以获取val中的结果,恢复保存的寄存器,最后将结果从val移动到fun。然而,在我们处理的特定表达式中,函数表达式是名称f,其求值是通过不改变任何寄存器的机器操作lookup_symbol_value完成的。我们在本节中实现的编译器将利用这一事实,并生成使用指令求值函数表达式的代码。
assign("fun",
list(op("lookup_symbol_value"), constant("f"), reg("env")))
lookup_symbol_value的参数在编译时从解析器对f(96, 22)的表示中提取。这段代码不仅避免了不必要的保存和恢复,还直接将查找的值分配给fun,而解释器将在val中获取结果,然后将其移动到fun。
编译器还可以优化对环境的访问。在分析了代码之后,编译器可以知道特定名称的值将位于哪个帧中,并直接访问该帧,而不是执行lookup_symbol_value搜索。我们将在第 5.5.6 节讨论如何实现这样的词法寻址。然而,在那之前,我们将专注于上述寄存器和堆栈优化的类型。编译器还可以执行许多其他优化,例如将原始操作“内联”编码,而不是使用通用的apply机制(参见练习 5.41);但我们在这里不会强调这些。本节的主要目标是在一个简化(但仍然有趣)的上下文中说明编译过程。
5.5.1 编译器的结构
在第 4.1.7 节中,我们修改了我们原始的元循环解释器,将分析与执行分开。我们分析每个组件以产生一个以环境为参数并执行所需操作的执行函数。在我们的编译器中,我们将基本上进行相同的分析。但是,我们不会生成执行函数,而是生成要由我们的寄存器机器运行的指令序列。
compile函数是编译器中的顶层调度。它对应于第 4.1.1 节的evaluate函数,第 4.1.7 节的analyze函数,以及第 5.4.1 节中显式控制求值器的eval_dispatch入口。编译器和解释器一样,使用了第 4.1.2 节中定义的组件语法函数。compile函数对要编译的组件的句法类型进行案例分析。对于每种类型的组件,它都会分派到一个专门的代码生成器。
function compile(component, target, linkage) {
return is_literal(component)
? compile_literal(component, target, linkage)
: is_name(component)
? compile_name(component, target, linkage)
: is_application(component)
? compile_application(component, target, linkage)
: is_operator_combination(component)
? compile(operator_combination_to_application(component),
target, linkage)
: is_conditional(component)
? compile_conditional(component, target, linkage)
: is_lambda_expression(component)
? compile_lambda_expression(component, target, linkage)
: is_sequence(component)
? compile_sequence(sequence_statements(component),
target, linkage)
: is_block(component)
? compile_block(component, target, linkage)
: is_return_statement(component)
? compile_return_statement(component, target, linkage)
: is_function_declaration(component)
? compile(function_decl_to_constant_decl(component),
target, linkage)
: is_declaration(component)
? compile_declaration(component, target, linkage)
: is_assignment(component)
? compile_assignment(component, target, linkage)
: error(component, "unknown component type – compile");
}
目标和链接
函数compile和它调用的代码生成器除了要编译的组件外,还需要两个参数。一个是目标,它指定编译后的代码将返回组件的值的寄存器。还有一个链接描述符,它描述了组件编译后的代码在执行完毕后应该如何进行。链接描述符可以要求代码执行以下三种操作之一:
-
继续到下一个指令序列(这是由链接描述符
"next"指定的), -
跳转到
continue寄存器的当前值,作为从函数调用返回的一部分(这由链接描述符"return"指定),或 -
跳转到命名的入口点(这是通过使用指定的标签作为链接描述符来指定的)。
例如,使用val寄存器作为目标和链接为"next"编译字面量5应该产生指令
assign("val", constant(5))
使用链接"return"编译相同的表达式应该产生指令
assign("val", constant(5)),
go_to(reg("continue"))
在第一种情况下,执行将继续进行下一个指令序列。在第二种情况下,我们将跳转到continue寄存器中存储的任何入口点。在这两种情况下,表达式的值将被放入目标val寄存器中。我们的编译器在编译返回语句的返回表达式时使用"return"链接。就像在显式控制求值器中一样,从函数调用返回发生在三个步骤中:
-
1. 将堆栈恢复到标记并恢复
continue(它保存了在函数调用开始时设置的继续) -
2. 计算返回值并将其放入
val -
3. 跳转到
continue中的入口点
返回语句的编译显式生成了用于恢复堆栈和恢复continue的代码。返回表达式使用目标val和链接"return"进行编译,以便计算返回值的生成代码将返回值放入val,并以跳转到continue结束。
指令序列和堆栈使用
每个代码生成器返回一个包含它为组件生成的目标代码的指令序列。复合组件的代码生成是通过组合子组件的简单代码生成器的输出来完成的,就像复合组件的求值是通过求值子组件来完成的。
组合指令序列的最简单方法是一个名为append_instruction_sequences的函数,它接受两个要依次执行的指令序列作为参数。它将它们附加在一起并返回组合的序列。也就是说,如果seq[1]和seq[2]是指令序列,那么求值
append_instruction_sequences(seq[1], seq[2])
产生序列
seq[1]
seq[2]
每当需要保存寄存器时,编译器的代码生成器使用preserving,这是一种更微妙的组合指令序列的方法。函数preserving接受三个参数:一组寄存器和两个要依次执行的指令序列。它以这样的方式附加序列,以便在第一个序列的执行过程中保留集合中每个寄存器的内容,如果这对于执行第二个序列是必要的。也就是说,如果第一个序列修改了寄存器并且第二个序列实际上需要寄存器的原始内容,那么preserving在附加序列之前将寄存器的save和restore包装在第一个序列周围。否则,preserving简单地返回附加的指令序列。因此,例如,
preserving(list(reg[1], reg[2]), seq[1], seq[2])
根据seq[1]和seq[2]如何使用reg[1]和reg[2],产生以下四种指令序列之一:
seq[1] save(reg[1]), save(reg[2]), save(reg[2]),
seq[2] seq[1] seq[1] save(reg[1]),
restore(reg[1]), restore(reg[2]), seq[1]
seq[2] seq[2] restore(reg[1]),
restore(reg[2]),
seq[2]
通过使用preserving来组合指令序列,编译器避免了不必要的堆栈操作。这也隔离了是否在preserving函数内生成save和restore指令的细节,将它们与编写每个单独代码生成器时出现的问题分开。实际上,除了调用函数的代码保存continue和返回函数的代码恢复它之外,代码生成器并没有显式产生save或restore指令:这些对应的save和restore指令是由不同的compile调用显式生成的,而不是由preserving匹配生成的(我们将在 5.5.3 节中看到)。
原则上,我们可以简单地将指令序列表示为指令列表。然后,append_instruction_sequences函数可以通过执行普通的列表append来组合指令序列。然而,preserving将是一个复杂的操作,因为它必须分析每个指令序列以确定序列如何使用其寄存器。preserving也将是低效的,因为它必须分析其每个指令序列参数,即使这些序列本身可能已经通过对preserving的调用构造,这样它们的部分已经被分析。为了避免这种重复的分析,我们将与每个指令序列关联一些关于其寄存器使用的信息。当我们构造基本指令序列时,我们将明确提供这些信息,并且组合指令序列的函数将从与要组合的序列相关联的信息中推导出组合序列的寄存器使用信息。
指令序列将包含三个信息:
-
必须在序列中的指令执行之前初始化的寄存器集合(这些寄存器被称为序列需要的寄存器),
-
指令序列中被指令修改的寄存器集合,
-
序列中的实际指令。
我们将把指令序列表示为其三个部分的列表。因此,指令序列的构造函数是
function make_instruction_sequence(needs, modifies, instructions) {
return list(needs, modifies, instructions);
}
例如,查找当前环境中符号x的值,将结果赋给val,然后继续执行的两条指令序列需要初始化寄存器env和continue,并修改寄存器val。因此,这个序列将被构造为
make_instruction_sequence
list("env", "continue"), list("val"), list(assign("val",
list(op("lookup_symbol_value"), constant("x"),
reg("env"))),
go_to(reg("continue"))));
组合指令序列的函数在 5.5.4 节中显示。
练习 5.32
在求值函数应用时,显式控制求值器总是在函数表达式的求值周围保存和恢复env寄存器,在每个参数表达式的求值周围保存和恢复env(最后一个除外),在每个参数表达式的求值周围保存和恢复argl,并在参数表达式序列的求值周围保存和恢复fun。对于以下每个应用,说出这些save和restore操作中哪些是多余的,因此可以通过编译器的preserving机制消除:
f("x", "y")
f()("x", "y")
f(g("x"), y)
f(g("x"), "y")
练习 5.33
使用preserving机制,编译器将避免在应用的函数表达式的求值周围保存和恢复env,在函数表达式是名称的情况下。我们也可以将这样的优化构建到求值器中。事实上,5.4 节的显式控制求值器已经通过将没有参数的应用视为特殊情况来执行类似的优化。
-
a.将显式控制求值器扩展为识别函数表达式为名称的组件的一个单独类,并利用这一事实来求值这些组件。
-
b. Alyssa P. Hacker 建议通过扩展求值器以识别越来越多的特殊情况,我们可以合并所有编译器的优化,这将消除编译的优势。你对这个想法有什么看法?
5.5.2 编译组件
在本节和下一节中,我们实现了compile函数分派到的代码生成器。
编译链接代码
一般来说,每个代码生成器的输出都将以由函数compile_linkage生成的指令结尾,这些指令实现了所需的链接。如果链接是"return",那么我们必须生成指令go_to(reg("continue"))。这需要continue寄存器,不修改任何寄存器。如果链接是"next",那么我们不需要包括任何额外的指令。否则,链接是一个标签,我们会生成一个go_to到该标签,这是一个不需要或修改任何寄存器的指令。
function compile_linkage(linkage) {
return linkage === "return"
? make_instruction_sequence(list("continue"), null,
list(go_to(reg("continue"))))
: linkage === "next"
? make_instruction_sequence(null, null, null)
: make_instruction_sequence(null, null,
list(go_to(label(linkage))));
}
链接代码通过保留 continue寄存器附加到指令序列,因为"return"链接将需要continue寄存器:如果给定的指令序列修改continue并且链接代码需要它,continue将被保存和恢复。
function end_with_linkage(linkage, instruction_sequence) {
return preserving(list("continue"),
instruction_sequence,
compile_linkage(linkage));
}
编译简单组件
字面表达式和名称的代码生成器构造指令序列,将所需的值分配给目标寄存器,然后按链接描述符指定的方式继续。
字面值在编译时从被编译的组件中提取,并放入assign指令的常量部分。对于名称,当运行编译程序时,会生成一个指令来使用lookup_symbol_value操作,以查找当前环境中与符号关联的值。与字面值一样,符号在编译时从被编译的组件中提取。因此,symbol_of_name(component)只在编译程序时执行一次,并且该符号出现为assign指令中的常量。
function compile_literal(component, target, linkage) {
const literal = literal_value(component);
return end_with_linkage(linkage,
make_instruction_sequence(null, list(target),
list(assign(target, constant(literal)))));
}
function compile_name(component, target, linkage) {
const symbol = symbol_of_name(component);
return end_with_linkage(linkage,
make_instruction_sequence(list("env"), list(target),
list(assign(target,
list(op("lookup_symbol_value"),
constant(symbol),
reg("env"))))));
}
这些任务说明修改目标寄存器,查找符号的指令需要env寄存器。
赋值和声明的处理方式与解释器中的处理方式类似。函数compile_assignment_declaration递归生成代码,计算与符号关联的值,并将更新环境中与符号关联的值的两条指令序列附加到其中,并将整个组件的值(赋值的值或声明的undefined)分配给目标寄存器。递归编译的目标为val和链接为"next",以便代码将其结果放入val并继续在其后附加的代码。附加的代码保留env,因为更新符号-值关联需要环境,并且计算值的代码可能是复杂表达式的编译,可能以任意方式修改寄存器。
function compile_assignment(component, target, linkage) {
return compile_assignment_declaration(
assignment_symbol(component),
assignment_value_expression(component),
reg("val"),
target, linkage);
}
function compile_declaration(component, target, linkage) {
return compile_assignment_declaration(
declaration_symbol(component),
declaration_value_expression(component),
constant(undefined),
target, linkage);
}
function compile_assignment_declaration(
symbol, value_expression, final_value,
target, linkage) {
const get_value_code = compile(value_expression, "val", "next");
return end_with_linkage(linkage,
preserving(list("env"),
get_value_code,
make_instruction_sequence(list("env", "val"),
list(target),
list(perform(list(op("assign_symbol_value"),
constant(symbol),
reg("val"),
reg("env"))),
assign(target, final_value)))));
}
附加的两条指令序列需要env和val并修改目标。请注意,尽管我们为此序列保留了env,但我们没有保留val,因为get_value_code旨在将其结果明确放入val以供此序列使用。(实际上,如果我们保留了val,我们将会有一个错误,因为这将导致在运行get_value_code后恢复val的先前内容。)
编译条件
使用给定目标和链接编译的条件代码的形式为
〈compilation of predicate, target val, linkage "next"〉
test(list(op("is_falsy"), reg("val"))),
branch(label("false_branch")),
"true_branch",
〈compilation of consequent with given target and given linkage or* after_cond〉
"false_branch",
〈compilation of alternative with given target and linkage〉
"after_cond"
为了生成这段代码,我们编译谓词、结果和替代,并将生成的代码与用于测试谓词结果的指令以及用于标记真假分支和条件结束的新生成标签组合起来。在这种代码排列中,如果测试为假,我们必须绕过真分支。唯一的小复杂之处在于如何处理真分支的链接。如果条件的链接是"return"或者一个标签,那么真假分支都将使用相同的链接。如果链接是"next",那么真分支将以跳过假分支代码到条件结束标签的跳转结束。
function compile_conditional(component, target, linkage) {
const t_branch = make_label("true_branch");
const f_branch = make_label("false_branch");
const after_cond = make_label("after_cond");
const consequent_linkage =
linkage === "next" ? after_cond : linkage;
const p_code = compile(conditional_predicate(component),
"val", "next");
const c_code = compile(conditional_consequent(component),
target, consequent_linkage);
const a_code = compile(conditional_alternative(component),
target, linkage);
return preserving(list("env", "continue"),
p_code,
append_instruction_sequences(
make_instruction_sequence(list("val"), null,
list(test(list(op("is_falsy"), reg("val"))),
branch(label(f_branch)))),
append_instruction_sequences(
parallel_instruction_sequences(
append_instruction_sequences(t_branch, c_code),
append_instruction_sequences(f_branch, a_code)),
after_cond)));
}
在谓词代码周围保留env寄存器,因为它可能会被true和false分支所需要,continue也被保留,因为它可能会被这些分支中的链接代码所需要。真假分支的代码(它们不是顺序执行的)使用特殊的组合器parallel_instruction_sequences进行附加,该组合器在 5.5.4 节中描述。
编译序列
语句序列的编译与显式控制求值器中它们的求值并行进行,唯一的例外是:如果一个return语句出现在序列的任何位置,我们将其视为最后一条语句。序列的每个语句都会被编译——最后一条语句(或return语句)使用指定为序列的链接,其他语句使用"next"链接(执行序列的其余部分)。单个语句的指令序列被附加在一起,以便保留env(需要用于序列的其余部分)和continue(可能需要用于序列末尾的链接)。
function compile_sequence(seq, target, linkage) {
return is_empty_sequence(seq)
? compile_literal(make_literal(undefined), target, linkage)
: is_last_statement(seq) ||
is_return_statement(first_statement(seq))
? compile(first_statement(seq), target, linkage)
: preserving(list("env", "continue"),
compile(first_statement(seq), target, "next"),
compile_sequence(rest_statements(seq),
target, linkage));
}
将return语句视为序列中的最后一条语句,避免编译return语句之后永远不会执行的“死代码”。删除is_return_statement检查不会改变对象程序的行为;然而,有许多原因不编译死代码,这超出了本书的范围(安全性、编译时间、对象代码的大小等),许多编译器对死代码会发出警告。
编译块
通过在块的编译体之前添加一个assign指令来编译块。该赋值通过将在块中声明的名称绑定到值"unassigned"的帧扩展了当前环境。这个操作既需要又修改了env寄存器。
function compile_block(stmt, target, linkage) {
const body = block_body(stmt);
const locals = scan_out_declarations(body);
const unassigneds = list_of_unassigned(locals);
return append_instruction_sequences(
make_instruction_sequence(list("env"), list("env"),
list(assign("env", list(op("extend_environment"),
constant(locals),
constant(unassigneds),
reg("env"))))),
compile(body, target, linkage));
}
编译 lambda 表达式
Lambda 表达式构造函数。lambda 表达式的对象代码必须具有以下形式
〈construct function object and assign it to target register〉
〈linkage〉
编译 lambda 表达式时,我们还会生成函数体的代码。虽然在函数构造时不会执行函数体,但是将其插入到对象代码中 lambda 表达式的代码之后是很方便的。如果 lambda 表达式的链接是一个标签或者"return",那么这样做是可以的。但是如果链接是"next",我们需要通过跳转到函数体之后插入的标签的链接来跳过函数体的代码。因此,对象代码的形式如下
〈construct function object and assign it to target register〉
〈code for given linkage〉 or go_to(label("after_lambda"))
〈compilation of function body〉
"after_lambda"
函数 compile_lambda_expression 生成构造函数对象的代码,然后是函数体的代码。函数对象将在运行时通过将当前环境(声明点的环境)与编译函数体的入口点(新生成的标签)组合来构造。
function compile_lambda_expression(exp, target, linkage) {
const fun_entry = make_label("entry");
const after_lambda = make_label("after_lambda");
const lambda_linkage =
linkage === "next" ? after_lambda : linkage;
return append_instruction_sequences(
tack_on_instruction_sequence(
end_with_linkage(lambda_linkage,
make_instruction_sequence(list("env"),
list(target),
list(assign(target,
list(op("make_compiled_function"),
label(fun_entry),
reg("env")))))),
compile_lambda_body(exp, fun_entry)),
after_lambda);
}
函数compile_lambda_expression使用特殊的组合器tack_on_ instruction_sequence(来自 5.5.4 节)而不是append_instruction_ sequences来将函数体附加到 lambda 表达式代码中,因为函数体不是将在进入组合序列时执行的指令序列的一部分;相反,它只是在序列中,因为那是一个方便的放置位置。
函数compile_lambda_body构造函数主体的代码。此代码以入口点的标签开头。接下来是指令,这些指令将导致运行时求值环境切换到正确的环境,以求值函数主体——即函数的环境,扩展为包括参数与调用函数时的参数的绑定。之后是函数主体的代码,增强以确保它以返回语句结束。增强的主体使用目标val进行编译,以便其返回值将被放置在val中。传递给此编译的链接描述符是无关紧要的,因为它将被忽略。⁴⁴由于需要链接参数,我们随意选择了"next"。
function compile_lambda_body(exp, fun_entry) {
const params = lambda_parameter_symbols(exp);
return append_instruction_sequences(
make_instruction_sequence(list("env", "fun", "argl"),
list("env"),
list(fun_entry,
assign("env",
list(op("compiled_function_env"),
reg("fun"))),
assign("env",
list(op("extend_environment"),
constant(params),
reg("argl"),
reg("env"))))),
compile(append_return_undefined(lambda_body(exp)),
"val", "next"));
}
为了确保所有函数最终都通过执行返回语句结束,compile_ lambda_body在 lambda 主体中附加了一个返回语句,其返回表达式是文字undefined。为此,它使用函数append_return_ undefined,该函数构造了解析器的标记列表表示(来自 4.1.2 节)的序列,其中包括主体和一个return undefined;语句。
function append_return_undefined(body) {
return list("sequence", list(body,
list("return_statement",
list("literal", undefined))));
}
对 lambda 主体的这种简单转换是确保没有显式返回的函数具有返回值undefined的第三种方法。在元循环求值器中,我们使用了返回值对象,它还在停止序列求值中发挥了作用。在显式控制求值器中,没有显式返回的函数继续到一个入口点,该入口点将undefined存储在val中。参见练习 5.35,了解处理插入返回语句的更加优雅的方法。
练习 5.34
42 脚注指出编译器并未识别所有死代码的实例。编译器要检测所有死代码的实例需要什么?
提示:答案取决于我们如何定义死代码。一个可能的(也有用的)定义是“在序列中跟随返回语句的代码”——但是在if (false) ...的结果分支中的代码呢?或者在练习 4.15 中调用run_forever()之后的代码呢?
练习 5.35
append_return_undefined的当前设计有点粗糙:它总是将return undefined;附加到 lambda 主体,即使在主体的每个执行路径中已经有了返回语句。重写append_return_undefined,使其仅在不包含返回语句的路径末尾插入return undefined;。在下面的函数上测试您的解决方案,用任何表达式替换e[1]和e[2],用任何(非返回)语句替换s[1]和s[2]。在t中,返回语句应该添加在两个(*)或仅在(**)中。在w和h中,返回语句应该添加在一个(*)中。在m中,不应添加返回语句。
5.5.3 编译应用程序和返回语句
编译过程的本质是编译函数应用程序。使用给定目标和链接的应用程序的代码具有以下形式
〈compilation of function expression, target fun, linkage "next"〉
〈evaluate argument expressions and construct argument list in* argl〉
〈compilation of function call with given target and linkage〉
寄存器env,fun和argl在函数和参数表达式的求值过程中可能需要保存和恢复。请注意,这是编译器中唯一指定目标不是val的地方。
所需的代码由compile_application生成。这将递归编译函数表达式,以生成将要应用的函数放入fun的代码,并编译参数表达式,以生成求值应用的各个参数表达式的代码。参数表达式的指令序列(由construct_arglist组合)与构造argl中的参数列表的代码组合在一起,生成的参数列表代码与函数代码和执行函数调用的代码(由compile_function_call生成)组合在一起。在附加代码序列时,必须在函数表达式的求值周围保留env寄存器(因为求值函数表达式可能会修改env,这将需要用于求值参数表达式),并且在构造参数列表时必须保留fun寄存器(因为求值参数表达式可能会修改fun,这将需要用于实际的函数应用)。continue寄存器在整个过程中也必须保留,因为它在函数调用中需要用于链接。
function compile_application(exp, target, linkage) {
const fun_code = compile(function_expression(exp), "fun", "next");
const argument_codes = map(arg => compile(arg, "val", "next"),
arg_expressions(exp));
return preserving(list("env", "continue"),
fun_code,
preserving(list("fun", "continue"),
construct_arglist(argument_codes),
compile_function_call(target, linkage)));
}
构建参数列表的代码将求值每个参数表达式为val,然后使用pair将该值与在argl中累积的参数列表组合起来。由于我们按顺序将参数添加到argl的前面,所以我们必须从最后一个参数开始,以第一个结束,这样参数将按顺序出现在生成的列表中。我们不想浪费一条指令来将argl初始化为空列表以准备进行这一系列的求值,因此我们让第一个代码序列构造初始的argl。参数列表构造的一般形式如下:
〈compilation of last argument, targeted to val〉
〈assign("argl", list(op("list"), reg("val"))),
〈compilation of next argument, targeted to val〉
〈assign("argl", list(op("pair"), reg("val"), reg("argl"))),
...
〈compilation of first argument, targeted to val〉
assign("argl", list(op("pair"), reg("val"), reg("argl"))),
在每个参数求值周围必须保留argl寄存器,除了第一个(这样迄今为止累积的参数不会丢失),并且在每个参数求值周围必须保留env(供后续参数求值使用)。
编译这个参数代码有点棘手,因为第一个要求值的参数表达式的特殊处理以及在不同位置需要保留argl和env。construct_arglist函数以求值各个参数表达式的代码作为参数。如果根本没有参数表达式,它只是发出指令
assign(argl, constant(null))
否则,construct_arglist创建代码,用最后一个参数初始化argl,并附加代码来求值其余的参数,并依次将它们添加到argl中。为了从后到前处理参数,我们必须按照compile_application提供的顺序反转参数代码序列的列表。
function construct_arglist(arg_codes) {
if (is_null(arg_codes)) {
return make_instruction_sequence(null, list("argl"),
list(assign("argl", constant(null))));
} else {
const rev_arg_codes = reverse(arg_codes);
const code_to_get_last_arg =
append_instruction_sequences(
head(rev_arg_codes),
make_instruction_sequence(list("val"), list("argl"),
list(assign("argl",
list(op("list"), reg("val"))))));
return is_null(tail(rev_arg_codes))
? code_to_get_last_arg
: preserving(list("env"),
code_to_get_last_arg,
code_to_get_rest_args(tail(rev_arg_codes)));
}
}
function code_to_get_rest_args(arg_codes) {
const code_for_next_arg =
preserving(list("argl"),
head(arg_codes),
make_instruction_sequence(list("val", "argl"), list("argl"),
list(assign("argl", list(op("pair"),
reg("val"), reg("argl"))))));
return is_null(tail(arg_codes))
? code_for_next_arg
: preserving(list("env"),
code_for_next_arg,
code_to_get_rest_args(tail(arg_codes)));
}
应用函数
在求值函数应用的元素之后,编译的代码必须将fun中的函数应用于argl中的参数。该代码执行的基本上与第 4.1.1 节中的元循环求值器中的apply函数或第 5.4.2 节中的显式控制求值器中的apply_dispatch入口相同的分发。它检查要应用的函数是原始函数还是编译函数。对于原始函数,它使用apply_primitive_function;我们很快将看到它如何处理编译函数。函数应用代码的形式如下:
test(list(op("primitive_function"), reg("fun"))),
branch(label("primitive_branch")),
"compiled_branch",
〈code to apply compiled function with given target and appropriate linkage〉
"primitive_branch",
assign(target,
list(op("apply_primitive_function"), reg("fun"), reg("argl"))),
〈linkage〉
"after_call"
注意,编译的分支必须跳过原始分支。因此,如果原始函数调用的链接是"next",则复合分支必须使用跳转到原始分支之后插入的标签的链接。(这类似于compile_conditional中真分支使用的链接。)
function compile_function_call(target, linkage) {
const primitive_branch = make_label("primitive_branch");
const compiled_branch = make_label("compiled_branch");
const after_call = make_label("after_call");
const compiled_linkage = linkage === "next" ? after_call : linkage;
return append_instruction_sequences(
make_instruction_sequence(list("fun"), null,
list(test(list(op("is_primitive_function"), reg("fun"))),
branch(label(primitive_branch)))),
append_instruction_sequences(
parallel_instruction_sequences(
append_instruction_sequences(
compiled_branch,
compile_fun_appl(target, compiled_linkage)),
append_instruction_sequences(
primitive_branch,
end_with_linkage(linkage,
make_instruction_sequence(list("fun", "argl"),
list(target),
list(assign(
target,
list(op("apply_primitive_function"),
reg("fun"), reg("argl")))))))),
after_call));
}
原始分支和复合分支(例如compile_ conditional中的真分支和假分支)使用parallel_instruction_sequences附加,而不是普通的append_instruction_sequences,因为它们不会按顺序执行。
应用编译函数
函数应用和返回的处理是编译器最微妙的部分。编译函数(由compile_lambda_expression构造)具有一个入口点,这是一个标签,指定函数代码的起始位置。在这个入口点的代码中,计算val中的结果,并通过执行编译返回语句的指令结束。
编译函数应用程序的代码与显式控制求值器(第 5.4.2 节)使用堆栈的方式相同:在跳转到编译函数的入口点之前,它将函数调用的继续保存到堆栈中,然后是一个标记,允许将堆栈恢复到调用之前的状态,并将继续保持在顶部。
// set up for return from function
save("continue"),
push_marker_to_stack(),
// jump to the function's entry point
assign("val", list(op("compiled_function_entry"), reg("fun"))),
go_to(reg("val")),
编译返回语句(使用compile_return_statement)会生成相应的代码来恢复堆栈并恢复并跳转到continue。
revert_stack_to_marker(),
restore("continue"),
〈evaluate the return expression and store the result in val〉
go_to(reg("continue")), // "return"-linkage code
除非函数进入无限循环,否则它将通过执行上述返回代码结束,该代码是由程序中的return语句或由compile_lambda_body插入的返回undefined生成的。
具有给定目标和链接的编译函数应用程序的直接代码将设置continue,使函数返回到本地标签而不是最终链接,以将函数值从val复制到目标寄存器(如果需要)。如果链接是标签,它将如下所示:
assign("continue", label("fun_return")), // where function should return to
save("continue"), // will be restored by the function
push_marker_to_stack(), // allows the function to revert stack to find fun_return
assign("val", list(op("compiled_function_entry"), reg("fun"))),
go_to(reg("val")), // eventually reverts stack, restores and jumps to continue
"fun_return", // the function returns to here
assign(target, reg("val")), // included if target is not val
go_to(label(linkage)), // linkage code
或者像这样-在开始时保存调用者的继续,以便在结束时恢复并转到它-如果链接是return(也就是说,如果应用程序在return语句中并且其值是要返回的结果):
save("continue"), // save the caller's continuation
assign("continue", label("fun_return")), // where function should return to
save("continue"), // will be restored by the function
push_marker_to_stack(), // allows the function to revert stack to find fun_return
assign("val", list(op("compiled_function_entry"), reg("fun"))),
go_to(reg("val")), // eventually reverts stack, restores and jumps to continue
"fun_return", // the function returns to here
assign(target, reg("val")), // included if target is not val
restore("continue"), // restore the caller's continuation
go_to(reg("continue")), // linkage code
这段代码设置continue,使函数返回到标签fun_return并跳转到函数的入口点。fun_return处的代码将函数的结果从val传输到目标寄存器(如果需要),然后跳转到链接指定的位置。(链接始终是return或标签,因为compile_function_call会将复合函数分支的next链接替换为after_call标签。)在跳转到函数的入口点之前,我们保存continue并执行push_marker_to_stack()以使函数能够返回到程序中预期的位置并具有预期的堆栈。revert_stack_to_marker()和restore("continue")指令由compile_return_statement为函数体中的每个return语句生成。
实际上,如果目标不是val,那么上面的代码就是我们的编译器将生成的代码。然而,通常情况下,目标是val(编译器指定不同寄存器的唯一时间是将函数表达式的求值目标指向fun),因此函数结果直接放入目标寄存器,无需跳转到复制它的特殊位置。相反,我们通过设置continue来简化代码,使被调用函数直接“返回”到调用者链接指定的位置:
〈set up continue for linkage and push the marker〉
assign("val", list(op("compiled_function_entry"), reg("fun"))),
go_to(reg("val")),
如果链接是一个标签,我们将设置continue,使函数继续在该标签处。(也就是说,被调用函数结束时的go_to(reg("continue"))相当于上面的fun_return处的go_to(label(linkage))。)
assign("continue", label(linkage)),
save("continue"),
push_marker_to_stack(),
assign("val", list(op("compiled_function_entry"), reg("fun"))),
go_to(reg("val")),
如果链接是return,我们不需要分配continue:它已经保存了所需的位置。(也就是说,被调用函数结束时的go_to(reg("continue"))会直接到达fun_return处的go_to(reg("continue"))所指定的位置。)
save("continue"),
push_marker_to_stack(),
assign("val", list(op("compiled_function_entry"), reg("fun"))),
go_to(reg("val")),
使用这种实现的return链接,编译器生成尾递归代码。在返回语句中的函数调用,其值是要返回的结果,进行直接转移,而不在堆栈上保存不必要的信息。
假设我们处理了链接为return且目标为val的函数调用的情况,方式与非val目标的情况相同。这将破坏尾递归。我们的系统仍然会为任何函数调用返回相同的值。但每次调用函数时,我们都会保存continue并在调用后返回以撤消(无用的)保存。这些额外的保存会在函数调用的嵌套中累积。⁴⁸
函数compile_fun_appl通过考虑四种情况生成上述函数应用代码,具体取决于调用的目标是否为val以及链接是否为return。请注意,指令序列被声明为修改所有寄存器,因为执行函数体可能以任意方式更改寄存器。⁴⁹
function compile_fun_appl(target, linkage) {
const fun_return = make_label("fun_return");
return target === "val" && linkage !== "return"
? make_instruction_sequence(list("fun"), all_regs,
list(assign("continue", label(linkage)),
save("continue"),
push_marker_to_stack(),
assign("val", list(op("compiled_function_entry"),
reg("fun"))),
go_to(reg("val"))))
: target !== "val" && linkage !== "return"
? make_instruction_sequence(list("fun"), all_regs,
list(assign("continue", label(fun_return)),
save("continue"),
push_marker_to_stack(),
assign("val", list(op("compiled_function_entry"),
reg("fun"))),
go_to(reg("val")),
fun_return,
assign(target, reg("val")),
go_to(label(linkage))))
: target === "val" && linkage === "return"
? make_instruction_sequence(list("fun", "continue"),
all_regs,
list(save("continue"),
push_marker_to_stack(),
assign("val", list(op("compiled_function_entry"),
reg("fun"))),
go_to(reg("val"))))
: // target !== "val" && linkage === "return"
error(target, "return linkage, target not val – compile");
}
我们已经展示了如何在链接是return时为函数应用生成尾递归链接代码,也就是说,当应用在返回语句中时,它的值是要返回的结果。同样,正如在 5.4.2 节中解释的那样,这里使用的堆栈标记机制(以及显式控制求值器)仅在这种情况下产生尾递归行为。为函数应用生成的代码的这两个方面结合在一起,确保当函数通过返回函数调用的值结束时,不会累积堆栈。
编译返回语句
无论给定的链接和目标如何,返回语句的代码都采用以下形式:
revert_stack_to_marker(),
restore("continue"), // saved by compile_fun_appl
〈evaluate the return expression and store the result in val〉
go_to(reg("continue")) // "return"-linkage code
使用标记还原堆栈的指令,然后恢复continue对应于compile_fun_appl生成的指令,用于保存continue和标记堆栈。通过使用return链接编译返回表达式时生成最终跳转到continue。函数compile_return_statement与所有其他代码生成器不同,因为它忽略了目标和链接参数——它总是使用目标val和链接return编译返回表达式。
function compile_return_statement(stmt, target, linkage) {
return append_instruction_sequences(
make_instruction_sequence(null, list("continue"),
list(revert_stack_to_marker(),
restore("continue"))),
compile(return_expression(stmt), "val", "return"));
}
5.5.4 组合指令序列
本节描述了指令序列的表示和组合的详细信息。回想一下 5.5.1 节,指令序列被表示为所需寄存器的列表,修改的寄存器和实际指令。我们还将标签(字符串)视为指令序列的退化情况,它不需要或修改任何寄存器。因此,为了确定指令序列所需和修改的寄存器,我们使用选择器。
function registers_needed(s) {
return is_string(s) ? null : head(s);
}
function registers_modified(s) {
return is_string(s) ? null : head(tail(s));
}
function instructions(s) {
return is_string(s) ? list(s) : head(tail(tail(s)));
}
要确定给定序列是否需要或修改给定寄存器,我们使用谓词。
function needs_register(seq, reg) {
return ! is_null(member(reg, registers_needed(seq)));
}
function modifies_register(seq, reg) {
return ! is_null(member(reg, registers_modified(seq)));
}
通过这些谓词和选择器,我们可以实现编译器中使用的各种指令序列组合器。
基本组合器是append_instruction_sequences。它以两个将按顺序执行的指令序列作为参数,并返回一个指令序列,其语句是两个序列的语句附加在一起。微妙的一点是确定所需和修改的寄存器。它修改了任一序列修改的寄存器;它需要那些必须在第一个序列运行之前初始化的寄存器(第一个序列需要的寄存器),以及第二个序列需要但第一个序列未初始化(修改)的寄存器。
函数append_instruction_sequences给出了两个指令序列seq1和seq2,并返回指令序列,其指令是seq1的指令,后跟seq2的指令,其修改的寄存器是seq1或seq2修改的寄存器,并且所需的寄存器是seq1所需的寄存器以及seq2所需的寄存器,这些寄存器不被seq1修改。(在集合操作方面,所需寄存器的新集合是seq1所需的寄存器的并集,与seq2所需的寄存器和seq1修改的寄存器的差集。)因此,append_instruction_sequences的实现如下:
function append_instruction_sequences(seq1, seq2) {
return make_instruction_sequence(
list_union(registers_needed(seq1),
list_difference(registers_needed(seq2),
registers_modified(seq1))),
list_union(registers_modified(seq1),
registers_modified(seq2)),
append(instructions(seq1), instructions(seq2)));
}
这个函数使用一些简单的操作来操作列表表示的集合,类似于第 2.3.3 节中描述的(无序)集合表示:
function list_union(s1, s2) {
return is_null(s1)
? s2
: is_null(member(head(s1), s2))
? pair(head(s1), list_union(tail(s1), s2))
: list_union(tail(s1), s2);
}
function list_difference(s1, s2) {
return is_null(s1)
? null
: is_null(member(head(s1), s2))
? pair(head(s1), list_difference(tail(s1), s2))
: list_difference(tail(s1), s2);
}
函数preserving,第二个主要的指令序列组合器,接受一个寄存器列表regs和两个要顺序执行的指令序列seq1和seq2。它返回一个指令序列,其指令是seq1的指令,后跟seq2的指令,seq1中被seq1修改但seq2所需的寄存器在seq1周围有适当的save和restore指令来保护。为了实现这一点,preserving首先创建一个具有所需的save,然后是seq1的指令,然后是所需的restore的序列。这个序列需要被保存和恢复的寄存器,以及seq1所需的寄存器,并修改了seq1修改的寄存器,但不包括被保存和恢复的寄存器。然后以通常的方式附加这个增强的序列和seq2。以下函数以递归方式实现了这种策略,遍历要保留的寄存器列表:
function preserving(regs, seq1, seq2) {
if (is_null(regs)) {
return append_instruction_sequences(seq1, seq2);
} else {
const first_reg = head(regs);
return needs_register(seq2, first_reg) &&
modifies_register(seq1, first_reg)
? preserving(tail(regs),
make_instruction_sequence(
list_union(list(first_reg),
registers_needed(seq1)),
list_difference(registers_modified(seq1),
list(first_reg)),
append(list(save(first_reg)),
append(instructions(seq1),
list(restore(first_reg))))),
seq2)
: preserving(tail(regs), seq1, seq2);
}
}
另一个序列组合器tack_on_instruction_sequence由compile_lambda_expression使用,用于将函数体附加到另一个序列。因为函数体不是“内联”执行作为组合序列的一部分,所以它的寄存器使用对嵌入它的序列的寄存器使用没有影响。因此,当我们将其附加到其他序列时,我们忽略函数体的所需和修改的寄存器集。
function tack_on_instruction_sequence(seq, body_seq) {
return make_instruction_sequence(
registers_needed(seq),
registers_modified(seq),
append(instructions(seq), instructions(body_seq)));
}
函数compile_conditional和compile_function_call使用一个特殊的组合器parallel_instruction_sequences来附加跟随测试的两个替代分支。这两个分支永远不会按顺序执行;对于测试的任何特定求值,将进入其中一个分支。因此,第二个分支所需的寄存器仍然需要由组合序列,即使这些寄存器被第一个分支修改。
function parallel_instruction_sequences(seq1, seq2) {
return make_instruction_sequence(
list_union(registers_needed(seq1),
registers_needed(seq2)),
list_union(registers_modified(seq1),
registers_modified(seq2)),
append(instructions(seq1), instructions(seq2)));
}
5.5.5 编译代码的示例
现在我们已经看到了编译器的所有元素,让我们看一个编译代码的示例,看看这些元素如何组合在一起。我们将通过将parse应用于程序的字符串表示(这里使用反引号ˋ...ˋ)来编译递归factorial函数的声明作为compile的第一个参数,反引号可以像单引号和双引号一样工作,但允许字符串跨越多行。
compile(parse(ˋ
function factorial(n) {
return n === 1
? 1
: factorial(n - 1) * n;
}
ˋ),
"val",
"next");
我们已经指定声明的值应放在val寄存器中。我们不在乎编译后的代码在执行声明后做什么,因此我们选择"next"作为链接描述符是任意的。
函数compile确定它得到了一个函数声明,因此将其转换为常量声明,然后调用compile_declaration。这将编译代码来计算要分配的值(目标为val),然后是安装声明的代码,然后是将声明的值(即值undefined)放入目标寄存器的代码,最后是链接代码。在计算值时,env寄存器被保留,因为它需要用于安装声明。因为链接是"next",所以在这种情况下没有链接代码。因此,编译代码的骨架如下
〈save env if modified by code to compute value〉
〈compilation of declaration value, target val, linkage "next"〉
〈restore env if saved above〉
perform(list(op("assign_symbol_value"),
constant("factorial"),
reg("val"),
reg("env"))),
assign("val", constant(undefined))
编译生成名称factorial的值的表达式是一个 lambda 表达式,其值是计算阶乘的函数。函数compile通过调用compile_lambda_expression来处理这个问题,它编译函数体,将其标记为新的入口点,并生成将函数体与运行时环境组合并将结果分配给val的指令。然后,序列跳过编译的函数代码,该代码插入到此处。函数代码本身首先通过将参数n绑定到函数参数的帧来扩展函数的声明环境。然后是实际的函数体。由于名称的值的代码不修改env寄存器,因此不会生成上面显示的可选的save和restore。(此时不执行entry1处的函数代码,因此其对env的使用是无关紧要的。)因此,编译代码的骨架变为
assign("val", list(op("make_compiled_function"),
label("entry1"),
reg("env"))),
go_to(label("after_lambda2")),
"entry1",
assign("env", list(op("compiled_function_env"), reg("fun"))),
assign("env", list(op("extend_environment"),
constant(list("n")),
reg("argl"),
reg("env"))),
〈compilation of function body〉
"after_lambda2",
perform(list(op("assign_symbol_value"),
constant("factorial"),
reg("val"),
reg("env"))),
assign("val", constant(undefined))
函数体总是使用目标val和链接"next"编译(由compile_lambda_body)。在这种情况下,函数体由单个返回语句组成:⁵⁰
return n === 1
? 1
: factorial(n - 1) * n;
函数compile_return_statement生成代码,使用标记还原堆栈并恢复continue寄存器,然后编译返回表达式,目标为val,链接为"return",因为其值将从函数返回。返回表达式是一个条件表达式,compile_conditional生成代码,首先计算谓词(目标为val),然后检查结果并在谓词为假时绕过真分支。在谓词代码周围保留env和continue寄存器,因为它们可能需要用于条件表达式的其余部分。真分支和假分支都使用目标val和链接"return"进行编译。(也就是说,条件的值,即由其任一分支计算得到的值,是函数的值。)
revert_stack_to_marker(),
restore("continue"),
〈save continue, env if modified by predicate and needed by branches〉
〈compilation of predicate, target val, linkage "next"〉
〈restore continue, env if saved above〉
test(list(op("is_falsy"), reg("val"))),
branch(label("false_branch4")),
"true_branch3",
〈compilation of true branch, target val, linkage "return"〉
"false_branch4",
〈compilation of false branch, target val, linkage "return"〉
"after_cond5",
谓词n === 1是一个函数应用(在转换运算符组合后)。这查找函数表达式(符号"===")并将该值放入fun中。然后将参数1和n的值组合成argl。然后测试fun是否包含原始函数或复合函数,并相应地分派到原始分支或复合分支。两个分支都在after_call标签处恢复。复合分支必须设置continue以跳过原始分支,并将标记推送到堆栈以匹配函数的编译返回语句中的还原操作。在函数和参数表达式的求值周围保留寄存器的要求不会导致寄存器的保存,因为在这种情况下,这些求值不会修改相关寄存器。
assign("fun", list(op("lookup_symbol_value"),
constant("==="), reg("env"))),
assign("val", constant(1)),
assign("argl", list(op("list"), reg("val"))),
assign("val", list(op("lookup_symbol_value"),
constant("n"), reg("env"))),
assign("argl", list(op("pair"), reg("val"), reg("argl"))),
test(list(op("is_primitive_function"), reg("fun"))),
branch(label("primitive_branch6")),
"compiled_branch7",
assign("continue", label("after_call8")),
save("continue"),
push_marker_to_stack(),
assign("val", list(op("compiled_function_entry"), reg("fun"))),
go_to(reg("val")),
"primitive_branch6",
assign("val", list(op("apply_primitive_function"),
reg("fun"),
reg("argl"))),
"after_call8",
真分支,即常量 1,编译(目标为val和链接return)为
assign("val", constant(1)),
go_to(reg("continue")),
假分支的代码是另一个函数调用,其中函数是符号"*"的值,参数是n和另一个函数调用的结果(对factorial的调用)。每个调用都设置了fun和argl以及自己的原始和复合分支。图 5.17 显示了factorial函数声明的完整编译。请注意,由于谓词中的函数调用修改了这些寄存器并且需要用于函数调用和分支中的“返回”链接,因此上面显示的continue和env的可能“保存”和“恢复”实际上是生成的。
图 5.17 factorial函数声明的编译。
练习 5.36
考虑以下阶乘函数的声明,它与上面给出的函数略有不同:
function factorial_alt(n) {
return n === 1
? 1
: n * factorial_alt(n - 1);
}
编译此函数并将生成的代码与factorial的代码进行比较。解释您发现的任何差异。这两个程序中哪一个执行效率更高?
练习 5.37
编译迭代阶乘函数
function factorial(n) {
function iter(product, counter) {
return counter > n
? product
: iter(product * counter, counter + 1);
}
return iter(1, 1);
}
注释生成的代码,显示迭代和递归版本的factorial之间的基本差异,使一个进程构建堆栈空间,另一个在恒定堆栈空间中运行。
练习 5.38
编译了哪个程序以生成图 5.18 中显示的代码?
图 5.18 编译器输出的示例。参见练习 5.38。
练习 5.39
我们的编译器为应用程序的参数产生什么样的求值顺序?是从左到右(如 ECMAScript 规范所规定的)还是从右到左,还是其他顺序?编译器中的哪个部分确定了这个顺序?修改编译器,使其产生其他求值顺序。(参见 5.4.1 节中对显式控制求值器的求值顺序的讨论。)改变参数求值的顺序如何影响构造参数列表的代码的效率?
练习 5.40
理解编译器对优化堆栈使用的“保留”机制的一种方法是看看如果我们不使用这个想法会生成什么额外的操作。修改“保留”,使其总是生成“保存”和“恢复”操作。编译一些简单的表达式,并识别生成的不必要的堆栈操作。将代码与保留机制完整生成的代码进行比较。
练习 5.41
我们的编译器在避免不必要的堆栈操作方面很聪明,但在编译语言的原始函数调用方面一点也不聪明,这些原始函数调用是通过机器提供的原始操作来实现的。例如,考虑编译计算a + 1的代码量:代码在argl中设置参数列表,将原始加法函数(通过在环境中查找符号"+"找到)放入fun,并测试函数是原始的还是复合的。编译器总是生成代码来执行测试,以及原始和复合分支的代码(只有一个会被执行)。我们没有展示实现原始的控制器部分,但我们假设这些指令利用了机器数据路径中的原始算术操作。考虑如果编译器可以开放代码原始操作——也就是说,如果它可以生成代码直接使用这些原始机器操作,将会生成多少更少的代码。表达式a + 1可能被编译成如下简单的形式⁵¹
assign("val", list(op("lookup_symbol_value"), constant("a"), reg("env"))),
assign("val", list(op("+"), reg("val"), constant(1)))
在这个练习中,我们将扩展我们的编译器以支持对选定原语的开放编码。将为这些原语函数的调用生成专用代码,而不是一般的函数应用代码。为了支持这一点,我们将用特殊的参数寄存器arg1和arg2来扩展我们的机器。机器的原始算术操作将从arg1和arg2中获取它们的输入。结果可以放入val、arg1或arg2中。
编译器必须能够识别源程序中开放编码原语的应用。我们将扩展compile函数中的分派,以识别这些原语的名称,以及它当前识别的句法形式。对于我们编译器的每个句法形式,都有一个代码生成器。在这个练习中,我们将为开放编码的原语构建一组代码生成器。
-
a. 与句法形式不同,开放编码的原语都需要求值它们的参数表达式。编写一个名为
spread_arguments的代码生成器,供所有开放编码代码生成器使用。函数spread_arguments应接受参数表达式列表,并将给定的参数表达式编译为连续的参数寄存器。注意,参数表达式可能包含对开放编码原语的调用,因此在参数表达式求值期间必须保留参数寄存器。 -
b. JavaScript 运算符
===、*、-和+等在寄存器机器中作为原始函数实现,并在全局环境中用符号===、*、-和+引用。在 JavaScript 中,不可能重新声明这些名称,因为它们不符合名称的句法限制。这意味着可以安全地开放编码它们。对于每个原始函数===、*、-和+,编写一个代码生成器,该代码生成器接受一个带有命名该函数的函数表达式的应用,以及一个目标和链接描述符,并生成代码将参数传播到寄存器,然后执行针对给定目标和给定链接的操作。使compile分派到这些代码生成器。 -
c. 尝试在“阶乘”示例上使用你的新编译器。将生成的代码与不使用开放编码产生的结果进行比较。
5.5.6 词法寻址
编译器执行的最常见优化之一是名称查找的优化。到目前为止,我们实现的编译器生成使用求值器机器的lookup_symbol_value操作的代码。这通过将名称与当前绑定的每个名称进行比较来搜索名称,通过运行时环境逐帧向外工作。如果框架嵌套深或名称很多,这种搜索可能很昂贵。例如,考虑在返回的五个参数的函数的应用中,求解表达式x * y * z时查找x的值的问题。
((x, y) =>
(a, b, c, d, e) =>
((y, z) => x * y * z)(a * b * x, c + d + x))(3, 4)
每次lookup_symbol_value搜索x时,它必须确定符号x不等于y或z(在第一个框架中),也不等于a、b、c、d或e(在第二个框架中)。因为我们的语言是词法作用域的,任何组件的运行时环境都将与组件所在的程序的词法结构相对应。因此,编译器在分析上述表达式时可以知道,每次应用函数时,x * y * z中的x的绑定将在当前框架的外部两个框架处找到,并且将是该框架中的第一个绑定。
我们可以利用这一事实,通过发明一种新的名称查找操作lexical_address_lookup,它接受环境和由两个数字组成的词法地址作为参数:帧编号,指定要跳过多少帧,和位移编号,指定在该帧中要跳过多少绑定。操作lexical_address_lookup将生成相对于当前环境存储在该词法地址的名称的值。如果我们将lexical_address_lookup操作添加到我们的机器中,我们可以让编译器生成使用这个操作引用名称的代码,而不是lookup_symbol_value。同样,我们的编译代码可以使用新的lexical_address_assign操作,而不是assign_symbol_value。使用词法寻址,对象代码中不需要包含任何名称的符号引用,帧在运行时也不需要包含符号。
为了生成这样的代码,编译器必须能够确定它即将编译引用的名称的词法地址。程序中名称的词法地址取决于代码中的位置。例如,在以下程序中,表达式e[1]中x的地址是(2,0)——向后两个帧,帧中的第一个名称。在那一点上,y的地址是(0,0),c的地址是(1,2)。在表达式e[2]中,x在(1,0),y在(1,1),c在(0,2)。
((x, y) =>
(a, b, c, d, e) =>
((y, z) => e1)(e2, c + d + x))(3, 4);
编译器产生使用词法寻址的代码的一种方法是维护一个称为编译时环境的数据结构。这个数据结构跟踪当执行特定的名称访问操作时,绑定将位于运行时环境的哪个帧的哪个位置。编译时环境是一个帧的列表,每个帧包含一个符号列表。与符号相关联的值将不会有,因为值不是在编译时计算的。(练习 5.47 将改变这一点,作为常量的优化。)编译时环境成为compile的一个额外参数,并传递给每个代码生成器。对compile的顶层调用使用包括所有原始函数和原始值名称的编译时环境。当编译 lambda 表达式的主体时,compile_lambda_body通过包含函数参数的帧扩展编译时环境,以便使用扩展的环境编译主体。同样,当编译块的主体时,compile_block通过包含主体的本地名称的帧扩展编译时环境。在编译的每个点上,compile_name和compile_assignment_declaration使用编译时环境以生成适当的词法地址。
练习 5.42 到 5.45 描述了如何完成词法寻址策略的草图,以便将词法查找纳入编译器。练习 5.46 和 5.47 描述了编译时环境的其他用途。
练习 5.42
编写一个实现新查找操作的函数lexical_address_lookup。它应该接受两个参数——词法地址和运行时环境,并返回存储在指定词法地址的名称的值。如果名称的值是字符串"unassigned",函数lexical_address_lookup应该发出错误信号。还要编写一个实现改变指定词法地址处名称的值的操作的函数lexical_address_assign。
练习 5.43
修改编译器以维护上述编译时环境。也就是说,向compile和各种代码生成器添加一个编译时环境参数,并在compile_lambda_body和compile_block中扩展它。
练习 5.44
编写一个函数find_symbol,它以符号和编译时环境作为参数,并返回相对于该环境的符号的词法地址。例如,在上面显示的程序片段中,在编译表达式e[1]期间的编译时环境是
list(list("y", "z"),
list("a", "b", "c", "d", "e"),
list("x", "y"))
函数find_symbol应该产生
find_symbol("c", list(list("y", "z"),
list("a", "b", "c", "d", "e"),
list("x", "y")));
list(1, 2)
find_symbol("x", list(list("y", "z"),
list("a", "b", "c", "d", "e"),
list("x", "y")));
list(2, 0)
find_symbol("w", list(list("y", "z"),
list("a", "b", "c", "d", "e"),
list("x", "y")));
"not found"
练习 5.45
使用练习 5.44 中的find_symbol,重写compile_assignment_declaration和compile_name以输出词法地址指令。在find_symbol返回"not found"的情况下(即名称不在编译时环境中),应报告编译时错误。在一些简单情况下测试修改后的编译器,例如本节开头的嵌套 lambda 组合。
练习 5.46
在 JavaScript 中,试图为声明为常量的名称分配新值会导致错误。练习 4.11 展示了如何在运行时检测此类错误。通过本节中介绍的技术,我们可以在编译时检测尝试为常量分配新值的行为。为此,扩展函数compile_lambda_body和compile_block以记录在编译时环境中名称是声明为变量(使用let或作为参数)还是常量(使用const或function)。修改compile_assignment以在检测到对常量的赋值时报告适当的错误。
练习 5.47
编译时对常量的了解打开了许多优化的大门,使我们能够生成更高效的目标代码。除了在练习 5.46 中扩展编译时环境以指示声明为常量的名称外,如果在编译时知道常量的值或其他可以帮助我们优化代码的信息,我们可以存储常量的值。
-
a. 诸如
const name = literal;的常量声明允许我们在声明的范围内用literal替换所有name的出现,这样就不必在运行时环境中查找name。这种优化称为常量传播。使用扩展的编译时环境存储字面常量,并修改compile_name以在生成的assign指令中使用存储的常量而不是lookup_symbol_value操作。 -
b. 函数声明是一个派生组件,它扩展为常量声明。让我们假设全局环境中原始函数的名称也被视为常量。如果我们进一步扩展我们的编译时环境以跟踪哪些名称指向编译函数,哪些指向原始函数,我们可以将检查函数是编译还是原始的测试从运行时移动到编译时。这使得目标代码更加高效,因为它通过编译器替换了在生成的代码中每个函数应用必须执行一次的测试。使用这样扩展的编译时环境,修改
compile_function_call,以便在编译时确定所调用的函数是编译还是原始时,只生成compiled_branch或primitive_branch中的指令。 -
c. 像第(a)部分那样用字面值替换常量名称为另一种优化铺平了道路,即用编译时计算的结果替换对字面值的原始函数的应用。这种优化称为常量折叠,通过在编译器中执行加法,将诸如
40 + 2之类的表达式替换为42。扩展编译器以对数字的算术运算和字符串连接执行常量折叠。
5.5.7 将编译代码与求值器进行接口
我们还没有解释如何将编译代码加载到求值器机器中,或者如何运行它。我们将假设明确控制求值器机器已经被定义,就像第 5.4.4 节中所述,其中还有脚注 43(第 5.5.2 节)中指定的其他操作。我们将实现一个名为compile_and_go的函数,该函数编译 JavaScript 程序,将生成的目标代码加载到求值器机器中,并使机器运行求值器全局环境中的代码,打印结果,并进入求值器的驱动循环。我们还将修改求值器,以便解释组件可以调用编译函数以及解释函数。然后,我们可以将编译函数放入机器中,并使用求值器调用它:
compile_and_go(parse(ˋ
function factorial(n) {
return n === 1
? 1
: factorial(n - 1) * n;
}
ˋ));
EC-求值值:
undefined
EC-求值输入:
factorial(5);
EC-求值值:
120
为了使求值器能够处理编译函数(例如,求值上面的factorial调用),我们需要更改apply_dispatch(第 5.4.2 节)处的代码,以便它识别编译函数(与复合函数或原始函数不同),并直接将控制转移到编译代码的入口点。⁵²
"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")),
在compiled_apply处,与compound_apply一样,我们将一个标记推送到堆栈,以便编译函数中的返回语句可以将堆栈恢复到此状态。请注意,在标记堆栈之前,在compiled_apply处没有保存continue,因为求值器被安排在apply_dispatch处,继续将位于堆栈顶部。
为了使我们能够在启动求值器机器时运行一些编译代码,我们在求值器机器的开头添加了一个branch指令,如果flag寄存器被设置,该指令将使机器转到新的入口点。⁵³
branch(label("external_entry")), // branches if flag is set
"read_evaluate_print_loop",
perform(list(op("initialize_stack"))),
...
external_entry处的代码假定机器以val包含的指令序列的位置启动,该指令序列将结果放入val,并以go_to(reg("continue"))结束。从这个入口点开始跳转到由val指定的位置,但首先分配continue,以便执行将返回到print_result,该函数打印val中的值,然后转到求值器的读取-求值-打印循环的开头。⁵⁴
"external_entry",
perform(list(op("initialize_stack"))),
assign("env", list(op("get_current_environment"))),
assign("continue", label("print_result")),
go_to(reg("val")),
现在我们可以使用以下函数来编译函数声明,执行编译代码,并运行读取-求值-打印循环,以便尝试该函数。因为我们希望编译代码继续到continue的位置,并在val中返回结果,所以我们使用val作为目标编译程序,并使用"return"作为链接。为了将编译器生成的目标代码转换为求值器寄存器机器的可执行指令,我们使用寄存器机器模拟器(第 5.2.2 节)中的assemble函数。为了使解释程序引用编译程序中顶层声明的名称,我们扫描顶层名称,并通过将这些名称绑定到"unassigned"来扩展全局环境,知道编译代码将为它们分配正确的值。然后,我们将val寄存器初始化为指向指令列表,设置flag以便求值器将转到external_entry,然后启动求值器。
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);
}
如果我们已经设置了堆栈监视,就像在第 5.4.4 节的末尾一样,我们可以检查编译代码的堆栈使用情况:
compile_and_go(parse(ˋ
function factorial(n) {
return n === 1
? 1
: factorial(n - 1) * n;
}
ˋ));
总推送次数= 0
最大深度= 0
EC-求值值:
undefined
EC-求值输入:
factorial(5);
总推送次数= 36
最大深度= 14
EC-求值值:
120
将此示例与使用相同函数的解释版本求值factorial(5)进行比较,该函数显示在第 5.4.4 节的末尾。解释版本需要 151 次推送和最大堆栈深度为 28。这说明了我们编译策略带来的优化。
解释和编译
有了本节中的程序,我们现在可以尝试解释和编译的替代执行策略。解释器将机器提升到用户程序的级别;编译器将用户程序降低到机器语言的级别。我们可以将 JavaScript 语言(或任何编程语言)视为建立在机器语言上的一系列连贯的抽象。解释器适用于交互式程序开发和调试,因为程序执行步骤是根据这些抽象组织的,因此对程序员更易理解。编译代码可以更快地执行,因为程序执行步骤是根据机器语言组织的,并且编译器可以进行跨越更高级抽象的优化。
解释和编译的选择也导致将语言移植到新计算机的不同策略。假设我们希望为新机器实现 JavaScript。一种策略是从第 5.4 节的显式控制求值器开始,并将其指令转换为新机器的指令。另一种策略是从编译器开始,并更改代码生成器,以便为新机器生成代码。第二种策略允许我们首先使用运行在原始 JavaScript 系统上的编译器编译任何 JavaScript 程序,并将其与运行时库的编译版本链接起来,在新机器上运行任何 JavaScript 程序。更好的是,我们可以编译编译器本身,并在新机器上运行它来编译其他 JavaScript 程序。或者我们可以编译第 4.1 节中的解释器之一,以产生在新机器上运行的解释器。
练习 5.48
通过比较编译代码和求值器在相同计算中使用的堆栈操作,我们可以确定编译器优化堆栈使用的程度,无论是在速度上(减少总堆栈操作次数)还是在空间上(减少最大堆栈深度)。将这种优化的堆栈使用与相同计算的特定用途机器的性能进行比较,可以在一定程度上反映编译器的质量。
-
a. 练习 5.28 要求您确定作为
n的函数,求值器计算n!所需的推送次数和最大堆栈深度。练习 5.13 要求您对图 5.11 中显示的特定用途阶乘机执行相同的测量。现在使用编译的factorial函数执行相同的分析。取编译版本中推送次数与解释版本中推送次数的比率,并对最大堆栈深度做同样的操作。由于计算
n!所需的操作次数和堆栈深度与n成线性关系,因此这些比率在n变大时应该接近常数。这些常数是多少?同样,找出特定用途机器的堆栈使用量与解释版本的使用量的比率。比较特定用途与解释代码的比率与编译与解释代码的比率。您应该会发现,特定用途的机器比编译代码更有效,因为手工定制的控制器代码应该比我们的基本通用编译器生成的代码要好得多。
-
b. 您能否提出改进编译器的建议,以帮助它生成性能更接近手工定制版本的代码?
练习 5.49
进行类似于练习 5.48 中的分析,以确定编译树递归斐波那契函数的有效性
function fib(n) {
return n < 2 ? n : fib(n - 1) + fib(n - 2);
}
与使用图 5.12 的专用斐波那契机器相比的效果。(有关解释性能的测量,请参见练习 5.30。)对于斐波那契,使用的时间资源与n不成线性关系;因此,堆栈操作的比值不会接近与n无关的极限值。
练习 5.50
本节描述了如何修改显式控制求值器,以便解释代码可以调用编译函数。展示如何修改编译器,以便编译函数不仅可以调用原始函数和编译函数,还可以调用解释函数。这需要修改compile_function_call来处理复合(解释)函数的情况。确保处理与compile_fun_appl中相同的所有target和linkage组合。要执行实际的函数应用,代码需要跳转到求值器的compound_apply入口点。这个标签不能在目标代码中直接引用(因为汇编器要求所有被汇编的代码引用的标签都在那里定义),所以我们将在求值器机器中添加一个名为compapp的寄存器来保存这个入口点,并添加一个指令来初始化它:
assign("compapp", label("compound_apply")),
branch(label("external_entry")), // branches if flag is set
"read_evaluate_print_loop",
...
要测试您的代码,请先声明一个调用函数g的函数f。使用compile_and_go编译f的声明并启动求值器。现在,在求值器中输入,声明g并尝试调用f。
练习 5.51
本节实现的compile_and_go接口很笨拙,因为编译器只能被调用一次(在启动求值器机器时)。通过提供一个compile_and_run原语来增强编译器-解释器接口,可以从显式控制求值器中调用它,如下所示:
EC-求值输入:
compile_and_run(parse(ˋ
function factorial(n) {
return n === 1
? 1
: factorial(n - 1) * n;
}
ˋ));
EC-求值值:
undefined
EC-求值输入:
factorial(5)
EC-求值值:
120
练习 5.52
作为使用显式控制求值器的读取-求值-打印循环的替代方案,设计一个执行读取-编译-执行-打印循环的寄存器机器。也就是说,该机器应该运行一个循环,读取一个程序,编译它,组装和执行生成的代码,并打印结果。在我们的模拟设置中很容易运行,因为我们可以安排调用函数compile和assemble作为“寄存器机器操作”。
练习 5.53
使用编译器编译第 4.1 节的元循环求值器,并使用寄存器机器模拟器运行此程序。因为解析器以字符串作为输入,所以您需要将程序转换为字符串。最简单的方法是使用反引号(-),就像我们对compile_and_go和compile_and_run的示例输入所做的那样。由于多层解释,生成的解释器运行速度会非常慢,但使所有细节正常工作是一个有益的练习。
练习 5.54
通过将第 5.4 节的显式控制求值器翻译成 C,开发一个简陋的 JavaScript 在 C 中的实现(或者您选择的其他低级语言)。为了运行这段代码,您还需要提供适当的存储分配例程和其他运行时支持。
练习 5.55
作为练习 5.54 的对照,修改编译器,使其将 JavaScript 函数编译成 C 指令序列。编译第 4.1 节的元循环求值器,以生成用 C 编写的 JavaScript 解释器。