前面的章节介绍了构成程序的基本元素。我们看到了原始函数和原始数据是如何组合成复合实体的,我们也了解到抽象对于帮助我们应对大型系统的复杂性是至关重要的。但是这些工具并不足以用于设计程序。有效的程序合成还需要组织原则,可以指导我们制定程序的整体设计。特别是,我们需要策略来帮助我们结构大型系统,使它们成为模块化,也就是说,它们可以被“自然地”划分为可以单独开发和维护的连贯部分。
一种强大的设计策略,特别适用于构建用于建模物理系统的程序,是基于被建模系统的结构来构建程序的结构。对于系统中的每个对象,我们构建一个相应的计算对象。对于每个系统动作,我们在计算模型中定义一个符号操作。我们使用这种策略的希望是,扩展模型以适应新对象或新动作将不需要对程序进行战略性的更改,只需要添加这些对象或动作的新符号模拟。如果我们在系统组织上取得了成功,那么要添加新功能或调试旧功能,我们只需要在系统的局部部分工作。
在很大程度上,我们组织大型程序的方式是由我们对待建模系统的看法所决定的。在本章中,我们将研究两种突出的组织策略,这些策略源自对系统结构的两种相当不同的“世界观”。第一种组织策略集中在对象上,将一个大型系统视为一组随时间可能发生变化的不同对象。另一种组织策略集中在系统中流动的信息流上,就像电气工程师看待信号处理系统一样。
对象为基础的方法和流处理方法都在编程中引发了重大的语言问题。对于对象,我们必须关注计算对象如何改变,但又保持其身份不变。这将迫使我们放弃我们旧的替换计算模型(第 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。
NUS CS1101S:SICP JavaScript 描述:三、模块化、对象和状态(2)https://developer.aliyun.com/article/1427725