- 以下均为个人阅读SICP的理解,如有不正确的地方欢迎留言交流
- 所有代码均在Racket上运行过
- bilibili上的SICP原版视频课程非常不错,强烈推荐
SICP从理论上讲解计算机程序的创建、执行和研究,它采用了Lisp语言作为辅助工具(不是专门讲授Lisp)来帮助学习者思考计算的产生、问题的分解、过程和数据的抽象。SICP第1章主要围绕过程(类似函数、方法)这一程序设计中最常见的形式展开的,分3个部分进行了论述,分别是基本元素(表达定义、应用规则、条件分支等)、过程计算(递归、迭代等)以及高级抽象(高阶过程、过程入参、过程返值等)。
程序设计的基本元素
我们知道程序语言既要能被计算机执行,还要便于使用者描述计算的过程,因此在大多数程序语言身上都可以看到三种元素:
- 基本表达式,是对最简单个体的表达,有点类似高级语言中的基本类型,比如:1、2、3、+、-、*、/等;
- 组合的方法,就是将基本表达式组合在一起的方法,由简入繁地表达复合元素,比如:3+4表达的就是一个数值计算的过程
- 抽象的方法,将组合进行命名后作为单元进行使用,这个比较容易理解,比如:函数
程序设计就是综合运用这些元素来构建我们对特定问题的答案域,通常会包含操作对象(数据)与操作规则(过程),比如:在程序中表达数学上的3+4就可以像这样(+ 3 4)。这种前缀表示(即运算符在前)方式与常规数学表达不太一样,但它的好处也很明显,比如可以有多个参数以及扩充嵌套,一旦习惯后就不容易产生歧义。我认为这种表达式的优势在于计算规则比较固定,即将最左值(运算符)应用于其他元素(运算对象)的过程。我们可以对比两者的差异如下:
3 * 5 + (10 - 6)
(+ (* 3 5) (- 10 6))
对比之下就能体会前缀方法更纯粹和直接,因为数学上的表达式隐含着计算的优先级,而前缀表示则把计算的过程显示地表达出来(比如上题就是一个显而易见的加法操作,只是其中的元素要是其他表达式的值),运算符总是位于表达式的最左侧而子表达式都用括号加以分割,这就方便了解释器按照以下规则工作:
- 求值该组合式的各个子表达式
- 将作为最左子表达式的值的那个过程应用于相应的实际参数
这是一个非常关键且基础的规则,首先要理解子表达式是指什么,以上面计算过程为例,子表达式包括了+、(* 3 5)、(- 10 6),注意运算符也是表达式,而(* 3 5)、(- 10 6)又包含了子表达式,按照要求先求值,由于都是基本表达式,所以(* 3 5)、(- 10 6)不产生任何变化;接下来就是将最左子表达式的值的过程应用于相应的实参,注意这里的强调了过程应用,就是真正地计算(* 3 5)、(- 10 6),进而得到(+ 15 4);再重复上面的步骤从而得到最后的结果19。从上面的描述可以发现这样的过程实际上就一个递归过程(反复地运用上面两条规则),这样就能理解书中的陈述:
在性质上,这一求值过程是递归的,也就是说,它在自己的工作步骤中,包含着调用这个规则本身的需要
这就揭示了一个计算机程序上很重要的事实:采用递归可以表达深度嵌套。这一思想几乎贯穿了第1章后面还能看到很多有力的例子,同时它也撬动了我的认知是我逐渐深入阅读的快乐之源。
现在把上面那段分析过程再提炼下,进一步得到:
- 数的值就是它们所表示的数值
- 内部运算符的值就是能完成相应操作的机器指令序列
- 其他名字的值就是在环境中关联这一名字的那个对象
这三句话说起来有点拗口,结合上面的分析,对于(* 3 5)来说如何计算子表达式、3、5?数值就是数值这一句就搞定了3和5,它们的结果就是3和5,而这个运算符就变成了机器指令序列,这样就得到了计算结果。那么第三句话该如何理解呢?这里要引入环境的概念,环境的作用就是存储名字-值对偶,因为我们不可能只在程序中使用具体数字或是其他简单的表达,那样我们程序就只能限定在非常小的用途上,要想办法扩大程序的应用就要解开这种约束,最简单的方式就是创建变量(名字标识符),它可以指代任何值(其实就是具体的一个对象、事物),而环境就是帮助程序去记忆这些名字-值的对应,就如同我们定义函数f(x)=ax+b,那么当我们使用f时其实就会自动地套入ax+b。第三句话实际上解释了将变量如何在表达式中进行代换,这在1.1.5节中将更加详细地进行说明。
准备工作都已经就绪了,现在可以来看看如何定义过程(也可以理解为函数,但从两者指代的含义上看,两者不完全一致):
(define ( ) )
看上去并不复杂,过程的调用也很简单,下面就来分析一下过程求值,用书中平方和的例子,它的计算过程如下:
(f 5)
(sum-of-squares (+ 5 1) (* 5 2))
(+ (square 6) (square 10))
(+ (* 6 6) (* 10 10))
(+ 36 100)
136
不难看出整个过程就是应用上面的计算规则(计算子式+最左值过程应用),相比之前简单的计算这里增加了对过程的调用(如sum-of-squares、square),而这个步骤实际也是计算了过程的值也就是关联了该过程名的计算方法这么一个对象,比如f就可以理解为被解释成了sum-of-squares (+ a 1) (* a 2),而(sum-of-squares (+ 5 1) (* 5 2))则是该过程应用于实际参数5之上,这里体现出的就是过程应用的代换模型,就是将复合过程应用于实际参数,就是在将过程体中的每个形参用相应的实参取代之后,对这一过程体求值。我觉得这里可以借助数学上的代数概念去理解(如下),基本上是一致的。
已知有如下函数g和f,求解f(5)
g(x) = ax + b
f(x) = 2ag(x) + bx + c
这题一般的解答过程是
f(x) = 2a(ax + b) + bx + c
f(5) = 2a(a*5 + b) + b*5 + c
在计算的过程中还存在一个计算时机问题,通常有应用序和正则序两种方式。应用序就是上面的计算步骤,在计算过程中会先计算每个子式的值再被左值应用;而正则式则不急于计算子式,它将凡是涉及复合过程的地方先进行展开,例如:(square (+ 5 1))在正则序中就会变为(* (+ 5 1) (+ 5 1)),讲这些的目的都是为了帮助我们理解解释器的工作方式(即如何计算我们程序中给出的求值过程)。
求解平方根的过程是一个用来观察复杂过程如何建构的不错例子(如下所示),它的流程十分简单,就是不断地用一个猜测值去逼近结果,如果猜测值精度不够就采用牛顿法改善,如果精度达到那么久获得最终结果。
(define (average a b)
(/ (+ a b) 2))
(define (improve guess x)
(average guess (/ x guess)))
(define (square x) (* x x))
(define (close-enough? a b)
(< (abs (- (square a) b)) 0.00001))
(define (sqrt y)
(define (sqrt-iter guess)
(if (close-enough? guess y)
guess
(sqrt-iter (improve guess y))))
(sqrt-iter 1.0))
从上面的过程我们看出这样一个计算平方根的问题被分解成若干个子问题:如何判断精度到达,如何改善猜测,每个子过程又再一次思考类似的问题直至无法分解为更小的子问题,这也是一种“递归”,更重要的是分解中的每一个过程完成了一件可以清楚标明的工作,这使它们可以被用作定义其他过程的模块。标明的工作意味着目的的明确性,但对于实现并不关注,从这个层面上看是对过程的抽象从而形成一种完美的“黑箱”(我更愿意称之为“积木”),由它便可以构建规模更大、功能更多的大型程序。也许你会觉得这种想法是自然而然的,没有什么值得大书特书的,但在实际工作中,我们总是自觉或不自觉地陷入到实现细节的思考中,甚至有时难以区分什么是模块什么是具体实现,这也导致了我们自己设计的程序中大量紧耦合代码。
练习
1.3 此题可以作为对1.1.6节条件表达式和谓词的练习,代码如下:
(define (sum-of-greater a b c)
(cond ((and (< c a) (< c b)) (+ a b))
((and (< b a) (< b c)) (+ a c))
((and (< a b) (< a c)) (+ b c))))
1.5 此题是对1.1.5节中正则序、应用序的考察,包含了对代换的应用
(define (p) (p))
(define (test x y)
(if (= x 0)
0
y))
当计算(test 0 (p)),按如下步骤
(test 0 (p))
(if (= x 0) 0
(p))
使用应用序则根据if的规则,首先计算x是否为0,此式为真则后续不再计算。使用正则序则根据先展开再计算的方式,那么(= x 0)和(p),而由于(p)再展开仍然是(p),就会在这进入死循环
1.6 此题既考察1.1.6条件表达式和谓语,也考察了对应用序的理解和应用,实际上问题的关键不在于用cond替代if,而是对于new-if的使用上。根据if规则,求值时解释器从求值其
(define (new-if predicate then-clause else-clause)
(cond (predicate then-clause)
(else else-clause)))
观察new-if就能发现,由于它是一个一般过程,所以应当遵循先计算各子式在用最左值应用,那么then-clause和else-clause就会分别进行计算,所以当用new-if来计算sqrt-iter时,就会去计算(good-enough? guess x)、guess、(sqrt-iter (improve guess x) x),而当计算sqrt-iter时就又会触发新的递归,依次类推会发现这将无穷无尽导致解释器报错
(define (sqrt-iter guess x)
(new-if (good-enough? guess x)
guess
(sqrt-iter (improve guess x) x)))
1.8 此题实际上套用sqrt-iter的流程,需要重新实现improve和good-enough?过程
(define (improve guess x)
(/ (+ (/ x (* guess guess)) (* 2 y)) 3)
(define (cube x) (* x x x))
(define (close-enough? a b)
(< (abs (- (cube a) b)) 0.00001))
(define (cubert y)
(define (cubert-iter guess)
(if (close-enough? guess y)
guess
(cubert-iter (improve guess y))))
(cubert-iter 1.0))
我们可以对比下sqrt-iter的流程就会发现应该能够存在一个通用的流程来把这两个统一起来,甚至可以应用到求解其他N次方根的过程,而只需要将过程作为参数即可,这就是1.3章要讲解的内容了。
(define (rt-iter guess x improve good-enough?)
(if (good-enough? guess x)
guess
(rt-iter (improve guess x) x improve good-enough?)))