原文:4 Metalinguistic Abstraction
译者:飞龙
...魔法就在于文字——Abracadabra,开门,以及其他——但一个故事中的魔法词在另一个故事中并不神奇。真正的魔法是理解哪些词起作用,何时起作用,以及为什么起作用;诀窍就是学会这个诀窍。
...而这些词是由我们字母表的字母组成的:我们可以用笔画出的几十个波浪线。这就是关键!如果我们能得到这个关键,也是宝藏!就好像——好像宝藏的关键 就是 宝藏!
——约翰·巴斯,《奇美拉》
在我们对程序设计的研究中,我们已经看到,专业程序员使用与所有复杂系统设计者使用的相同的一般技术来控制设计的复杂性。他们将原始元素组合成复合对象,将复合对象抽象成更高级的构建块,并通过采用适当的系统结构的大规模视图来保持模块化。在说明这些技术时,我们使用 JavaScript 作为描述过程和构建计算数据对象和过程的语言,以模拟现实世界中复杂现象。然而,随着我们面对越来越复杂的问题,我们会发现 JavaScript,或者任何固定的编程语言,都无法满足我们的需求。为了更有效地表达我们的想法,我们必须不断转向新的语言。建立新语言是控制工程设计复杂性的强大策略;通过采用新语言,我们经常可以增强处理复杂问题的能力,使我们能够以不同的方式描述(因此思考)问题,使用特别适合手头问题的原语、组合手段和抽象手段。¹
编程赋予了多种语言。有物理语言,比如特定计算机的机器语言。这些语言涉及数据和控制的表示,以存储的个别位和原始机器指令。机器语言程序员关心如何利用给定的硬件来建立系统和实用程序,以有效地实现资源有限的计算。高级语言建立在机器语言基础上,隐藏了关于数据表示和程序表示的担忧,这些语言具有组合和抽象的手段,比如函数声明,适用于系统的大规模组织。
元语言抽象——建立新语言——在所有工程设计领域都起着重要作用。对于计算机编程来说尤为重要,因为在编程中,我们不仅可以制定新语言,还可以通过构建求值器来实现这些语言。编程语言的求值器(或解释器)是一个函数,当应用于语言的语句或表达式时,执行求值该语句或表达式所需的操作。把这看作编程中最基本的想法绝非夸大:
确定编程语言中语句和表达式的含义的求值器只是另一个程序。
要理解这一点就是改变我们作为程序员的形象。我们开始把自己看作语言的设计者,而不仅仅是他人设计的语言的使用者。
事实上,我们几乎可以将任何程序视为某种语言的求值器。例如,第 2.5.3 节的多项式处理系统体现了多项式算术规则,并将其实现为对列表结构数据的操作。如果我们增加这个系统的函数来读取和打印多项式表达式,我们就有了一个处理符号数学问题的特定目的语言的核心。第 3.3.4 节的数字逻辑模拟器和第 3.3.5 节的约束传播器本身就是合法的语言,每种语言都有自己的原语、组合手段和抽象手段。从这个角度来看,应对大规模计算机系统的技术与构建新计算机语言的技术融为一体,计算机科学本身不再(也不会更少)只是构建适当描述性语言的学科。
我们现在开始了解语言建立在其他语言基础上的技术之旅。在本章中,我们将以 JavaScript 为基础,将求值器实现为 JavaScript 函数。我们将通过为 JavaScript 本身构建一个求值器来迈出理解语言如何实现的第一步。我们的求值器实现的语言将是 JavaScript 的一个子集。尽管本章描述的求值器是针对 JavaScript 的特定子集编写的,但它包含了为顺序机器编写程序设计语言的求值器的基本结构。(事实上,大多数语言处理器深藏其中一个小型求值器。)为了说明和讨论的目的,求值器已经简化,并且一些重要的特性被省略了,这些特性对于生产质量的 JavaScript 系统来说是重要的。然而,这个简单的求值器足以执行本书中大部分的程序。
将求值器作为 JavaScript 程序可访问的一个重要优势是,我们可以通过将其描述为对求值器程序的修改来实现替代求值规则。我们可以利用这种能力的一个地方是,以更好的效果获得对计算模型体现时间概念的额外控制,这在第 3 章的讨论中是如此核心。在那里,我们通过使用流来解耦世界中的时间表示与计算机中的时间,来减轻一些状态和赋值的复杂性。然而,我们的流程序有时会很笨重,因为它们受到 JavaScript 的应用顺序求值的限制。在 4.2 节中,我们将改变基础语言,以提供更优雅的方法,通过修改求值器来提供正则顺序求值。
第 4.3 节实现了一个更有雄心的语言变化,即语句和表达式具有多个值,而不仅仅是一个单一值。在这种非确定性计算语言中,自然地表达生成所有可能值的过程,然后搜索满足某些约束的值。就计算模型和时间而言,这就像时间分支成一组“可能的未来”,然后搜索适当的时间线。通过我们的非确定性求值器,跟踪多个值和执行搜索都由语言的基础机制自动处理。
在第 4.4 节中,我们实现了一种逻辑编程语言,其中知识是以关系的形式表达,而不是以输入和输出的计算形式。尽管这使得语言与 JavaScript 或任何传统语言都大不相同,但我们将看到逻辑编程求值器与 JavaScript 求值器共享基本结构。
4.1 元循环求值器
我们的 JavaScript 求值器将作为 JavaScript 程序实现。用 JavaScript 实现 JavaScript 程序的求值似乎是循环的。然而,求值是一个过程,因此用 JavaScript 描述求值过程是合适的,毕竟,这是我们描述过程的工具。³ 用相同语言编写的求值器被称为元循环求值器。
元循环求值器本质上是对 3.2 节中描述的求值环境模型的 JavaScript 表述。回想一下,该模型指定了函数应用的求值有两个基本步骤:
-
- 求值函数应用时,首先求值子表达式,然后将函数子表达式的值应用于参数子表达式的值。
-
2. 将复合函数应用于一组参数时,求值函数体在新环境中。为构造此环境,通过将函数对象的环境部分扩展为函数的参数绑定到应用函数的参数的帧。
这两条规则描述了求值过程的本质,即在环境中要求值的语句和表达式被简化为要应用于参数的函数,然后再简化为在新环境中要求值的新语句和表达式,依此类推,直到我们到达名称,其值在环境中查找,以及运算符和原始函数,这些直接应用(见图 4.1)。⁴ 这种求值循环将由求值器中两个关键函数evaluate和apply之间的相互作用体现出来,这些函数在 4.1.1 节中描述(见图 4.1)。
图 4.1 evaluate-apply循环揭示了计算机语言的本质。
求值器的实现将依赖于定义要求值的语句和表达式的语法的函数。我们将使用数据抽象使求值器独立于语言的表示。例如,我们不会选择将赋值表示为以名称开头后跟=的字符串,而是使用抽象谓词is_assignment来测试赋值,并使用抽象选择器assignment_symbol和assignment_value_expression来访问赋值的部分。4.1.2 节中提出的数据抽象层将使求值器保持独立于具体的语法问题,例如解释语言的关键字,以及表示程序组件的数据结构的选择。还有在 4.1.3 节中描述的操作,用于指定函数和环境的表示。例如,make_function构造复合函数,lookup_symbol_value访问名称的值,apply_primitive_function将原始函数应用于给定的参数列表。
4.1.1 求值器的核心
求值过程可以描述为evaluate和apply两个函数之间的相互作用。
函数evaluate
函数evaluate以程序组件(语句或表达式)和环境作为参数。它对组件进行分类并指导其求值。函数evaluate被构造为对要求值的组件的句法类型进行案例分析。为了保持函数的一般性,我们抽象地表达了组件类型的确定,不对各种组件类型的具体表示做出承诺。每种组件类型都有一个语法谓词来测试它,并选择其部分的抽象手段。这种抽象语法使我们可以通过使用相同的求值器,但使用不同的语法函数集合来轻松地看到如何改变语言的语法。
原始表达式
-
对于文字表达式,比如数字,
evaluate返回它们的值。 -
函数
evaluate必须在环境中查找名称以找到它们的值。
组合
-
对于函数应用,
evaluate必须递归求值应用的函数表达式和参数表达式。得到的函数和参数被传递给apply,后者处理实际的函数应用。 -
操作符组合被转换为函数应用,然后进行求值。
句法形式
-
条件表达式或语句需要对其部分进行特殊处理,以便在谓词为真时求值结果,否则求值替代方案。
-
λ表达式必须通过将λ表达式指定的参数和主体与求值的环境一起打包,转换为可应用的函数。
-
一系列语句需要按照它们出现的顺序求值其组件。
-
一个块需要在反映块内声明的所有名称的新环境中求值其主体。
-
返回语句必须产生一个值,该值成为导致返回语句求值的函数调用的结果。
-
函数声明被转换为常量声明,然后进行求值。
-
常量或变量声明或赋值必须调用
evaluate进行递归计算,以计算与正在声明或分配的名称关联的新值。必须修改环境以反映名称的新值。
这是evaluate的声明:
function evaluate(component, env) {
return is_literal(component)
? literal_value(component)
: is_name(component)
? lookup_symbol_value(symbol_of_name(component), env)
: is_application(component)
? apply(evaluate(function_expression(component), env),
list_of_values(arg_expressions(component), env))
: is_operator_combination(component)
? evaluate(operator_combination_to_application(component), env)
: is_conditional(component)
? eval_conditional(component, env)
: is_lambda_expression(component)
? make_function(lambda_parameter_symbols(component),
lambda_body(component), env)
: is_sequence(component)
? eval_sequence(sequence_statements(component), env)
: is_block(component)
? eval_block(component, env)
: is_return_statement(component)
? eval_return_statement(component, env)
: is_function_declaration(component)
? evaluate(function_decl_to_constant_decl(component), env)
: is_declaration(component)
? eval_declaration(component, env)
: is_assignment(component)
? eval_assignment(component, env)
: error(component, "unknown syntax – evaluate");
}
为了清晰起见,evaluate已经被实现为使用条件表达式的案例分析。这样做的缺点是,我们的函数只处理了一些可区分的语句和表达式类型,而且没有新的类型可以在不编辑evaluate的声明的情况下定义。在大多数解释器实现中,根据组件的类型进行分派是以数据导向的方式进行的。这允许用户添加evaluate可以区分的新类型的组件,而无需修改evaluate本身的声明。(见练习 4.3。)
名称的表示由语法抽象处理。在内部,求值器使用字符串来表示名称,我们将这样的字符串称为符号。函数evaluate中使用的symbol_of_name从名称中提取其表示的符号。
应用
函数apply接受两个参数,一个函数和一个应用该函数的参数列表。函数apply将函数分类为两种:它调用apply_primitive_function来应用原始函数;它通过求值组成函数主体的块来应用复合函数。复合函数的主体的求值环境是通过扩展函数携带的基本环境来构建的,以包括将函数的参数绑定到要应用函数的参数的帧。这是apply的声明:
function apply(fun, args) {
if (is_primitive_function(fun)) {
return apply_primitive_function(fun, args);
} else if (is_compound_function(fun)) {
const result = evaluate(function_body(fun),
extend_environment(
function_parameters(fun),
args,
function_environment(fun)));
return is_return_value(result)
? return_value_content(result)
: undefined;
} else {
error(fun, "unknown function type – apply");
}
}
为了返回一个值,JavaScript 函数需要求值一个返回语句。如果一个函数在不求值返回语句的情况下终止,将返回值undefined。为了区分这两种情况,返回语句的求值将返回表达式的结果包装成一个返回值。如果函数体的求值产生了这样一个返回值,就会检索返回值的内容;否则将返回值undefined。⁶
函数参数
当evaluate处理函数应用时,它使用list_of_values来生成要应用函数的参数列表。函数list_of_values以应用的参数表达式作为参数。它求值每个参数表达式并返回相应值的列表:⁷
function list_of_values(exps, env) {
return map(arg => evaluate(arg, env), exps);
}
条件语句
函数eval_conditional求值给定环境中条件组件的谓词部分。如果结果为真,则求值结果,否则求值替代结果:
function eval_conditional(component, env) {
return is_truthy(evaluate(conditional_predicate(component), env))
? evaluate(conditional_consequent(component), env)
: evaluate(conditional_alternative(component), env);
}
请注意,求值器不需要区分条件表达式和条件语句。
在eval_conditional中使用is_truthy突出了实现语言和实现语言之间的连接问题。conditional_predicate在正在实现的语言中进行求值,因此产生该语言中的一个值。解释器谓词is_truthy将该值转换为可以由实现语言中的条件表达式测试的值:真实的元循环表示可能与底层 JavaScript 的表示不同。⁸
序列
函数eval_sequence由evaluate用于求值顶层或块中的语句序列。它以语句序列和环境作为参数,并按照它们出现的顺序求值这些语句。返回的值是最终语句的值,但如果序列中任何语句的求值产生了返回值,那么将返回该值,并且忽略后续的语句。⁹
function eval_sequence(stmts, env) {
if (is_empty_sequence(stmts)) {
return undefined;
} else if (is_last_statement(stmts)) {
return evaluate(first_statement(stmts), env);
} else {
const first_stmt_value =
evaluate(first_statement(stmts), env);
if (is_return_value(first_stmt_value)) {
return first_stmt_value;
} else {
return eval_sequence(rest_statements(stmts), env);
}
}
}
块
函数eval_block处理块。在块中声明的变量和常量(包括函数)具有整个块作为它们的作用域,因此在求值块的主体之前会“扫描出”它们。块的主体是根据通过将每个本地名称绑定到特殊值"unassigned"来扩展当前环境的环境进行求值。这个字符串作为一个占位符,在声明求值之前,访问名称的值会导致运行时错误(见第 1 章脚注 56 中的练习 4.12)。
function eval_block(component, env) {
const body = block_body(component);
const locals = scan_out_declarations(body);
const unassigneds = list_of_unassigned(locals);
return evaluate(body, extend_environment(locals,
unassigneds,
env));
}
function list_of_unassigned(symbols) {
return map(symbol => "unassigned", symbols);
}
函数scan_out_declarations收集在函数体中声明的所有符号名称的列表。它使用declaration_symbol从找到的声明语句中检索表示名称的符号。
function scan_out_declarations(component) {
return is_sequence(component)
? accumulate(append,
null,
map(scan_out_declarations,
sequence_statements(component)))
: is_declaration(component)
? list(declaration_symbol(component))
: null;
}
我们忽略嵌套在另一个块中的声明,因为该块的求值会处理它们。函数scan_out_declarations只在序列中查找声明,因为条件语句、函数声明和 lambda 表达式中的声明总是在嵌套块中。
返回语句
函数eval_return_statement用于求值返回语句。正如在apply和序列的求值中所看到的,返回语句的求值结果需要是可识别的,以便函数体的求值可以立即返回,即使在返回语句之后还有语句。为此,返回语句的求值将返回表达式的求值结果包装在一个返回值对象中。¹⁰
function eval_return_statement(component, env) {
return make_return_value(evaluate(return_expression(component),
env));
}
赋值和声明
函数eval_assignment处理对名称的赋值。(为了简化我们的求值器的表示,我们不仅允许对变量进行赋值,还允许对常量进行错误的赋值。练习 4.11 解释了我们如何区分常量和变量,并防止对常量进行赋值。)函数eval_assignment调用值表达式上的evaluate来找到要赋值的值,并调用assignment_symbol来检索表示名称的符号。函数eval_assignment将符号和值传递给assign_symbol_value,以安装在指定环境中。赋值的求值返回被赋的值。
function eval_assignment(component, env) {
const value = evaluate(assignment_value_expression(component),
env);
assign_symbol_value(assignment_symbol(component), value, env);
return value;
}
常量和变量声明都由is_declaration语法谓词识别。它们的处理方式类似于赋值,因为eval_block已经将它们的符号绑定到当前环境中的"unassigned"。它们的求值将"unassigned"替换为值表达式的求值结果。
function eval_declaration(component, env) {
assign_symbol_value(
declaration_symbol(component),
evaluate(declaration_value_expression(component), env), env);
return undefined;
}
函数的返回值由return语句确定,因此eval_declaration中的返回值undefined只在声明发生在顶层,即在任何函数体之外时才重要。在这里,我们使用返回值undefined来简化表示;练习 4.8 描述了在 JavaScript 中求值顶层组件的真实结果。
练习 4.1
请注意,我们无法确定元循环求值器是从左到右还是从右到左求值参数表达式。它的求值顺序是从底层 JavaScript 继承的:如果map中pair的参数是从左到右求值的,那么list_of_values将从左到右求值参数表达式;如果pair的参数是从右到左求值的,那么list_of_values将从右到左求值参数表达式。
编写一个list_of_values的版本,无论底层 JavaScript 的求值顺序如何,都从左到右求值参数表达式。还要编写一个list_of_values的版本,从右到左求值参数表达式。
4.1.2 表示组件
程序员将程序编写为文本,即一系列以编程环境或文本编辑器输入的字符。要运行我们的求值器,我们需要从 JavaScript 值开始表示这个程序文本。在 2.3.1 节中,我们介绍了字符串来表示文本。我们希望求值诸如 1.1.2 节中的"const size = 2; 5 * size;"之类的程序。不幸的是,这样的程序文本并不能为求值器提供足够的结构。在这个例子中,程序部分"size = 2"和"5 * size"看起来相似,但含义完全不同。通过检查程序文本来实现抽象语法函数,如declaration_value_expression,将会很困难且容易出错。因此,在本节中,我们引入了一个名为parse的函数,将程序文本转换为标记列表表示,类似于 2.4.2 节的标记数据。例如,对上面的程序字符串应用parse会产生一个反映程序结构的数据结构:一个序列,其中包含一个将名称size与值 2 关联起来的常量声明和一个乘法。
parse("const size = 2; 5 * size;");
list("sequence",
list(list("constant_declaration",
list("name", "size"), list("literal", 2)),
list("binary_operator_combination", "*",
list("literal", 5), list("name", "size"))))
求值器使用的语法函数访问parse产生的标记列表表示。
求值器类似于第 2.3.2 节讨论的符号微分程序。这两个程序都操作符号数据。在这两个程序中,对对象进行操作的结果是通过递归地对对象的部分进行操作,并以一种取决于对象类型的方式将结果组合起来。在这两个程序中,我们使用数据抽象来将操作的一般规则与对象的表示方式分离开来。在微分程序中,这意味着相同的微分函数可以处理前缀形式的代数表达式,中缀形式的代数表达式,或者其他形式的代数表达式。对于求值器,这意味着被求值的语言的语法完全由parse和分类和提取parse产生的标记列表的部分的函数确定。
图 4.2 描述了由语法谓词和选择器形成的抽象屏障,这些语法谓词和选择器将求值器与程序的标记列表表示接口,这又与字符串表示由parse分隔开。下面我们描述程序组件的解析,并列出相应的语法谓词和选择器,以及如果需要的话的构造函数。
图 4.2 求值器中的语法抽象。
文字表达式
文字表达式被解析为带有标签"literal"和实际值的标记列表。
《literal-expression 》 = list("literal", value)
其中value是由literal-expression字符串表示的 JavaScript 值。这里《literal-expression》表示解析字符串literal-expression的结果。
parse("1;");
list("literal", 1)
parse("'hello world';");
list("literal", "hello world")
parse("null;");
list("literal", null)
文字表达式的语法谓词是is_literal。
function is_literal(component) {
return is_tagged_list(component, "literal");
}
它是根据函数is_tagged_list定义的,该函数标识以指定字符串开头的列表:
function is_tagged_list(component, the_tag) {
return is_pair(component) && head(component) === the_tag;
}
解析文字表达式产生的列表的第二个元素是其实际的 JavaScript 值。用于检索值的选择器是literal_value。
function literal_value(component) {
return head(tail(component));
}
literal_value(parse("null;"));
null
在本节的其余部分,我们只列出语法谓词和选择器,并省略它们的声明,如果它们只是访问明显的列表元素。
我们为文字提供了一个构造函数,这将很方便:
function make_literal(value) {
return list("literal", value);
}
名称
名称的标记列表表示包括标签"name"作为第一个元素和表示名称的字符串作为第二个元素。
《 name 》 = list("name", symbol)
其中symbol是一个包含构成程序中name的字符的字符串。名称的语法谓词是is_name。可以使用选择器symbol_of_name访问符号。我们为名称提供了一个构造函数,供operator_combination_to_application使用:
function make_name(symbol) {
return list("name", symbol);
}
表达式语句
我们不需要区分表达式和表达式语句。因此,parse可以忽略这两种组件之间的区别:
《 expression; 》 = 《 expression 》
函数应用
函数应用的解析如下:
《 fun-expr(arg-expr[1], ..., arg-expr[n]) 》=
list("application",
《 fun-expr 》,
list(《 arg-expr[1] 》, ..., 《 arg-expr[n] 》))
我们将is_application声明为语法谓词,function_expression和arg_expressions作为选择器。我们添加了一个函数应用的构造函数,供operator_combination_to_application使用:
function make_application(function_expression, argument_expressions) {
return list("application",
function_expression, argument_expressions);
}
条件
条件表达式的解析如下:
《 predicate ? consequent-expression : alternative-expression 》=
list("conditional_expression",
《 predicate 》,
《 consequent-expression 》,
《 alternative-expression 》)
类似地,条件语句的解析如下:
《 if (predicate) consequent-block else alternative-block 》=
list("conditional_statement",
《 predicate 》,
《 consequent-block 》,
《 alternative-block 》)
语法谓词is_conditional对两种条件都返回true,选择器conditional_predicate,conditional_consequent和conditional_alternative可以应用于两种条件。
Lambda 表达式
解析主体为表达式的 lambda 表达式,就好像主体由包含单个返回语句的块解析,返回表达式是 lambda 表达式的主体。
《 (name[1], ..., name[n]) => expression 》 =
《 (name[1], ..., name[n]) => { return expression ; } 》
解析主体为块的 lambda 表达式如下:
《 (name[1], ..., name[n]) => block 》=
list("lambda_expression",
list(《 name[1] 》, ..., 《 name[n] 》),
《 block 》)
语法谓词是is_lambda_expression,lambda 表达式的主体选择器是lambda_body。称为lambda_parameter_symbols的参数选择器还从名称中提取符号。
function lambda_parameter_symbols(component) {
return map(symbol_of_name, head(tail(component)));
}
函数function_decl_to_constant_decl需要一个 lambda 表达式的构造函数:
function make_lambda_expression(parameters, body) {
return list("lambda_expression", parameters, body);
}
序列
序列语句将一系列语句打包成一个单独的语句。语句序列的解析如下:
《 statement[1] ... statement[n] 》 =
list("sequence", list(《 statement[1] 》, ..., 《 statement[n] 》))
语法谓词是is_sequence,选择器是sequence_statements。我们使用first_statement检索语句列表的第一个语句,使用rest_statements检索剩余的语句。我们使用谓词is_empty_sequence测试列表是否为空,并使用谓词is_last_statement测试列表是否只包含一个元素。¹¹
function first_statement(stmts) { return head(stmts); }
function rest_statements(stmts) { return tail(stmts); }
function is_empty_sequence(stmts) { return is_null(stmts); }
function is_last_statement(stmts) { return is_null(tail(stmts)); }
块
块的解析如下:¹²
《 { statements } 》 = list("block", 《 statements 》 )
这里statements指的是一系列语句,如上所示。语法谓词是is_block,选择器是block_body。
返回语句
返回语句的解析如下:
《 return expression; 》 = list("return_statement", 《 expression 》 )
语法谓词和选择器分别是is_return_statement和return_expression。
赋值
赋值的解析如下:
《 name = expression 》 = list("assignment", 《 name 》 , 《 expression 》 )
语法谓词是is_assignment,选择器是assignment_symbol和assignment_value_expression。符号包装在表示名称的标记列表中,因此assignment_symbol需要将其解包。
function assignment_symbol(component) {
return symbol_of_name(head(tail(component))));
}
常量、变量和函数声明
常量和变量声明的解析如下:
《 const name = expression; 》 =
list("constant_declaration", 《 name 》, 《 expression 》)
《 let name = expression; 》 =
list("variable_declaration", 《 name 》, 《 expression 》)
选择器declaration_symbol和declaration_value_expression适用于两种情况。
function declaration_symbol(component) {
return symbol_of_name(head(tail(component)));
}
function declaration_value_expression(component) {
return head(tail(tail(component)));
}
函数function_decl_to_constant_decl需要一个常量声明的构造函数:
function make_constant_declaration(name, value_expression) {
return list("constant_declaration", name, value_expression);
}
函数声明的解析如下:
function name(name[1], ... name[n]) block 》=
list("function_declaration",
《 name 》,
list(《 name[1] 》, ..., 《 name[n] 》),
《 block 》)
语法谓词is_function_declaration识别这些。选择器是function_declaration_name,function_declaration_parameters和function_declaration_body。
语法谓词is_declaration对所有三种声明返回true。
function is_declaration(component) {
return is_tagged_list(component, "constant_declaration") ||
is_tagged_list(component, "variable_declaration") ||
is_tagged_list(component, "function_declaration");
}
派生组件
我们语言中的一些语法形式可以根据涉及其他语法形式的组件来定义,而不是直接实现。一个例子是函数声明,evaluate将其转换为值表达式为 lambda 表达式的常量声明。¹³
function function_decl_to_constant_decl(component) {
return make_constant_declaration(
function_declaration_name(component),
make_lambda_expression(
function_declaration_parameters(component),
function_declaration_body(component)));
}
以这种方式实现函数声明的求值简化了求值器,因为它减少了必须明确指定求值过程的语法形式的数量。
同样,我们定义操作符组合以函数应用的形式。操作符组合是一元或二元的,并且在标记列表表示中携带其操作符符号作为第二个元素:
《 unary-operator expression 》=
list("unary_operator_combination",
"unary-operator",
list(《 expression 》))
其中*是!(逻辑否定)或-unary(数值否定),并且
《 expression[1] binary-operator expression[2] 》=
list("binary_operator_combination",
"binary-operator",
list(《 expression[1] 》, 《 expression[2] 》))
其中binary-operator是+,-,*,/,%,===,!==,>,<,>=或<=。语法谓词是is_operator_combination,is_unary_operator_combination和is_binary_operator_combination,选择器是operator_symbol,first_operand和second_operand。
求值器使用operator_combination_to_application将操作符组合转换为一个函数应用,其函数表达式是操作符的名称:
function operator_combination_to_application(component) {
const operator = operator_symbol(component);
return is_unary_operator_combination(component)
? make_application(make_name(operator),
list(first_operand(component)))
: make_application(make_name(operator),
list(first_operand(component),
second_operand(component)));
}
我们选择将组件(如函数声明和操作符组合)实现为语法转换的派生组件。逻辑组合操作也是派生组件(参见练习 4.4)。
练习 4.2
parse的逆操作称为unparse。它以parse生成的标记列表作为参数,并返回一个符合 JavaScript 表示的字符串。
-
a. 按照
evaluate的结构(不包括环境参数),编写一个名为unparse的函数,但是产生一个表示给定组件的字符串,而不是对其进行求值。回想一下,从第 3.3.4 节中得知,操作符+可以应用于两个字符串以将它们连接起来,原始函数stringify将值(如 1.5、true、null和undefined)转换为字符串。请注意,通过使用括号(总是或在必要时)括起来,以保持操作符的优先级。 -
b. 在解决本节中的后续练习时,您的
unparse函数会派上用场。通过向结果字符串添加" "(空格)和"\n"(换行)字符,以遵循本书中 JavaScript 程序中使用的缩进样式,改进unparse。为了使文本更易于阅读,向程序文本中添加(或删除)此类空白字符称为美化打印。
练习 4.3
重写evaluate,以便以数据导向的方式进行分派。将此与练习 2.73 中的数据导向微分函数进行比较。 (您可以使用标记列表表示的标记作为组件类型。)
练习 4.4
回想一下,从第 1.1.6 节中得知,逻辑组合操作&&和||是条件表达式的语法糖:逻辑连接expression[1] && expression[2]是expression[1] ? expression[2] : false的语法糖,逻辑析取expression[1] || expression[2]是expression[1] ? true : expression[2]的语法糖。它们的解析如下:
《 expression[1] logical-operation expression[2] 》=
list("logical_composition",
"logical-operation",
list(《 expression[1] 》, 《 expression[2] 》))
其中logical-operation是&&或||。通过声明适当的语法函数和求值函数eval_and和eval_or,将&&和||作为求值器的新语法形式。或者,展示如何将&&和||实现为派生组件。
练习 4.5
-
a. 在 JavaScript 中,lambda 表达式不能具有重复参数。第 4.1.1 节中的求值器没有检查这一点。
-
修改求值器,以便任何尝试应用具有重复参数的函数都会发出错误信号。
-
实现一个
verify函数,检查给定程序中的任何 lambda 表达式是否包含重复参数。有了这样一个函数,我们可以在将其传递给evaluate之前检查整个程序。
为了在 JavaScript 的求值器中实现此检查,您更喜欢这两种方法中的哪一种?为什么?
-
-
b. 在 JavaScript 中,lambda 表达式的参数必须与 lambda 表达式的主体块中直接声明的名称不同(而不是在内部块中)。使用上面的首选方法来检查这一点。
练习 4.6
Scheme 语言包括一个名为let的变体。我们可以通过规定let声明隐式引入一个新的块,该块的主体包括声明和声明出现的语句序列中的所有后续语句,来近似 JavaScript 中let的行为。例如,程序
let* x = 3;
let* y = x + 2;
let* z = x + y + 5;
display(x * z);
显示 39 并且可以被视为一种简写
{
let x = 3;
{
let y = x + 2;
{
let z = x + y + 5;
display(x * z);
}
}
}
-
a. 在这样一个扩展的 JavaScript 语言中编写一个程序,当一些关键字
let的出现被替换为let*时,其行为会有所不同。 -
b. 通过设计合适的标记列表表示并编写解析规则,将
let*引入为一个新的语法形式。声明标记列表表示的语法谓词和选择器。 -
c. 假设
parse实现了您的新规则,请编写一个let_star_to_nested_let函数,以转换给定程序中的任何let*的出现,如上所述。然后,通过运行evaluate(let_star_to_nested_let(p))来求值扩展语言中的程序p。 -
d. 作为一种替代方案,考虑通过向
evaluate添加一个子句来实现let*,该子句识别新的语法形式并调用一个名为eval_let_star_declaration的函数。为什么这种方法行不通?
练习 4.7
JavaScript 支持重复执行给定语句的*while循环*。具体来说,
while (predicate) { body }
求值predicate,如果结果为true,则求值body,然后再次求值整个while循环。一旦predicate求值为false,while循环终止。
例如,回想一下第 3.1.3 节中迭代阶乘函数的命令式版本:
function factorial(n) {
let product = 1;
let counter = 1;
function iter() {
if (counter > n) {
return product;
} else {
product = counter * product;
counter = counter + 1;
return iter();
}
}
return iter();
}
我们可以使用while循环来制定相同的算法,如下所示:
function factorial(n) {
let product = 1;
let counter = 1;
while (counter <= n) {
product = counter * product;
counter = counter + 1;
}
return product;
}
当循环被解析如下:
《 while (predicate) block 》 =
list("while_loop", 《 predicate 》, 《 block 》)
-
a. 声明一个语法谓词和选择器来处理
while循环。 -
b. 声明一个名为
while_loop的函数,该函数接受谓词和主体作为参数,每个参数由一个没有参数的函数表示,并模拟while循环的行为。然后factorial函数如下所示:function factorial(n) { let product = 1; let counter = 1; while_loop(() => counter <= n, () => { product = counter * product; counter = counter + 1; }); return product; }你的
while_loop函数应该生成一个迭代过程(参见第 1.2.1 节)。 -
c. 通过定义一个转换函数
while_to_application,将while循环安装为一个派生组件,利用你的while_loop函数。 -
d. 当程序员在循环的主体内决定从包含循环的函数返回时,使用这种方法实现
while循环会出现什么问题? -
e. 改变你的方法来解决这个问题。直接为求值器安装
while循环,使用一个名为eval_while的函数如何? -
f. 遵循这种直接的方法,实现一个
break;语句,它立即终止它所在的循环。 -
g. 实现一个
predicate;语句,它只终止它所在的循环迭代,并继续求值while循环的谓词。
练习 4.8
函数主体的求值结果由其返回语句确定。继续参考脚注 9 和第 4.1.1 节中声明的求值,这个练习解决了一个问题,即由一系列语句(声明、块、表达式语句和条件语句)组成的 JavaScript 程序在任何函数主体之外的情况下应该是什么结果。
对于这样的程序,JavaScript 在产生值和不产生值的语句之间进行静态区分。(这里的“静态”意味着我们可以通过检查程序而不是运行它来进行区分。)所有声明都不产生值,所有表达式语句和条件语句都产生值。表达式语句的值是表达式的值。条件语句的值是执行的分支的值,如果该分支不产生值,则值为undefined。如果块的主体(语句序列)是产生值的,则块是产生值的,然后它的值是其主体的值。如果序列的任何组成语句是产生值的,则序列是产生值的,然后它的值是其最后产生值的组成语句的值。最后,如果整个程序不产生值,则其值为undefined。
-
a. 根据这个规范,以下四个程序的值是什么?
1; 2; 3; 1; { if (true) {} else { 2; } } 1; const x = 2; 1; { let x = 2; { x = x + 3; } } -
b. 修改求值器以符合这个规范。
4.1.3 求值器数据结构
除了定义组件的表示形式之外,求值器实现还必须定义求值器在程序执行过程中内部操作的数据结构,例如函数和环境的表示以及true和false的表示。
谓词的测试
为了将条件语句的谓词限制为适当的谓词(求值为布尔值的表达式),我们坚持要求is_truthy函数只应用于布尔值,并且我们只接受布尔值true为真值。is_truthy的相反称为is_falsy。
function is_truthy(x) {
return is_boolean(x)
? x
: error(x, "boolean expected, received");
}
function is_falsy(x) { return ! is_truthy(x); }
表示函数
为了处理原始数据,我们假设有以下函数可用:
-
apply_primitive_function(fun, args)将给定的原始函数应用于列表
args中的参数值,并返回应用的结果。 -
is_primitive_function(fun)测试
fun是否为原始函数。
这些处理原始数据的机制在 4.1.4 节中进一步描述。
使用构造函数make_function构建复合函数,由参数、函数体和环境组成:
function make_function(parameters, body, env) {
return list("compound_function", parameters, body, env);
}
function is_compound_function(f) {
return is_tagged_list(f, "compound_function");
}
function function_parameters(f) { return list_ref(f, 1); }
function function_body(f) { return list_ref(f, 2); }
function function_environment(f) { return list_ref(f, 3); }
表示返回值
我们在 4.1.1 节中看到,当遇到return语句时,序列的求值终止,如果函数体的求值没有遇到return语句,则函数应用的求值需要返回值undefined。为了识别返回语句导致的值,我们引入返回值作为求值器数据结构。
function make_return_value(content) {
return list("return_value", content);
}
function is_return_value(value) {
return is_tagged_list(value, "return_value");
}
function return_value_content(value) {
return head(tail(value));
}
环境操作
求值器需要操作来操作环境。如 3.2 节所述,环境是帧的序列,其中每个帧都是将符号与其对应值关联的绑定表。我们使用以下操作来操作环境:
-
lookup_symbol_value(symbol, env)返回在环境
env中绑定到symbol的值,如果symbol未绑定,则发出错误。 -
extend_environment(symbols, values, base-env)返回一个新环境,由一个新帧组成,其中列表
symbols中的符号绑定到列表values中的相应元素,封闭环境是环境base-env。 -
assign_symbol_value(symbol, value, env)找到
env中symbol绑定的最内层帧,并更改该帧,使symbol现在绑定到value,如果symbol未绑定,则发出错误。
为了实现这些操作,我们将环境表示为帧的列表。环境的封闭环境是列表的tail。空环境就是空列表。
function enclosing_environment(env) { return tail(env); }
function first_frame(env) { return head(env); }
const the_empty_environment = null;
每个环境的帧都表示为两个列表的对:一个是该帧中绑定的名称列表,另一个是相关值的列表。
function make_frame(symbols, values) { return pair(symbols, values); }
function frame_symbols(frame) { return head(frame); }
function frame_values(frame) { return tail(frame); }
通过将符号与值关联的新帧扩展环境,我们将一个由符号列表和值列表组成的帧添加到环境中。如果符号的数量与值的数量不匹配,则发出错误。
function extend_environment(symbols, vals, base_env) {
return length(symbols) === length(vals)
? pair(make_frame(symbols, vals), base_env)
: error(pair(symbols, vals),
length(symbols) < length(vals)
? "too many arguments supplied"
: "too few arguments supplied");
}
这是在 4.1.1 节中由apply使用的,将函数的参数绑定到其参数。
要在环境中查找符号,我们扫描第一个帧中的符号列表。如果找到所需的符号,我们返回值列表中的相应元素。如果在当前帧中找不到符号,则搜索封闭环境,依此类推。如果达到空环境,则发出"未绑定的名称"错误。
function lookup_symbol_value(symbol, env) {
function env_loop(env) {
function scan(symbols, vals) {
return is_null(symbols)
? env_loop(enclosing_environment(env))
: symbol === head(symbols)
? head(vals)
: scan(tail(symbols), tail(vals));
}
if (env === the_empty_environment) {
error(symbol, "unbound name");
} else {
const frame = first_frame(env);
return scan(frame_symbols(frame), frame_values(frame));
}
}
return env_loop(env);
}
要在指定的环境中为符号分配新值,我们扫描符号,就像在lookup_symbol_value中一样,并在找到时更改相应的值。
function assign_symbol_value(symbol, val, env) {
function env_loop(env) {
function scan(symbols, vals) {
return is_null(symbols)
? env_loop(enclosing_environment(env))
: symbol === head(symbols)
? set_head(vals, val)
: scan(tail(symbols), tail(vals));
}
if (env === the_empty_environment) {
error(symbol, "unbound name – assignment");
} else {
const frame = first_frame(env);
return scan(frame_symbols(frame), frame_values(frame));
}
}
return env_loop(env);
}
这里描述的方法只是表示环境的许多合理方法中的一种。由于我们使用数据抽象来将求值器的其余部分与表示的详细选择隔离开来,如果需要,我们可以更改环境表示。 (见练习 4.9。)在生产质量的 JavaScript 系统中,求值器环境操作的速度,特别是符号查找的速度,对系统的性能有重大影响。这里描述的表示虽然在概念上很简单,但并不高效,通常不会在生产系统中使用。¹⁶
练习 4.9
我们可以将框架表示为绑定的列表,其中每个绑定都是一个符号-值对,而不是将框架表示为列表对。重写环境操作以使用这种替代表示。
练习 4.10
函数lookup_symbol_value和assign_symbol_value可以用更抽象的函数来表达环境结构的遍历。定义一个捕获常见模式的抽象,并根据这个抽象重新定义这两个函数。
练习 4.11
我们的语言通过使用不同的关键字const和let区分常量和变量,并阻止对常量进行赋值。然而,我们的解释器并没有利用这种区别;函数assign_symbol_value将愉快地为给定的符号分配一个新值,而不管它是作为常量还是变量声明的。通过在尝试在赋值的左侧使用常量时调用函数error来纠正这个缺陷。您可以按照以下步骤进行:
-
引入谓词
is_constant_declaration和is_variable_declaration,允许您区分这两种类型。如 4.1.2 节所示,parse通过使用标签"constant_declaration"和"variable_declaration"来区分它们。 -
更改
scan_out_declarations和(如果必要)extend_environment,使常量在绑定它们的框架中与变量区分开来。 -
更改
assign_symbol_value,使其检查给定的符号是作为变量还是常量声明的,并在后一种情况下发出错误信号,不允许对常量进行赋值操作。 -
更改
eval_declaration,使其在遇到常量声明时调用一个新函数assign_constant_value,该函数不执行您在assign_symbol_value中引入的检查。 -
如果需要,更改
apply以确保仍然可以对函数参数进行赋值。
练习 4.12
-
a. JavaScript 的规范要求实现在尝试访问名称的值之前对其声明进行求值时发出运行时错误(请参见 3.2.4 节的末尾)。为了在求值器中实现这种行为,更改
lookup_symbol_value,如果它找到的值是"unassigned",则发出错误信号。 -
b.同样,如果我们尚未求值其
let声明,我们就不应该为变量分配新值。更改赋值的求值,以便在这种情况下,对使用let声明的变量进行赋值会发出错误信号。
练习 4.13
在我们在本书中使用的 ECMAScript 2015 的严格模式之前,JavaScript 变量的工作方式与 Scheme 变量有很大不同,这将使得将 Scheme 适应到 JavaScript 的工作变得不那么引人注目。
-
a.在 ECMAScript 2015 之前,JavaScript 中声明局部变量的唯一方法是使用关键字
var而不是关键字let。使用var声明的变量的作用域是立即周围的函数声明或 lambda 表达式的整个主体,而不仅仅是立即封闭的块。修改scan_out_declarations和eval_block,使得使用const和let声明的名称遵循var的作用域规则。 -
b. 在非严格模式下,JavaScript 允许未声明的名称出现在赋值语句的
=左侧。这样的赋值会将新的绑定添加到全局环境中。修改函数assign_symbol_value使赋值行为如此。严格模式禁止这样的赋值,旨在使程序更安全。通过阻止赋值向全局环境添加绑定来解决了什么安全问题?
4.1.4 作为程序运行求值器
有了求值器,我们手头上有了一个描述(用 JavaScript 表达)JavaScript 语句和表达式如何被求值的过程。将求值器表达为程序的一个优点是我们可以运行这个程序。这使我们在 JavaScript 中运行时,得到了 JavaScript 本身如何求值表达式的工作模型。这可以作为实验求值规则的框架,正如我们将在本章后面所做的那样。
我们的求值器程序最终将表达式简化为原始函数的应用。因此,我们运行求值器所需要的就是创建一个机制,调用底层 JavaScript 系统来模拟原始函数的应用。
每个原始函数名称和运算符都必须有一个绑定,这样当evaluate求值原始应用的函数表达式时,它将找到一个对象传递给apply。因此,我们建立了一个全局环境,将唯一对象与原始函数和运算符的名称相关联,这些名称可以出现在我们将要求值的表达式中。全局环境还包括undefined和其他名称的绑定,以便它们可以在要求值的表达式中用作常量。
function setup_environment() {
return extend_environment(append(primitive_function_symbols,
primitive_constant_symbols),
append(primitive_function_objects,
primitive_constant_values),
the_empty_environment);
}
const the_global_environment = setup_environment();
我们如何表示原始函数对象并不重要,只要apply能够使用is_primitive_function和apply_primitive_function函数识别和应用它们。我们选择将原始函数表示为以字符串"primitive"开头并包含在底层 JavaScript 中实现该原始函数的函数的列表。
function is_primitive_function(fun) {
return is_tagged_list(fun, "primitive");
}
function primitive_implementation(fun) { return head(tail(fun)); }
函数setup_environment将从列表中获取原始名称和实现函数:¹⁷
const primitive_functions = list(list("head", head ),
list("tail", tail ),
list("pair", pair ),
list("is_null", is_null ),
list("+", (x, y) => x + y ),
〈more primitive functions〉
);
const primitive_function_symbols =
map(f => head(f), primitive_functions);
const primitive_function_objects =
map(f => list("primitive", head(tail(f))),
primitive_functions);
与原始函数类似,我们通过函数setup_environment在全局环境中定义其他原始常量。
const primitive_constants = list(list("undefined", undefined),
list("math_PI", math_PI)
〈more primitive constants〉
);
const primitive_constant_symbols =
map(c => head(c), primitive_constants);
const primitive_constant_values =
map(c => head(tail(c)), primitive_constants);
要应用原始函数,我们只需使用底层 JavaScript 系统将实现函数应用于参数:¹⁸
function apply_primitive_function(fun, arglist) {
return apply_in_underlying_javascript(
primitive_implementation(fun), arglist);
}
为了方便运行元循环求值器,我们提供了一个驱动循环,模拟了底层 JavaScript 系统的读取-求值-打印循环。它打印一个提示符并将输入程序读取为一个字符串。它将程序字符串转换为标记列表表示的语句,如 4.1.2 节所述的过程,称为解析,由原始函数parse完成。我们在每个打印的结果之前加上一个输出提示,以区分程序的值和可能打印的其他输出。驱动循环获取前一个程序的程序环境作为参数。如 3.2.4 节末尾所述,驱动循环将程序视为在一个块中:它扫描出声明,通过包含每个名称绑定到"unassigned"的框架扩展给定的环境,并根据扩展的环境求值程序,然后将其作为参数传递给驱动循环的下一次迭代。
const input_prompt = "M-evaluate input: ";
const output_prompt = "M-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 = evaluate(program, program_env);
user_print(output_prompt, output);
return driver_loop(program_env);
}
}
我们使用 JavaScript 的prompt函数从用户那里请求并读取输入字符串:
function user_read(prompt_string) {
return prompt(prompt_string);
}
当用户取消输入时,函数prompt返回null。我们使用一个特殊的打印函数user_print,以避免打印复合函数的环境部分,这可能是一个非常长的列表(甚至可能包含循环)。
function user_print(string, object) {
function prepare(object) {
return is_compound_function(object)
? "< compound-function >"
: is_primitive_function(object)
? "< primitive-function >"
: is_pair(object)
? pair(prepare(head(object)),
prepare(tail(object)))
: object;
}
display(string + " " + stringify(prepare(object)));
}
现在我们需要做的就是初始化全局环境并启动驱动程序循环来运行求值器。以下是一个示例交互:
const the_global_environment = setup_environment();
driver_loop(the_global_environment);
M-求值输入:
function append(xs, ys) {
return is_null(xs)
? ys
: pair(head(xs), append(tail(xs), ys));
}
M-求值值:
undefined
M-求值输入:
append(list("a", "b", "c"), list("d", "e", "f"));
M-求值值:
["a", ["b", ["c", ["d", ["e", ["f", null]]]]]]
练习 4.14
Eva Lu Ator 和 Louis Reasoner 各自对元循环求值器进行实验。Eva 输入了map的定义,并运行了一些使用它的测试程序。它们都很好。相比之下,Louis 安装了map的系统版本作为元循环求值器的原语。当他尝试时,事情变得非常糟糕。解释为什么 Louis 的map失败,即使 Eva 的工作正常。
4.1.5 数据作为程序
在考虑一个求值 JavaScript 语句和表达式的 JavaScript 程序时,类比可能会有所帮助。程序含义的一个操作视图是,程序是对一个抽象(也许是无限大的)机器的描述。例如,考虑计算阶乘的熟悉程序:
function factorial(n) {
return n === 1
? 1
: factorial(n - 1) * n;
}
我们可以将这个程序看作是一个包含递减、乘法和相等测试部分的机器的描述,还有一个两位置开关和另一个阶乘机器。(阶乘机器是无限的,因为它包含另一个阶乘机器。)图 4.3 是阶乘机器的流程图,显示了部件如何连接在一起。
图 4.3 阶乘程序,视为一个抽象机器。
以类似的方式,我们可以将求值器视为一个非常特殊的机器,它以描述一个机器作为输入。根据这个输入,求值器配置自身以模拟所描述的机器。例如,如果我们向求值器提供factorial的定义,如图 4.4 所示,求值器将能够计算阶乘。
图 4.4 求值器模拟阶乘机器。
从这个角度来看,我们的求值器被视为通用机器。当这些机器被描述为 JavaScript 程序时,它模仿其他机器。¹⁹这是令人震惊的。试着想象一个类似的电路求值器。这将是一个电路,它以编码其他电路计划的信号作为输入,比如一个滤波器。给定这个输入,电路求值器将表现得像一个具有相同描述的滤波器。这样一个通用电路几乎是难以想象的复杂。值得注意的是,程序求值器是一个相当简单的程序。²⁰
求值器的另一个引人注目的方面是,它充当了我们编程语言中操作的数据对象和编程语言本身之间的桥梁。想象一下,求值器程序(用 JavaScript 实现)正在运行,用户正在向求值器输入程序并观察结果。从用户的角度来看,输入程序如x * x;是编程语言中的一个程序,求值器应该执行它。然而,从求值器的角度来看,程序只是一个字符串,或者在解析后是一个标记列表表示,根据一套明确定义的规则进行操作。
用户的程序是求值器的数据并不一定会引起混淆。事实上,有时忽略这种区别并给用户明确地将一个字符串作为 JavaScript 语句进行求值的能力是很方便的,使用 JavaScript 的原始函数eval,它以字符串作为参数。它解析字符串,并且——只要它在语法上是正确的——在eval应用的环境中求值所得到的表示。因此,
eval("5 * 5;");
和
evaluate(parse("5 * 5;"), the_global_environment);
都将返回 25。²¹
练习 4.15
给定一个一参数函数f和一个对象a,如果求值表达式f(a)返回一个值(而不是以错误消息终止或永远运行),则称f在a上“停止”。证明不可能编写一个函数halts,它可以正确地确定对于任何函数f和对象a,f是否在a上停止。使用以下推理:如果你有这样一个函数halts,你可以实现以下程序:
function run_forever() { return run_forever(); }
function strange(f) {
return halts(f, f)
? run_forever();
: "halted";
}
现在考虑求值表达式strange(strange)并展示任何可能的结果(无论是停止还是永远运行)都违反了halts的预期行为。
4.1.6 内部声明
在 JavaScript 中,声明的作用域是紧邻声明的整个块,而不仅仅是从声明发生的地方开始的块的部分。本节将更详细地讨论这个设计选择。
让我们重新审视第 3.2.4 节中在函数f的主体中本地声明的相互递归函数is_even和is_odd。
function f(x) {
function is_even(n) {
return n === 0
? true
: is_odd(n - 1);
}
function is_odd(n) {
return n === 0
? false
: is_even(n - 1);
}
return is_even(x);
}
我们的意图是,函数is_even主体中的名称is_odd应该指的是在is_even之后声明的函数is_odd。名称is_odd的作用域是f的整个主体块,而不仅仅是从is_odd的声明发生的地方开始的f主体的部分。事实上,当我们考虑is_odd本身是根据is_even定义的时候——所以is_even和is_odd是相互递归的函数——我们看到这两个声明的唯一令人满意的解释是将它们视为is_even和is_odd同时添加到环境中。更一般地,在块结构中,局部名称的作用域是在求值声明的整个块中。
在第 4.1.1 节的元循环求值器中,块的求值通过扫描块中的声明并使用包含所有声明名称绑定的帧扩展当前环境来实现局部名称的同时作用域。因此,在求值块体的新环境中已经包含了is_even和is_odd的绑定,任何一个这些名称的出现都指向正确的绑定。一旦它们的声明被求值,这些名称就绑定到它们声明的值,即具有扩展环境作为环境部分的函数对象。因此,例如,当is_even在f的主体中被应用时,它的环境已经包含了符号is_odd的正确绑定,而在is_even的主体中求值名称is_odd会检索到正确的值。
练习 4.16
考虑第 1.3.2 节中的函数f_3:
function f_3(x, y) {
const a = 1 + x * y;
const b = 1 - y;
return x * square(a) + y * b + a * b;
}
-
a. 绘制在求值
f_3的返回表达式期间生效的环境的图表。 -
b. 在求值函数应用时,求值器创建两个帧:一个用于参数,一个用于在函数的主体块中直接声明的名称,而不是在内部块中声明的名称。由于所有这些名称具有相同的作用域,一个实现可以合并这两个帧。更改求值器,使得对主体块的求值不会创建新的帧。您可以假设这不会导致帧中出现重复的名称(练习 4.5 证明了这一点)。
练习 4.17
Eva Lu Ator 正在编写程序,其中函数声明和其他语句是交错的。她需要确保在应用函数之前对声明进行求值。她抱怨道:“为什么求值器不能处理这个琐事,并且将所有函数声明提升到它们出现的块的开头?块外的函数声明应该提升到程序的开头。”
-
a. 修改求值器以遵循 Eva 的建议。
-
b. JavaScript 的设计者决定遵循 Eva 的方法。讨论这个决定。
-
c. 此外,JavaScript 的设计者决定允许使用赋值重新分配函数声明的名称。相应地修改您的解决方案并讨论这一决定。
练习 4.18
在我们的解释器中,递归函数是通过一种迂回的方式获得的:首先声明将引用递归函数的名称,并将其分配给特殊值"unassigned";然后在该名称的范围内定义递归函数;最后将定义的函数分配给名称。当递归函数被应用时,主体中名称的任何出现都会正确地引用递归函数。令人惊讶的是,可以在不使用声明或赋值的情况下指定递归函数。以下程序通过应用递归阶乘函数计算 10 的阶乘:²³
(n => (fact => fact(fact, n))
((ft, k) => k === 1
? 1
: k * ft(ft, k - 1)))(10);
-
a. 通过求值表达式来检查这确实计算了阶乘。为计算斐波那契数设计一个类似的表达式。
-
b. 考虑上面给出的函数
f:function f(x) { function is_even(n) { return n === 0 ? true : is_odd(n - 1); } function is_odd(n) { return n === 0 ? false : is_even(n - 1); } return is_even(x); }填写缺失的表达式以完成对
f的替代声明,该声明没有内部函数声明:function f(x) { return ((is_even, is_odd) => is_even(is_even, is_odd, x)) ((is_ev, is_od, n) => n === 0 ? true : is_od(〈??〉, 〈??〉, 〈??〉), (is_ev, is_od, n) => n === 0 ? false : is_ev( 〈??〉, 〈??〉, 〈??〉)); }
顺序声明处理
我们 4.1.1 节的求值器设计对块的求值施加了运行时负担:它需要扫描块的主体以查找本地声明的名称,使用绑定这些名称的新框架扩展当前环境,并在此扩展环境中求值块主体。或者,块的求值可以使用空框架扩展当前环境。然后,块主体中每个声明的求值将向该框架添加一个新的绑定。为了实现这一设计,我们首先简化eval_block:
function eval_block(component, env) {
const body = block_body(component);
return evaluate(body, extend_environment(null, null, env);
}
函数eval_declaration不再能假定环境已经为该名称绑定。它不再使用assign_symbol_value来更改现有绑定,而是调用一个新函数add_binding_to_frame,将名称绑定到值表达式的值的第一个框架中的环境中。
function eval_declaration(component, env) {
add_binding_to_frame(
declaration_symbol(component),
evaluate(declaration_value_expression(component), env),
first_frame(env));
return undefined;
}
function add_binding_to_frame(symbol, value, frame) {
set_head(frame, pair(symbol, head(frame)));
set_tail(frame, pair(value, tail(frame)));
}
顺序声明处理后,声明的范围不再是直接包围声明的整个块,而只是从声明发生的地方开始的块的一部分。尽管我们不再具有同时的范围,但顺序声明处理将正确地求值本节开头的函数f的调用,但出于“意外”的原因:由于内部函数的声明首先出现,直到所有这些函数都声明完毕之前,不会求值对这些函数的任何调用。因此,is_odd在执行is_even时已经被声明。实际上,对于任何内部声明首先出现在主体中且声明的值表达式的求值实际上不使用任何声明的名称的函数,顺序声明处理将给出与我们在 4.1.1 节中的扫描名称求值器相同的结果。练习 4.19 展示了一个不遵守这些限制的函数的示例,因此替代求值器与我们的扫描名称求值器并不等价。
顺序声明处理比扫描名称更高效且更易于实现。但是,使用顺序处理时,名称引用的声明可能取决于求值块中语句的顺序。在练习 4.19 中,我们看到对于是否希望这样做的观点可能会有不同。
练习 4.19
Ben Bitdiddle,Alyssa P. Hacker 和 Eva Lu Ator 正在就求值程序的期望结果进行争论
const a = 1;
function f(x) {
const b = a + x;
const a = 5;
return a + b;
}
f(10);
Ben 断言应该使用声明的顺序处理结果:b被声明为 11,然后a被声明为 5,因此结果是 16。Alyssa 反对相互递归需要内部函数声明的同时作用规则,并且认为将函数名称与其他名称区别对待是不合理的。因此,她主张在第 4.1.1 节中实现的机制。这将导致在计算b的值时,a尚未被赋值。因此,在 Alyssa 看来,该函数应该产生错误。Eva 有第三种观点。她说,如果a和b的声明确实是同时的,那么在计算b时应该使用a的值 5。因此,在 Eva 看来,a应该是 5,b应该是 15,结果应该是 20。你支持这些观点中的哪一个(如果有的话)?你能想出一种实现内部声明的方法,使其符合 Eva 的期望吗?
4.1.7 将语法分析与执行分离
上面实现的求值器很简单,但非常低效,因为组件的语法分析与其执行交织在一起。因此,如果一个程序被执行多次,它的语法将被分析多次。例如,考虑使用以下factorial定义来求值factorial(4):
function factorial(n) {
return n === 1
? 1
: factorial(n - 1) * n;
}
每次调用factorial时,求值器必须确定函数体是条件表达式并提取谓词。只有这样才能求值谓词并根据其值进行分派。每次求值表达式factorial(n - 1) * n或子表达式factorial(n - 1)和n - 1时,求值器必须执行evaluate中的情况分析,以确定表达式是一个应用程序,并必须提取其函数表达式和参数表达式。这种分析是昂贵的。重复执行它是浪费的。
我们可以通过安排事物,使得语法分析只执行一次,从而使求值器变得更加高效。我们将evaluate分成两部分,analyze函数只接受组件。它执行语法分析并返回一个新函数,执行函数,它封装了执行分析组件所需的工作。执行函数以环境作为参数并完成求值。这样做可以节省工作,因为analyze只会在组件上调用一次,而执行函数可能会被多次调用。
通过将分析和执行分开,evaluate现在变成了
function evaluate(component, env) {
return analyze(component)(env);
}
调用analyze的结果是要应用于环境的执行函数。analyze函数与第 4.1.1 节中的原始evaluate执行的情况分析相同,只是我们分派的函数只执行分析,而不是完全求值。
function analyze(component) {
return is_literal(component)
? analyze_literal(component)
: is_name(component)
? analyze_name(component)
: is_application(component)
? analyze_application(component)
: is_operator_combination(component)
? analyze(operator_combination_to_application(component))
: is_conditional(component)
? analyze_conditional(component)
: is_lambda_expression(component)
? analyze_lambda_expression(component)
: is_sequence(component)
? analyze_sequence(sequence_statements(component))
: is_block(component)
? analyze_block(component)
: is_return_statement(component)
? analyze_return_statement(component)
: is_function_declaration(component)
? analyze(function_decl_to_constant_decl(component))
: is_declaration(component)
? analyze_declaration(component)
: is_assignment(component)
? analyze_assignment(component)
: error(component, "unknown syntax – analyze");
}
这是最简单的语法分析函数,处理文字表达式。它返回一个执行函数,忽略其环境参数,只返回文字的值。
function analyze_literal(component) {
return env => literal_value(component);
}
查找名称的值仍然必须在执行阶段完成,因为这取决于知道环境。
function analyze_name(component) {
return env => lookup_symbol_value(symbol_of_name(component), env);
}
分析一个应用程序,我们分析函数表达式和参数表达式,并构造一个执行函数,该函数调用函数表达式的执行函数(以获取要应用的实际函数)和参数表达式的执行函数(以获取实际参数)。然后我们将这些传递给execute_application,这类似于第 4.1.1 节中的apply。execute_application函数与apply不同之处在于,复合函数的函数体已经被分析过,因此不需要进行进一步的分析。相反,我们只需在扩展环境上调用函数体的执行函数。
function analyze_application(component) {
const ffun = analyze(function_expression(component));
const afuns = map(analyze, arg_expressions(component));
return env => execute_application(ffun(env),
map(afun => afun(env), afuns));
}
function execute_application(fun, args) {
if (is_primitive_function(fun)) {
return apply_primitive_function(fun, args);
} else if (is_compound_function(fun)) {
const result = function_body(fun)
(extend_environment(function_parameters(fun),
args,
function_environment(fun)));
return is_return_value(result)
? return_value_content(result)
: undefined;
} else {
error(fun, "unknown function type – execute_application");
}
}
对于条件语句,我们在分析时提取并分析谓词、结果和替代。
function analyze_conditional(component) {
const pfun = analyze(conditional_predicate(component));
const cfun = analyze(conditional_consequent(component));
const afun = analyze(conditional_alternative(component));
return env => is_truthy(pfun(env)) ? cfun(env) : afun(env);
}
分析 lambda 表达式也实现了效率的主要提升:我们只对 lambda 主体进行一次分析,即使由 lambda 表达式的求值产生的函数可能被多次应用。
function analyze_lambda_expression(component) {
const params = lambda_parameter_symbols(component);
const bfun = analyze(lambda_body(component));
return env => make_function(params, bfun, env);
}
对一系列语句的分析更为复杂。序列中的每个语句都经过分析,产生一个执行函数。这些执行函数组合在一起,产生一个接受环境作为参数并按顺序调用每个单独执行函数的执行函数。
function analyze_sequence(stmts) {
function sequentially(fun1, fun2) {
return env => {
const fun1_val = fun1(env);
return is_return_value(fun1_val)
? fun1_val
: fun2(env);
};
}
function loop(first_fun, rest_funs) {
return is_null(rest_funs)
? first_fun
: loop(sequentially(first_fun, head(rest_funs)),
tail(rest_funs));
}
const funs = map(analyze, stmts);
return is_null(funs)
? env => undefined
: loop(head(funs), tail(funs));
}
块的主体只被扫描一次以获取局部声明。当调用块的执行函数时,这些绑定将被安装在环境中。
function analyze_block(component) {
const body = block_body(component);
const bfun = analyze(body);
const locals = scan_out_declarations(body);
const unassigneds = list_of_unassigned(locals);
return env => bfun(extend_environment(locals, unassigneds, env));
}
对于返回语句,我们分析返回表达式。返回语句的执行函数只是调用返回表达式的执行函数,并将结果包装在返回值中。
function analyze_return_statement(component) {
const rfun = analyze(return_expression(component));
return env => make_return_value(rfun(env));
}
函数analyze_assignment必须推迟实际设置变量,直到执行时才会提供环境。然而,分析赋值值表达式(递归地)在分析期间是效率的主要提升,因为赋值值表达式现在只会被分析一次。对于常量和变量声明也是如此。
function analyze_assignment(component) {
const symbol = assignment_symbol(component);
const vfun = analyze(assignment_value_expression(component));
return env => {
const value = vfun(env);
assign_symbol_value(symbol, value, env);
return value;
};
}
function analyze_declaration(component) {
const symbol = declaration_symbol(component);
const vfun = analyze(declaration_value_expression(component));
return env => {
assign_symbol_value(symbol, vfun(env), env);
return undefined;
};
}
我们的新求值器使用与 4.1.2、4.1.3 和 4.1.4 节中相同的数据结构、语法函数和运行时支持函数。
练习 4.20
扩展本节中的求值器以支持while循环。(见练习 4.7。)
练习 4.21
Alyssa P. Hacker 不明白为什么analyze_sequence需要这么复杂。所有其他分析函数都是相应求值函数(或 4.1.1 节中的evaluate子句)的直接转换。她期望analyze_sequence看起来像这样:
function analyze_sequence(stmts) {
function execute_sequence(funs, env) {
if (is_null(funs)) {
return undefined;
} else if (is_null(tail(funs))) {
return head(funs)(env);
} else {
const head_val = head(funs)(env);
return is_return_value(head_val)
? head_val
: execute_sequence(tail(funs), env);
}
}
const funs = map(analyze, stmts);
return env => execute_sequence(funs, env);
}
Eva Lu Ator 向 Alyssa 解释,文本中的版本在分析时更多地求值了序列的工作。Alyssa 的序列执行函数不是内置调用各个执行函数,而是按顺序循环调用这些函数:实际上,尽管序列中的各个语句已经被分析,但序列本身还没有被分析。
比较analyze_sequence的两个版本。例如,考虑常见情况(函数体的典型情况),即序列只有一个语句。Alyssa 程序生成的执行函数会做什么工作?上述文本中程序生成的执行函数又会做什么工作?这两个版本在包含两个表达式的序列中如何比较?
练习 4.22
设计并进行一些实验,比较原始的元循环求值器与本节中的版本的速度。使用你的结果来估计在各种函数中分析与执行所花费的时间比例。
4.2 惰性求值
现在我们有了一个表达为 JavaScript 程序的求值器,我们可以通过修改求值器来实验语言设计的替代选择。事实上,新语言通常是通过首先编写一个将新语言嵌入到现有高级语言中的求值器来发明的。例如,如果我们希望与 JavaScript 社区的其他成员讨论对 JavaScript 的某个修改方面,我们可以提供一个体现了这种改变的求值器。接收者可以使用新的求值器进行实验,并发送评论作为进一步的修改。高级实现基础不仅使得测试和调试求值器更容易;此外,嵌入使得设计者能够从基础语言中吸取特性,就像我们嵌入的 JavaScript 求值器使用了基础 JavaScript 的原语和控制结构一样。设计者只有在以后(如果有必要)才需要费力地在低级语言或硬件中构建完整的实现。在本节和下一节中,我们将探讨一些提供显著额外表达能力的 JavaScript 变体。
4.2.1 正常顺序和应用顺序
在第 1.1 节,我们开始讨论求值模型时,我们注意到 JavaScript 是一种 应用顺序 语言,也就是说,当函数被应用时,JavaScript 函数的所有参数都会被求值。相反,正常顺序 语言会延迟求值函数参数,直到实际参数值被需要为止。延迟求值函数参数直到最后可能的时刻(例如,直到它们被原始操作所需)被称为延迟求值。²⁹ 考虑函数
function try_me(a, b) {
return a === 0 ? 1 : b;
}
在 JavaScript 中求值try_me(0, head(null));会导致错误。使用延迟求值,就不会出现错误。求值该语句将返回 1,因为参数head(null)永远不会被求值。
利用延迟求值的一个例子是声明一个函数unless
function unless(condition, usual_value, exceptional_value) {
return condition ? exceptional_value : usual_value;
}
可以在诸如下面的语句中使用
unless(is_null(xs), head(xs), display("error: xs should not be null"));
这在应用顺序语言中不起作用,因为在调用unless之前通常值和异常值都会被求值(参见练习 1.6)。延迟求值的一个优点是,一些函数,比如unless,即使求值它们的一些参数会产生错误或不会终止,也可以进行有用的计算。
如果在求值参数之前进入函数体,则我们说该函数对该参数是 非严格 的。如果在进入函数体之前求值参数,则我们说该函数对该参数是 严格 的。³⁰ 在纯粹的应用顺序语言中,所有函数对每个参数都是严格的。在纯粹的正常顺序语言中,所有复合函数对每个参数都是非严格的,原始函数可以是严格的也可以是非严格的。还有一些语言(参见练习 4.29)允许程序员对他们定义的函数的严格性进行详细控制。
一个引人注目的例子是一个可以有用地变为非严格的函数pair(或者一般来说,几乎任何数据结构的构造函数)。即使元素的值未知,也可以进行有用的计算,将元素组合成数据结构并对生成的数据结构进行操作。例如,计算列表的长度而不知道列表中各个元素的值是完全有意义的。我们将在第 4.2.3 节中利用这个想法,将第 3 章的流实现为由非严格对组成的列表。
练习 4.23
假设(在普通的应用顺序 JavaScript 中)我们按上面所示定义unless,然后根据unless定义factorial如下
function factorial(n) {
return unless(n === 1,
n * factorial(n - 1),
1);
}
如果我们尝试求值factorial(5)会发生什么?我们的函数在正常顺序语言中会工作吗?
练习 4.24
Ben Bitdiddle 和 Alyssa P. Hacker 对于实现诸如unless之类的惰性求值的重要性存在分歧。Ben 指出可以在应用序中实现unless作为一个语法形式。Alyssa 反驳说,如果这样做,unless将只是语法,而不是可以与高阶函数一起使用的函数。在这个论点的双方填写细节。展示如何将unless实现为一个派生组件(类似于操作符组合),通过在evaluate中捕获函数表达式为unless的应用。给出一个可能有用的情况的例子,其中unless作为函数而不是语法形式可用。
4.2.2 惰性求值的解释器
而不仅仅是evaluate,我们使用
基本思想是,在应用函数时,解释器必须确定哪些参数需要求值,哪些需要延迟。延迟的参数不会被求值;相反,它们会被转换为称为thunk的对象。thunk 必须包含在需要时产生参数值所需的信息,就好像它在应用时已经被求值一样。因此,thunk 必须包含参数表达式和函数应用被求值的环境。
在 thunk 中求值表达式的过程称为forcing。通常情况下,只有在需要其值时才会强制执行 thunk:当它被传递给将使用 thunk 值的原始函数时;当它是条件语句的谓词的值时;当它是即将被应用为函数的函数表达式的值时。我们可以选择是否对 thunk 进行记忆化,类似于第 3.5.1 节中对流的优化。使用记忆化时,第一次强制执行 thunk 时,它会存储计算出的值。后续的强制执行只需返回存储的值,而不重复计算。我们将使我们的解释器进行记忆化,因为这对许多应用来说更有效率。然而,这里有一些棘手的考虑。
修改求值器
惰性求值和第 4.1 节中的求值器在evaluate和apply中对函数应用的处理上的主要区别。
evaluate的is_application子句变成
: is_application(component)
? apply(actual_value(function_expression(component), env),
arg_expressions(component), env)
这几乎与第 4.1.1 节中evaluate的is_application子句相同。然而,对于惰性求值,我们调用apply并传入参数表达式,而不是对它们进行求值后产生的参数。由于如果参数需要延迟,我们将需要环境来构造 thunk,因此我们也必须传递环境。我们仍然求值函数表达式,因为apply需要实际的函数来进行分派(原始函数与复合函数)并应用它。
在这一节中,我们将实现一个与 JavaScript 相同的正常顺序语言,只是每个参数中的复合函数是非严格的。原始函数仍然是严格的。修改第 4.1.1 节的求值器,使其解释的语言以这种方式运行并不困难。几乎所有所需的更改都集中在函数应用周围。
function actual_value(exp, env) {
return force_it(evaluate(exp, env));
}
来代替,这样如果表达式的值是 thunk,它将被强制执行。
我们的新版本的apply也几乎与第 4.1.1 节中的版本相同。不同之处在于evaluate传入了未求值的参数表达式:对于原始函数(严格的),我们在应用原始函数之前求值所有参数;对于复合函数(非严格的),我们在应用函数之前延迟所有参数。
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");
}
}
处理参数的函数与 4.1.1 节中的list_of_values几乎相同,只是list_of_delayed_args延迟参数而不是求值它们,而list_of_arg_values使用actual_value而不是evaluate:
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);
}
我们必须更改求值器的另一个地方是在处理条件语句时,我们必须使用actual_value而不是evaluate来获取谓词表达式的值,然后再测试它是真还是假:
function eval_conditional(component, env) {
return is_truthy(actual_value(conditional_predicate(component), env))
? evaluate(conditional_consequent(component), env)
: evaluate(conditional_alternative(component), env);
}
最后,我们必须更改driver_loop函数(来自 4.1.4 节),以使用actual_value而不是evaluate,这样如果延迟的值传播回读取-求值-打印循环,它将在打印之前被强制。我们还更改提示,指示这是惰性求值器:
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);
}
}
做出这些更改后,我们可以启动求值器并对其进行测试。成功求值 4.2.1 节中讨论的try_me表达式表明解释器正在执行惰性求值:
const the_global_environment = setup_environment(); driver_loop(the_global_environment);
左求值输入:
function try_me(a, b) {
return a === 0 ? 1 : b;
}
左求值值:
undefined
左求值输入:
try_me(0, head(null));
左求值值:
`1`
表示 thunk
我们的求值器必须安排在将函数应用于参数时创建 thunk,并稍后强制这些 thunk。一个 thunk 必须将表达式与环境打包在一起,以便稍后可以生成参数。为了强制 thunk,我们只需从 thunk 中提取表达式和环境,并在环境中求值表达式。我们使用actual_value而不是evaluate,以便在表达式的值本身是 thunk 的情况下,我们将强制执行,依此类推,直到达到不是 thunk 的东西:
function force_it(obj) {
return is_thunk(obj)
? actual_value(thunk_exp(obj), thunk_env(obj))
: obj;
}
打包表达式与环境的一种简单方法是创建一个包含表达式和环境的列表。因此,我们可以按照以下方式创建 thunk:
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))); }
实际上,我们为解释器想要的不完全是这样,而是已经被记忆的 thunk。当强制 thunk 时,我们将通过用其值替换存储的表达式并更改thunk标记来将其转换为已求值的 thunk,以便可以识别它已经被求值。³⁴
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;
}
}
注意,相同的delay_it函数在有记忆和无记忆的情况下都有效。
练习 4.25
假设我们向惰性求值器输入以下声明:
let count = 0;
function id(x) {
count = count + 1;
return x;
}
给出以下交互序列中的缺失值,并解释你的答案。
const w = id(id(10));
左求值输入:
count;
左求值值:
〈response〉
左求值输入:
w;
左求值值:
〈response〉
左求值输入:
count;
左求值值:
〈response〉
练习 4.26
函数evaluate在将函数表达式传递给apply之前使用actual_value而不是evaluate来求值函数表达式,以强制函数表达式的值。给出一个演示这种强制需求的示例。
练习 4.27
展示一个程序,你期望它在没有记忆的情况下运行得比有记忆的情况慢得多。另外,考虑以下交互,其中id函数的定义如练习 4.25 中所述,count从 0 开始:
function square(x) {
return x * x;
}
左求值输入:
square(id(10));
左求值值:
〈response〉
左求值输入:
count;
左求值值:
〈response〉
给出求值器记忆和不记忆时的响应。
练习 4.28
改过自新的 C 程序员赛·D·费克特担心一些副作用可能永远不会发生,因为惰性求值器不会强制序列中的语句。由于序列中语句的值可能不会被使用(语句可能只是为了其效果而存在,例如赋值给变量或打印),因此可能没有后续使用这个值的情况(例如作为原始函数的参数),这将导致它被强制。因此,赛认为在求值序列时,我们必须强制序列中的所有语句。他建议修改 4.1.1 节中的evaluate_sequence,以使用actual_value而不是evaluate:
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);
}
}
}
-
本·比特迪德尔认为赛伊是错误的。他向赛伊展示了练习 2.23 中描述的
for_each函数,这给出了一个具有副作用的序列的重要示例:function for_each(fun, items) { if (is_null(items)){ return "done"; } else { fun(head(items)); for_each(fun, tail(items)); } }他声称文本中的求值者(具有原始的
eval_sequence)正确处理了这一点:L-evaluate input: for_each(display, list(57, 321, 88)); 57 321 88 L-evaluate value: "done"解释为什么 Ben 对
for_each的行为是正确的。 -
b. Cy 同意 Ben 关于
for_each示例的观点,但说这不是他在提出对eval_sequence的更改时考虑的程序类型。他在惰性求值器中声明了以下两个函数: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))); }原始
eval_sequence的f1(1)和f2(1)的值是多少?Cy 对eval_sequence的建议更改后的值会是多少? -
c. Cy 还指出,按照他的建议更改
eval_sequence不会影响部分 a 中示例的行为。解释为什么这是真的。 -
d. 你认为序列在惰性求值器中应该如何处理?你喜欢 Cy 的方法,文本中的方法,还是其他方法?
练习 4.29
本节中采用的方法有些不愉快,因为它对 JavaScript 进行了不兼容的更改。实现惰性求值作为向上兼容的扩展可能更好,即普通 JavaScript 程序将像以前一样工作。我们可以通过在函数声明内部引入可选参数声明作为新的语法形式来实现这一点,以让用户控制参数是否延迟。顺便说一句,我们可能也可以让用户选择是否延迟记忆。例如,声明
function f(a, b, c, d) {
parameters("strict", "lazy", "strict", "lazy_memo");
...
}
将f定义为一个四个参数的函数,其中在调用函数时会求值第一个和第三个参数,第二个参数会延迟,第四个参数既延迟又被记忆。您可以假设参数声明始终是函数声明体中的第一条语句,如果省略了参数声明,则所有参数都是严格的。因此,普通函数声明将产生与普通 JavaScript 相同的行为,而在每个复合函数的每个参数上添加"lazy_memo"声明将产生本节中定义的惰性求值器的行为。设计并实现所需的更改以产生 JavaScript 的这种扩展。parse函数将参数声明视为函数应用程序,因此您需要修改apply以分派到新的语法形式的实现。您还必须安排evaluate或apply确定何时延迟参数,并相应地强制或延迟参数,并且必须安排强制记忆或不适当。
4.2.3 流作为惰性列表
在 3.5.1 节中,我们展示了如何将流实现为延迟列表。我们使用 lambda 表达式构造了一个“承诺”来计算流的尾部,而不是在以后实际实现该承诺。我们被迫创建流作为一种新的数据对象,类似但不完全相同于列表,这要求我们重新实现许多用于流的普通列表操作(map,append等)。
使用惰性求值,流和列表可以是相同的,因此不需要单独的列表和流操作。我们需要做的就是安排pair是非严格的。实现这一点的一种方法是将惰性求值器扩展为允许非严格的原语,并将pair实现为其中之一。一个更简单的方法是回想一下(第 2.1.3 节)根本没有必要将pair实现为原语。相反,我们可以将对偶表示为函数:³⁶
function pair(x, y) {
return m => m(x, y);
}
function head(z) {
return z((p, q) => p);
}
function tail(z) {
return z((p, q) => q);
}
根据这些基本操作,列表操作的标准定义将适用于无限列表(流)以及有限列表,并且流操作可以实现为列表操作。以下是一些示例:
function list_ref(items, n) {
return n === 0
? head(items)
: list_ref(tail(items), n - 1);
}
function map(fun, items) {
return is_null(items)
? null
: pair(fun(head(items)),
map(fun, tail(items)));
}
function scale_list(items, factor) {
return map(x => x * factor, items);
}
function add_lists(list1, list2) {
return is_null(list1)
? list2
: is_null(list2)
? list1
: pair(head(list1) + head(list2),
add_lists(tail(list1), tail(list2)));
}
const ones = pair(1, ones);
const integers = pair(1, add_lists(ones, integers));
左求值输入:
list_ref(integers, 17);
左求值值:
18
请注意,这些懒惰列表甚至比第 3 章的流更懒惰:列表的头部和尾部都被延迟。事实上,甚至访问懒惰对的head或tail也不需要强制列表元素的值。只有在真正需要时才会强制该值,例如用作原语的参数,或者作为答案打印时。
懒惰的对也有助于在第 3.5.4 节中出现的流的问题,我们发现,构建具有循环的系统的流模型可能需要我们在程序中添加额外的 lambda 表达式来延迟,除了构造流对所需的 lambda 表达式。通过惰性求值,所有函数的参数都被统一延迟。例如,我们可以按照我们在第 3.5.4 节最初打算的方式实现函数来集成列表和解决微分方程:
function integral(integrand, initial_value, dt) {
const int = pair(initial_value,
add_lists(scale_list(integrand, dt),
int));
return int;
}
function solve(f, y0, dt) {
const y = integral(dy, y0, dt);
const dy = map(f, y);
return y;
}
左求值输入:
list_ref(solve(x => x, 1, 0.001), 1000);
左求值值:
2.716924
练习 4.30
给出一些例子,说明第 3 章的流和本节中描述的“更懒惰”的惰性列表之间的区别。你如何利用这种额外的懒惰?
练习 4.31
Ben Bitdiddle 通过求值表达式来测试上述懒惰列表的实现
head(list("a", "b", "c"));
令他惊讶的是,这产生了一个错误。经过一番思考,他意识到从原始list函数获得的“列表”与新定义的pair、head和tail操作的列表是不同的。修改求值器,使得在驱动循环中键入原始list函数的应用程序将产生真正的惰性列表。
练习 4.32
修改求值器的驱动循环,以便懒惰的对和列表以某种合理的方式打印出来。(你打算如何处理无限列表?)你可能还需要修改懒惰对的表示,以便求值器能够识别它们以便打印它们。
4.3 非确定性计算
在本节中,我们通过将支持自动搜索的功能内置到求值器中,扩展 JavaScript 求值器以支持一种称为非确定性计算的编程范式。这对于语言的改变比第 4.2 节中引入的惰性求值更为深刻。
非确定性计算,如流处理,对于“生成和测试”应用程序非常有用。考虑从两个正整数列表开始,并找到一对整数,一个来自第一个列表,一个来自第二个列表,它们的和是素数的任务。我们在第 2.2.3 节中看到了如何处理这个问题,并在第 3.5.3 节中使用无限流。我们的方法是生成所有可能的对的序列,并过滤这些对以选择其和为素数的对。无论我们是否像在第 2 章中那样实际生成整个对序列,还是像在第 3 章中那样交替生成和过滤,对于计算组织的基本形象来说都是无关紧要的。
非确定性方法唤起了不同的形象。想象一下,我们简单地选择(以某种方式)从第一个列表中选择一个数字,从第二个列表中选择一个数字,并要求(使用某种机制)它们的和是素数。这由以下函数表示:
function prime_sum_pair(list1, list2) {
const a = an_element_of(list1);
const b = an_element_of(list2);
require(is_prime(a + b));
return list(a, b);
}
这个函数似乎只是重新陈述了问题,而不是指定了解决问题的方法。尽管如此,这是一个合法的非确定性程序。
关键思想在于非确定性语言中的组件可以有多个可能的值。例如,an_element_of可能返回给定列表的任何元素。我们的非确定性程序求值器将通过自动选择一个可能的值并跟踪选择来工作。如果后续要求不满足,求值器将尝试不同的选择,并将不断尝试新的选择,直到求值成功,或者直到我们用尽了选择。就像惰性求值器使程序员摆脱了值如何延迟和强制的细节一样,非确定性程序求值器将使程序员摆脱选择如何进行的细节。
对比非确定性求值和流处理所唤起的不同时间形象是有启发性的。流处理使用惰性求值来解耦可能答案流被组装的时间和实际流元素产生的时间。求值器支持这样一个错觉,即所有可能的答案都以一个无时间的序列摆在我们面前。而非确定性求值中,一个组件代表了一组可能世界的探索,每个世界由一组选择确定。一些可能的世界导致了死胡同,而另一些则有有用的值。非确定性程序求值器支持这样一个错觉,即时间分支,我们的程序有不同的可能执行历史。当我们遇到死胡同时,我们可以重新访问之前的选择点,并沿着不同的分支继续。
下面实现的非确定性程序求值器称为amb求值器,因为它基于一个称为amb的新的语法形式。我们可以在amb求值器驱动循环中键入prime_sum_pair的上述声明(以及is_prime、an_element_of和require的声明),并按如下方式运行该函数:
amb-求值输入:
prime_sum_pair(list(1, 3, 5, 8), list(20, 35, 110));
开始一个新问题
amb-求值值:
[3, [20, null]]
返回的值是在求值器重复选择每个列表中的元素,直到成功选择之后获得的。
第 4.3.1 节介绍了amb并解释了它如何通过求值器的自动搜索机制支持非确定性。第 4.3.2 节介绍了非确定性程序的示例,第 4.3.3 节详细介绍了如何通过修改普通 JavaScript 求值器来实现amb求值器。
4.3.1 搜索和amb
为了支持非确定性,我们引入了一个称为amb的新的语法形式。表达式amb(``e[1], e[2], ... , e[n]``)以“模棱两可”的方式返回n个表达式e[i]中的一个值。例如,表达式
list(amb(1, 2, 3), amb("a", "b"));
可以有六个可能的值:
list(1, "a") list(1, "b") list(2, "a")
list(2, "b") list(3, "a") list(3, "b")
一个带有单个选择的amb表达式会产生一个普通(单个)值。
没有选择的amb表达式——表达式amb()——是一个没有可接受值的表达式。在操作上,我们可以将amb()看作是一个导致计算“失败”的表达式:计算中止,不产生值。利用这个想法,我们可以表达一个特定的谓词表达式p必须为真的要求如下:
function require(p) {
if (! p) {
amb();
} else {}
}
使用amb和require,我们可以实现上面使用的an_element_of函数:
function an_element_of(items) {
require(! is_null(items));
return amb(head(items), an_element_of(tail(items)));
}
如果列表为空,则an_element_of的应用将失败。否则,它会模棱两可地返回列表的第一个元素或从列表的其余部分中选择的一个元素。
我们还可以表示无限范围的选择。以下函数可能返回大于或等于给定n的任何整数:
function an_integer_starting_from(n) {
return amb(n, an_integer_starting_from(n + 1));
}
这类似于第 3.5.2 节中描述的integers_starting_from流函数,但有一个重要的区别:流函数返回一个表示以n开头的所有整数序列的对象,而amb函数返回一个单个整数。
抽象地说,我们可以想象求值amb表达式会导致时间分成分支,其中计算在每个分支上继续,使用表达式的可能值之一。我们说amb代表一个非确定性选择点。如果我们有一台具有足够多动态分配的处理器的机器,我们可以以直接的方式实现搜索。执行将继续进行,就像在顺序机器中一样,直到遇到amb表达式。在这一点上,将分配更多的处理器,并初始化以继续选择所暗示的所有并行执行。每个处理器将按顺序进行,就好像它是唯一的选择,直到它通过遇到失败而终止,或者进一步细分,或者完成。⁴¹
另一方面,如果我们有一台只能执行一个进程(或几个并发进程)的机器,我们必须按顺序考虑各种选择。人们可以想象修改求值器,在遇到选择点时随机选择一个分支进行跟踪。然而,随机选择很容易导致失败的值。我们可以尝试一遍又一遍地运行求值器,做出随机选择,并希望找到一个非失败的值,但更好的方法是系统地搜索所有可能的执行路径。我们将在本节中开发和使用的amb求值器实现了以下系统搜索:当求值器遇到amb的应用时,它最初选择第一个备选方案。这个选择本身可能导致进一步的选择。求值器总是在每个选择点最初选择第一个备选方案。如果一个选择导致失败,那么求值器会自动地⁴² 回溯到最近的选择点,并尝试下一个备选方案。如果在任何选择点用完备选方案,求值器将回到上一个选择点并从那里继续。这个过程导致了一种被称为深度优先搜索或按时间顺序回溯的搜索策略。⁴³
驱动循环
amb求值器的驱动循环具有一些不寻常的特性。它读取一个程序,并打印第一个非失败执行的值,就像上面显示的prime_sum_pair示例一样。如果我们想要看到下一个成功执行的值,我们可以要求解释器回溯并尝试生成第二个非失败执行。这是通过输入retry来表示的。如果给出除retry之外的任何其他输入,解释器将开始一个新问题,丢弃上一个问题中未探索的备选方案。以下是一个示例交互:
amb-求值输入:
prime_sum_pair(list(1, 3, 5, 8), list(20, 35, 110));
开始一个新问题
amb-求值值:
[3, [20, null]]
amb-求值输入:
retry
amb-求值值:
[3, [110, null]]
amb-求值输入:
retry
amb-求值值:
[8, [35, null]]
amb-求值输入:
retry
没有更多的值
prime_sum_pair([1, [3, [5, [8, null]]]], [20, [35, [110, null]]])
amb-求值输入:
prime_sum_pair(list(19, 27, 30), list(11, 36, 58));
开始一个新问题
amb-求值值:
[30, [11, null]]
练习 4.33
编写一个名为an_integer_between的函数,该函数返回给定边界之间的整数。这可以用来实现一个函数,找到勾股数三元组,即在给定边界之间的整数三元组(i,j,k),使得i ≤ j和i² + j² = k²,如下所示:
function a_pythogorean_triple_between(low, high) {
const i = an_integer_between(low, high);
const j = an_integer_between(i, high);
const k = an_integer_between(j, high);
require(i * i + j * j === k * k);
return list(i, j, k);
}
练习 4.34
练习 3.69 讨论了如何生成所有勾股数三元组的流,对要搜索的整数大小没有上限。解释为什么在练习 4.33 中的函数中简单地将an_integer_between替换为an_integer_starting_from不是生成任意勾股数三元组的充分方式。编写一个实际可以实现这一点的函数。(也就是说,编写一个函数,重复输入retry理论上最终会生成所有的勾股数三元组。)
练习 4.35
Ben Bitdiddle 声称生成勾股数的以下方法比练习 4.33 中的方法更有效。他正确吗?(提示:考虑必须探索的可能性数量。)
function a_pythagorean_triple_between(low, high) {
const i = an_integer_between(low, high);
const hsq = high * high;
const j = an_integer_between(i, high);
const ksq = i * i + j * j;
require(hsq >= ksq);
const k = math_sqrt(ksq);
require(is_integer(k));
return list(i, j, k);
}
4.3.2 非确定性程序的示例
第 4.3.3 节描述了amb求值器的实现。然而,我们首先给出一些它的使用示例。非确定性编程的优势在于我们可以抑制搜索是如何进行的细节,从而以更高的抽象级别表达我们的程序。
逻辑谜题
以下谜题(改编自 Dinesman 1968)是一个典型的简单逻辑谜题:
软件公司 Gargle 正在扩张,Alyssa、Ben、Cy、Lem 和 Louis 将搬进新大楼的一排五个私人办公室。Alyssa 不会搬进最后一个办公室。Ben 不会搬进第一个办公室。Cy 既不搬进第一个办公室,也不搬进最后一个办公室。Lem 搬进比 Ben 晚的一个办公室。Louis 的办公室不会在 Cy 的旁边。Cy 的办公室也不会在 Ben 的旁边。谁搬进哪个办公室?
我们可以通过列举所有可能性并施加给定的限制来直接确定谁搬进哪个办公室:⁴⁴
function office_move() {
const alyssa = amb(1, 2, 3, 4, 5);
const ben = amb(1, 2, 3, 4, 5);
const cy = amb(1, 2, 3, 4, 5);
const lem = amb(1, 2, 3, 4, 5);
const louis = amb(1, 2, 3, 4, 5);
require(distinct(list(alyssa, ben, cy, lem, louis)));
require(alyssa !== 5);
require(ben !== 1);
require(cy !== 5);
require(cy !== 1);
require(lem > ben);
require(math_abs(louis - cy) !== 1);
require(math_abs(cy - ben) !== 1);
return list(list("alyssa", alyssa),
list("ben", ben),
list("cy", cy),
list("lem", lem),
list("louis", louis));
}
求值表达式office_move()的结果是
list(list("alyssa", 3), list("ben", 2), list("cy", 4),
list("lem", 5), list("louis", 1))
尽管这个简单的函数有效,但速度非常慢。练习 4.37 和 4.38 讨论了一些可能的改进。
练习 4.36
修改办公室搬迁函数,省略 Louis 的办公室不会在 Cy 的旁边的要求。对于这个修改后的谜题有多少解?
练习 4.37
办公室搬迁函数中限制的顺序是否影响答案?它是否影响找到答案的时间?如果你认为它很重要,通过重新排列限制从给定的函数中获得更快的程序。如果你认为它不重要,请阐述你的观点。
练习 4.38
在办公室搬迁问题中,人们搬进办公室的分配集合有多少个,在办公室分配必须不同的要求之前和之后?生成所有可能的人员到办公室的分配,然后依靠回溯来消除它们是非常低效的。例如,大多数限制只依赖于一个或两个人员-办公室名称,因此可以在为所有人员选择办公室之前施加。编写并演示一个更有效的非确定性函数,它基于生成仅仅是之前的限制已经排除的那些可能性来解决这个问题。
练习 4.39
编写一个普通的 JavaScript 程序来解决办公室搬迁谜题。
练习 4.40
解决以下“说谎者”谜题(改编自 Phillips 1934):
Alyssa、Cy、Eva、Lem 和 Louis 在 SoSoService 商务午餐。他们的餐点一个接一个地到达,比他们下订单的时间晚了很多。为了取悦 Ben,他们决定每个人都说一句真话和一句假话关于他们的订单:
Alyssa:“Lem 的餐第二个到了。我的第三个到了。”
Cy:“我的先到了。Eva 的第二个到了。”
Eva:“我的第三个到了,可怜的 Cy 的最后一个到了。”
Lem:“我的第二个到了。Louis 的第四个到了。”
Louis:“我的第四个到了。Alyssa 的第一个到了。”
五个用餐者的真实用餐顺序是什么?
练习 4.41
使用amb求值器来解决以下谜题(改编自 Phillips 1961):
Alyssa、Ben、Cy、Eva 和 Louis 分别选择 SICP JS 的不同章节,并解决该章节中的所有练习。Louis 解决了“函数”章节中的练习,Alyssa 解决了“数据”章节中的练习,Cy 解决了“状态”章节中的练习。他们决定相互检查对方的工作,Alyssa 自愿检查“元”章节中的练习。由 Ben 解决“寄存器机器”章节中的练习,并由 Louis 检查。检查“函数”章节中的练习的人解决了 Eva 检查的练习。谁检查“数据”章节中的练习?
尝试编写程序,使其运行效率高(参见练习 4.38)。还要确定如果我们不知道 Alyssa 检查“Meta”章节中的练习,有多少解决方案。
练习 4.42
练习 2.42 描述了在国际象棋棋盘上放置皇后,使得没有两个皇后互相攻击的“八皇后谜题”。编写一个非确定性程序来解决这个谜题。
解析自然语言
设计为接受自然语言作为输入的程序通常从尝试解析输入开始,即将输入与某种语法结构匹配。例如,我们可以尝试识别由冠词后跟名词后跟动词组成的简单句子,如“The cat eats”。为了完成这样的分析,我们必须能够识别单词的词性。我们可以从一些分类各种单词的列表开始:
const nouns = list("noun", "student", "professor", "cat", "class");
const verbs = list("verb", "studies", "lectures", "eats", "sleeps");
const articles = list("article", "the", "a");
我们还需要一个语法,即一组描述语法元素如何由更简单的元素组成的规则。一个非常简单的语法可能规定一个句子总是由两部分组成——一个名词短语后跟一个动词——而一个名词短语由一个冠词后跟一个名词组成。有了这个语法,句子“The cat eats”被解析如下:
list("sentence",
list("noun-phrase", list("article", "the"), list("noun", "cat"),
list("verb", "eats"))
我们可以用一个简单的程序生成这样的解析,该程序为每个语法规则分别定义了函数。为了解析一个句子,我们识别它的两个组成部分,并返回带有符号sentence的这两个元素的列表:
function parse_sentence() {
return list("sentence",
parse_noun_phrase(),
parse_word(verbs));
}
类似地,名词短语通过找到一个冠词后跟一个名词来解析:
function parse_noun_phrase() {
return list("noun-phrase",
parse_word(articles),
parse_word(nouns));
}
在最低级别,解析归结为反复检查下一个尚未解析的单词是否属于所需词性的单词列表。为了实现这一点,我们维护一个全局变量not_yet_parsed,它是尚未解析的输入。每次检查一个单词时,我们要求not_yet_parsed必须非空,并且它应该以指定列表中的一个单词开头。如果是这样,我们就从not_yet_parsed中删除该单词,并返回该单词以及它的词性(该词性位于列表的开头):
function parse_word(word_list) {
require(! is_null(not_yet_parsed));
require(! is_null(member(head(not_yet_parsed), tail(word_list))));
const found_word = head(not_yet_parsed);
not_yet_parsed = tail(not_yet_parsed);
return list(head(word_list), found_word);
}
要开始解析,我们所需要做的就是将not_yet_parsed设置为整个输入,尝试解析一个句子,并检查是否有剩余的内容:
let not_yet_parsed = null;
function parse_input(input) {
not_yet_parsed = input;
const sent = parse_sentence();
require(is_null(not_yet_parsed));
return sent;
}
现在我们可以尝试解析器,并验证它是否适用于我们的简单测试句子:
amb-求值输入:
parse_input(list("the", "cat", "eats"));
开始一个新问题
amb-求值值:
list("sentence",
list("noun-phrase", list("article", "the"), list("noun", "cat")),
list("verb", "eats"))
amb求值器在这里很有用,因为使用require来表达解析约束非常方便。然而,当我们考虑更复杂的语法,其中有关于单元如何分解的选择时,自动搜索和回溯确实很有回报。
让我们在我们的语法中添加一个介词列表:
const prepositions = list("prep", "for", "to", "in", "by", "with");
并定义介词短语(例如,“为猫”)为介词后跟一个名词短语:
function parse_prepositional_phrase() {
return list("prep-phrase",
parse_word(prepositions),
parse_noun_phrase());
}
现在我们可以定义一个句子为一个名词短语后跟一个动词短语,其中动词短语可以是一个动词,也可以是一个由介词短语扩展的动词短语:
function parse_sentence() {
return list("sentence",
parse_noun_phrase(),
parse_verb_phrase());
}
function parse_verb_phrase() {
function maybe_extend(verb_phrase) {
return amb(verb_phrase,
maybe_extend(list("verb-phrase",
verb_phrase,
parse_prepositional_phrase())));
}
return maybe_extend(parse_word(verbs));
}
顺便说一下,我们还可以详细说明名词短语的定义,以允许“a cat in the class”这样的内容。我们过去称之为名词短语的东西,现在称之为简单名词短语,名词短语现在可以是一个简单名词短语,也可以是由介词短语扩展的名词短语:
function parse_simple_noun_phrase() {
return list("simple-noun-phrase",
parse_word(articles),
parse_word(nouns));
}
function parse_noun_phrase() {
function maybe_extend(noun_phrase) {
return amb(noun_phrase,
maybe_extend(list("noun-phrase",
noun_phrase,
parse_prepositional_phrase())));
}
return maybe_extend(parse_simple_noun_phrase());
}
我们的新语法让我们能够解析更复杂的句子。例如
parse_input(list("the", "student", "with", "the", "cat",
"sleeps", "in", "the", "class"));
产生
list("sentence",
list("noun-phrase",
list("simple-noun-phrase",
list("article", "the"), list("noun", "student")),
list("prep-phrase", list("prep", "with"),
list("simple-noun-phrase",
list("article", "the"),
list("noun", "cat")))),
list("verb-phrase",
list("verb", "sleeps"),
list("prep-phrase", list("prep", "in"),
list("simple-noun-phrase",
list("article", "the"),
list("noun", "class")))))
注意,给定的输入可能有多个合法的解析。在句子“The professor lectures to the student with the cat.”中,可能是教授正在和猫一起讲课,也可能是学生有这只猫。我们的非确定性程序找到了这两种可能性:
parse_input(list("the", "professor", "lectures",
"to", "the", "student", "with", "the", "cat"));
产生
list("sentence",
list("simple-noun-phrase",
list("article", "the"), list("noun", "professor")),
list("verb-phrase",
list("verb-phrase",
list("verb", "lectures"),
list("prep-phrase", list("prep", "to"),
list("simple-noun-phrase",
list("article", "the"),
list("noun", "student")))),
list("prep-phrase", list("prep", "with"),
list("simple-noun-phrase",
list("article", "the"),
list("noun", "cat")))))
要求求值器重试会产生
list("sentence",
list("simple-noun-phrase",
list("article", "the"), list("noun", "professor")),
list("verb-phrase",
list("verb", "lectures"),
list("prep-phrase", list("prep", "to"),
list("noun-phrase",
list("simple-noun-phrase",
list("article", "the"),
list("noun", "student")),
list("prep-phrase", list("prep", "with"),
list("simple-noun-phrase",
list("article", "the"),
list("noun", "cat")))))))
练习 4.43
使用上面给出的语法,以下句子可以有五种不同的解析方式:“The professor lectures to the student in the class with the cat.” 给出这五种解析并解释它们之间的意义差异。
练习 4.44
第 4.1 和 4.2 节的求值器不确定参数表达式的求值顺序。我们将看到amb求值器会从左到右对它们进行求值。解释为什么如果参数表达式以其他顺序进行求值,我们的解析程序将无法工作。
练习 4.45
Louis Reasoner 建议,由于动词短语要么是一个动词,要么是一个动词短语后跟一个介词短语,因此将函数parse_verb_phrase声明为以下方式(名词短语也是如此)会更加直接:
function parse_verb_phrase() {
return amb(parse_word(verbs),
list("verb-phrase",
parse_verb_phrase(),
parse_prepositional_phrase()));
}
这样行得通吗?如果我们交换amb中表达式的顺序,程序的行为会改变吗?
练习 4.46
扩展上面给出的语法以处理更复杂的句子。例如,您可以扩展名词短语和动词短语以包括形容词和副词,或者您可以处理并列句。
练习 4.47
Alyssa P. Hacker 对生成有趣的句子更感兴趣,而不是解析它们。她认为,通过简单地更改函数parse_word,使其忽略“输入句子”,而总是成功并生成一个合适的单词,我们可以使用我们为解析构建的程序来进行生成。实现 Alyssa 的想法,并展示生成的前六个或更多句子。