我们通常将世界视为由独立的对象组成,每个对象都有一个随时间变化的状态。如果一个对象的行为受其历史影响,我们就说它"有状态"。例如,银行账户是有状态的,因为"我能取 100 美元吗?"这个问题的答案取决于存款和取款交易的历史。我们可以通过一个或多个状态变量来刻画对象的状态,这些变量共同维护了足够的历史信息,以确定对象的当前行为。在一个简单的银行系统中,我们可以通过当前余额而不是记住整个账户交易历史来刻画账户的状态。
在一个由许多对象组成的系统中,对象很少是完全独立的。每个对象可能通过交互影响其他对象的状态,从而将一个对象的状态变量与其他对象的状态变量耦合起来。实际上,将系统视为由独立对象组成的视图,在系统的状态变量可以被分组为紧密耦合的子系统、而各子系统之间仅松散耦合时最为有用。
这种系统视图可以成为组织系统的计算模型的有力框架。为了使这样的模型模块化,它应该被分解为模拟系统中实际对象的计算对象。每个计算对象必须有自己描述实际对象状态的局部状态变量。由于被建模系统中的对象状态随时间变化,相应计算对象的状态变量也必须变化。如果我们选择用计算机中经过的时间来模拟系统中的时间流,那么我们必须有一种方法来构造行为随程序运行而变化的对象。特别地,如果我们希望用编程语言中的普通符号名称来模拟状态变量,那么语言必须提供一个赋值运算符,使我们能够改变与名称相关联的值。
为了说明具有时变状态的计算对象的含义,让我们模拟从银行账户取钱的情形。我们将使用一个过程withdraw来完成,它接受一个参数amount(取款金额)。如果账户中有足够的钱来取款,那么withdraw返回取款后的剩余余额。否则,withdraw返回消息资金不足。例如,如果我们最初在账户中存入 100 美元,使用withdraw应得到以下响应序列:
(withdraw 25)
75
(withdraw 25)
50
(withdraw 60)
"Insufficient funds"
(withdraw 15)
35
注意,表达式(withdraw 25)被求值两次,却产生了不同的值。这对过程来说是一种新的行为。到目前为止,我们所有的过程都可以视为计算数学函数的规约。对过程的调用计算将该函数应用于给定参数的值,而对同一过程用相同参数进行两次调用总是产生相同的结果。1
为了实现withdraw,我们可以使用一个变量balance来表示账户中的余额,并将withdraw定义为访问balance的过程。Withdraw过程检查balance是否至少与请求的amount一样大。如果是,withdraw将balance减去amount并返回balance的新值。否则,withdraw返回资金不足的消息。以下是balance和withdraw的定义:
(define balance 100)
(define (withdraw amount)
(if (>= balance amount)
(begin (set! balance (- balance amount))
balance)
"Insufficient funds"))
递减balance由以下表达式完成:
(set! balance (- balance amount))
(set! <name> <new-value>)
其中<name>是一个符号,<new-value>是任意表达式。Set!改变<name>,使其值变为对<new-value>求值得到的结果。在我们这里,我们正在改变balance,使其新值为从balance的旧值减去amount的结果。2
Withdraw还使用了begin特殊形式,使得在if测试为真的情况下两个表达式可以被求值:先递减balance,然后返回balance的值。一般而言,对表达式
(begin <exp1> <exp2> ... <expk>)
的求值会导致表达式<exp1>到<expk>被顺序求值,并将最后一个表达式<expk>的值作为整个begin形式的值返回。3
尽管withdraw的工作方式符合预期,但变量balance带来一个问题。如上所述,balance是在全局环境中定义的名称,任何过程都可以自由访问或修改它。如果我们能以某种方式使balance成为withdraw内部的变量,使得withdraw成为唯一能直接访问balance的过程,而任何其他过程只能间接访问balance(通过调用withdraw),那将会好得多。这将更准确地建模这样的概念:balance是withdraw用来跟踪账户状态的局部状态变量。
我们可以通过如下重写定义来使balance成为withdraw的内部变量:
(define new-withdraw
(let ((balance 100))
(lambda (amount)
(if (>= balance amount)
(begin (set! balance (- balance amount))
balance)
"Insufficient funds"))))
这里我们使用let来建立一个环境,其中包含一个局部变量balance,绑定到初始值 100。在这个局部环境内部,我们使用lambda创建了一个过程,它以amount为参数,行为与之前的withdraw过程相同。这个过程——作为对let表达式求值的结果返回——就是new-withdraw,其行为与withdraw完全相同,但其变量balance不能被任何其他过程访问。4
将set!与局部变量结合使用,是我们将用于构造具有局部状态的计算对象的一般编程技术。不幸的是,使用这种技术引发了一个严重的问题:当我们首次引入过程时,我们还引入了替代求值模型(第1.1.5节)来提供过程应用的含义解释。我们说应用一个过程应该被解释为用参数的值替换形式参数后求值过程体。问题在于,一旦我们将赋值引入语言,替代就不再是过程应用的充分模型了。(我们将在第3.1.3节看到原因。)因此,从技术上讲,我们目前无法理解为什么new-withdraw过程会像上述声称的那样表现。为了真正理解像new-withdraw这样的过程,我们需要开发一个新的过程应用模型。在第3.2节中,我们将引入这样的模型,同时解释set!和局部变量。但在此之前,我们先考察一些以new-withdraw为基调的变体。
以下过程make-withdraw创建"取款处理器"。Make-withdraw中的形式参数balance指定了账户中的初始金额。5
(define (make-withdraw balance)
(lambda (amount)
(if (>= balance amount)
(begin (set! balance (- balance amount))
balance)
"Insufficient funds")))
Make-withdraw可以这样使用来创建两个对象W1和W2:
(define W1 (make-withdraw 100))
(define W2 (make-withdraw 100))
(W1 50)
50
(W2 70)
30
(W2 40)
"Insufficient funds"
(W1 40)
10
注意W1和W2是完全独立的对象,每个都有自己的局部状态变量balance。从一个对象取款不会影响另一个。
我们还可以创建同时处理存款和取款的对象,从而可以表示简单的银行账户。以下是一个返回具有指定初始余额的"银行账户对象"的过程:
(define (make-account balance)
(define (withdraw amount)
(if (>= balance amount)
(begin (set! balance (- balance amount))
balance)
"Insufficient funds"))
(define (deposit amount)
(set! balance (+ balance amount))
balance)
(define (dispatch m)
(cond ((eq? m 'withdraw) withdraw)
((eq? m 'deposit) deposit)
(else (error "Unknown request -- MAKE-ACCOUNT"
m))))
dispatch)
对make-account的每次调用都建立一个环境,其中有一个局部状态变量balance。在这个环境内部,make-account定义了访问balance的过程deposit和withdraw,以及另一个接受"消息"作为输入并返回两个局部过程之一的过程dispatch。Dispatch过程本身作为表示银行账户对象的值返回。这正是我们在第2.4.3节中看到的消息传递编程风格,不过这里我们将其与修改局部变量的能力结合使用。
Make-account可以这样使用:
(define 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的调用:
(define acc2 (make-account 100))
将产生一个完全独立的账户对象,它维护自己的局部balance。
练习 3.1. 累加器是一个过程,它被重复调用并接受一个数值参数,将其参数累加到一个和中。每次调用时,它返回当前已累积的和。编写一个过程make-accumulator,它生成累加器,每个维护一个独立的和。Make-accumulator的输入应指定和的初始值;例如:
(define A (make-accumulator 5))
(A 10)
15
(A 10)
25
练习 3.2. 在软件测试应用中,能够统计给定过程在计算过程中被调用的次数是很有用的。编写一个过程make-monitored,它接受一个过程f作为输入,f本身接受一个参数。Make-monitored返回的结果是第三个过程,比如称为mf,它通过维护一个内部计数器来跟踪自己被调用的次数。如果对mf的输入是特殊符号how-many-calls?,那么mf返回计数器的值。如果输入是特殊符号reset-count,那么mf将计数器重置为零。对于任何其他输入,mf返回在该输入上调用f的结果,并递增计数器。例如,我们可以创建一个sqrt过程的受监控版本:
(define s (make-monitored sqrt))
(s 100)
10
(s 'how-many-calls?)
1
练习 3.3. 修改make-account过程,使其创建受密码保护的账户。也就是说,make-account应该接受一个符号作为额外参数,如下所示:
(define acc (make-account 100 'secret-password))
生成的账户对象只有在请求伴随创建账户时使用的密码时才处理该请求,否则应返回投诉:
((acc 'secret-password 'withdraw) 40)
60
((acc 'some-other-password 'deposit) 50)
"Incorrect password"
练习 3.4. 修改练习3.3中的make-account过程,增加另一个局部状态变量,使得如果账户被连续七次以上用错误密码访问,它将调用过程call-the-cops。
正如我们将看到的,将赋值引入编程语言将把我们带入一堆困难的概念问题。然而,将系统视为具有局部状态的对象集合是维护模块化设计的有力技术。作为一个简单例子,考虑一个过程rand的设计,每当它被调用时,返回一个随机选择的整数。
所谓"随机选择"的含义一点也不清楚。我们想要的是对rand的连续调用产生一个具有均匀分布统计特性的数列。我们不在这里讨论生成合适数列的方法。相反,让我们假设我们有一个过程rand-update,它具有这样的性质:如果我们从一个给定的数x1开始,然后构造:
x2 = (rand-update x1)
x3 = (rand-update x2)
那么序列 x1, x2, x3, ... 将具有期望的统计特性。6
我们可以将rand实现为一个具有局部状态变量x的过程,x被初始化为某个固定值random-init。每次对rand的调用计算当前x的rand-update,将其作为随机数返回,并将其存储为x的新值。
(define rand
(let ((x random-init))
(lambda ()
(set! x (rand-update x))
x)))
当然,我们可以通过直接调用rand-update来生成相同的随机数列,而不使用赋值。然而,这意味着我们程序的任何使用随机数的部分都必须显式记住当前值x,以作为参数传递给rand-update。要认识到这会多么烦人,考虑使用随机数来实现一种称为蒙特卡洛模拟的技术。
蒙特卡洛方法包括从一个大的集合中随机选择样本实验,然后根据对这些实验结果的统计所估计的概率进行推断。例如,我们可以利用 6/
2 是两个随机选择的整数没有公因子的概率来逼近
;也就是说,它们的最大公约数为 1。7 为了获得对
的逼近,我们进行大量实验。在每个实验中,我们随机选择两个整数并进行测试,检查它们的 GCD 是否为 1。测试通过的比例给出了 6/
2 的估计,由此我们得到对
的逼近。
我们程序的核心是一个过程monte-carlo,它接受实验次数和实验作为参数,实验表示为一个无参数过程,每次运行时返回真或假。Monte-carlo运行指定次数的实验并返回一个数字,指示实验为真的比例。
(define (estimate-pi trials)
(sqrt (/ 6 (monte-carlo trials cesaro-test))))
(define (cesaro-test)
(= (gcd (rand) (rand)) 1))
(define (monte-carlo trials experiment)
(define (iter trials-remaining trials-passed)
(cond ((= trials-remaining 0)
(/ trials-passed trials))
((experiment)
(iter (- trials-remaining 1) (+ trials-passed 1)))
(else
(iter (- trials-remaining 1) trials-passed))))
(iter trials 0))
现在让我们尝试直接使用rand-update而不是rand来进行相同的计算,这是我们如果不使用赋值来模拟局部状态就必须采用的方式:
(define (estimate-pi trials)
(sqrt (/ 6 (random-gcd-test trials random-init))))
(define (random-gcd-test trials initial-x)
(define (iter trials-remaining trials-passed x)
(let ((x1 (rand-update x)))
(let ((x2 (rand-update x1)))
(cond ((= trials-remaining 0)
(/ trials-passed trials))
((= (gcd x1 x2) 1)
(iter (- trials-remaining 1)
(+ trials-passed 1)
x2))
(else
(iter (- trials-remaining 1)
trials-passed
x2))))))
(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)2 + (y - 7)2< 32 来描述。要估计该区域描述的区域的面积,首先选择一个包含该区域的矩形。例如,对角顶点在 (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节中使用的random过程来实现这一点,该过程返回一个小于其输入的非负数。8
(define (random-in-range low high)
(let ((range (- high low)))
(+ low (random range))))
练习 3.6. 能够重置随机数生成器以从给定值开始产生一个序列是很有用的。设计一个新的rand过程,它接受一个参数,该参数要么是符号generate,要么是符号reset,行为如下:(rand 'generate)产生一个新的随机数;((rand 'reset) <new-value>)将内部状态变量重置为指定的<new-value>。因此,通过重置状态,可以生成可重复的序列。这在测试和调试使用随机数的程序时非常方便。
正如我们所看到的,set!操作使我们能够对具有局部状态的对象进行建模。然而,这一优势是有代价的。我们的编程语言不能再以第1.1.5节中引入的过程应用替代模型来解释。而且,没有具有"良好"数学特性的简单模型可以成为处理编程语言中对象和赋值的合适框架。
只要我们不用赋值,对同一过程用相同参数求值两次将产生相同结果,因此过程可以被视为计算数学函数。完全不使用赋值的编程,正如我们在本书前两章中所做的那样,被称为函数式编程。
要理解赋值如何使事情复杂化,考虑第3.1.1节中make-withdraw过程的一个简化版本,它不检查余额不足的情况:
(define (make-simplified-withdraw balance)
(lambda (amount)
(set! balance (- balance amount))
balance))
(define W (make-simplified-withdraw 25))
(W 20)
5
(W 10)
- 5
将此过程与以下不使用set!的make-decrementer过程比较:
(define (make-decrementer balance)
(lambda (amount)
(- balance amount)))
Make-decrementer返回一个过程,它从指定量balance中减去其输入,但与make-simplified-withdraw不同,没有累积效果影响连续调用:
(define D (make-decrementer 25))
(D 20)
5
(D 10)
15
我们可以用替代模型来解释make-decrementer如何工作。例如,让我们分析对表达式的求值:
((make-decrementer 25) 20)
我们首先通过用 25 替换make-decrementer体中的balance来简化组合的运算符。这会将表达式简化为:
((lambda (amount) (- 25 amount)) 20)
现在我们通过用 20 替换lambda表达式体中的amount来应用运算符:
(- 25 20)
最终答案是 5。
然而,观察如果我们尝试对make-simplified-withdraw进行类似的替代分析会发生什么:
((make-simplified-withdraw 25) 20)
我们首先通过用 25 替换make-simplified-withdraw体中的balance来简化运算符。这将表达式简化为:9
((lambda (amount) (set! balance (- 25 amount)) 25) 20)
现在我们通过用 20 替换lambda表达式体中的amount来应用运算符:
(set! balance (- 25 20)) 25
如果我们遵循替代模型,我们将不得不认为过程应用的含义是先将balance设置为 5,然后返回 25 作为表达式的值。这样得到了错误的答案。为了得到正确答案,我们必须在某种程度上区分balance的第一次出现(在set!的效果之前)和balance的第二次出现(在set!的效果之后),而替代模型无法做到这一点。
这里的问题在于,替代最终是基于这样的概念:语言中的符号本质上是值的名称。但一旦我们引入set!和变量值可以改变的思想,变量就不能再简单地是名称了。现在变量在某种程度上引用了一个可以存储值的地方,而存储在此处的值可以改变。在第3.2节中,我们将看到环境如何在计算模型中扮演"地方"的角色。
此处浮出水面的问题比特定计算模型的崩溃更为深刻。一旦我们将变化引入计算模型,许多以前直接了当的概念就变得有问题了。考虑两个事物"相同"的概念。
假设我们两次调用make-decrementer并带相同的参数来创建两个过程:
(define D1 (make-decrementer 25))
(define D2 (make-decrementer 25))
D1和D2相同吗?一个可接受的答案是肯定的,因为D1和D2具有相同的计算行为——每个都是一个从 25 中减去其输入的过程。事实上,在任何计算中D1都可以替代D2而不改变结果。
与此对比,对make-simplified-withdraw的两次调用:
(define W1 (make-simplified-withdraw 25))
(define 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而不改变表达式的求值结果。
如果一个语言支持"相等者可以替代相等者"而不改变表达式的值,则称该语言是引用透明的。当我们在计算机语言中包含set!时,引用透明性被违反了。这使得判断何时可以通过替换等价表达式来简化表达式变得棘手。因此,对使用赋值的程序进行推理变得极其困难。
一旦我们放弃引用透明性,计算对象"相同"的含义就变得难以用形式化的方式捕获。事实上,在我们的程序所建模的现实世界中,"相同"的含义本身就不清楚。一般来说,只有通过修改一个对象然后观察另一个对象是否发生了同样的变化,我们才能确定两个看似相同的对象确实是"同一个"。但是,除了两次观察"同一个"对象并看其属性是否从一次观察到下一次有所不同之外,我们还能如何判断一个对象是否"改变"了呢?因此,没有某种先验的"相同性"概念,我们就无法确定"变化",而不观察变化的效果也无法确定相同性。
作为这个问题如何在编程中出现的例子,考虑 Peter 和 Paul 有一个存了 100 美元的银行账户的情况。将其建模为以下两种方式有很大的不同:
(define peter-acc (make-account 100))
(define paul-acc (make-account 100))
和将其建模为:
(define peter-acc (make-account 100))
(define paul-acc peter-acc)
在第一种情况下,两个银行账户是不同的。Peter 进行的交易不会影响 Paul 的账户,反之亦然。然而,在第二种情况下,我们将paul-acc定义为与peter-acc相同的东西。实际上,Peter 和 Paul 现在有一个联名银行账户,如果 Peter 从peter-acc取款,Paul 会观察到paul-acc中的钱变少。这两种相似但不同的情况可能在构建计算模型时引起混乱。特别是对于共享账户,有一个对象(银行账户)有两个不同的名称(peter-acc和paul-acc)这一点尤其令人困惑;如果我们在程序中搜索所有可能改变paul-acc的地方,我们还必须记得也要查找改变peter-acc的地方。10
参考上面关于"相同性"和"变化"的评论,观察到如果 Peter 和 Paul 只能查看他们的银行余额,而不能执行改变余额的操作,那么两个账户是否不同的问题就是无意义的。一般来说,只要我们从不修改数据对象,我们就可以将一个复合数据对象精确地视为其各个部分的总体。例如,一个有理数由其分子和分母决定。但这种观点在存在变化的情况下不再有效,此时复合数据对象具有与其组成部分不同的"身份"。即使我们通过取款改变余额,银行账户仍然是"同一个"银行账户;反过来,我们也可以有两个具有相同状态信息的不同银行账户。这种复杂性不是我们编程语言的后果,而是我们将银行账户视为一个对象的感知的结果。例如,我们通常不会将有理性视为一个具有身份的可变对象,以使我们能够改变分子而仍然拥有"同一个"有理数。
与函数式编程相比,大量使用赋值的编程被称为命令式编程。除了引发关于计算模型的复杂问题外,以命令式风格编写的程序容易出现函数式程序不会出现的错误。例如,回顾第1.2.1节的迭代阶乘程序:
(define (factorial n)
(define (iter product counter)
(if (> counter n)
product
(iter (* counter product)
(+ counter 1))))
(iter 1 1))
我们可以在内部迭代循环中不传递参数,而是采用更命令式的风格,使用显式赋值来更新变量product和counter的值:
(define (factorial n)
(let ((product 1)
(counter 1))
(define (iter)
(if (> counter n)
product
(begin (set! product (* counter product))
(set! counter (+ counter 1))
(iter))))
(iter)))
这并没有改变程序产生的结果,但它引入了一个微妙的陷阱。我们如何决定赋值语句的顺序?碰巧,按原样编写的程序是正确的。但是如果以相反的顺序编写赋值语句:
(set! counter (+ counter 1))
(set! product (* counter product))
将产生一个不同的、不正确的结果。一般来说,使用赋值进行编程迫使我们必须仔细考虑赋值的相对顺序,以确保每条语句使用的都是已被正确改变的变量版本。这个问题在函数式程序中根本不会出现。11 如果我们考虑多个进程并发执行的应用程序,命令式程序的复杂性会变得甚至更糟。我们将在第3.4节回到这个问题。但在此之前,我们将先解决为包含赋值的表达式提供计算模型的问题,并探索在模拟中使用具有局部状态的对象。
练习 3.7. 考虑由make-account创建的银行账户对象,以及练习3.3中描述的密码修改。假设我们的银行系统需要能够创建联名账户。定义一个过程make-joint来完成这一点。Make-joint应该接受三个参数。第一个是受密码保护的账户。第二个参数必须匹配定义该账户时使用的密码,make-joint操作才能继续。第三个参数是一个新密码。Make-joint创建一个使用新密码对原始账户的额外访问。例如,如果peter-acc是一个密码为open-sesame的银行账户,那么
(define 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。
1 实际上,这不完全正确。一个例外是第1.2.6节中的随机数生成器。另一个例外涉及我们在第2.4.3节中引入的操作/类型表,其中具有相同参数的对get的两次调用的值取决于中间对put的调用。另一方面,在我们引入赋值之前,我们没有办法自己创建这样的过程。
2 set!表达式的值依赖于实现。Set!应仅用于其效果,而不是其值。
名称set!反映了 Scheme 中使用的命名惯例:改变变量值(或改变数据结构,正如我们将在第3.3节中看到的)的操作的名称以感叹号结尾。这类似于用问号结尾的名称来指定谓词的惯例。
3 我们已经在程序中隐式使用了begin,因为在 Scheme 中,过程体可以是一个表达式序列。同样,cond表达式中每个子句的<结果表达式>部分也可以是表达式序列而不是单个表达式。
4 编程语言术语中,变量balance被称为封装在new-withdraw过程内部。封装反映了被称为隐藏原则的一般系统设计原则:通过保护系统的各部分彼此隔离,即只为那些"需要知道"的系统部分提供信息访问,可以使系统更加模块化和健壮。
5 与上面的new-withdraw相比,我们不需要使用let来使balance成为局部变量,因为形式参数已经是局部的。这一点在第3.2节关于求值的环境模型的讨论之后将更加清楚。(另参见练习3.10。)
6 实现rand-update的一种常见方法是使用规则:x 更新为 ax + b 模 m,其中 a、b 和 m 是适当选择的整数。Knuth 1981 的第 3 章包含了对生成随机数列和建立其统计特性的技术的广泛讨论。注意,rand-update过程计算的是一个数学函数:给定相同输入两次,它产生相同输出。因此,由rand-update产生的数列当然不是"随机的",如果我们坚持数列中的每个数与前面的数无关的话。"真正随机性"与所谓的伪随机序列(由确定的计算产生但具有合适的统计特性)之间的关系是一个涉及数学和哲学中困难问题的复杂课题。Kolmogorov、Solomonoff 和 Chaitin 在澄清这些问题方面取得了巨大进展;相关讨论见 Chaitin 1975。
7 这个定理归功于 E. Cesàro。讨论和证明见 Knuth 1981 第 4.5.2 节。
8 MIT Scheme 提供了这样一个过程。如果给random一个精确整数(如在第1.2.6节中),它返回一个精确整数,但如果给它一个小数值(如在本练习中),它返回一个小数值。
9 我们不替换set!表达式中出现的balance,因为set!中的<name>不被求值。如果我们确实替换了它,我们将得到(set! 25 (- 25 amount)),这没有意义。
10 单个计算对象被多个名称访问的现象称为别名。联名银行账户的情况说明了一个非常简单的别名例子。在第3.3节中,我们将看到更复杂的例子,例如"不同"的复合数据结构共享部分。如果我们忘记了对一个对象的更改也可能作为"副作用"更改了一个"不同"的对象(因为两个"不同"的对象实际上是同一对象的不同别名),程序中就可能出现错误。这些所谓的副作用错误非常难以定位和分析,以至于一些人提议编程语言的设计方式应该不允许副作用或别名(Lampson et al. 1981; Morris, Schmidt, and Wadler 1980)。
11 有鉴于此,具有讽刺意味的是,编程入门通常是以高度命令式的方式教授的。这可能是 20 世纪 60 年代和 70 年代普遍存在的信念的残余,即调用过程的过程必然比执行赋值的过程效率更低。(Steele (1977) 驳斥了这一论点。)或者,这可能反映了这样一种观点:逐步赋值比过程调用更容易让初学者可视化。无论原因如何,这往往使初学者编程者背负着"我应该在这个赋值之前还是之后设置这个变量"的担忧,这些担忧会使编程复杂化并掩盖重要的思想。