1.1 The Elements of Programming - CloneableX/SICP-learning GitHub Wiki

一门强大的编程语言不止需要能够命令计算机执行任务,它还应该能够作为一种框架存在,这种框架可以在我们组织关于思考过程的概念时发挥作用。所以描述一门语言时,我们还应该特别注意它是否能够将简单的概念组合为复杂概念。编程语言的这种能力与下列三种机制密切相关:

  • 基础表达式(primitive expression),通过它语言可以表达所关注的最简单实体
  • 组合(means of combination),它能够帮助语言将简单的元素组合为新元素
  • 抽象(means of abstraction),组合而来的新元素可以被命名以及作为一个整体维护

在程序中我们需要处理两种元素,分别是程式和数据(稍后我们将向展示它们并不会显得十分独特)。我们可以这样理解,数据是我们打算维护的东西,而程式是关于维护数据规则的描述。所以,任何强大的编程语言都应该可以表达基础数据和基础程式,并且提供结合、抽象程式和数据的方法。

在本章我们只处理数字型数据,这使我们可以专注构建程式的规则。在后面的章节我们将使用这些规则构建程式处理组合而成的数据。

表达式

通过在 Scheme 的解释器中实验一些典型的表达式可以帮助我们轻松地开始学习。试着想象你在一个电脑终端前敲下一句表达式,稍后解释器便会向你展示相应的计算结果。

你可能会输入一些基础的表达式,比如一个数字(更确切地说是以 10 进制表达的数字)。如果你输入 486 解释器将输出 486

除了单独输入一个数字之外,也可以输入数字与程式的组合(比如 +*),此时解释器会将计算结果输出,例如:

(+ 137 349)
486

(- 1000 334)
666

(* 5 99)
495

(/ 10 5)
2

(+ 2.7 10)
12.7

上述表达式都以小括号作为边界应用其中的程式,一般我们称其为 组合(combinations)。在组合中最左侧的元素是 操作符(operator),其他的元素叫做 操作数(operands)。组合计算结果是通过将计算数作为操作符对应的程式的 参数(arguments) 应用产生。

将操作符放置在所有操作数的左边被称为 前缀表示法(prefix notation),一开始你可能会感到迷惑,因为它与学过的数学表达式习惯有所不同。前缀表示法有许多优势,其中之一便是它能够接受任意个数的参数,就如同下述例子:

(+ 21 35 12 7)
75

(* 25 4 12)
1200

上述表达式都未曾表现出二义性,因为操作符一直处于组合的最左侧,并且组合都被小括号框定。

前缀表示法的第二个优势是它以一种直接了当的方式允许嵌套组合的形式存在,例如:

(+ (* 3 5) (- 10 6))
19

理论上没有限制嵌套的深度,因为在嵌套的复杂度上 Lisp 解释器尚可应付,不过却会为人带来不小的麻烦,例如:

(+ (* 3 (+ (* 2 4) (+ 3 5)))(+ (- 10 7) 6))

虽然解释器能够轻松地计算出结果 57,但为了方便阅读我们会将上述代码书写为如下形式:

(+ (* 3
       (+ (* 2 4)
          (+ 3 5)))
    (+ (- 10 7)
       6))

上述格式化代码的方式被称为 pretty-printing,这导致较长的组合表达式中操作数会纵向排列,代码的格式化将使表达式的结构展示更加清晰。

即使是十分复杂的表达式,解释器都是按照从终端读取、计算、输出结果的方式处理,也就是说这种解释器按照 read-eval-print 循环 运行,我们即使不命令其输出结果,解释器也会自行输出。

命名及环境

编程语言能够提供通过名字指向可计算对象的手段是极其重要的。当我们说名字指向一个 变量(variable) 时,也表示 数值(value) 是一个对象。

在 Scheme 中通过 define 关键字为其他事物命名,例如:

(define size 2)

上述表达式命令解释器将数值 2 与名字 size 绑定,绑定关系一旦完成我们便可以通过对应的名字指向对应的数值:

size
2

(* 5 size)
10

下面还有更多关于使用 define 的示例:

(define pi 3.14159)
(define radius 10)
(* pi (* radius radius))
314.159

(define circumference (* 2 pi radius))
circumference
62.8318

define 也是 Scheme 中最简单的抽象方式,它允许我们通过名字指向复合计算的结果,比如上述例子中的 circumference。通常情况下,计算对象都拥有较复杂的结构,这种结构会导致难以记忆,也难以在每次使用时都重复编写其细节。纵然,一步步构建的计算对象也伴随着逐步递增的复杂度,但在构建的过程中因为逐步为细节命名的方式使其易用性得到了提升。这个特性鼓励增量开发和程序测试,同时也促使 Lisp 程序通常都由一些较为简单的程式构成。

同时也要清晰地意识到将数值与符号关联起来的可能性是建立在解释器必须维护一些存储以追踪名字对象的匹配关系上。这种存储称为 环境(environment)(更确切地说是全局环境,因为后面我们还会看到一次计算可能包含多个环境)。

运算复合表达式

本章我们的目标之一是分析关于程式化思考的问题。接下来我们将复合表达式的运算作为案例进行研究,当运算复合表达式时解释器会进行如下处理:

  • 计算复合表达式的子表达式
  • 向子表达式中的数值(操作数)应用子表达式最左侧的程式(操作符)

上述简单的规则已经说明了普遍计算过程的关键点。因为如果获得复合表达式的计算结果,我们必须先运算复合表达式的每个子表达式,所以运算规则是天然 递归(recursive) 的。

通常递归的表达方式嵌套复合表达式也可以简洁地表述。如下示例的运算

(* (+ 2 (* 4 6))
   (+ 3 5 7))

上述运算需要在四个不同的复合表达式中应用运算规则。我们将整个计算过程用树形图的方式展示,如下图

1-1

上图中每个节点的分支都表示对应操作符和操作数的来源,终端节点(没有分支指向的节点)表示操作符和数。根据树形图,我们可以想象操作数都是向上过滤的,从终端节点开始,不断地结合并弄破上一级节点。一般情况下我们都见识过递归在处理继承关系上的强大之处,实际上这种向上过滤的运算规则形式也是一种常见的 树形累积(tree accumulation) 的例子。

接着观察让我们开始进行计算的第一步,它并不是一个复合表达式,而是一些数字、内置操作符和名字的基础表达式。我们通过下列规定处理最基础的表达式:

  • 数字的值就是数字的名字
  • 内置运算符的值是完成相应操作的机器指令序列
  • 其他名字的值就是与名字绑定的存在于环境中的对象

我们也可以将第二条规定也第三条规定等同起来,因为 +* 等标识都存在于全局环境中,不过它们的值是对应的机器指令。这个道理的关键点意味着表达式中的符号的功能由其在环境中的角色决定。所以在交互性语言(例如 Lisp)中,如果不事先指定 x 在此环境中的值而讨论 (+ x 1) 这个表达式将显得毫无意义。在第三章我们也将看到,计算发生的环境中提供的上下文将成为理解程序执行的关键要素。

需要注意的是前面的运算规则并没有包含关于定义的部分。例如,计算 (define x 3)define 运行的参数并不是两个,因为 define 的目标是将 x 和指定的数值关联上,也就是说 (define x 3) 并不是一个复合表达式。

这种常规运算规则中的异常情况称为 特殊形式(special forms)define 只是其中一个示例,稍后我们还将见到其他的特殊形式。每种特殊形式都有自己的运算规则,不同表达式的规则就形成了编程语言的语法。与其他大量的编程语言比较中,Lisp 拥有十分简单的语法,因为它只由一个简单的通用规则和少量几个特殊形式的规则组成。

复合程式

我们已经能够肯定 Lisp 中的下列元素必然会出现任何一个强大的编程语言中:

  • 数字和算术运算就是基础数据和程式
  • 内嵌复合表达式提供了组合运算的方法
  • 定义名字与数值的关联提供了有限的抽象方法

现在我们要学习 程式定义(procedure definitions),大量强大的抽象技术都是通过组合运算并提供相应的关联名称实现。

我们先研究如何表达平方这个概念。我们会说,平方就是把一个数和它自己相乘,通过 Scheme 表达如下:

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

我们可以按照下列方式理解:

(define (square    x)       (*        x     x))
   |       |       |         |        |     |
  TO      square  something, multiply it by itself.

上述示例就是 组合运算(compound procedure),并为其取名为 square。此程式表示将某数与自己相乘的运算。用于相乘的因数命名为 x,它代表的角色与其在自然语言中表示的角色一样。通过定义计算创造了组合运算,并且将其命名为 square

通常一个程式的定义如下:

(define (<name> <formal parameters>)
  <body>)

其中 <name> 是一个关联环境中程式定义的标识,<formal parameters> 关联程式的参数值的一组名字,并在 <body> 使用。<body> 是一个表达式,它将在程式应用时将形式参数替换为实际参数运行。<name><formal parameters> 包含在一组小括号中,就像被定义的程式被调用时形态一样。

既然已经定义了 square,我们可以如下使用它:

(square 21)
441

(square (+ 2 5))
49

(sqaure (square 3))
81

当然,在定义其他程式时我们也可以在其中使用 square。例如 x2 + y2 可以如下表达:

(+ (square x) (square y))

我们可以定义一个平方和的程式,计算两个参数的平方之和:

(define (sum-of-squares x y)
  (+ (square x) (square y)))

(sum-of-squares 3 4)
25

现在我们甚至可以通过 sum-of-squares 构建更多程式:

(define (f a)
  (sum-of-squares (+ a 1) (* a 2)))

(f 5)
136

复合运算与基本运算的方式基本一致。诚然,通过观察上述对 sum-of-squares 的定义我们无从知晓 square 是如同 +* 一样定义在解释器中的,还是通过复合程式定义的。

程式应用的替换模型

如果要计算复合程式,解释器按一定的规则解析到基础程式才可以开始计算,就如同 1.1.3 节描述的那样。按照这些规则,解释器会运算复合程式的元素(也就是复合程式中操作符的值),并向这些程式传递对应的参数(指复合程式中操作数的值)。

我们可以假设基础程式的运算规则应该完整地构建在解释器中,而对于复合程式应用的过程将如下:

向复合程式传递参数,并将形式参数替换为对应的数值,然后计算对应结果

为了解释此过程,让我们计算如下复合程式

(f 5)

其中的 f 是我们在 1.1.4 节定义的程式,我们回到 f 的程式体中:

(sum-of-sqaures (+ a 1) (* a 2))

接着将形式参数 a 替换为 5:

(sum-of-squares (+ 5 1) (* 5 2))

所以问题变化为两个操作数与操作符 sum-of-squares 的复合程式运算。运算此程式可以拆解为三个子问题解决,将操作符解析为对应的程式以及运算得到两个确切的计算数。目前程式中 (+ 5 1) 运算结果为 6,(* 5 2) 结果为 10,所以需要将 6 和 10 应用于 sum-of-squares 程式。6 和 10 会分别替换 sum-of-squares 程式中的形参 xy

(+ (square 6) (square 10))

如果继续应用 square 的程式定义将得到如下程式:

(+ (* 6 6) (* 10 10))

先计算乘法:

(+ 36 100)

最后再计算加法:

136

整个运算过程我们称为 替换模型(substitution model),它是一种决定程式应用方式的模型,这也正是本章所关心的。不过还是有两点需要强调:

  • 替换的目的是为了帮助我们思考程式如何运算,而不是在描述解释器如何运作。真正的解释器并不会通过操作形式参数替换数值的方式维护程式的文本内容,再计算出结果。在实际中,替换的操作是通过为形式参数提供局部环境的方式实现。在第三章和第四章我们将完整的讨论相关知识,到时我们将研究解释器的实现细节。

  • 根据本书的目标,我们会逐步介绍关于解释器运作的一系列计算模型,并在第五章完整实现一个解释器和编译器。替换模型仅仅是第一个被介绍到的模型,它只是一种帮我们开启关于运算过程形式的方式。通常在对科学和工程现象建模时,我们会从较简化、较不完备的开始。随着我们了解到更多事物的详情后,这些简单的模式将不再适应现状,并被更合适的模型替换,替换模型当然也会顺应这种规律。当我们在第三章使用伴随 mutalbe data 的程式时,将会看到替换模型的缺陷,这促使我们要使用更合适的模型解释程式的运算过程。

应用序 VS 常规序

根据 1.1.3 节的运算描述,解释器会计算操作符和操作数的结果,并将这些结果作为参数传递至程式中。这里并只有一种运算方式,另一种计算模型只有在计算数数值需要时才进行运算。它会先将计算数表达式作为参数应用,直到表达式中只有基础计算符为止,接着再进行运算。如果按照这种方式展开程式,(f 5) 的计算过程应该如下:

(sum-of-squares (+ 5 1) (* 5 2))
(+ (square (+ 5 1)) (square (* 5 2)))
(+ (* (+ 5 1) (+ 5 1)) (* (* 5 2) (* 5 2)))

接着再如下收敛:

(+ (* 6 6) (* 10 10))
(+    36      100)
136

虽然两种计算模型得到的结果相同,但过程却截然不同。实际上在上述的计算过程中,在 (* x x) 中的 x 被替换后,(+ 5 1)(* 5 2) 被运算了两次。

这种被完全展开后再应用的计算模型被称为 常规序运算(normal-order evaluation),相比之下,先计算参数结果再应用至程式的方式被称为 应用序运算(applicative-order evaluation)(解释器通常使用这种方式)。只要可以通过替换方式建模的程式(并应用合法数值)无论是采用应用序还是常规序都能够得到相同的结果,

Lisp 采用了应用序计算,有部分考虑是为了避免表达式的多次运算进而提高效率,例如上述例子提到的 (+ 5 1)(* 5 2)。更重要的是,当我们处理无法使用替换模型的程式时,处理常规序运算将带来更多麻烦。话说回来,常规序运算依然是一个十分有价值的工具,我们会在第三章和第四章时再研究关于它的应用。

条件表达式和谓词

目前我们能够定义的程式能力有限,因为我们没有办法在程式中作出判断,并根据判断的结果执行不同的操作。有一个实际的情况,目前我们没有办法根据计算一个数字的绝对值,也就是说无法根据一个数字的正、零、负情况计算对应结果。

这种结构称为 事例分析(case analysis),在 Lisp 也有对应的形式,称做 cond(也就是 conditional 的简写),它的具体使用如下:

(define (abs x)
  (cond ((> x 0) x)
        ((= x 0) 0)
        ((< x 0) (- x))))

通常条件表达式的结构如下:

(cond (<p1> <e1>)
      (<p2> <e2>)
      (<p3> <e3>))

cond 关键字之后跟随着组小括号包含的表达式

(<p> <e>)

称作 规则(clause),每组表达式中的第一个表达式称为 谓词(predicate),计算结果通常为 true 或 false。

条件表达式按如下方式计算。谓词 <p1> 会先被计算,如果计算结果是 false 将继续计算 <p2>,如果 <p2> 的值也是 false,将计算 <p3>。此过程将持续到当有一个谓词计算结果为 true 时为止,接着解释器会返回对应的 结果表达式(consequent expression) <e>。如果没有任何一个谓词结果是 true,cond 表达式的结果将是 undefined

谓词是一种表达式,应用于程式并返回 true 或 false。计算绝对值的程式 abs 中使用几个基础谓词 ><=,它们接收两个参数,并将第一个参数与第二个参数比较得出 true 或 false 的结果。另一种编写绝对值程式的方式如下:

(define (abs x)
  (cond ((< x 0) (- x))
        (else x)))

将上述表达式翻译为人类语言就是:如果 x 小于 0 则返回 -x,否则返回 x。else 是一种用于 cond 最后一个规则谓词部分的关键词。它表示如果之前的谓词表达式都返回 false,它将返回对应的结果表达式的计算结果。事实上 else 的谓词表达式返回的结果一直都为 true。

还有另一种方式也可以编写绝对值表达式,如下:

(define (abs x)
  (if (< x 0)
      (- x)
      x))

上述例子使用了 if 形式,它是一种特殊的条件表达式类型,只能用于只存在两种情况的事例分析。if 表达式的表现形式如下:

(if <predicate> <consequent> <alternative>)

运算 if 表达式时,解释器会先运算 <predicate> 部分,如果结果为 true,解释器会将 <consequent> 的结果计算并返回,否则将返回 <alternative> 的运算结果。

说回之前提到的基础谓词操作符,它们都是一些比较操作,不过我们也可以构建一些复合谓词。比较常用的有以下这些:

  • (and <e1> ... <en>) 解释器会按从左至右的顺序逐个运算表达式 <e>。如果任意一个 <e> 结果为 false,and 表达式的结果也将是 false,剩下的表达式 <e> 都不会再计算。如果所有的 <e> 结果都为 true,and 表达式的结果将是最后一个表达式的计算结果。

  • (or <e1> ... <en>) 解释器会按从左至右的顺序逐个执行表达式 <e>。如果任意一个表达式 <e> 的结果为 true,or 表达式的结果将是 true,剩下的表达式将不再被计算。如果所有的 <e> 计算结果都为 false,or 表达式的结果也为 false。

  • (not <e>)<e> 表达式的结果为 false 时 not 表达式的结果为 true,反之亦然。

要注意的是 andor 是一种特殊形式,它们并不是程式,因为它们的子表达式不需要全部执行,而 not 是一个基础程式。

比如判断数字 x 处理 5 < x < 10 区间可以表示为:

(and (> x 5) (< x 10))

当然我们也可以定义相应的程式判断两个数字的大于等于关系:

(define (>= x y) (or (> x y) (= x y)))

或者换一种表达方式:

(define (>= x y) (not (< x y)))

例子:牛顿法求平方根

如同开篇介绍的一样,程式就像数学函数一样,都是通过一个或多个参数得到一个结果。但计算机程式与数学函数之间存在着一个巨大的差别,就是程式是更加高效。

我们可以思考一下关于计算平方根的问题,先定义一个平方根函数,如下:

上述是一条十分严谨的数学函数,通过此函数可以知道一个数是否为另一个数的平方根,或者说它描述了一个关于平方根的事实。但是此函数定义并没有描述一个程式,关于如何求出一个数字的平方根它只字未提。我们将上述平方根定义转化为 Lisp 的伪代码形式:

(define (sqrt x)
  (the y (and (>= y 0))
              (= (square y) x)))

这只是重复了一遍问题而已。

通过上述的比较,函数与程式的之间的差别是描述事物本质和如何做之间的差别,也就是描述知识和实践知识之间的区别。在数学上我们通常只需要关注描述知识,但在计算机科学上我们通常需要关心实践知识的方面。

所以如何计算平方根呢?最常用的方法之一是通过牛顿法持续逼近,也就是说我们要不断去猜 y 是否为 x 的平方根,并通过计算 y 与 x/y 的平均值产生更优(更接近真正的平方根)的数值猜想。例如,我们按照下列步骤计算 2 的平方根:

持续这个过程,我们将获取越来越逼近真实平方根的数值。

现在让我们将这个过程公式化,并将其转化为程式。开始时我们需要两个数值(一个是需要被计算出平方根的数,另一个是我们猜测的数值),如果猜测的数值达到我们的理想状态则平方根结果就计算完成;反之,则继续使用优化的猜想数值参与上述计算过程。我们先编写了一个大致的程式框架:

(define (sqrt-iter guess x)
  (if (good-enough? guess x)
      guess
      (sqrt-iter (improve guess x) x)))

猜想数值需要通过旧猜想数值和被求平方根的数计算得到:

(define (improve guess x)
  (average guess (/ x guess)))

(define (average x y)
  (/ (+ x y) 2))

接着需要讨论什么叫做猜测数值足够好,下面我们详细解释,不过它不见得是一个好方案(可以查看练习 1.7)。我们认为猜测数值足够好是指猜测数值的平方与目标数值的差值小于某个值,目前我们设计为 0.001:

(define (good-enough? guess x)
  (< (abs (- (square guess) x)) 0.001))

最后我们还需要一个入口启用程式,无论何时我们都猜测目标数字的平方根是 1,如下:

(define (sqrt x)
  (sqrt-iter 1.0 x))

如果我们在解释器键入了上述定义,接下来便可以像其他程式一样使用 sqrt 了:

(sqrt 9)
3.00009155413138

(sqrt (+ 100 37))
11.704699917758145

(sqrt (+ (sqrt 2) (sqrt 3)))
1.7739279023207892

(square (sqrt 1000))
1000.000369924366

sqrt 的例子恰好也证明了本书介绍的程序语言足以编写 C 或 Pascal 能够写出的纯数学函数。说起来有些惊喜,在 Scheme 还没有提供迭代相关的结构时,我们已经可以让程序循环运行了。同时 sqrt-iter 也展示了如何在不调用基础程式的情况下实现迭代效果。

程式的黑盒抽象

sqrt 是第一个由一组手动定义的程式组成的新程式,而且其中的 sqrt-iter 是一个递归程式。定义一个递归程式总是让人不安,它仿佛一个无始无终的环一般,让人难以明白其中的意义,更不要说指定计算机执行一个明确定义的过程了。在 1.2 节我们将更详细地探讨此问题,但是现在我们需要先考虑通过图例说明 sqrt 如何运行的。

Procedural decomposition of the sqrt program

观察上图,我们将计算平方根的问题拆解为下列几个子问题:

  • 如何评判一个猜想数值是否足够好
  • 如何优化猜想数值
  • 及其他问题

上述的每个子问题都通过单独的程式解决。而整个 sqrt 程序如同程式的集群,与我们分解的子问题相互对应。

分解问题策略的重点并不是将问题简化并拆分,毕竟我们总是能够将大问题分解。相对而言,每个程式都可以完成独立的功能,并且轻易地应用于其他程式中,这才是重点。比如,当我们在 good-enough? 程式中使用 square 时,square 对于我们就如同黑盒一般。我们并不会时刻关心此程式如何计算产生结果,我们只关心它将计算某数的平方并返回。平方计算的细节被忽略,或者稍后需要时再关注。就如同 good-enough? 关注的一样,square 对于它并不是一个程式而是一个程式的抽象,又称为 程式抽象(procedural abstraction)。在抽象层级,任何计算平方的程式没有优劣之分。

因此,如果只考虑程式计算结果,下列两个程式应该无法被区分。因为它们都接收一个数字作为参数,并返回此数字的平方结果。

(define (square x) (* x x))
(define (square x) (exp (double (log x))))
(define (double x) (+ x x))

所以一个程式的定义应该让人忽略其实现细节。毕竟程式的使用者并不一定会亲自编写它,可能只是在某个程式中使用了它,此时对于使用者被使用的程式就是黑盒。一个使用者不应该因为要使用此程式就必须了解其中的实现细节。

局部名称

程式的参数名称对于使用者应该是个可以忽略的细节,所以下列两个程式应该也无法区分:

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

虽然这个道理简单,但它的意义十分重大。最显而易见的一个意义就是程式参数作用范围仅限于程式体中。例如,我们在 good-enough? 中使用 square 程式时:

(define (good-enough? guess x)
  (< (abs (- (square guess) x))
     0.001))

good-enough? 的目的是确定猜想值的平方与真实值的差值,此程式两个参数的名字分别为 guess 和 x,需要进行平方运算的是 guess。如果 square 程式定义时虽然参数也名为 x,但因为参数作用域的影响,good-enough? 不会影响 square 中的参数。

如果参数作用域不止局限于当前程式体中,good-enough? 中的参数 x 与 square 中的参数 x 将发生混淆,同样 good-enough? 的行为将极大地受到 square 实现细节的影响,此时 square 将无法被作为黑盒使用。

程式的形式参数是程式定义中的特殊角色,它并不在意形式参数叫什么名字。通常我们称为 参数绑定(bound variable),我们会描述为程式的定义绑定了它的形式参数,也就是说一个被绑定的变量通过定义统一修改名称,程式的定义并不会发生变化。如果一个变量没有被绑定,我们说它是自由的。定义绑定的一组表达式称为该名字的作用域。在一个程式的定义中,形参就是绑定变量的声明,而该程式体就是这些绑定变量的作用域。

good-enough? 的定义中,guessx 是绑定变量,而 <-sqaure 是自由变量。也就是说,good-enough? 应该将 guessx<-square 的名字区分开(如果我们将 guess 重命名为 abs,程序将发生错误,因为自由变量并不能转换为绑定变量)。但 good-enough? 并不需要区分自由变量,因为它基于这样一个事实,abs 是计算绝对值的函数,如果它被替换为 cos 只是说明需要用不同的函数进行计算而已。

内部定义及代码块结构

目前我们已经有了一种命名方式将变化隔离,就是程式的形参只对程式体内发生作用。不过计算平方根的程序还能用于说明局部名字作用域的另一种用法。目前的程序是一组分开的程式构成:

(define (sqrt x)
  (sqrt-iter 1.0 x))

(define (sqrt-iter guess x)
  (if (good-enough? guess x)
      guess
      (sqrt-iter (improve guess x) x)))

(define (good-enough? guess x)
  (< (abs (- (square guess) x)) 0.001))

(define (improve guess x)
  (average guess (/ x guess)))

上述程式的问题在于,对于 sqrt 的用户只要知道 sqrt 即可,但现在出现在他眼前的还有 sqrt-itergood-enough? 等一堆程式。这将导致用户无法再为自己定义一个名为 good-enough? 的程式,因为它已经在 sqrt-iter 中被使用。这个问题在由多个开发人员开发大型系统时尤为突出。例如,在一个大型关于数学第三方包的开发时,很多数学函数都存在连续逼近最优解的情况,此时 good-enough?improve 将成为常用的辅助函数。这种情况我们更希望子程式能够隐藏在 sqrt 内部,以此达到与其他类似功能程式共存的目的。为了完成这个目标,我们允许程式拥有自己的内部定义,就如同下面重写的 sqrt 函数一样:

(define (sqrt x)
  (define (good-enough? guess x)
    (< (abs (- (square guess) x)) 0.001))
  (define (improve guess x) (average guess (/ x guess)))
  (define (sqrt-iter guess x)
    (if (good-enough? guess x)
        guess
        (sqrt-iter (improve guess x) x)))
  (sqrt-iter 1.0 x))

这种将定义内嵌的方式称为 代码块结构(block structure),是一种较基础但却简单有效解决包命名问题的方式,不过还有一种更好的方式后面再研究。另外,我们可以对内部定义的程式进行简化。因为内部定义的程式也属于 sqrt 形参绑定变量的作用域,所以在内部定义的程式间传递 x 显得不那么必要。也就是说,我们允许 x 成为内部定义程式的自由变量,而 x 的值来自调用 sqrt 时传入的参数值,这种规则称为 词法作用域(lexical scoping)

(define (sqrt x)
  (define (good-enough? guess)
    (< (abs (- (square guess) x)) 0.001))
  (define (improve guess) (average guess (/ x guess)))
  (define (sqrt-iter guess)
    (if (good-enough? guess)
        guess
        (sqrt-iter (improve guess))))
  (sqrt-iter 1.0))

我们将使用代码块结构把大型的程序切分为较易维护的代码片段。代码块结构最早起源于编程语言 Algol 60,这种技术出现在最先进的语言中,并且是大型程序结构化的好帮手。

⚠️ **GitHub.com Fallback** ⚠️