《SICP》读书笔记之一:构造过程抽象(上)

时间:2022-07-23
本文章向大家介绍《SICP》读书笔记之一:构造过程抽象(上),主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

本章节将介绍有关计算过程(computational process)的知识。计算过程是存在于计算机里的一类抽象事物。在其演化过程中,这些过程会去操作一些被称为数据(data)的抽象事物。而人们则会创造程序(programs)来指导这些过程。在正常工作的计算机里,一个计算过程将精密而准确地执行相应的程序。

为了描述计算过程,我们需要一种合适的语言,本书中将使用 Lisp。Lisp 并不是一种主流语言,但其非常适合用来学习程序构造和数据结构。Lisp 的一个重要特征是其将过程本身作为一种数据来表达和操作,这种表示过程的数据在 Lisp 中被称为 procedures

1 程序设计的基本元素

一个强有力的程序设计语言不仅能够指导计算机执行任务,还能够作为一种框架,帮助我们组织自己关于过程的思想。因此,当我们描述一种语言时,我们需要关注该语言所提供的将简单想法结合起来构造复杂想法的方法。该方法的实现一般包含以下三种机制:

  • 基本表达式(primitive expressions),用来表示该语言所关注的最简单的实体
  • 组合的方法(means of combination),用来从简单元素出发构造复合元素
  • 抽象的方法(means of abstraction),用来对复合元素命名,并将其当作单元去操作

在程序设计中,我们会处理两种类型的元素:过程(procedures)和数据(data)。通俗来说,数据是一种我们希望去操作的东西,而过程则是对用来操作数据的规则的描述。因此,任何强有力的编程语言都应该能够描述基本的数据和基本过程,并提供对数据和过程进行组合和抽象的方法。

在本章中,我们只会处理简单的数值数据,专注于构造过程(building procedures)的规则。

1.1 表达式

我们首先将通过几个与 Lisp 方言 Scheme 的解释器的交互实例来介绍表达式。目前 Scheme 已经更名为 Racket,可以去这里下载安装。

一种最基本的表达式就是数字,在 Lisp 中输入一个数,解释器将打印出该数字。

Input: 486
Output: 486

我们可以将表示数字的表达式和表示初级过程的表达式(如 +*)结合起来,组成一种复合表达式,表示把该过程应用于数字,例如:

Input: (+ 137 349)
Output: 486

像上面这样的表达式被称为组合式(combinations),其构成方式是通过一对括号界定一个表达式列表,以表示一个过程应用。列表最左边的元素被称为运算符(operator),其他元素都被称为运算对象(operands)。一个组合式的值通过将运算符指定的过程应用于运算对象对应的参数值来得到。

这种将运算符放到运算对象左边的约定被称为前缀表示(prefix notation),其与常规的数学表示有着较大差别,但是这种表示方式有着其独有的优点:

  1. 能够适应可能带有任意个参数的过程,不会产生歧义
  2. 可以非常直接地进行嵌套
Input: (+ 21 35 12 7)
Output: 75
Input: (+ (* 3 5) (- 10 6))
Output: 19

虽然对于嵌套的深度没有限制,但如果将嵌套全部写在一行,则可读性会大大下降,如:

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

我们可以通过缩进来更好地显示出表达式的结构,这种格式规则被称为美观打印(pretty-printing),一般 Lisp 系统都会提供换行自动美观缩进的功能。

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

即使对于非常复杂的表达式,解释器也总是按照同样的基本循环运作:从终端读入一个表达式,对这个表达式求值,然后打印出结果。这种运作模式被称为 read-eval-print-loop (REPL),注意并不需要显示地要求解释器打印表达式的值,解释器会自动打印。

1.2 命名与环境

程序设计语言需要提供一种通过名字去引用计算对象的方式。我们称该名字标记了一个变量(variable),其值为指定的计算对象。

在 Lisp 方言 Scheme 中,通过 define 给事物命名,其格式如下:

(define size 2)

一旦名字 size 与数字 2 关联后,我们就可以通过该名字去引用 2 这个值了:

Input: (* 5 size)
Output: 10

define 是我们所用的语言里最简单的抽象方法,它允许我们用简单的名字去引用单个值或组合运算的结果。借助于这种名字-对象关联的方式,我们可以一步步地构造越来越复杂的计算性对象,进而构造出一个复杂的程序。实际上,一个 Lisp 程序通常都是由一大批相对简单的过程组成的。

我们之所以能够将值与符号关联,而后又能提取出这些值,是因为解释器保持着某种存储,以便于追踪所创建的名字-对象关联。这种存储被称为环境(environment),更精确地来说,是全局环境(global environment),因为之后我们将看到,在一个计算中可能涉及若干不同环境。

1.3 组合式的求值

本章中,我们希望专注于过程性思维。而在组合式求值中,解释器本身就是在遵循一个过程而工作的,具体来说:

  1. 求值该组合式的各个子表达式
  2. 过程(最左边的子表达式的值,即运算符)应用于参数(其他表达式的值,即运算对象)

上述步骤表明,为了实现对组合式的求值过程,需要首先对组合式里的每个元素执行同样的求值过程。因此,该求值过程是递归(recursive)的,即其包含调用自己本身。

递归的思想可以非常简洁地求值深度嵌套的组合式,如下所示:

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

对于上述嵌套组合式,其包含 4 个不同的组合式,我们可以用树来表示该组合式的求值过程,如下图所示。运算对象的值从终端节点开始,不断向上穿行,在越来越高的层次中组合起来,最终到达顶端。这种“值向上穿行”的形式的求解是一类更一般的计算过程的一个例子,这种计算过程被称为树形积累(tree accumulation)。

通过对上述过程中第一步的反复执行,最终我们会到达某一点,该点需要求解的不是组合式而是基本表达式,如数字、内置运算符或是其他名字。对于基本情况,我们按如下规则处理:

  • 数字的值即为它们所表示的数值
  • 内置运算符的值即为能完成相应操作的机器指令序列
  • 其他名字的值即在环境中关联该名字的对象

在上述规则中,第二条可以看做第三条的特例。需要特别强调,环境的作用就是确定表达式中各个符号的意义。

需要注意,上述求值规则对于定义变量并不适用。我们将诸如此类不适用于一般求值规则的表达式称为特殊形式(special forms)。每个特殊形式都有其自身的求值规则,各种不同种类的表达式(以及对应的求值规则)构成了程序设计语言的语法形式。与其他语言相比,Lisp 的语法非常简单,其对表达式的求值可以描述为一个简单的通用规则加上一组针对特殊形式(不多)的专门规则

1.4 复合过程

到目前为止,我们已经了解了 Lisp 中的一些重要元素:

  • 数字和算术运算作为基本的数据和过程
  • 组合式的嵌套提供了一种组合多个操作的方法
  • 命名定义提供了一种简单的抽象手段

现在我们将来学习过程定义(procedure defintions),这是一种更强大的抽象技术,它可以为复合操作命名,将其作为一个单元调用。

过程定义的一般形式如下:

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

其中 <name> 是一个符号,将在环境中关联该过程。<formal parameters> (形式参数)是一些名字,它们用在过程体中,用于表示过程应用时与它们对应的各个实际参数。<body> 是一个表达式(可以是多个表达式的嵌套),用于求出该过程对应的值。这种定义后的过程被称为复合过程(compound procedure)。

下面给出了一个求数字的平方的例子:

(define (square x) (* x x))
Input: (square (+ 2 5))
Output: 49

(define (sum-of-squares x y)
  (+ (square x) (square y)))
Input: (sum-of-squares 3 4)
Output: 25

复合过程的使用方式与基本过程完全一样。

1.5 过程应用的代换模型

对运算符为复合过程的组合式,其求值过程与基本组合式完全一致:先由解释器对组合式的各个元素求值,然后将得到的过程(运算符的值)应用于参数(运算对象的值)。

对于基本组合式,将基本过程应用于参数的机制已经在解释器中实现了。而对于复合过程,其应用的机制如下:

将过程体中的每个形参用相应的实参替代之后,对这一过程体求值。

我们将通过一个例子来描述这个过程。首先定义如下复合过程:

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

我们对如下组合式求值:

(f 5)

首先提取 f 的体:

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

然后用实际参数 5 替换其中的形式参数:

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

这样问题被转化为对另一个组合式的求值,该组合式包含两个运算对象和一个运算符 sum-of-squares,采用类似的方法,用 6 和 10 替换形式参数,我们得到:

(+ (square 6) (square 10))

再基于 square 的定义,得到:

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

上式为嵌套的基本组合式,其值为:

Output: 136

以上过程被称为过程应用的代换模型(substitution model)。它可以被理解为确定过程应用的“意义”的一种模型,这里有两点需要强调:

  • 代换的目的只是为了帮助我们领会过程应用,并不是对解释器实际工作方式的描述
  • 在之后的章节中,我们会介绍一系列有关解释器如何工作的复杂模型,代换模型只是其中的第一个——作为形式化地思考求值过程的起点

1.5.1 应用序和正则序

除了 1.3 节中描述的求值过程外,我们还可以采用另外一种求值模型,该模型先不求出运算对象的值,用其对应的表达式去替换形式参数,直到得到一个只包含基本运算符的表达式。使用这种方法时,之前的例子的求值过程如下:

(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

求得的结果与之前相同,但是过程有所不同。特别地,对于 (+ 5 1)(* 5 2) 的求值各做两次。

这种“完全展开再规约”的求值方法被称为正则序求值(normal-order evaluation),与之对应的是在解释器中实际使用的“先求值参数再应用”的方法,其被称为应用序求值(applicative-order evaluation)。可以证明,对于可以使用代换模型进行模拟并产生合法值的过程应用,正则序和应用序求值将产生同样的值。

Lisp 使用应用序求值,主要有两点原因:

  • 避免对于表达式的重复求值,提高效率
  • 在超出代换模型可以模拟的范围后,正则序求值将变得非常复杂

而另一方面,正则序求值也可以成为特别有价值的工具,将在之后进行介绍。

1.6 条件表达式和谓词

目前为止我们定义出的过程类的表达能力还非常有限,还没有办法去进行某些检测,然后根据检测的结果进行不同的操作。例如,我们还无法定义一个过程,来计算一个数的绝对值,如下式所示:

这种结构被称为分情况分析(case analysis)。在 Lisp 中有一种针对这类分情况分析的特殊形式,称为条件表达式 cond,其使用形式如下:

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

条件表达式的一般形式为:

(cond (<p1> <e1>)
      (<p1> <e1>)
      ...
      (<pn> <en>

符号 cond 后面跟着一系列用括号括起的表达式对 <p> <e> ,它们被称为子句(clauses),每个子句中的第一个表达式称为谓词(predicate),其值将被解释为(true)或(false)。

条件表达式的求值过程如下:首先求值谓词 <p1>,如果其值为 false,则求值 <p2>,如果其值为 false,则求值 <p3>,直到发现某个谓词的值为 true 为止。此时解释器返回该谓词所对应的结果表达式 <e> 的值,作为整个条件表达式的值。如果没有 <p> 的值为 true,则 cond 的值为未定义。

术语谓词用来指返回 true 或 false 的(复合)过程,以及能求出 true 或 false 的表达式。在上面的例子中,使用了基本谓词 >,<,和 =。这些谓词以两个数为参数,检查第一个数是否大于、小于或等于第二个数,据此返回真或假。

绝对值求解过程还可以通过如下形式表达:

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

这里使用了特殊符号 else,用来代替最后一个子句中的 <p>。如果之前的所有子句都被跳过,那么 cond 就会返回最后一个 <e> 的值。实际上,任何可以求值为 true 的表达式都可以放在该处,体现相同的作用。

下面是另一种条件表达式的形式:

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

这里使用了特殊形式 if,它是条件表达式的一种受限形式,适用于分情况分析中只有两种情况。if 表达式的一般形式如下:

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

在求值 if 表达式时,解释器首先对 <predicate> 部分求值,如果其值为 true,则求值 <consequent> 部分,返回其值;否则求值 <alternative> 部分,返回其值。需要注意在 if 表达式中,<consequent><alternative> 只能是单个表达式,而在 cond 中每个子句的 <e> 部分可以是一个表达式序列,其中最后一个表达式的值作为 cond 的值。

除了基本谓词之外,还要一些逻辑复合运算符,利用它们可以构造出复合谓词,最常用的三个复合运算符是:

  • (and <e1> ... <en>) 解释器将从左到右一个个地求值 <e>,如果任意一个 <e> 的值为假,则该表达式的值为假,剩余的 <e> 都不用再求值了。如果所有 <e> 的值为真,则该表达式的值为最后一个 <e> 的值。
  • (or <e1> ...<en>) 解释器将从左到右一个个地求值 <e>,如果任意一个 <e> 的值为真,则该表达式的值为该 <e> 的值 ,剩余的 <e> 都不用再求值了。如果所有 <e> 的值为假,则该表达式的值为假。
  • (not <e>) 如果 <e> 的值为假,该表达式的值为真,否则为假

注意:andor 都是特殊形式而非过程,因为其子表达式并不一定都被求值,not 则是一个普通的过程。

1.7 实例:采用牛顿方法求平方根

不断重复这一过程,我们可以得到对平方根越来越精确的近似值。

现在,让我们用过程的语言来描述这一计算过程。首先我们有一个被开方数的值和一个猜测值,如果猜测值足够好,那么直接返回即可;否则,我们需要重复上述过程(逐步逼近)来改进猜测值。这一基本策略可以表述为如下过程:

(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))

那么什么时候猜测值才算足够好呢,这里采用的方法是不断改进答案直至其足够接近平方根,使得其平方与被开方数之差小于某个事先确定的误差值(这里取 0.001):

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

我们在谓词的名字后面加了一个问号帮助区分,这只是一种风格上的约定。最后,我们需要设置一个初始值来启动,一般设置为 1(实际上为 1.0,大部分 Lisp 实现中没有区别):

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

现在,我们就可以像其他过程一样使用 sqrt 了:

Input: (sqrt 9)
Output: 3.00009155413138

Intput: (sqrt (+ 100 37))
Output: 11.704699917758145

Input: (sqrt (+ (sqrt 2) (sqrt 3)))
Output: 1.7739279023207892

这个 sqrt 程序说明,目前我们所学的语法已经足够写出任何纯粹的数值计算程序了。虽然我们还没有介绍任何迭代结构(循环),但利用常规的过程调用,我们也可以实现这一点。

1.8 过程作为黑盒抽象

sqrt 是我们用一组手工定义的过程来实现一个计算过程的第一个例子。需要注意的是, sqrt-iter 的定义是递归(recursive)的,即这一过程的定义基于它本身。我们将在下一节中详细介绍这一思想。

我们可以观察到,计算平方根的问题被自然地分解为若干子问题,每一个子问题都要通过一个独立的过程来完成。整个 sqrt 过程可以看做一簇过程,如下图所示。

这种分解的重要性,并不仅仅体现在其将程序分解为几个部分,而是体现在每个过程都完成了一个可以清楚标明的任务,这些任务可以被用作定义其他过程的模块。例如,当我们定义 good-enough? 时,就是将 square 过程看作一个黑盒子,我们并不关心该过程是如何计算出结果的,只关心其能够计算出平方这个事实。从这个层面上看,square 并不是一个过程,而是对一个过程的抽象,即所谓的过程抽象(procedural abstraction)。

因此,如果只考虑返回值,下列几个计算平方的过程是不可区分的:

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

作为过程的使用者,其可能不需要去自己写这些过程,而是从其他程序员那里作为一个黑箱而接受它。用户在使用一个过程时,并不需要去弄清它是如何实现的。

1.8.1 局部名

在过程实现中,一个不需要使用者去关心的细节是实现者对于过程中形式参数名称的选择。因此,下面两个过程应该是不可区分的:

(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 表示第二个参数。而送给 square 的实际参数就是 guess。如果 square 的作者也用 x 表示(形式)参数,那么就可以明显地看出在 good-enough? 里的 x 必须和 square 里的 x 不同,在过程 square 运行时,绝不应该影响到 good-enough 中所使用的的那个 x 的值。

过程的形式参数在过程定义中扮演着非常特殊的角色,即形式参数的具体名字是什么,其实完全没有关系。这种名称被称为约束变量(bound variable),我们称一个过程的定义约束了它的所有形式参数。如果在一个完整的过程定义中将某个约束变量统一换名,这一过程定义的意义将不会有任何改变。如果一个变量不是被约束的,我们就称它为自由的。一个约束定义的名称所对应的表达式集合称为该名称的作用域(scope),在过程定义中,被声明为该过程的形式参数的那些约束变量,以该过程体作为其作用域。

good-enough? 的定义中,guessx 是约束变量,而 <-abssquare 是自由的。需要注意约束变量的命名不能与自由变量相同,否则程序会将自由变量转为约束变量,引起错误。即过程的定义是依赖于自由变量的,而该自由变量的定义位于本过程之外。

1.8.2 内部定义和块结构

平方根程序还说明了另一种对名称进行隔离的情况。目前该程序由几个相互分离的过程组成:

(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 这个过程重要的,其他过程只会干扰他们的思维。这种定义方法会使得 good-enough? 过程只能有一种实现,如果其他程序需要使用其他的逐步逼近过程,则无法在定义相同名称的过程了。因此,我们希望将这些子过程局部化,将它们隐藏到 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),它可以解决上述的名字包装问题。实际上,我们还可以对这一结构进行优化,注意到在子过程中,x 的定义是通用的,因此我们不需要再将其作为约束变量定义,再将其传入每个过程,而是直接将其设置为内部定义中的自由变量,如下所示:

(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))

x 在调用外围过程 sqrt 时获取参数值。这种方式被称为词法作用域(lexical scoping),其要求一个过程中的自由变量来自外围过程定义中的约束,即应该在定义该外围过程的环境中去寻找它们。

之后我们将广泛的使用这种块结构,帮助我们将大程序分解成一些容易把握的片段,注意嵌套的定义必须出现在过程体之前。

思维导图