如何绘制环境图

利用 EnvDraw 工具和上一课的规则,从零开始学习绘制环境图,覆盖 define、过程调用、set! 与 let。

EnvDraw

EnvDraw 是一个很酷的程序,你可以在课程账号上运行它来辅助绘制环境图。使用方法:

  1. 在终端输入 envdraw。(如果不行,先 SSH 到 torus,再输入该命令。)这个命令会打开一个 STk 解释器。
  2. 在解释器里输入 (envdraw)。
  3. 你会看到一个新的 EnvDraw 窗口打开。在 EnvDraw> 提示符下,试着定义 square 函数。然后观察 EnvDraw 窗口,看看会发生什么!

概述

现在你要开始自己绘制环境图了!我们从基础讲起,然后逐步加深。绘制这些图所需的规则,全都在上一课里。等到本课结束,你必须全部掌握。

本课展示的所有示例环境图都取自 EnvDraw 程序。

定义

我们先在 Scheme 里定义一个变量,看看对应的环境图。具体来说,我们要画下面这行代码的图:

(define x 3)

第一步永远是画出全局环境。别忘了给它标注标签!接下来要弄清 define 怎么处理。看看上一课的规则,你应该已经知道:define 会向当前框架添加一个新绑定。画出来就是这样:

define x 3

就这样!在全局环境里写下「x」,画一个箭头指向 3,齐活。(注意:在本课程中,你自己的环境图不必写「[其他绑定]」。EnvDraw 加上它是为了完整性。)

接下来我们定义过程。在 STk 解释器里输入下面的代码会发生什么?我们接着之前的图往下画。

(define (square x) (* x x))

第一步,把上面的代码改写成使用 lambda 的形式:

(define square (lambda (x) (* x x)))

注意,这个表达式现在拥有和 (define x 3) 完全一样的基本组成部分。所以我们遵循完全相同的步骤:在全局框架里写下「square」,画一个箭头指向这个 lambda。我们把 lambda 画成双气泡:第一个气泡指向参数和过程体,第二个气泡指向定义环境,也就是看到 lambda 时的当前环境。现在你的图应该变成这样:

define square

现在完成了!回顾一下:先画 lambda,让第一个气泡指向参数和过程体,第二个气泡指向定义环境。接着在全局环境里写下「square」,让它指向这个 lambda。

下一课我们会讲如何真正调用刚定义的 square 函数。

这些例子忽略了一个非常重要的点:define 并不总是往全局环境里加东西。它加东西的地方是当前框架(只不过在上面这些例子里,当前框架恰好是全局环境)。至于如何判断当前框架是哪个,我们后面会讲。

应用基本过程

现在,我们来画下面这行代码的环境图:

(define y (+ 3 4))

它和上一个例子的区别在于:必须先把 + 过程应用到 3 和 4 上,才能给 y 赋值。你可以假设所有基本过程都由魔法完成应用,不需要为它们画任何东西。因此,完整的环境图就是这样的:

define y 3 4

应用自定义过程

假设我们现在想真正调用之前定义的 square 函数。调用代码如下:

(square 5)

调用用户定义的过程,按以下步骤进行:

  1. 创建一个框架,把过程的形式参数绑定到实际参数的值上。
square 5 第一步
  1. 用这个新框架扩展过程的定义环境。
square 5 第二步
  1. 求值过程体,以新框架作为当前框架。

最后一步其实不涉及修改环境图。相反,到这一步我们才最终得出调用的值。为了求值 square 的过程体,我们先要求出 x 的值。变量总是使用第一个可用的绑定。这意味着我们查当前框架里 x -> 5 的绑定,而不是全局框架里 x -> 3 的绑定。求出 x 的值后,把它和自身相乘(记住,你可以直接假设这是魔法完成的)。现在我们完成了!5 自乘得到 25。

记住,只有复合过程调用才会创建新框架!

原子表达式

求值原子表达式(比如查符号的值)的关键,在于判断哪个框架是当前框架。在讲这个之前,先记住原子表达式的求值规则:

  1. 数字、字符串、#t、#f 自求值。
  2. 如果表达式是符号,找第一个可用的绑定。(也就是说:先查当前框架;查不到,就查当前框架「后面」的框架;如此继续,直到全局框架。)

难点全在规则 2 上。回顾一下上一课的环境图:

square 5 第二步

记住,新框架只在调用用户定义的过程时才会画出。因此,只有当你位于另一个函数的作用域内时,当前框架才可能不同于全局环境。这一点极其重要,但现在不必过于纠结。请确保你理解了目前的所有示例。本课后面我们会引入更复杂的例子。

检验理解

回想一下,我们目前的环境图是这样的:square 5 第二步 如果我现在往解释器里输入 x,它的值会是多少?

现在我把 cube 函数这样(不恰当地)定义:

(define (cube x) (* y y y))

请先画出与这个定义对应的环境图,把它添加到我们已经画好的环境图上。

再画出求值下面代码后得到的环境图:

(cube 2)

它输出什么?

无所事事的框架

到现在,我们的环境图已经有三个框架:全局框架、E1 和 E2。E1 和 E2 分别是调用 square 和 cube 时创建的。然而,这些函数一返回(执行完),框架 E1 和 E2 就没用了!它们不再可达,其中的绑定也不再重要。

但并非总是如此。接下来几课我们会看一些代码,让这些框架在过程调用结束后很久仍然有用。

使用 set!

现在来看 set! 怎么处理。你可能记得,set! 改变第一个可用的绑定。记住,找第一个可用的绑定,是先查当前框架,再查它「后面」的框架。

检验理解

动手试试!从一张白纸开始,画出与下面几行代码对应的环境图:

(define x 3)
(define (change x n)
  (set! x n))
(change x 5)

x 的值是多少?

怎样才能修正 change 过程,让全局环境里 x 的值发生变化?请指出所有可行的修正方法。

使用 let

let 常常让很多同学头疼。但别绝望!每当被 let 困住时,记住这几条简单规则:

  1. 把 let 改写成 lambda 语句加一次调用。

例如,可以把

(let ((x 7)
      (y 10))
    (+ x y))

改写成

((lambda (x y) (+ x y)) 7 10)
  1. 画出对应的 lambda。记住,lambda 就是一个双气泡,箭头要画对。
  2. 用合适的参数调用这个 lambda。记住,这包括画一个新框架,并把形式参数绑定到实际参数的值上。

只要能记住这几条简单规则,你就不会有任何麻烦!

检验理解

请画出下面代码的环境图:

(let ((x 7)
      ( y 10))
    (+ x y))

现在来点更复杂的。画出求值下面代码后得到的环境图:

(define (make-withdraw initial-amount)
    (let ((balance initial-amount))
        (lambda (amount)
            (if (>= balance amount)
                (begin (set! balance (- balance amount))
                       balance)
                "Insufficient funds"))))
(define W1 (make-withdraw 100))
(W1 50)

要点回顾

到这一步,绘制任何环境图所需的知识你已经全部掌握了,无论它多复杂!将来碰到问题时,别忘了回到基础!再复杂的代码,也能归结成几条简单的规则。

如果到目前为止有哪怕一处内容你不理解,就去求助!环境图正是很多同学觉得困难的专题之一。

接下来

利用新学到的求值环境模型知识,下一课我们将讲解如何巧妙地用 lambda 和 let 实现面向对象编程。

关于测验

笔记

参加第 8 课测验(Quiz 8)时,除了双面小抄之外,你还被允许携带一份环境图绘制规则。