原文:3 Modularity, Objects, and State
译者:飞龙
变化中安宁
(即使它在变化,它仍然保持不变。)
——赫拉克利特
变化越大,越是相同。
——阿方斯·卡尔
前面的章节介绍了构成程序的基本元素。我们看到了原始函数和原始数据是如何组合成复合实体的,我们也了解到抽象对于帮助我们应对大型系统的复杂性是至关重要的。但是这些工具并不足以用于设计程序。有效的程序合成还需要组织原则,可以指导我们制定程序的整体设计。特别是,我们需要策略来帮助我们结构大型系统,使它们成为模块化,也就是说,它们可以被“自然地”划分为可以单独开发和维护的连贯部分。
一种强大的设计策略,特别适用于构建用于建模物理系统的程序,是基于被建模系统的结构来构建程序的结构。对于系统中的每个对象,我们构建一个相应的计算对象。对于每个系统动作,我们在计算模型中定义一个符号操作。我们使用这种策略的希望是,扩展模型以适应新对象或新动作将不需要对程序进行战略性的更改,只需要添加这些对象或动作的新符号模拟。如果我们在系统组织上取得了成功,那么要添加新功能或调试旧功能,我们只需要在系统的局部部分工作。
在很大程度上,我们组织大型程序的方式是由我们对待建模系统的看法所决定的。在本章中,我们将研究两种突出的组织策略,这些策略源自对系统结构的两种相当不同的“世界观”。第一种组织策略集中在对象上,将一个大型系统视为一组随时间可能发生变化的不同对象。另一种组织策略集中在系统中流动的信息流上,就像电气工程师看待信号处理系统一样。
对象为基础的方法和流处理方法都在编程中引发了重大的语言问题。对于对象,我们必须关注计算对象如何改变,但又保持其身份不变。这将迫使我们放弃我们旧的替换计算模型(第 1.1.5 节),转而采用更机械但理论上不太可解的环境模型计算。处理对象、改变和身份的困难是需要在计算模型中处理时间的一个基本结果。当我们允许程序并发执行的可能性时,这些困难变得更加严重。当我们在模型中将模拟时间与计算机在求值过程中发生的事件顺序分离时,流方法可以得到最充分的利用。我们将使用一种称为延迟求值的技术来实现这一点。
3.1 分配和本地状态
我们通常将世界看作由独立的对象组成,每个对象都有随时间变化的状态。如果一个对象的行为受其历史影响,那么就说这个对象“有状态”。例如,银行账户有状态,因为对于问题“我可以取 100 美元吗?”的答案取决于存款和取款交易的历史。我们可以通过一个或多个状态变量来描述对象的状态,这些变量中包含了足够的关于历史的信息,以确定对象的当前行为。在一个简单的银行系统中,我们可以通过当前余额来描述账户的状态,而不是通过记住整个账户交易历史。
在由许多对象组成的系统中,这些对象很少是完全独立的。每个对象可能通过相互作用影响其他对象的状态,这些相互作用将一个对象的状态变量与其他对象的状态变量耦合在一起。事实上,当系统的状态变量可以被分成紧密耦合的子系统,并且这些子系统只与其他子系统松散耦合时,系统由独立对象组成的观点是最有用的。
这种对系统的观点可以是组织系统的计算模型的强大框架。为了使这样的模型具有模块化,它应该被分解成模拟系统中实际对象的计算对象。每个计算对象必须有其自己的本地状态变量来描述实际对象的状态。由于被建模系统中的对象的状态随时间变化,相应计算对象的状态变量也必须改变。如果我们选择通过编程语言中的普通符号名称来模拟系统中的时间流逝,那么语言必须提供赋值操作来使我们能够改变与名称关联的值。
3.1.1 本地状态变量
为了说明我们所说的具有时间变化状态的计算对象,让我们模拟从银行账户中取钱的情况。我们将使用一个名为withdraw的函数来实现这一点,该函数以要提取的amount作为参数。如果账户中有足够的钱来容纳提款,那么withdraw应该返回提款后剩余的余额。否则,withdraw应该返回消息资金不足。例如,如果我们开始
在账户中有 100 美元的情况下,我们应该使用withdraw获得以下响应序列:
withdraw(25);
75
withdraw(25);
50
withdraw(60);
"Insufficient funds"
withdraw(15);
35
注意,表达式withdraw(25)被求值两次,产生不同的值。这是函数的一种新行为。到目前为止,我们所有的 JavaScript 函数都可以被视为计算数学函数的规范。对函数的调用计算了应用于给定参数的函数的值,并且对具有相同参数的同一函数的两次调用总是产生相同的结果。¹
到目前为止,我们所有的名称都是不可变的。当应用函数时,其参数引用的值从不改变,一旦声明被求值,声明的名称就不会改变其值。为了实现像withdraw这样的函数,我们引入了变量声明,它使用关键字let,除了使用关键字const的常量声明。我们可以声明一个变量balance来表示账户中的余额,并将withdraw定义为一个访问balance的函数。withdraw函数检查balance是否至少与请求的amount一样大。如果是,withdraw将balance减去amount并返回balance的新值。否则,withdraw返回资金不足的消息。这是balance和withdraw的声明:
let balance = 100;
function withdraw(amount) {
if (balance >= amount) {
balance = balance - amount;
return balance;
} else {
return "Insufficient funds";
}
}
通过表达式语句来减少balance
balance = balance - amount;
赋值表达式的语法是
name = new-value
这里的name已经用let声明或作为函数参数,并且new-value是任何表达式。赋值改变了name,使得其值是通过求值new-value得到的结果。在这种情况下,我们正在改变balance,使其新值是从先前的balance值中减去amount得到的结果。²
withdraw函数还使用语句序列来导致两个语句在if测试为真的情况下被求值:首先减少balance,然后返回balance的值。一般来说,执行一个序列
stmt[1] stmt[2] ...stmt[n]
导致语句stmt[1]到stmt[n]按顺序进行求值。
尽管withdraw的功能符合预期,但变量balance存在问题。如上所述,balance是程序环境中定义的一个名称,并且可以自由访问和修改。如果我们可以将balance作为withdraw的内部变量,那将会更好,这样withdraw将是唯一可以直接访问balance的函数,任何其他函数只能间接访问balance(通过调用withdraw)。这将更准确地模拟balance是withdraw用来跟踪账户状态的本地状态变量的概念。
我们可以通过以下方式将balance作为withdraw的内部变量来重写定义:
function make_withdraw_balance_100() {
let balance = 100;
return amount => {
if (balance >= amount) {
balance = balance - amount;
return balance;
} else {
return "Insufficient funds";
}
};
}
const new_withdraw = make_withdraw_balance_100();
我们在这里所做的是使用let建立一个具有本地变量balance的环境,绑定到初始值 100。在这个本地环境中,我们使用 lambda 表达式创建一个函数,该函数以amount作为参数,并且像我们之前的withdraw函数一样行为。这个函数——作为make_withdraw_balance_100函数的主体求值的结果返回——行为与withdraw完全相同,但它的变量balance不可被任何其他函数访问。
将赋值与变量声明结合起来是我们用于构建具有本地状态的计算对象的一般编程技术。不幸的是,使用这种技术会引发一个严重的问题:当我们首次引入函数时,我们还引入了求值的替换模型(第 1.1.5 节)来解释函数应用的含义。我们说,应用一个函数,其主体是一个返回语句,应该被解释为用参数的值替换后求值函数的返回表达式。对于主体更复杂的函数,我们需要用参数的值替换来求值整个主体。问题在于,一旦我们在语言中引入赋值,替换就不再是函数应用的充分模型。(我们将在第 3.1.3 节看到为什么会这样。)因此,从技术上讲,我们目前无法理解new_withdraw函数的行为方式。为了真正理解new_withdraw这样的函数,我们需要开发一个新的函数应用模型。在第 3.2 节中,我们将介绍这样一个模型,以及对赋值和变量声明的解释。然而,首先,我们将检查new_withdraw所建立的主题的一些变化。
函数的参数以及使用let声明的名称都是变量。以下函数make_withdraw创建“取款处理器”。make_withdraw中的参数balance指定了账户中的初始金额。
function make_withdraw(balance) {
return amount => {
if (balance >= amount) {
balance = balance - amount;
return balance;
} else {
return "Insufficient funds";
}
};
}
函数make_withdraw可以如下使用来创建两个对象W1和W2:
const W1 = make_withdraw(100);
const W2 = make_withdraw(100);
W1(50);
50
W2(70);
30
W2(40);
"Insufficient funds"
W1(40);
10
观察到W1和W2是完全独立的对象,每个对象都有自己的本地状态变量balance。从一个对象中提取不会影响另一个对象。
我们还可以创建处理存款和取款的对象,因此我们可以表示简单的银行账户。以下是一个返回具有指定初始余额的“银行账户对象”的函数:
function make_account(balance) {
function withdraw(amount) {
if (balance >= amount) {
balance = balance - amount;
return balance;
} else {
return "Insufficient funds";
}
}
function deposit(amount) {
balance = balance + amount;
return balance;
}
function dispatch(m) {
return m === "withdraw"
? withdraw
: m === "deposit"
? deposit
: error(m, "unknown request – make_account");
}
return dispatch;
}
每次调用make_account都会设置一个具有本地状态变量balance的环境。在此环境中,make_account定义了访问balance的函数deposit和withdraw,以及一个接受“消息”作为输入并返回两个本地函数之一的附加函数dispatch。dispatch函数本身作为代表银行账户对象的值返回。这正是我们在 2.4.3 节中看到的消息传递编程风格,尽管在这里我们将其与修改本地变量的能力结合使用。
函数make_account可以如下使用:
const acc = make_account(100);
acc("withdraw")(50);
50
acc("withdraw")(60);
"Insufficient funds"
acc("deposit")(40);
90
acc("withdraw")(60);
30
每次调用acc都会返回本地定义的deposit或withdraw函数,然后将其应用于指定的amount。与make_withdraw一样,对make_account的另一个调用
const acc2 = make_account(100);
将产生一个完全独立的账户对象,该对象维护其自己的本地balance。
练习 3.1
累加器是一个反复调用的函数,每次只接受一个数字参数并将其累积到总和中。每次调用它时,它都会返回当前累积的总和。编写一个函数make_accumulator,它生成累加器,每个累加器都维护一个独立的总和。make_accumulator的输入应该指定总和的初始值;例如
const a = make_accumulator(5);
a(10);
15
a(10);
25
练习 3.2
在软件测试应用程序中,能够计算在计算过程中调用给定函数的次数是很有用的。编写一个函数make_monitored,该函数以一个函数f作为输入,该函数本身接受一个输入。make_monitored返回的结果是第三个函数,称为mf,它通过维护内部计数器来跟踪其被调用的次数。如果mf的输入是字符串"how many calls",那么mf将返回计数器的值。如果输入是字符串"reset count",那么mf将计数器重置为零。对于任何其他输入,mf返回调用f对该输入的结果并增加计数器。例如,我们可以制作sqrt函数的监视版本:
const s = make_monitored(math_sqrt);
s(100);
10
s("how many calls");
`1`
练习 3.3
修改make_account函数,使其创建受密码保护的账户。也就是说,make_account应该接受一个字符串作为额外的参数,如下所示
const acc = make_account(100, "secret password");
生成的账户对象应该只处理在创建账户时附带的密码,并且否则应该返回投诉:
acc("secret password", "withdraw")(40);
60
acc("some other password", "deposit")(40);
"Incorrect password"
练习 3.4
通过添加另一个本地状态变量修改练习 3.3 的make_account函数,以便如果一个账户连续访问超过七次并且密码不正确,它会调用函数call_the_cops。
3.1.2 引入赋值的好处
正如我们将看到的,将赋值引入我们的编程语言会导致一系列困难的概念问题。然而,将系统视为具有本地状态的对象集合是一种维护模块化设计的强大技术。举一个简单的例子,考虑设计一个函数rand,每次调用该函数时,它都会返回一个随机选择的整数。
“随机选择”是什么意思并不清楚。我们想要的是连续调用rand产生具有均匀分布统计特性的数字序列。我们不会在这里讨论生成适当序列的方法。相反,让我们假设我们有一个函数rand_update,如果我们从给定的数字x[1]开始并形成
`x[2]` = rand_update(`x[1]`);
`x[3]` = rand_update(`x[2]`);
然后值序列x[1], x[2], x[3], ...,将具有所需的统计特性。⁷
我们可以将rand实现为一个带有本地状态变量x的函数,该变量初始化为某个固定值random_init。每次调用rand都会计算x的当前值的rand_update,将其作为随机数返回,并将其存储为x的新值。
function make_rand() {
let x = random_init;
return () => {
x = rand_update(x);
return x;
};
}
const rand = make_rand();
当然,我们可以通过直接调用rand_update来生成相同的随机数序列,而不使用赋值。然而,这意味着我们程序的任何部分使用随机数都必须明确记住x的当前值,以便作为rand_update的参数传递。要意识到这将是多么烦人,考虑使用随机数来实现一种称为蒙特卡罗模拟的技术。
蒙特卡罗方法包括从一个大集合中随机选择样本实验,然后根据从这些实验结果制表估计的概率进行推断。例如,我们可以利用6/π²是两个随机选择的整数没有公共因子的概率来近似π;也就是说,它们的最大公约数为 1 的概率。为了获得对π的近似值,我们进行大量实验。在每次实验中,我们随机选择两个整数并进行测试,以查看它们的最大公约数是否为 1。测试通过的次数所占的比例给出了我们对6/π²的估计,从中我们获得了对π的近似值。
我们程序的核心是一个名为monte_carlo的函数,它以尝试实验的次数和实验作为参数,实验表示为每次运行时返回真或假的无参数函数。函数monte_carlo对指定次数的试验运行实验,并返回一个数字,告诉我们实验被发现为真的试验的比例。
function estimate_pi(trials) {
return math_sqrt(6 / monte_carlo(trials, dirichlet_test));
}
function dirichlet_test() {
return gcd(rand(), rand()) === 1;
}
function monte_carlo(trials, experiment) {
function iter(trials_remaining, trials_passed) {
return trials_remaining === 0
? trials_passed / trials
: experiment()
? iter(trials_remaining - 1, trials_passed + 1)
: iter(trials_remaining - 1, trials_passed);
}
return iter(trials, 0);
}
现在让我们尝试使用rand_update直接进行相同的计算,而不是使用rand,这是我们不得不采取的方式,如果我们不使用赋值来模拟局部状态:
function estimate_pi(trials) {
return math_sqrt(6 / random_gcd_test(trials, random_init));
}
function random_gcd_test(trials, initial_x) {
function iter(trials_remaining, trials_passed, x) {
const x1 = rand_update(x);
const x2 = rand_update(x1);
return trials_remaining === 0
? trials_passed / trials
: gcd(x1, x2) === 1
? iter(trials_remaining - 1, trials_passed + 1, x2)
: iter(trials_remaining - 1, trials_passed, x2);
}
return iter(trials, 0, initial_x);
}
虽然程序仍然很简单,但它暴露了一些痛苦的模块性漏洞。在我们程序的第一个版本中,使用rand,我们可以直接将蒙特卡罗方法表达为一个通用的monte_carlo函数,该函数以任意的experiment函数作为参数。在我们程序的第二个版本中,随机数生成器没有局部状态,random_gcd_test必须明确操作随机数x1和x2,并通过迭代循环将x2重新输入到rand_update中。随机数的显式处理将累积测试结果的结构与我们特定实验使用两个随机数的事实交织在一起,而其他蒙特卡罗实验可能使用一个随机数或三个随机数。甚至顶层函数estimate_pi也必须关注提供初始随机数。随机数生成器的内部泄漏到程序的其他部分,使我们难以将蒙特卡罗思想隔离出来,以便将其应用于其他任务。在程序的第一个版本中,赋值封装了随机数生成器的状态在rand函数内部,使得随机数生成的细节保持独立于程序的其他部分。
蒙特卡洛示例所展示的一般现象是:从复杂过程的某一部分的角度来看,其他部分似乎随时间变化。它们具有隐藏的随时间变化的局部状态。如果我们希望编写的计算机程序的结构反映了这种分解,我们将创建计算对象(例如银行账户和随机数生成器),其行为随时间变化。我们用局部状态变量模拟状态,并用对这些变量的赋值来模拟状态的变化。
通过引入赋值和将状态隐藏在局部变量中的技术,我们可以以比必须通过传递额外参数显式操作所有状态更模块化的方式来构建系统。然而,不幸的是,正如我们将看到的那样,事情并不那么简单。
练习 3.5
蒙特卡洛积分是一种通过蒙特卡洛模拟来估计定积分的方法。考虑计算由谓词P(x, y)描述的空间区域的面积,该谓词对于区域中的点(x, y)为真,对于不在区域中的点为假。例如,以(5, 7)为中心的半径为 3 的圆内的区域由测试(x – 5)² + (y – 7)² = 3²的谓词描述。为了估计由这样一个谓词描述的区域的面积,首先选择一个包含该区域的矩形。例如,对角线在(2, 4)和(8, 10)的矩形包含上述圆。所需的积分是矩形中位于该区域内的部分的面积。我们可以通过随机选择位于矩形中的点(x, y),并对每个点测试P(x, y)来估计积分。如果我们尝试这样做很多次,那么落在该区域内的点的比例应该给出矩形中位于该区域内的比例的估计。因此,将这个比例乘以整个矩形的面积应该产生积分的估计。
实现蒙特卡洛积分作为一个名为estimate_integral的函数,该函数以谓词P、矩形的上下界x1、x2、y1和y2以及进行估计所需的试验次数作为参数。您的函数应该使用与上面用于估计π的相同的monte_carlo函数。使用您的estimate_integral通过测量单位圆的面积来估计π。
您会发现有一个从给定范围中随机选择一个数字的函数是很有用的。以下的random_in_range函数实现了这一点,它是基于 1.2.6 节中使用的math_random函数实现的,该函数返回小于 1 的非负数。
function random_in_range(low, high) {
const range = high - low;
return low + math_random() * range;
}
练习 3.6
能够重置随机数生成器以产生从给定值开始的序列是很有用的。设计一个新的rand函数,它被调用时带有一个参数,该参数是字符串"generate"或字符串"reset",并且行为如下:rand("generate")产生一个新的随机数;rand("reset")(new-value)将内部状态变量重置为指定的new-value。因此,通过重置状态,可以生成可重复的序列。在测试和调试使用随机数的程序时,这些是非常方便的。
3.1.3 引入赋值的成本
正如我们所看到的,赋值使我们能够模拟具有局部状态的对象。然而,这种优势是有代价的。我们的编程语言不再能够根据我们在 1.1.5 节中介绍的函数应用替换模型来解释。此外,在处理对象和编程语言中的赋值时,没有简单的具有“良好”数学属性的模型可以成为一个足够的框架。
只要我们不使用赋值,对相同参数的同一函数的两次求值将产生相同的结果,因此函数可以被视为计算数学函数。因此,没有使用任何赋值的编程,就像我们在本书的前两章中所做的那样,因此被称为函数式编程。
要理解赋值如何使事情复杂化,考虑 3.1.1 节中make_withdraw函数的简化版本,它不需要检查金额是否不足:
function make_simplified_withdraw(balance) {
return amount => {
balance = balance - amount;
return balance;
};
}
const W = make_simplified_withdraw(25);
W(20);
5
W(10);
-5
将此函数与不使用赋值的以下make_decrementer函数进行比较:
function make_decrementer(balance) {
return amount => balance - amount;
}
函数make_decrementer返回一个从指定金额balance中减去其输入的函数,但是在连续调用中没有累积效果,就像make_simplified_withdraw一样:
const D = make_decrementer(25);
D(20);
`5`
D(10);
15
我们可以使用替换模型来解释make_decrementer的工作原理。例如,让我们分析表达式的求值
make_decrementer(25)(20)
我们首先通过在make_decrementer的主体中用 25 替换balance来简化应用的函数表达式。这将表达式简化为
(amount => 25 - amount)(20)
现在我们通过在 lambda 表达式的主体中用 20 替换amount来应用函数:
25 - 20
最终答案是 5。
然而,观察一下,如果我们尝试用make_simplified_withdraw进行类似的替换分析会发生什么:
make_simplified_withdraw(25)(20)
我们首先通过在make_simplified_withdraw的主体中用 25 替换balance来简化函数表达式。这将表达式简化为⁹
(amount => {
balance = 25 - amount;
return 25;
})(20)
现在我们通过在 lambda 表达式的主体中用 20 替换amount来应用函数:
balance = 25 - 20;
return 25;
如果我们坚持替换模型,我们将不得不说函数应用的含义是首先将balance设置为 5,然后返回 25 作为表达式的值。这得到了错误的答案。为了得到正确的答案,我们必须以某种方式区分balance的第一次出现(在赋值的效果之前)和balance的第二次出现(在赋值的效果之后),而替换模型无法做到这一点。
这里的问题是,替换基本上是基于这样一个概念,即我们语言中的名称本质上是值的符号。这对常量效果很好。但是,一个变量的值可以随着赋值而改变,不能简单地成为一个值的名称。变量在某种程度上指的是一个值可以被存储的地方,而存储在这个地方的值可以改变。在 3.2 节中,我们将看到环境如何在我们的计算模型中扮演“位置”的角色。
相同和变化
这里出现的问题比计算模型的简单崩溃更加深刻。一旦我们在计算模型中引入变化,许多以前简单明了的概念就变得棘手。考虑两个事物“相同”的概念。
假设我们用相同的参数两次调用make_decrementer来创建两个函数:
const D1 = make_decrementer(25);
const D2 = make_decrementer(25);
D1和D2是相同的吗?一个可以接受的答案是是,因为D1和D2具有相同的计算行为——每个都是从 25 中减去其输入的函数。实际上,D1可以在任何计算中替换为D2而不改变结果。
与此形成对比的是两次调用make_simplified_withdraw:
const W1 = make_simplified_withdraw(25);
const W2 = make_simplified_withdraw(25);
W1和W2是相同的吗?当然不是,因为对W1和W2的调用具有不同的效果,如下交互序列所示:
W1(20);
5
W1(20);
-15
W2(20);
5
即使W1和W2在某种意义上是“相等”的,因为它们都是通过求值相同的表达式make_simplified_withdraw(25)创建的,但并不是说W1可以在任何表达式中替换为W2而不改变表达式的结果。
一个支持“等号可以替换为等号”概念的语言在不改变表达式的值的情况下被称为引用透明。当我们在计算机语言中包含赋值时,引用透明性就会被违反。这使得确定何时可以通过替换等价表达式来简化表达式变得非常棘手。因此,对使用赋值的程序进行推理变得极其困难。
一旦我们放弃了引用透明性,计算对象“相同”的概念就变得难以以正式的方式捕捉。事实上,我们的程序模拟的现实世界中“相同”的含义本身就不太清晰。通常情况下,我们只能通过修改一个对象,然后观察另一个对象是否以相同的方式发生了变化,来确定两个看似相同的对象是否确实是“同一个”。但是,我们如何判断一个对象是否“改变”,除了观察“相同”的对象两次并查看对象的某些属性是否从一次观察到下一次观察发生了变化?因此,我们无法在没有某种先验的“相同”概念的情况下确定“改变”,也无法在没有观察到改变的效果的情况下确定相同。
举个编程中出现这个问题的例子,考虑一下彼得和保罗各自有 100 美元的银行账户的情况。将这种情况建模为
const peter_acc = make_account(100);
const paul_acc = make_account(100);
和将其建模为
const peter_acc = make_account(100);
const paul_acc = peter_acc;
在第一种情况下,两个银行账户是不同的。彼得的交易不会影响保罗的账户,反之亦然。然而,在第二种情况下,我们已经定义paul_acc与peter_acc是同一件事。实际上,彼得和保罗现在有一个联合银行账户,如果彼得从peter_acc中取款,保罗会发现paul_acc中的钱变少。这两种相似但不同的情况可能会在构建计算模型时造成混淆。特别是对于共享账户,令人困惑的是有一个对象(银行账户)有两个不同的名称(peter_acc和paul_acc);如果我们正在寻找程序中所有可能改变paul_acc的地方,我们必须记得也要查看那些改变peter_acc的地方。¹⁰
关于“相同”和“改变”的上述评论,可以观察到,如果彼得和保罗只能查看他们的银行余额,并且不能执行改变余额的操作,那么两个账户是否不同的问题就没有意义了。一般来说,只要我们不修改数据对象,我们就可以认为复合数据对象恰好是其各部分的总和。例如,有理数是通过给出其分子和分母来确定的。但是,在存在改变的情况下,复合数据对象具有一个与其组成部分不同的“身份”。即使我们通过取款改变了银行账户的余额,银行账户仍然是“相同的”银行账户;反之,我们可以有两个具有相同状态信息的不同银行账户。这种复杂性是我们对银行账户作为一个对象的感知的结果,而不是我们的编程语言的结果。例如,我们通常不将有理数视为具有身份的可变对象,这样我们就可以改变分子但仍然拥有“相同”的有理数。
命令式编程的陷阱
与函数式编程相比,大量使用赋值的编程被称为命令式编程。除了引发关于计算模型的复杂性之外,以命令式风格编写的程序容易出现在函数式程序中不会出现的错误。例如,回想一下 1.2.1 节中的迭代阶乘程序(这里使用条件语句而不是条件表达式):
function factorial(n) {
function iter(product, counter) {
if (counter > n) {
return product;
} else {
return iter(counter * product,
counter + 1);
}
}
return iter(1, 1);
}
与在内部迭代循环中传递参数不同,我们可以采用更加命令式的风格,通过显式赋值来更新变量product和counter的值:
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();
}
这并不会改变程序产生的结果,但它确实引入了一个微妙的陷阱。我们如何决定赋值的顺序?事实上,程序按照原样编写是正确的。但是,如果将赋值的顺序写反
counter = counter + 1;
product = counter * product;
一般来说,使用赋值进行编程会迫使我们仔细考虑赋值的相对顺序,以确保每个语句都使用了已更改的变量的正确版本。这个问题在函数式程序中根本不会出现。
如果考虑到多个进程同时执行的应用程序,那么命令式程序的复杂性将变得更加糟糕。我们将在第 3.4 节中回到这一点。然而,首先,我们将解决涉及赋值的表达式的计算模型,并探讨在设计模拟中使用具有局部状态的对象的用途。
练习 3.7
考虑make_account创建的银行账户对象,其中包括练习 3.3 中描述的密码修改。假设我们的银行系统需要能够创建联合账户。定义一个名为make_joint的函数来实现这一点。函数make_joint应该有三个参数。第一个是受密码保护的账户。第二个参数必须与账户定义时的密码匹配,才能进行make_joint操作。第三个参数是一个新密码。函数make_joint将使用新密码创建对原始账户的额外访问。例如,如果peter_acc是一个密码为"open sesame"的银行账户,则
const paul_acc = make_joint(peter_acc, "open sesame", "rosebud");
将允许使用名称paul_acc和密码"rosebud"在peter_acc上进行交易。您可能希望修改您对练习 3.3 的解决方案,以适应这一新功能。
练习 3.8
当我们在第 1.1.3 节中定义了求值模型时,我们说求值表达式的第一步是求值其子表达式。但我们从未指定子表达式应该以何种顺序进行求值(例如,从左到右还是从右到左)。当我们引入赋值时,操作符组合的操作数的求值顺序可能会影响结果。定义一个简单的函数f,使得求值f(0) + f(1)将返回 0,如果+的操作数从左到右进行求值,但如果操作数从右到左进行求值,则返回 1。
3.2 求值的环境模型
当我们在第 1 章介绍了复合函数时,我们使用了求值的替换模型(第 1.1.5 节)来定义应用函数到参数的含义:
- 要将复合函数应用到参数上,用相应的参数替换每个参数后,求值函数的返回表达式(更一般地说,是主体)。
一旦我们允许在我们的编程语言中进行赋值,这样的定义就不再合适。特别是,第 3.1.3 节认为,在存在赋值的情况下,一个名称不能仅仅被认为是代表一个值。相反,一个名称必须以某种方式指定一个“位置”,在这个位置中值可以被存储。在我们的新的求值模型中,这些位置将被维护在称为环境的结构中。
环境是一个帧的序列。每个帧都是一个绑定(可能为空)的表,它将名称与相应的值关联起来。(单个帧最多可以包含一个名称的绑定。)每个帧还有一个指向其封闭环境的指针,除非出于讨论的目的,该帧被认为是全局的。与环境相关的名称的值是由环境中包含该名称的第一个帧中的绑定给出的值。如果序列中的任何帧都没有为名称指定绑定,则称该名称在环境中是未绑定的。
图 3.1 显示了一个简单的环境结构,由三个标记为 I、II 和 III 的框架组成。在图中,A、B、C 和 D 是指向环境的指针。C 和 D 指向相同的环境。名称z和x在框架 II 中绑定,而y和x在框架 I 中绑定。环境 D 中x的值为 3。相对于环境 B,x的值也是 3。这是这样确定的:我们检查序列中的第一个框架(框架 III),并没有找到x的绑定,所以我们继续到封闭的环境 D,并在框架 I 中找到了绑定。另一方面,相对于环境 A,x的值为 7,因为序列中的第一个框架(框架 II)包含了x绑定到 7。相对于环境 A,框架 II 中x绑定到 7 被称为屏蔽了框架 I 中x绑定到 3。
图 3.1 一个简单的环境结构。
环境对于求值过程至关重要,因为它决定了表达式应该在其中环境中进行求值的上下文。事实上,可以说编程语言中的表达式本身并没有任何意义。相反,表达式只有在某个环境中进行求值时才会获得意义。甚至对于像display(1)这样直接的表达式的解释也取决于理解在其中名称display指的是显示值的原始函数的上下文。因此,在我们的求值模型中,我们将始终讨论相对于某个环境求值表达式。为了描述与解释器的交互,我们假设存在一个全局环境,由一个单一框架(没有封闭环境)组成,其中包括与原始函数相关联的名称的值。例如,display是原始显示函数的名称的想法被捕捉为名称display在全局环境中绑定到原始显示函数。
在求值程序之前,我们在全局环境中添加一个新框架,即程序框架,得到程序环境。我们将程序顶层声明的名称添加到这个框架中,这些名称在任何块之外声明。然后,给定的程序将相对于程序环境进行求值。
3.2.1 求值规则
解释器求值函数应用的整体规范与我们在第 1.1.4 节首次介绍时保持一致:
-
要求值一个应用:
-
1. 求值应用的子表达式。¹²
-
2. 将函数子表达式的值应用于参数子表达式的值。
-
求值环境模型取代了替换模型,以指定将复合函数应用于参数的含义。
在求值环境模型中,函数始终是一个由一些代码和指向环境的指针组成的对。函数只能通过求值 lambda 表达式来创建。这会产生一个函数,其代码是从 lambda 表达式的文本中获取的,其环境是求值 lambda 表达式以产生函数的环境。例如,考虑函数声明
function square(x) {
return x * x;
}
在程序环境中求值。函数声明语法等同于底层的隐式 lambda 表达式。使用¹³也是等效的
const square = x => x * x;
这将求值x => x * x并将square绑定到结果值,都在程序环境中。
图 3.2 显示了求值此声明语句的结果。全局环境包含程序环境。为了减少混乱,在此图之后,我们将不显示全局环境(因为它总是相同的),但是通过从程序环境向上的指针来提醒我们它的存在。函数对象是一个对,其代码指定函数有一个参数,即x,和一个函数体return x * x;。函数的环境部分是指向程序环境的指针,因为这是求值 lambda 表达式以生成函数的环境。一个新的绑定,将函数对象与名称square关联起来,已添加到程序帧中。
图 3.2 在程序环境中求值function square(x) { return x * x; }所产生的环境结构。
一般来说,const,function和let会向帧中添加绑定。常量不允许赋值,因此我们的环境模型需要区分指向常量的名称和指向变量的名称。我们通过在名称后面的冒号后写一个等号来表示名称是常量。我们认为函数声明等同于常量声明;请参见图 3.2 中冒号后的等号。
现在我们已经看到了函数是如何创建的,我们可以描述函数是如何应用的。环境模型指定:要将函数应用于参数,创建一个新的环境,其中包含一个将参数绑定到参数值的帧。此帧的封闭环境是函数指定的环境。现在,在这个新环境中,求值函数体。
为了展示这条规则是如何遵循的,图 3.3 说明了在程序环境中求值表达式square(5)所创建的环境结构,其中square是在图 3.2 中生成的函数。应用函数会导致创建一个新的环境,图中标记为 E1,它以一个帧开始,其中函数的参数x绑定到参数 5。请注意,环境 E1 中的名称x后面跟着一个冒号,没有等号,这表明参数x被视为变量。从这个帧向上指的指针显示了帧的封闭环境是程序环境。这里选择程序环境,因为这是square函数对象的一部分所指示的环境。在 E1 中,我们求值函数体,return x * x;。由于 E1 中x的值是 5,结果是5 * 5,即 25。
图 3.3 在程序环境中求值square(5)所创建的环境。
函数应用的环境模型可以总结为两条规则:
-
通过构建一个帧,将函数的参数绑定到调用的参数,然后在构建的新环境的上下文中求值函数体,将函数对象应用于一组参数。新帧的封闭环境是被应用的函数对象的环境部分。应用的结果是在求值函数体时遇到的第一个
return语句的返回表达式的结果。 -
通过在给定环境中求值 lambda 表达式来创建函数。生成的函数对象是一个对,包括 lambda 表达式的文本和指向创建函数的环境的指针。
最后,我们指定了赋值的行为,这个操作迫使我们首先引入环境模型。在某个环境中求值表达式name = value会找到环境中名称的绑定。也就是说,找到环境中包含名称绑定的第一个框架。如果绑定是变量绑定——在框架中名称后面只有:表示——那么该绑定将被更改以反映变量的新值。否则,如果框架中的绑定是常量绑定——在名称后面由:=表示——赋值会发出“对常量赋值”的错误。如果环境中的名称未绑定,则赋值会发出“变量未声明”的错误。
这些求值规则虽然比替换模型复杂得多,但仍然相当简单。此外,求值模型虽然抽象,但提供了解释器如何求值表达式的正确描述。在第 4 章中,我们将看到这个模型如何作为实现工作解释器的蓝图。以下各节通过分析一些说明性程序详细阐述了该模型的细节。
3.2.2 应用简单函数
当我们在 1.1.5 节介绍了替换模型时,我们展示了应用f(5)的求值结果为 136,给定以下函数声明:
function square(x) {
return x * x;
}
function sum_of_squares(x, y) {
return square(x) + square(y);
}
function f(a) {
return sum_of_squares(a + 1, a * 2);
}
我们可以使用环境模型分析相同的例子。图 3.4 显示了通过在程序环境中求值f,square和sum_of_squares的定义而创建的三个函数对象。每个函数对象由一些代码组成,以及指向程序环境的指针。
图 3.4 程序框架中的函数对象。
在图 3.5 中,我们看到通过求值表达式f(5)创建的环境结构。对f的调用创建了一个新的环境 E1,从一个框架开始,其中f的参数a绑定到参数 5。在 E1 中,我们求值f的主体:
return sum_of_squares(a + 1, a * 2);
图 3.5 通过使用图 3.4 中的函数求值f(5)而创建的环境。
为了求值返回语句,我们首先求值返回表达式的子表达式。第一个子表达式sum_of_squares的值是一个函数对象。(注意如何找到这个值:我们首先查找 E1 的第一个框架,其中不包含sum_of_squares的绑定。然后我们继续到封闭环境,即程序环境,并找到图 3.4 中显示的绑定。)其他两个子表达式通过应用原始操作+和*来求值两个组合a + 1和a * 2,分别获得 6 和 10。
现在我们将函数对象sum_of_squares应用于参数 6 和 10。这将导致一个新的环境 E2,其中参数x和y绑定到参数。在 E2 中,我们求值语句
return square(x) + square(y);
这导致我们求值square(x),其中square在程序框架中找到,x为 6。再次,我们建立一个新的环境 E3,在其中x绑定到 6,并在其中求值square的主体,即return x * x;。同样作为应用sum_of_squares的一部分,我们必须求值子表达式square(y),其中y为 10。对square的第二次调用创建了另一个环境 E4,在其中square的参数x绑定到 10。在 E4 中,我们必须求值return x * x;。
需要注意的重要一点是,每次调用square都会创建一个包含x绑定的新环境。我们可以在这里看到不同的帧是如何保持分开的不同的名为x的本地变量的。请注意,square创建的每个帧都指向程序环境,因为这是square函数对象指定的环境。
在子表达式被求值之后,结果被返回。square的两次调用生成的值被sum_of_squares相加,这个结果被f返回。由于我们这里的重点是环境结构,我们不会详细讨论这些返回值是如何从调用传递到调用的;然而,这也是求值过程的一个重要方面,我们将在第 5 章中详细讨论它。
练习 3.9
在 1.2.1 节中,我们使用替换模型来分析两个计算阶乘的函数,一个是递归版本
function factorial(n) {
return n === 1
? 1
: n * factorial(n - 1);
}
和迭代版本
function factorial(n) {
return fact_iter(1, 1, n);
}
function fact_iter(product, counter, max_count) {
return counter > max_count
? product
: fact_iter(counter * product,
counter + 1,
max_count);
}
展示了使用factorial函数的每个版本来求值factorial(6)的环境结构。¹⁶
3.2.3 帧作为本地状态的存储库
我们可以转向环境模型,看看如何使用函数和赋值来表示具有本地状态的对象。例如,考虑通过调用函数创建的“取款处理器”(来自第 3.1.1 节)
function make_withdraw(balance) {
return amount => {
if (balance >= amount) {
balance = balance - amount;
return balance;
} else {
return "insufficient funds";
}
};
}
让我们描述一下
const W1 = make_withdraw(100);
接着
W1(50);
50
图 3.6 显示了在程序环境中声明make_withdraw函数的结果。这产生了一个包含指向程序环境的指针的函数对象。到目前为止,这与我们已经看到的例子没有什么不同,只是函数主体中的返回表达式本身是一个 lambda 表达式。
图 3.6 在程序环境中定义make_withdraw的结果。
当我们将函数make_withdraw应用到一个参数时,计算的有趣部分发生了:
const W1 = make_withdraw(100);
我们通常是通过设置环境 E1 来开始的,在这个环境中,参数balance绑定到参数 100。在这个环境中,我们求值make_withdraw的主体,即返回语句,其返回表达式是一个 lambda 表达式。对这个 lambda 表达式的求值构造了一个新的函数对象,其代码由 lambda 表达式指定,其环境是 E1,lambda 表达式被求值以产生函数的环境。由对make_withdraw的调用返回的结果是这个函数对象。由于常量声明本身是在程序环境中被求值的,所以它在程序环境中绑定到W1。图 3.7 显示了生成的环境结构。
图 3.7 求值const W1 = make_withdraw(100);的结果。
现在我们可以分析当W1应用到一个参数时会发生什么:
W1(50);
50
我们首先构建一个帧,在这个帧中,W1的参数amount绑定到参数 50。需要注意的关键点是,这个帧的封闭环境不是程序环境,而是环境 E1,因为这是由W1函数对象指定的环境。在这个新环境中,我们求值函数的主体:
if (balance >= amount) {
balance = balance - amount;
return balance;
} else {
return "insufficient funds";
}
生成的环境结构如图 3.8 所示。正在求值的表达式引用了amount和balance。变量amount将在环境中的第一个帧中找到,而balance将通过跟随封闭环境指针到 E1 中找到。
图 3.8 应用函数对象W1创建的环境。
当执行赋值时,E1 中balance的绑定被更改。在调用W1完成时,balance为 50,并且仍然由函数对象W1指向包含balance的帧。绑定amount的帧(我们执行了更改balance的代码)不再相关,因为构造它的函数调用已经终止,并且没有来自环境其他部分的指针指向该帧。下次调用W1时,这将构建一个绑定amount的新帧,其封闭环境为 E1。我们看到 E1 充当了为函数对象W1保存局部状态变量的“位置”。图 3.9 显示了调用W1后的情况。
图 3.9 调用W1后的环境。
观察当我们通过再次调用make_withdraw创建第二个withdraw对象时会发生什么:
const W2 = make_withdraw(100);
这产生了图 3.10 中的环境结构,显示W2是一个函数对象,即一个带有一些代码和一个环境的对。W2的环境 E2 是通过调用make_withdraw创建的。它包含一个带有自己的局部绑定balance的帧。另一方面,W1和W2具有相同的代码:make_withdraw主体中 lambda 表达式指定的代码。¹⁷我们在这里看到了为什么W1和W2表现为独立对象。对W1的调用引用存储在 E1 中的状态变量balance,而对W2的调用引用 E2 中存储的balance。因此,对一个对象的局部状态的更改不会影响另一个对象。
图 3.10 使用const W2 = make_withdraw(100);创建第二个对象。
练习 3.10
在make_withdraw函数中,局部变量balance作为make_withdraw的参数创建。我们还可以使用我们可以称之为立即调用的 lambda 表达式单独创建局部状态变量,如下所示:
function make_withdraw(initial_amount) {
return (balance =>
amount => {
if (balance >= amount) {
balance = balance - amount;
return balance;
} else {
return "insufficient funds";
}
})(initial_amount);
}
外部 lambda 表达式在求值后立即被调用。它的唯一目的是创建一个名为balance的局部变量,并将其初始化为initial_amount。使用环境模型分析make_withdraw的这个替代版本,绘制类似上面的图形以说明交互。
const W1 = make_withdraw(100);
W1(50);
const W2 = make_withdraw(100);
展示make_withdraw的两个版本创建具有相同行为的对象。这两个版本的环境结构有何不同?
3.2.4 内部声明
在本节中,我们处理包含声明的函数体或其他块(例如条件语句的分支)。每个块为在块中声明的名称打开一个新的作用域。为了在给定环境中求值一个块,我们通过一个包含在块的主体中直接声明的所有名称的新帧来扩展该环境,然后在新构建的环境中求值主体。
1.1.8 节介绍了函数可以具有内部声明的概念,从而导致块结构,如下面的函数计算平方根:
function sqrt(x) {
function is_good_enough(guess) {
return abs(square(guess) - x) < 0.001;
}
function improve(guess) {
return average(guess, x / guess);
}
function sqrt_iter(guess){
return is_good_enough(guess)
? guess
: sqrt_iter(improve(guess));
}
return sqrt_iter(1);
}
现在我们可以使用环境模型来看为什么这些内部声明的行为符合预期。图 3.11 显示了在求值表达式sqrt(2)时,内部函数is_good_enough首次被调用,其中guess等于 1。
图 3.11 带有内部声明的sqrt函数。
观察环境的结构。名称sqrt在程序环境中绑定到一个函数对象,其关联的环境是程序环境。当调用sqrt时,形成了一个新的环境 E1,它是程序环境的下属,在其中参数x绑定到 2。然后在 E1 中求值了sqrt的主体。该主体是一个带有本地函数声明的块,因此 E1 被扩展为这些声明的新框架,导致新的环境 E2。然后在 E2 中求值了该块的主体。由于主体中的第一条语句是...
function is_good_enough(guess) {
return abs(square(guess) - x) < 0.001;
}
求值此声明在环境 E2 中创建了函数is_good_enough。更准确地说,E2 中的第一个框架中的名称is_good_enough绑定到一个函数对象,其关联的环境是 E2。类似地,improve和sqrt_iter在 E2 中被定义为函数。为简洁起见,图 3.11 仅显示了is_good_enough的函数对象。
在定义了本地函数之后,仍然在环境 E2 中求值了表达式sqrt_iter(1)。因此,在环境 E2 中绑定到sqrt_iter的函数对象被调用,并以 1 作为参数。这创建了一个环境 E3,在其中sqrt_iter的参数guess绑定到 1。然后sqrt_iter调用is_good_enough,并以guess的值(来自 E3)作为is_good_enough的参数。这建立了另一个环境 E4,在其中is_good_enough的参数guess绑定到 1。尽管sqrt_iter和is_good_enough都有一个名为guess的参数,但这些是位于不同框架中的两个不同的本地变量。此外,E3 和 E4 都将 E2 作为其封闭环境,因为sqrt_iter和is_good_enough函数都将 E2 作为其环境部分。这的一个结果是is_good_enough主体中出现的名称x将引用 E1 中出现的x的绑定,即调用原始sqrt函数时的x的值。
因此,环境模型解释了使本地函数声明成为模块化程序的两个关键属性。
-
本地函数的名称不会干扰封闭函数之外的名称,因为当块在求值时,本地函数名称将绑定在创建时的框架中,而不是绑定在程序环境中。
-
本地函数可以通过使用参数名称作为自由名称来访问封闭函数的参数。这是因为本地函数的主体在比封闭函数的求值环境低的环境中进行求值。
练习 3.11
在 3.2.3 节中,我们看到环境模型如何描述具有本地状态的函数的行为。现在我们已经看到了内部声明的工作原理。典型的消息传递函数包含了这两个方面。考虑 3.1.1 节中的银行账户函数:
function make_account(balance) {
function withdraw(amount) {
if (balance >= amount) {
balance = balance - amount;
return balance;
} else {
return "Insufficient funds";
}
}
function deposit(amount) {
balance = balance + amount;
return balance;
}
function dispatch(m) {
return m === "withdraw"
? withdraw
: m === "deposit"
? deposit
: "Unknown request: make_account";
}
return dispatch;
}
展示由交互序列生成的环境结构
const acc = make_account(50);
acc("deposit")(40);
90
acc("withdraw")(60);
30
acc的本地状态在哪里保存?假设我们定义另一个帐户。
const acc2 = make_account(100);
如何保持两个帐户的本地状态不同?acc和acc2之间共享环境结构的哪些部分?
更多关于块的内容
正如我们所看到的,sqrt中声明的名称的作用域是sqrt的整个主体。这解释了为什么相互递归可以工作,就像这种(相当浪费的)检查非负整数是否为偶数的方式一样。
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期间调用is_even时,环境图看起来像调用sqrt_iter时的图 3.11 中的图。函数is_even和is_odd在 E2 中绑定到指向 E2 的环境中调用这些函数的函数对象。因此,is_even中的is_odd指的是正确的函数。尽管is_odd在is_even之后定义,但这与sqrt_iter的主体中improve和sqrt_iter本身指向正确的函数没有区别。
有了处理块内声明的方法,我们可以重新审视顶层的名称声明。在 3.2.1 节中,我们看到在顶层声明的名称被添加到程序框架中。更好的解释是整个程序被放置在一个隐式块中,在全局环境中进行求值。上面描述的块的处理然后处理顶层:全局环境通过包含隐式块中声明的所有名称的绑定的框架进行扩展。该框架是程序框架,结果环境是程序环境。
我们说一个块的主体在一个包含在块主体中直接声明的所有名称的环境中进行求值。当进入块时,局部声明的名称被放入环境中,但没有关联的值。在求值块主体时,其声明的求值将名称分配给右边的表达式的结果,就好像声明是一个赋值一样。由于名称添加到环境中是与声明的求值分开的,整个块都在名称的范围内,一个错误的程序可能会在其声明被求值之前尝试访问名称的值;未分配名称的求值会发出错误信号。¹⁸
3.3 用可变数据建模
第 2 章讨论了复合数据作为构建计算对象的手段,这些对象有几个部分,以模拟具有多个方面的现实世界对象。在该章中,我们介绍了数据抽象的学科,根据这一学科,数据结构是根据构造函数来指定的,构造函数创建数据对象,选择器访问复合数据对象的部分。但是现在我们知道第 2 章没有涉及的数据的另一个方面。希望模拟由具有不断变化状态的对象组成的系统,这导致我们需要修改复合数据对象,以及构造和从中选择。为了模拟具有不断变化状态的复合对象,我们将设计数据抽象,以包括除选择器和构造函数之外的操作,称为变异器,这些操作修改数据对象。例如,模拟银行系统需要我们改变账户余额。因此,用于表示银行账户的数据结构可能允许一个操作
set_balance(account, new-value)
更改指定帐户的余额为指定的新值的操作。定义了突变器的数据对象称为可变数据对象。
第 2 章介绍了对偶对作为合成复合数据的通用“粘合剂”。我们从定义对偶对的基本突变器开始这一部分,以便对偶对可以作为构造可变数据对象的构建块。这些突变器极大地增强了对偶对的表示能力,使我们能够构建除了我们在第 2.2 节中使用的序列和树之外的数据结构。我们还提供了一些模拟的示例,其中复杂系统被建模为具有局部状态的对象集合。
3.3.1 可变列表结构
对对的基本操作——pair、head和tail——可以用来构造列表结构和从列表结构中选择部分,但它们无法修改列表结构。到目前为止,我们使用的列表操作也是如此,比如append和list,因为这些可以用pair、head和tail来定义。要修改列表结构,我们需要新的操作。
对于对来说,原始的修改器是set_head和set_tail。函数set_head接受两个参数,第一个参数必须是对。它修改这个对,用set_head的第二个参数的指针替换head指针。¹⁹
例如,假设x绑定到list(list("a", "b"), "c", "d"),y绑定到list("e", "f"),如图 3.12 所示。求值表达式set_head(x, y)修改了x绑定的对,用y的值替换了它的head。操作的结果如图 3.13 所示。结构x已被修改,现在等价于list(list("e", "f"), "c", "d")。代表列表list("a", "b")的对,由被替换的指针标识,现在已从原始结构中分离。²⁰
图 3.12 列表x:list(list("a", "b"), "c", "d")和y:list("e", "f")。
图 3.13 set_head(x, y)对图 3.12 中的列表的影响。
将图 3.13 与图 3.14 进行比较,它说明了执行的结果
const z = pair(y, tail(x));
x和y绑定到图 3.12 中的原始列表。现在,名称z绑定到由pair操作创建的新对;x绑定的列表保持不变。set_tail操作类似于set_head。唯一的区别是用tail指针替换对的head指针。在图 3.12 中执行set_tail(x, y)的效果如图 3.15 所示。这里,x的tail指针已被替换为指向list("e", "f")。此外,曾经是x的tail的列表list("c", "d")现在已从结构中分离。
图 3.14 const z = pair(y, tail(x));对图 3.12 中的列表的影响。
图 3.15 set_tail(x, y)对图 3.12 中的列表的影响。
函数pair通过创建新的对来构建新的列表结构,而set_head和set_tail修改现有的对。事实上,我们可以使用这两个修改器来实现pair,再加上一个get_new_pair函数,它返回一个不属于任何现有列表结构的新对。我们获得新对,将其head和tail指针设置为指定的对象,并将新对作为pair的结果返回。²¹
function pair(x, y) {
const fresh = get_new_pair();
set_head(fresh, x);
set_tail(fresh, y);
return fresh;
}
练习 3.12
在 2.2.1 节中引入了以下用于追加列表的函数:
function append(x, y) {
return is_null(x)
? y
: pair(head(x), append(tail(x), y));
}
函数append通过将x的元素依次添加到y的前面来形成一个新的列表。函数append_mutator类似于append,但它是一个修改器而不是构造器。它通过将它们拼接在一起来追加列表,修改x的最后一个对,使其tail现在是y。(使用空的x调用append_mutator是一个错误。)
function append_mutator(x, y) {
set_tail(last_pair(x), y);
return x;
}
这里last_pair是一个返回其参数中的最后一个对的函数:
function last_pair(x) {
return is_null(tail(x))
? x
: last_pair(tail(x));
}
考虑交互
const x = list("a", "b");
const y = list("c", "d");
const z = append(x, y);
z;
["a", ["b", ["c", ["d, null]]]]
tail(x);
response
const w = append_mutator(x, y);
w;
["a", ["b", ["c", ["d", null]]]]
tail(x);
response
缺少的response是什么?绘制框和指针图来解释你的答案。
练习 3.13
考虑以下make_cycle函数,它使用了练习 3.12 中定义的last_pair函数:
function make_cycle(x) {
set_tail(last_pair(x), x);
return x;
}
绘制一个框和指针图,显示由z创建的结构
const z = make_cycle(list("a", "b", "c"));
如果我们尝试计算last_pair(z)会发生什么?
练习 3.14
以下功能非常有用,尽管有些晦涩:
function mystery(x) {
function loop(x, y) {
if (is_null(x)) {
return y;
} else {
const temp = tail(x);
set_tail(x, y);
return loop(temp, x);
}
}
return loop(x, null);
}
函数loop使用“临时”名称temp来保存x的tail的旧值,因为下一行的set_tail会破坏tail。解释mystery一般是做什么的。假设v由以下定义
const v = list("a", "b", "c", "d");
绘制代表v绑定的列表的框和指针图。假设我们现在求值
const w = mystery(v);
绘制框和指针图,显示在求值此程序后v和w的结构。v和w的值将打印为什么?
共享和身份
我们在 3.1.3 节中提到了由赋值引入的“相同”和“改变”的理论问题。当不同的数据对象之间共享个别成对时,这些问题在实践中会出现。例如,考虑以下结构形成的结构
const x = list("a", "b");
const z1 = pair(x, x);
如图 3.16 所示,z1是一个head和tail都指向同一个x的成对。z1的head和tail共享x是pair实现的直接方式的结果。一般来说,使用pair构造列表将导致成对的交织结构,其中许多单个成对被许多不同的结构共享。
图 3.16 由pair(x, x)形成的列表z1。
与图 3.16 相比,图 3.17 显示了由此创建的结构
const z2 = pair(list("a", "b"), list("a", "b"));
在这个结构中,两个list("a", "b")列表中的成对是不同的,尽管它们包含相同的字符串。²²
图 3.17 由pair(list("a", "b"), list("a", "b"))形成的列表z2。
当被视为列表时,z1和z2都代表“相同”的列表:
list(list("a", "b"), "a", "b")
一般来说,如果我们只使用pair,head和tail在列表上操作,共享是完全不可检测的。但是,如果我们允许在列表结构上使用变异器,共享就变得重要。作为共享可能产生的差异的一个例子,考虑以下函数,该函数修改了应用于它的结构的head:
function set_to_wow(x) {
set_head(head(x), "wow");
return x;
}
尽管z1和z2是“相同”的结构,但将set_to_wow应用于它们会产生不同的结果。对于z1,改变head也会改变tail,因为在z1中head和tail是相同的成对。对于z2,head和tail是不同的,因此set_to_wow只修改head:
z1;
[["a", ["b", null]], ["a", ["b", null]]]
set_to_wow(z1);
[["wow", ["b", null]], ["wow", ["b", null]]]
z2;
[["a", ["b", null]], ["a", ["b", null]]]
set_to_wow(z2);
[["wow", ["b", null]], ["a", ["b", null]]]
检测列表结构中的共享的一种方法是使用原始谓词===,我们在 1.1.6 节中引入了它来测试两个数字是否相等,并在 2.3.1 节中扩展了它来测试两个字符串是否相等。当应用于两个非原始值时,x === y测试x和y是否是相同的对象(即x和y是否作为指针相等)。因此,对于图 3.16 和 3.17 中定义的z1和z2,head(z1) === tail(z1)为真,head(z2) === tail(z2)为假。
如下一节所示,我们可以利用共享来大大扩展可以由成对表示的数据结构的范围。另一方面,共享也可能是危险的,因为对结构所做的修改也会影响其他恰好共享修改部分的结构。变异操作set_head和set_tail应该谨慎使用;除非我们对数据对象的共享有很好的理解,否则变异可能会产生意想不到的结果。²³
练习 3.15
绘制框和指针图,解释set_to_wow对上述z1和z2结构的影响。
练习 3.16
Ben Bitdiddle 决定编写一个函数来计算任何列表结构中的成对数。“很容易”,他推理道。“任何结构中的成对数是head中的数加上tail中的数再加一来计算当前的成对数。”于是 Ben 写下了以下函数
function count_pairs(x) {
return ! is_pair(x)
? 0
: count_pairs(head(x)) +
count_pairs(tail(x)) + 1;
}
展示这个函数是不正确的。特别是,绘制盒和指针图,表示由恰好三对组成的列表结构,Ben 的函数将返回 3;返回 4;返回 7;根本不返回。
练习 3.17
设计练习 3.16 中count_pairs函数的正确版本,该函数返回任何结构中不同对的数量。(提示:遍历结构,维护一个辅助数据结构,用于跟踪已经计数的对。)
练习 3.18
编写一个函数,检查列表并确定它是否包含循环,也就是说,一个试图通过连续的tail找到列表末尾的程序会进入无限循环。练习 3.13 构建了这样的列表。
练习 3.19
使用仅占用恒定空间的算法重新执行练习 3.18。(这需要一个非常聪明的想法。)
突变只是赋值
当我们引入复合数据时,我们在 2.1.3 节中观察到,对可以纯粹用函数表示:
function pair(x, y) {
function dispatch(m) {
return m === "head"
? x
: m === "tail"
? y
: error(m, "undefined operation – pair");
}
return dispatch;
}
function head(z) { return z("head"); }
function tail(z) { return z("tail"); }
对于可变数据,同样的观察是正确的。我们可以使用赋值和本地状态将可变数据对象实现为函数。例如,我们可以扩展上面的对实现,以处理set_head和set_tail,类似于我们在 3.1.1 节中使用make_account实现银行账户的方式:
function pair(x, y) {
function set_x(v) { x = v; }
function set_y(v) { y = v; }
return m => m === "head"
? x
: m === "tail"
? y
: m === "set_head"
? set_x
: m === "set_tail"
? set_y
: error(m, "undefined operation – pair");
}
function head(z) { return z("head"); }
function tail(z) { return z("tail"); }
function set_head(z, new_value) {
z("set_head")(new_value);
return z;
}
function set_tail(z, new_value) {
z("set_tail")(new_value);
return z;
}
理论上,只需要赋值就可以解释可变数据的行为。一旦我们承认在我们的语言中进行赋值,我们就提出了所有问题,不仅是赋值的问题,而且是可变数据的问题。²⁴
练习 3.20
绘制环境图来说明语句序列的求值
const x = pair(1, 2);
const z = pair(x, x);
set_head(tail(z), 17);
head(x);
17
使用上面给出的对的函数实现。(比较练习 3.11。)
3.3.2 表示队列
改变器set_head和set_tail使我们能够使用对来构建不能仅用pair、head和tail构建的数据结构。本节展示了如何使用对来表示称为队列的数据结构。3.3.3 节将展示如何表示称为表的数据结构。
队列是一个序列,其中项目被插入到一端(称为队列的后端),并从另一端(前端)删除。图 3.18 显示了一个最初为空的队列,其中插入了项目a和b。然后移除了a,插入了c和d,并移除了b。因为项目总是按照插入的顺序移除,所以队列有时被称为FIFO(先进先出)缓冲区。
图 3.18 队列操作。
在数据抽象方面,我们可以将队列视为以下一组操作定义:
-
一个构造器:
make_queue()返回一个空队列(不包含任何项目的队列)。
-
一个谓词:
is_empty_queue(queue)测试队列是否为空。
-
一个选择器:
front_queue(queue)返回队列前端的对象,如果队列为空则发出错误信号;它不修改队列。
-
两个改变器:
insert_queue(queue, item)在队列的后端插入项目,并将修改后的队列作为其值返回。
delete_queue(queue)移除队列前端的项目,并返回修改后的队列作为其值,如果在删除前队列为空,则发出错误信号。
因为队列是一系列项目,我们当然可以将其表示为普通列表;队列的前端将是列表的head,在队列中插入项目将相当于在列表末尾添加一个新元素,从队列中删除项目只是取列表的tail。然而,这种表示是低效的,因为为了插入一个项目,我们必须扫描列表直到达到末尾。由于我们扫描列表的唯一方法是通过连续的tail操作,因此对于n个项目的列表,这种扫描需要Θ(n)步骤。通过对列表表示的简单修改,可以克服这个缺点,使得队列操作可以实现为需要Θ(1)步骤;也就是说,需要的步骤数与队列的长度无关。
列表表示的困难之处在于需要扫描以找到列表的末尾。我们需要扫描的原因是,尽管将列表表示为一对对的链表是标准的方法,它很容易为我们提供指向列表开头的指针,但它并没有为我们提供指向末尾的指针。避免这个缺点的修改是将队列表示为列表,以及一个额外的指针,指示列表中的最后一对。这样,当我们要插入一个项目时,我们可以查看后指针,从而避免扫描列表。
然后,队列表示为一对指针,front_ptr和rear_ptr,分别指示普通列表中的第一对和最后一对。由于我们希望队列是一个可识别的对象,我们可以使用pair来组合这两个指针。因此,队列本身将是这两个指针的pair。图 3.19 说明了这种表示。
图 3.19 将队列实现为具有前端和后端指针的列表。
为了定义队列操作,我们使用以下函数,这些函数使我们能够选择和修改队列的前端和后端指针:
function front_ptr(queue) { return head(queue); }
function rear_ptr(queue) { return tail(queue); }
function set_front_ptr(queue, item) { set_head(queue, item); }
function set_rear_ptr(queue, item) { set_tail(queue, item); }
现在我们可以实现实际的队列操作。如果队列的前端指针是空列表,我们将考虑队列为空:
function is_empty_queue(queue) { return is_null(front_ptr(queue)); }
make_queue构造函数返回一个最初为空的队列,其head和tail都是空列表:
function make_queue() { return pair(null, null); }
要选择队列前端的项目,我们返回由前端指针指示的对的head:
function front_queue(queue) {
return is_empty_queue(queue)
? error(queue, "front_queue called with an empty queue")
: head(front_ptr(queue));
}
要在队列中插入一个项目,我们遵循图 3.20 中指示的结果的方法。我们首先创建一个新的对,其head是要插入的项目,其tail是空列表。如果队列最初为空,我们将队列的前端和后端指针设置为这个新对。否则,我们修改队列中的最后一对,使其指向新对,并且将后端指针设置为新对。
function insert_queue(queue, item) {
const new_pair = pair(item, null);
if (is_empty_queue(queue)) {
set_front_ptr(queue, new_pair);
set_rear_ptr(queue, new_pair);
} else {
set_tail(rear_ptr(queue), new_pair);
set_rear_ptr(queue, new_pair);
}
return queue;
}
图 3.20 在图 3.19 的队列上使用insert_queue(q, "d")的结果。
要删除队列前端的项目,我们只需修改前端指针,使其现在指向队列中的第二个项目,可以通过跟随第一个项目的tail指针找到(参见图 3.21):²⁵
function delete_queue(queue) {
if (is_empty_queue(queue)) {
error(queue, "delete_queue called with an empty queue");
} else {
set_front_ptr(queue, tail(front_ptr(queue)));
return queue;
}
}
图 3.21 在图 3.20 的队列上使用delete_queue(q)的结果。
练习 3.21
Ben Bitdiddle 决定测试上述队列实现。他将函数输入 JavaScript 解释器,并开始尝试它们:
const q1 = make_queue();
insert_queue(q1, "a");
[["a", null], ["a", null]]
insert_queue(q1, "b");
[["a", ["b", null]], ["b", null]]
delete_queue(q1);
[["b", null], ["b", null]]
delete_queue(q1);
[null, ["b", null]]
“这全都错了!”他抱怨道。“解释器的响应显示最后一个项目被插入队列两次。当我删除两个项目时,第二个b仍然存在,所以队列不是空的,尽管它应该是。”Eva Lu Ator 建议 Ben 误解了发生了什么。“不是项目被插入队列两次,”她解释道。“只是标准的 JavaScript 打印机不知道如何理解队列表示。如果你想正确打印队列,你必须为队列定义自己的打印函数。”解释 Eva Lu 所说的。特别是,说明 Ben 的示例产生了它们所产生的打印结果。定义一个函数print_queue,该函数以队列作为输入并打印队列中的项目序列。
练习 3.22
我们可以将队列表示为具有本地状态的函数,而不是将队列表示为一对指针。本地状态将包括指向普通列表的开头和结尾的指针。因此,make_queue函数将具有以下形式
function make_queue() {
let front_ptr = ...;
let rear_ptr = ...;
〈declarations of internal functions〉
function dispatch(m) {...}
return dispatch;
}
完成make_queue的定义,并使用此表示提供队列操作的实现。
练习 3.23
双端队列(“deque”)是一个序列,其中项目可以在前端或后端插入和删除。双端队列的操作包括构造函数make_deque,谓词is_empty_deque,选择器front_deque和rear_deque,以及变异器front_insert_deque,front_delete_deque,rear_insert_deque和rear_delete_ deque。展示如何使用对表示 deque,并给出操作的实现。²⁶所有操作应在Θ(1)步骤中完成。
3.3.3 表示表
当我们在第 2 章研究了各种表示集合的方式时,在第 2.3.3 节中提到了通过识别键索引的记录表的维护任务。在第 2.4.3 节中的数据导向编程的实现中,我们广泛使用了二维表,其中使用两个键存储和检索信息。在这里,我们看到如何将表构建为可变列表结构。
首先考虑一维表,其中每个值都存储在单个键下。我们将表实现为记录的列表,每个记录都实现为一个由键和相关值组成的对。这些记录通过将head指向连续记录的对粘合在一起形成列表。这些粘合对被称为表的支柱。为了在向表中添加新记录时有一个可以更改的位置,我们将表构建为头列表。头列表在开头有一个特殊的支柱对,其中包含一个虚拟的“记录”——在这种情况下是任意选择的字符串"table"。图 3.22 显示了表的盒子和指针图。
a: 1
b: 2
c: 3
图 3.22 以头列表形式表示的表。
为了从表中提取信息,我们使用lookup函数,该函数以键作为参数并返回相关值(如果在该键下没有存储值,则返回undefined)。lookup函数是根据assoc操作定义的,该操作期望键和记录列表作为参数。请注意,assoc从不看到虚拟记录。assoc函数返回具有给定键作为head的记录。²⁷然后lookup函数检查assoc返回的结果记录是否不是undefined,并返回记录的值(tail)。
function lookup(key, table) {
const record = assoc(key, tail(table));
return is_undefined(record)
? undefined
: tail(record);
}
function assoc(key, records) {
return is_null(records)
? undefined
: equal(key, head(head(records)))
? head(records)
: assoc(key, tail(records));
}
要在指定的键下向表中插入一个值,我们首先使用assoc来查看表中是否已经存在具有该键的记录。如果没有,我们通过将键与值进行pair形成一个新记录,并将其插入到表的记录列表的头部(在虚拟记录之后)。如果已经存在具有该键的记录,我们将该记录的tail设置为指定的新值。表的标题为我们提供了一个固定的位置,以便插入新记录。²⁸
function insert(key, value, table) {
const record = assoc(key, tail(table));
if (is_undefined(record)) {
set_tail(table,
pair(pair(key, value), tail(table)));
} else {
set_tail(record, value);
}
return "ok";
}
要构造一个新表,我们只需创建一个包含字符串"table"的列表:
function make_table() {
return list("table");
}
二维表
在二维表中,每个值都由两个键索引。我们可以将这样的表构造为一个一维表,其中每个键都标识一个子表。图 3.23 显示了该表的框和指针图。
"math":
"+": 43
"-": 45
"*": 42
"letters":
"a": 97
"b": 98
该对象有两个子表。(子表不需要特殊的标题字符串,因为标识子表的键就起到了这个作用。)
图 3.23 二维表。
当我们查找一个项目时,我们使用第一个键来标识正确的子表。然后我们使用第二个键来标识子表中的记录。
function lookup(key_1, key_2, table) {
const subtable = assoc(key_1, tail(table));
if (is_undefined(subtable)) {
return undefined;
} else {
const record = assoc(key_2, tail(subtable));
return is_undefined(record)
? undefined
: tail(record);
}
}
要在一对键下插入一个新项目,我们使用assoc来查看是否已经存储了第一个键下的子表。如果没有,我们构建一个包含单个记录(key_2,value)的新子表,并将其插入到第一个键下的表中。如果有一个
如果第一个键的子表已经存在,我们将使用上面描述的一维表的插入方法将新记录插入到该子表中:
function insert(key_1, key_2, value, table) {
const subtable = assoc(key_1, tail(table));
if (is_undefined(subtable)) {
set_tail(table,
pair(list(key_1, pair(key_2, value)), tail(table)));
} else {
const record = assoc(key_2, tail(table));
if (is_undefined(record)) {
set_tail(subtable,
pair(pair(key_2, value), tail(subtable)));
} else {
set_tail(record, value);
}
}
return "ok";
}
创建本地表
上面定义的lookup和insert操作将表作为参数。这使我们能够使用访问多个表的程序。处理多个表的另一种方法是为每个表单独拥有lookup和insert函数。我们可以通过过程化地表示一个表来实现这一点,将其作为一个对象,该对象将内部表作为其本地状态的一部分。当发送适当的消息时,这个“表对象”提供用于在内部表上操作的函数。以下是以这种方式表示的二维表的生成器:
function make_table() {
const local_table = list("table");
function lookup(key_1, key_2) {
const subtable = assoc(key_1, tail(local_table));
if (is_undefined(subtable)) {
return undefined;
} else {
const record = assoc(key_2, tail(subtable));
return is_undefined(record)
? undefined
: tail(record);
}
}
function insert(key_1, key_2, value) {
const subtable = assoc(key_1, tail(local_table));
if (is_undefined(subtable)) {
set_tail(local_table,
pair(list(key_1, pair(key_2, value)),
tail(local_table)));
} else {
const record = assoc(key_2, tail(subtable));
if (is_undefined(record)) {
set_tail(subtable,
pair(pair(key_2, value), tail(subtable)));
} else {
set_tail(record, value);
}
}
}
function dispatch(m) {
return m === "lookup"
? lookup
: m === "insert"
? insert
: error(m, "unknown operation – table");
}
return dispatch;
}
使用make_table,我们可以实现第 2.4.3 节中用于数据导向编程的get和put操作,如下所示:
const operation_table = make_table();
const get = operation_table("lookup");
const put = operation_table("insert");
函数get以两个键作为参数,put以两个键和一个值作为参数。这两个操作都访问同一个本地表,该表封装在通过调用make_table创建的对象中。
练习 3.24
在上面的表实现中,使用equal(由assoc调用)来测试键的相等性。这并不总是适当的测试。例如,我们可能有一个具有数字键的表,在这种情况下,我们不需要与我们查找的数字完全匹配,而只需要在某个公差范围内的数字。设计一个表构造函数make_table,它以一个same_key函数作为参数,该函数将用于测试键的“相等性”。函数make_table应返回一个dispatch函数,该函数可用于访问本地表的适当lookup和insert函数。
练习 3.25
将一维和二维表泛化,展示如何实现一个表,其中值存储在任意数量的键下,并且不同数量的键下可能存储不同的值。lookup和insert函数应以用于访问表的键列表作为输入。
练习 3.26
上面实现的搜索表需要扫描记录列表。这基本上是第 2.3.3 节的无序列表表示。对于大表,可能更有效地以不同的方式构造表。描述一个表实现,其中(键,值)记录使用二叉树组织,假设键可以以某种方式排序(例如,按数字或字母顺序)。 (比较第 2 章的练习 2.66。)
练习 3.27
记忆化(也称为制表法)是一种使函数能够记录先前计算过的值的技术。这种技术可以极大地改善程序的性能。记忆化函数维护一个表,其中存储了以产生值的参数为键的先前调用的值。当记忆化函数被要求计算一个值时,它首先检查表,看看值是否已经存在,如果是,就返回该值。否则,它以普通方式计算新值,并将其存储在表中。作为记忆化的一个例子,回想一下第 1.2.2 节中用于计算斐波那契数的指数过程:
function fib(n) {
return n === 0
? 0
: n === 1
? 1
: fib(n - 1) + fib(n - 2);
}
相同函数的记忆化版本是
const memo_fib = memoize(n => n === 0
? 0
: n === 1
? 1
: memo_fib(n - 1) +
memo_fib(n - 2)
);
其中记忆器定义为
function memoize(f) {
const table = make_table();
return x => {
const previously_computed_result =
lookup(x, table);
if (is_undefined(previously_computed_result)) {
const result = f(x);
insert(x, result, table);
return result;
} else {
return previously_computed_result;
}
};
}
绘制一个环境图来分析memo_fib(3)的计算。解释为什么memo_fib计算第 n 个斐波那契数的步骤数量与 n 成比例。如果我们简单地将memo_fib定义为memoize(fib),这种方案是否仍然有效?
3.3.4 数字电路模拟器
设计复杂的数字系统,如计算机,是一项重要的工程活动。数字系统是通过连接简单元素构建的。尽管这些单独元素的行为很简单,但它们的网络可能具有非常复杂的行为。计算机模拟提议的电路设计是数字系统工程师使用的重要工具。在本节中,我们设计了一个用于执行数字逻辑模拟的系统。这个系统代表了一种称为事件驱动模拟的程序类型,其中动作(“事件”)触发以后发生的更多事件,这些事件又触发更多事件,依此类推。
我们的电路的计算模型将由与构成电路的基本组件对应的对象组成。有电线,它们携带数字信号。数字信号在任何时刻只能有两个可能值之一,0 和 1。还有各种类型的数字功能框,它们将携带输入信号的电线连接到其他输出电线。这些框从它们的输入信号计算输出信号。输出信号的延迟时间取决于功能框的类型。例如,反相器是一个原始功能框,它反转其输入。如果反相器的输入信号变为 0,则一个反相器延迟后,反相器将把其输出信号更改为 1。如果反相器的输入信号变为 1,则一个反相器延迟后,反相器将把其输出信号更改为 0。我们以图 3.24 中的符号来绘制反相器。与门也显示在图 3.24 中,它是一个具有两个输入和一个输出的原始功能框。它将其输出信号驱动到与输入的逻辑与值相同的值。也就是说,如果其两个输入信号都变为 1,则一个与门延迟时间后,与门将强制其输出信号为 1;否则输出将为 0。或门是一个类似的两输入原始功能框,它将其输出信号驱动到与输入的逻辑或值相同的值。也就是说,如果至少一个输入信号为 1,则输出将变为 1;否则输出将变为 0。
图 3.24 数字逻辑模拟器中的原始函数。
我们可以将原始函数连接在一起,以构建更复杂的函数。为了实现这一点,我们将一些功能框的输出连接到其他功能框的输入。例如,图 3.25 中显示的半加器电路由一个或门、两个与门和一个反相器组成。它接收两个输入信号A和B,并有两个输出信号S和C。当A和B中恰好有一个为 1 时,S将变为 1,当A和B都为 1 时,C将变为 1。从图中我们可以看到,由于涉及到的延迟,输出可能在不同的时间生成。数字电路设计中的许多困难都源于这一事实。
图 3.25 一个半加器电路。
我们现在将构建一个用于建模我们希望研究的数字逻辑电路的程序。该程序将构建计算对象,对信号进行建模。功能框将由强制执行信号之间正确关系的函数进行建模。
我们模拟的一个基本元素将是一个名为make_wire的函数,用于构建信号线。例如,我们可以按照以下方式构建六根信号线:
const a = make_wire();
const b = make_wire();
const c = make_wire();
const d = make_wire();
const e = make_wire();
const s = make_wire();
我们通过调用一个构造该类型框的函数将一个函数框连接到一组线上。构造函数的参数是要连接到框的线。例如,鉴于我们可以构建与门、或门和反相器,我们可以将图 3.25 中显示的半加器连接在一起:
or_gate(a, b, d);
"ok"
and_gate(a, b, c);
"ok"
inverter(c, e);
"ok"
and_gate(d, e, s);
"ok"
更好的是,我们可以通过定义一个名为half_ adder的函数来显式命名这个操作,该函数构建这个电路,给定要连接到半加器的四根外部线:
function half_adder(a, b, s, c) {
const d = make_wire();
const e = make_wire();
or_gate(a, b, d);
and_gate(a, b, c);
inverter(c, e);
and_gate(d, e, s);
return "ok";
}
制定这个定义的优势在于,我们可以使用half_adder本身作为创建更复杂电路的构建块。例如,图 3.26 展示了由两个半加器和一个或门组成的全加器。我们可以按照以下方式构建一个全加器:
function full_adder(a, b, c_in, sum, c_out) {
const s = make_wire();
const c1 = make_wire();
const c2 = make_wire();
half_adder(b, c_in, s, c1);
half_adder(a, s, sum, c2);
or_gate(c1, c2, c_out);
return "ok";
}
定义了full_adder作为一个函数后,我们现在可以将其用作创建更复杂电路的构建块。(例如,参见练习 3.30。)
图 3.26 一个全加器电路。
实质上,我们的模拟器为我们提供了构建电路语言的工具。如果我们采用了我们在第 1.1 节中研究 JavaScript 时所采用的关于语言的一般观点,我们可以说原始功能框构成了语言的原始元素,将框连接在一起提供了一种组合的手段,指定框作为函数的连线模式作为抽象的手段。
原始功能框
原始功能框实现了一根线上的信号变化如何影响其他线上的信号的“力量”。为了构建功能框,我们使用以下操作:
-
get_signal(wire)返回信号线上的当前值。
-
set_signal(wire, new-value):将信号线上的信号值更改为新值。
-
add_action(wire, function-of -no-arguments):断言指定的函数应该在线上的信号值发生变化时运行。这些函数是信号值变化传递给其他线的工具。
此外,我们将使用一个名为after_delay的函数,该函数接受一个时间延迟和一个要运行的函数,并在给定延迟后执行给定的函数。
使用这些功能,我们可以定义原始的数字逻辑功能。要通过反相器将输入连接到输出,我们使用add_action将输入线与一个函数关联起来,每当输入线上的信号值发生变化时,该函数就会运行。该函数计算输入信号的logical_not,然后在一个inverter_delay之后,将输出信号设置为这个新值:
function inverter(input, output) {
function invert_input() {
const new_value = logical_not(get_signal(input));
after_delay(inverter_delay,
() => set_signal(output, new_value));
}
add_action(input, invert_input);
return "ok";
}
function logical_not(s) {
return s === 0
? 1
: s === 1
? 0
: error(s, "invalid signal");
}
与门稍微复杂一些。如果门的任一输入发生变化,则必须运行动作函数。它计算输入电线上的信号值的logical_and(使用类似于logical_not的函数),并设置在and_gate_delay之后在输出电线上发生新值的变化。
function and_gate(a1, a2, output) {
function and_action_function() {
const new_value = logical_and(get_signal(a1),
get_signal(a2));
after_delay(and_gate_delay,
() => set_signal(output, new_value));
}
add_action(a1, and_action_function);
add_action(a2, and_action_function);
return "ok";
}
练习 3.28
将或门定义为原始函数框。您的or_gate构造函数应类似于and_gate。
练习 3.29
构建或门的另一种方法是作为一个复合数字逻辑设备,由与门和反相器构建而成。定义一个函数or_gate来实现这一点。或门的延迟时间是多少,用and_gate_delay和inverter_delay来表示?
练习 3.30
图 3.27 显示了由串联n个全加器形成的链式进位加法器。这是用于加法两个n位二进制数的最简单形式的并行加法器。输入A[1]、A[2]、A[3],...,A[n]和B[1]、B[2]、B[3],...,B[n]是要相加的两个二进制数(每个A[k]和B[k]都是 0 或 1)。电路生成 S[1], S [2], S [3], ..., S [n],和C,加法的进位。编写一个函数ripple_carry_adder来生成这个电路。该函数应该接受三个n个电线的列表作为参数——A[k]、B[k]和S[k],还有另一个电线C。链式进位加法器的主要缺点是需要等待进位信号传播。以与门、或门和反相器的延迟来表示,获得n位链式进位加法器的完整输出所需的延迟是多少?
图 3.27 一个用于n位数字的链式进位加法器。
代表电线
在我们的模拟中,电线将是一个计算对象,具有两个本地状态变量:signal_value(最初为 0)和要在信号变化时运行的action_functions集合。我们使用消息传递样式实现电线,作为一组本地函数以及选择适当的本地操作的dispatch函数,就像我们在第 3.1.1 节中的简单银行账户对象中所做的那样:
function make_wire() {
let signal_value = 0;
let action_functions = null;
function set_my_signal(new_value) {
if (signal_value !== new_value) {
signal_value = new_value;
return call_each(action_functions);
} else {
return "done";
}
}
function accept_action_function(fun) {
action_functions = pair(fun, action_functions);
fun();
}
function dispatch(m) {
return m === "get_signal"
? signal_value
: m === "set_signal"
? set_my_signal
: m === "add_action"
? accept_action_function
: error(m, "unknown operation – wire");
}
return dispatch;
}
本地函数set_my_signal测试新的信号值是否改变了电线上的信号。如果是,它将运行每个动作函数,使用以下函数call_each,该函数调用无参数函数列表中的每个项目:
function call_each(functions) {
if (is_null(functions)) {
return "done";
} else {
head(functions)();
return call_each(tail(functions));
}
}
本地函数accept_action_function将给定的函数添加到要运行的函数列表中,然后运行新函数一次。(参见练习 3.31。)
设置本地dispatch函数后,我们可以提供以下函数来访问线上的本地操作:³⁰
function get_signal(wire) {
return wire("get_signal");
}
function set_signal(wire, new_value) {
return wire("set_signal")(new_value);
}
function add_action(wire, action_function) {
return wire("add_action")(action_function);
}
电线具有时变信号,可以逐步连接到设备,这是可变对象的典型特征。我们将它们建模为具有本地状态变量的函数,这些状态变量通过赋值进行修改。创建新电线时,将分配一组新的状态变量(通过make_wire中的let语句),并构造并返回一个新的dispatch函数,捕获具有新状态变量的环境。
电线被各种设备共享,这些设备已连接到它们。因此,通过与一个设备的交互所做的更改将影响连接到电线的所有其他设备。电线通过在建立连接时提供的动作函数来将更改通知给其邻居。
议程
完成模拟器所需的唯一事情是after_delay。这里的想法是我们维护一个数据结构,称为agenda,其中包含要执行的计划。议程定义了以下操作:
-
make_agenda():返回一个新的空议程。
-
is_empty_agenda(agenda)如果指定的议程为空,则为真。
-
first_agenda_item(agenda)返回日程表上的第一项。
-
remove_first_agenda_item(agenda)通过删除第一项来修改日程表。
-
add_to_agenda(time, action, agenda)通过添加给定的动作函数来修改日程表,以便在指定时间运行。
-
current_time(agenda)返回当前模拟时间。
我们使用的特定日程表由the_agenda表示。函数after_delay向the_agenda添加新元素:
function after_delay(delay, action) {
add_to_agenda(delay + current_time(the_agenda),
action,
the_agenda);
}
模拟由propagate函数驱动,该函数按顺序执行the_agenda上的每个函数。一般来说,随着模拟的运行,新的项目将被添加到日程表中,只要日程表上还有项目,propagate就会继续模拟:
function propagate() {
if (is_empty_agenda(the_agenda)) {
return "done";
} else {
const first_item = first_agenda_item(the_agenda);
first_item();
remove_first_agenda_item(the_agenda);
return propagate();
}
}
一个示例模拟
以下函数在动作上放置一个“探针”,展示了模拟器的运行。探针告诉导线,每当其信号值发生变化时,它应该打印新的信号值,以及当前时间和标识导线的名称。
function probe(name, wire) {
add_action(wire,
() => display(name + " " +
stringify(current_time(the_agenda)) +
", new value = " +
stringify(get_signal(wire))));
}
我们首先初始化日程表,并为原始函数框架指定延迟:
const the_agenda = make_agenda();
const inverter_delay = 2;
const and_gate_delay = 3;
const or_gate_delay = 5;
现在我们定义了四根导线,并在其中两根上放置了探针:
const input_1 = make_wire();
const input_2 = make_wire();
const sum = make_wire();
const carry = make_wire();
probe("sum", sum);
"sum 0, new value = 0"
probe("carry", carry);
"carry 0, new value = 0"
接下来,我们连接半加器电路中的导线(如图 3.25 所示),将input_1上的信号设置为 1,并运行模拟:
half_adder(input_1, input_2, sum, carry);
"ok"
set_signal(input_1, 1);
"done"
propagate();
"sum 8, new value = 1"
"done"
sum信号在时间 8 时变为 1。现在距离模拟开始已经过去了八个时间单位。此时,我们可以将input_2上的信号设置为 1,并允许值传播:
set_signal(input_2, 1);
"done"
propagate();
"carry 11, new value = 1"
"sum 16, new value = 0"
"done"
在时间 11 时,carry变为 1,而在时间 16 时,sum变为 0。
练习 3.31
在make_wire中定义的内部函数accept_action_function指定了当新的动作函数被添加到导线时,立即运行该函数。解释为什么这种初始化是必要的。特别是,通过上面段落中的半加器示例追踪,并说出如果我们将accept_action_function定义为何,系统的响应会有何不同
function accept_action_function(fun) {
action_functions = pair(fun, action_functions);
}
实施日程表
最后,我们详细介绍了日程表数据结构,该结构保存了计划用于将来执行的函数。
日程表由时间段组成。每个时间段都是一个数字(时间)和一个队列(参见练习 3.32),该队列保存了计划在该时间段内运行的函数。
function make_time_segment(time, queue) {
return pair(time, queue);
}
function segment_time(s) { return head(s); }
function segment_queue(s) { return tail(s); }
我们将使用 3.3.2 节中描述的队列操作来操作时间段队列。
日程表本身是一个时间段的一维表。它与 3.3.3 节中描述的表不同之处在于,时间段将按照时间递增的顺序进行排序。此外,我们在日程表的头部存储当前时间(即,上次处理的动作的时间)。新构建的日程表没有时间段,并且当前时间为 0:
function make_agenda() { return list(0); }
function current_time(agenda) { return head(agenda); }
function set_current_time(agenda, time) {
set_head(agenda, time);
}
function segments(agenda) { return tail(agenda); }
function set_segments(agenda, segs) {
set_tail(agenda, segs);
}
function first_segment(agenda) { return head(segments(agenda)); }
function rest_segments(agenda) { return tail(segments(agenda)); }
如果日程表没有时间段,则为空:
function is_empty_agenda(agenda) {
return is_null(segments(agenda));
}
要向日程表添加一个动作,我们首先检查日程表是否为空。如果是,我们为该动作创建一个时间段,并将其安装在日程表中。否则,我们扫描日程表,检查每个时间段的时间。如果我们找到了一个与我们指定时间相符的时间段,我们就将该动作添加到相关队列中。如果我们到达了晚于我们指定时间的时间,我们就在它之前插入一个新的时间段到日程表中。如果我们到达了日程表的末尾,我们必须在末尾创建一个新的时间段。
function add_to_agenda(time, action, agenda) {
function belongs_before(segs) {
return is_null(segs) || time < segment_time(head(segs));
}
function make_new_time_segment(time, action) {
const q = make_queue();
insert_queue(q, action);
return make_time_segment(time, q);
}
function add_to_segments(segs) {
if (segment_time(head(segs)) === time) {
insert_queue(segment_queue(head(segs)), action);
} else {
const rest = tail(segs);
if (belongs_before(rest)) {
set_tail(segs, pair(make_new_time_segment(time, action),
tail(segs)));
} else {
add_to_segments(rest);
}
}
}
const segs = segments(agenda);
if (belongs_before(segs)) {
set_segments(agenda,
pair(make_new_time_segment(time, action), segs));
} else {
add_to_segments(segs);
}
}
删除日程表中的第一项的函数会删除第一个时间段中的队列中的项目。如果这个删除使时间段为空,我们就把它从时间段列表中移除。
function remove_first_agenda_item(agenda) {
const q = segment_queue(first_segment(agenda));
delete_queue(q);
if (is_empty_queue(q)) {
set_segments(agenda, rest_segments(agenda));
} else {}
}
第一个日程表项位于第一个时间段的队列的头部。每当我们提取一个项目时,我们也会更新当前时间:
function first_agenda_item(agenda) {
if (is_empty_agenda(agenda)) {
error("agenda is empty – first_agenda_item");
} else {
const first_seg = first_segment(agenda);
set_current_time(agenda, segment_time(first_seg));
return front_queue(segment_queue(first_seg));
}
}
练习 3.32
待在议程的每个时间段内运行的函数被保存在一个队列中。因此,每个时间段的函数按照它们被添加到议程的顺序被调用(先进先出)。解释为什么必须使用这个顺序。特别是,追踪一个与门的行为,当它的输入在同一个时间段内从0,1变为1,0,并说出如果我们将一个时间段的函数存储在一个普通列表中,只在前面添加和删除函数时,行为会有何不同。