局部状态变量
学习如何使用赋值(set!)和 begin 让过程拥有局部状态变量,并认识替换求值模型的局限。
内容预览
先快速浏览一下本课要讲的内容:
(define (make-account balance)
(define (withdraw amount)
(set! balance (- balance amount)) balance)
(define (deposit amount)
(set! balance (+ balance amount)) balance)
(define (dispatch msg)
(cond
((eq? msg 'withdraw) withdraw)
((eq? msg 'deposit) deposit) ) )
dispatch)
你觉得这个过程是做什么用的?有头绪了吗?
取款
我们从银行账户里取点钱。为此要使用过程 withdraw,它接受一个参数,表示取出的金额。如果账户里的钱够用,withdraw 就返回取款后剩下的余额;否则返回消息 "Insufficient funds"(余额不足)。例如,账户初始有 100 美元,用 withdraw 应该得到下面这一串响应:
(withdraw 25)
75
(withdraw 25)
50
(withdraw 60)
"Insufficient funds"
(withdraw 15)
35
注意,表达式 (withdraw 25) 求值两次,得到了不同的结果。
等等,同一个过程用相同的参数调用,不是应该返回相同的值吗?
到目前为止确实如此,但这是一个过程展现出的全新行为。我们之前的所有过程都可以看作通过竖直线检验的函数:调用一个过程,就是求该函数在给定参数下的值,同一过程用相同参数调用两次,结果总是一样的。但现在的情况不同,每发生一笔交易,余额都必须改变。否则,我们都要发财了!
为了实现 withdraw,可以用变量 balance 表示账户里的钱,然后把 withdraw 定义成访问 balance 的过程。withdraw 检查 balance 是否不小于请求的金额:如果是,就把 balance 减去 amount,并返回 balance 的新值;否则返回「余额不足」消息。balance 和 withdraw 的定义如下:
(define balance 100)
(define (withdraw amount)
(if (>= balance amount)
(begin (set! balance (- balance amount))
balance)
"Insufficient funds"))
减少余额靠的是下面这个表达式:
(set! balance (- balance amount))
这里用到了特殊形式 set!,它的语法是:
(set! [name] [new-value])
其中 [name] 是一个符号,[new-value] 是任意表达式。set! 会改变 [name],让它的值变成对 [new-value] 求值的结果。在当前场景中,我们把 balance 改成从原值中减去 amount 后的结果。
withdraw 还用到了特殊形式 begin,让 if 测试为真时依次求值两个表达式:先把 balance 减掉,再返回 balance 的值。一般来说,对表达式
(begin [exp1] [exp2] ... [expk])
求值,会按顺序依次求值 [exp1] 到 [expk],并以最后一个表达式 [expk] 的值作为整个 begin 形式的值返回。
请在 STk 解释器里动手试试 withdraw、set! 和 begin!
有点不对劲……
在继续之前,再看一眼 withdraw 和 balance 的定义:
(define balance 100)
(define (withdraw amount)
(if (>= balance amount)
(begin (set! balance (- balance amount))
balance)
"Insufficient funds"))
你看出什么可能惹麻烦的地方了吗?
麻烦找到了
问题出在变量 balance 上。按上面这样写,balance 是定义在全局环境(global environment)里的名字,任何过程都能自由地查看或修改它。如果能想办法让 balance 成为 withdraw 内部的东西就更好:只有 withdraw 能直接访问 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 不会被任何其他过程访问到。
> (new-withdraw 10)
90
> (new-withdraw 30)
60
请在 STk 解释器里试试 new-withdraw,确保你理解它的工作原理。
make-account
下面是 SICP 中 make-account 过程的简化版:
(define (make-account balance)
(define (withdraw amount)
(set! balance (- balance amount)) balance)
(define (deposit amount)
(set! balance (+ balance amount)) balance)
(define (dispatch msg)
(cond ((eq? msg 'withdraw) withdraw)
((eq? msg 'deposit) deposit) ) )
dispatch)
现在,试着用局部状态变量重写它。把下面代码中的空白填上,使结果与上面的 make-account 行为完全一致——也就是说,它响应相同的消息,返回相同的值。两个过程的区别只是:下面的版本把 make-account 的内部包在了 let 里,且参数名不同。
(define (make-account init-amount)
(let (______________________)
(define (withdraw amount)
(set! balance (- balance amount)) balance)
(define (deposit amount)
(set! balance (+ balance amount)) balance)
(define (dispatch msg)
(cond ((eq? msg 'withdraw) withdraw)
((eq? msg 'deposit) deposit) ) )
dispatch) )
接下来,改写任一版本的 make-account,让它收到消息 balance 时返回账户当前余额,收到消息 init-balance 时返回开户时的初始金额。例如:
> (define acc (make-account 100))
acc
> (acc 'balance)
100
再做一项修改:让它收到消息 transactions(即任何存款或取款操作)时,返回自开户以来所有交易的列表。例如:
> (define acc (make-account 100))
acc
> ((acc 'withdraw) 50)
50
> ((acc 'deposit) 10)
60
> (acc 'balance)
60
> (acc 'transactions)
((deposit 10) (withdraw 50))
在看下面的完整解答之前,先把你自己的定义放到 STk 解释器里试试,确保你理解了 make-account 的每一行代码。
我们的解答如下:
(define (make-account init-amount)
(let ((balance init-amount)
(transactions '()))
(define (withdraw amount)
(set! balance (- balance amount))
(set! transactions (cons (list 'withdraw amount) transactions))
balance)
(define (deposit amount)
(set! balance (+ balance amount))
(set! transactions (cons (list 'deposit amount) transactions))
balance)
(define (dispatch msg)
(cond ((eq? msg 'withdraw) withdraw)
((eq? msg 'deposit) deposit)
((eq? msg 'balance) balance)
((eq? msg 'transactions) transactions) ) )
dispatch) )
替换求值模型
给定如下定义:
(define (plus1 var)
(set! var (+ var 1))
var)
请按照替换模型,求下面表达式的结果:
(plus1 5)
也就是说,请写出把 5 代入 plus1 体中 var 后得到的表达式,再求出它的值。
现在在 STk 解释器里试试。你得到的答案一样吗?为什么一样,或者为什么不一样?
引入赋值(assignment)伴随着相当高的代价。说到这里你可能已经意识到:我们不能再使用替换求值模型了,因为它会给出错误的值。问题的根源在于,替换终究建立在这样一个观念上——我们语言里的符号本质上就是值的名字。可是一旦引入 set!、引入变量的值可以改变这一概念,变量就不再只是名字了。现在,变量在某种意义上指向一个可以存放值的地方,而这个地方存放的值是会变的。
那我要怎么求值这些过程呢?
新的求值模型在下一课等着你。
要点回顾
本课你学习了:
- 如何实现局部状态变量
- 赋值的代价
- 如何使用
set!和begin
接下来
进入下一课,学习新的求值模型!