局部状态变量

学习如何使用赋值(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!、引入变量的值可以改变这一概念,变量就不再只是名字了。现在,变量在某种意义上指向一个可以存放值的地方,而这个地方存放的值是会变的。

那我要怎么求值这些过程呢?

新的求值模型在下一课等着你。

要点回顾

本课你学习了:

  1. 如何实现局部状态变量
  2. 赋值的代价
  3. 如何使用 set! 和 begin

接下来

进入下一课,学习新的求值模型!