【计算机程序的构造和解释】第一章 构造过程抽象

第一章 复习笔记

前言 

一个计算机语言并不仅仅是让计算机去执行操作的一种方式,而是一种表达有关方法学的思想的新颖的形式化媒介。最基本的材料不是特定语言的语法,不是有效计算的巧妙算法,不是算法的数学分析活着计算的本质基础,而是一些能够控制大型软件系统的智力复杂度的技术。

计算过程是存在于计算机里的一类抽象事物,在其演化过程中,这些过程会去操作一些成为数据的抽象事物。

一个好的编程语言不只是指挥电脑去实现任务。更是作为一个框架,通过它来组织我们思考实现任务计算的过程。


Scheme  是交互式语言,其解释器运行时反复执行一个“读入 - 求值 - 打印循环”(Read-Evaluate-Print LoopREPL)。每次循环:

1)读入一个完整的输入表达式(即,“一个程序”)

2)对其进行求值(计算),得到一个值(还可能有其他效果)

3)输出求得的值(也是一个表达式)

Scheme 编程就是构造各种表达式

这样,当我们描述一个语言的时候,我们应该额外去关注如何组织简单的思想去表达复杂的想法。每个强大的语言都有3个机制去实现它。

1.  基本表达式(primitive expressions)。他表达了整个语言最简单的个体,也就是最基本的组成部分。

2.  组合的方法(means of combination)。将简单单元结合成复杂的结构的方法。

3.  抽象的方法(abstraction),作为单元来命名和操作混合元素(此处的混合元素包括混合的数据结构和方法)。

@在此处,用系统的观点看,1个是组份,第二个是关系,第三个是方法/动作。


C语言来看,其 基本的表达式是由基本语法所能够写出来的基本表达式语句和基本的数据结构。

组合的方法是使用结构体(思想)的方法来组装最简单的数据结构。

抽象的方法是函数。

在编程中,我们处理两种基本类型:过程和数据。(稍后我们会看到,他们并没有如此清晰的界限)。不严谨的说,数据是这样的东西,我们将操作他,而过程呢,被描述为规则和操作数据。如此,任何强大的编程语言应该诶描述为原始数据和原始过程,并且应该有方法去连接和抽象过程和数据。 


与 语言对比

是一个编译型语言

     a.   程序有完整的结构,简单的表达式或语句并不构成完整程序
     b.   编制好的程序需要经过编译后才能投入运行

从语言的结构看, 语言有

  a. 描述基本计算的表达式

     b.  描述基本动作的语句
     c.  语句之上的各种组合机制(描述控制流)

     d.  函数是语言里的抽象机制,用于把一段可能很复杂的计算抽象为一个简单形式的命令


 C  语言严格地区分了“数据”和操作数据的“过程”(代码)

后面将看到,在 Scheme 里,数据和过程(代码)可以自然的来回转化:数据可变为被执行的代码,代码可以作为被处理的数据


1.1 程序设计的基本元素

1.1.1 表达式

lisp使用前缀表达。可以接受多个参数。参数也可以是表达式。可以嵌套多层。

(* 25 4 12)

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


C语言的表达式采用中缀和前缀的混合形式

     1. 各种二元运算符和条件运算符(?:)采用中缀形式,运算符位于运算对象之间

     2.  函数调用是前缀形式,参数放在函数名后的括号内,逗号分隔

 
语言的表达式表示不统一,但更接近数学里的常规写法

     1. 表达式的结构也可以任意嵌套

     2. 在写复杂的表达式时,也应该采用某种格式良好的写法


由于常规运算采用中缀表示

     1.  需要有括号机制以便描述所需的运算顺序

     2.  提供优先级的规定,以尽可能减少写括号的麻烦

     3.  不能很方便地表示多元运算(如 +  * 等)


1.1.2 命名和环境

编程语言必须提供为对象命名的机制,这是最基本的抽象机制

Scheme 把名字标识符称为变量,其值就是与之关联的对象

使用define 作为 起别名/定义变量 的关键词。

(define size 2)

计算对象可能有很复杂的结构,可能是经过复杂费时的计算得到如何每次用时都重新计算,既费时又费力给一次计算得到的结果命名,能方便地多次使用写复杂的程序,常是为构造出复杂的不易得到的对象。通过逐步构造和命名可以分解构造过程,使之可以逐步递增地进行。建立对象与名字的关联是这种过程中最重要的抽象手段构造出的值可以存入变量供以后用,说明 Scheme 解释器有存储能力。这种存储称为“环境”,表达式在环境中求值define 建立或修改环境中名字与值的关联表达式总在当前环境中求值(后面讨论)变量的值由环境中获得Scheme 的全局环境里预先定义了一批名字-对象关联(预定义的对象),主要是预定义运算(和各种过程)


C 程序里的名字和环境

C语言没有明确的环境机制,但理解程序行为需要环境的概念

² 函数,全局变量和其他全局定义的名字位于全局环境里。每个相应的声明或定义给全局环境引入一个新名字(及其定义)

² 进入一个函数可能有新的局部定义(参数,局部变量等),形成局部环境。进入函数里的复合结构还可能有新的局部定义

² 局部环境中的定义覆盖全局环境中同名的已有定义;内层局部环境里的定义覆盖外围环境中同名的已有定义

表达式在当前环境中求值,语句可能修改当前环境中有效定义的变量


注意  Scheme   C  语言的重要差异:

² 里定义变量要给定变量类型Scheme 里引入变量无需说明类型

² 类型确定变量可保存值的范围(静态性质,限定程序的动态行为)

² 不说明类型,意味着变量取值范围无限制(可保存任意值)

² 不限定类型带来灵活性,也使我们不可能做静态的类型检查


1.1. 组合式的求值

求值一个组合式,要

1) 求值该组合式的各个子表达式

2) 将作为最左子表达式(运算符)的值的那个过程应用于相应的实际参数,即其他子表达式(运算对象)的值


采用递归的思想来描述深度嵌套的问题,把递归看成一种处理层次性结构(树)的一种技术。

上述规则说明计算过程的一些情况:例:(* (+ 2 (* 4 6)) (+ 3 5 7))

² 组合式求值要求先求值子表达式。因此求值过程是递归的

² 求值过程可以用树表示,先取得终端(叶)结点的值后向上累积

² 最终在树根得到整个表达式的值

² 树具有递归结构,递归处理很自然

【计算机程序的构造和解释】第一章 构造过程抽象_第1张图片

组合式求值的递归终将到达基本表达式,这时的值可以直接得:

² 数的值是其自身(它们所表示的数值)

² 内部运算符的值是系统里实现相关运算的指令序列

² 其他名字的值在当前环境里找,找到相应名字-值关联时取出对应的值作为求值结果


可以把基本运算符(如  + )和其他预定义对象(如  define )都看作名字,在环境中查找关联的“值”。这样就统一了后两种情况

² 环境为程序里用的名字提供定义。如果求值中遇到一个名字,在当时环境没有它的定义,解释器将报错

求值规则也有例外。如 (define x 1) 中的 x 不应该求值,是要求为名字 x 关联一个新值。这说明 define 要求特殊的求值规则要求特殊求值规则的名字称为特殊形式(special form)。Scheme 有一组特殊形式,define 是其一。每个特殊形式有自己的求值规则


1.1.4  复合过程

表达式可能很长,复杂计算中常出现重复或类似表达式,为控制程序复杂性,需要有抽象机制,Scheme 提供“过程定义”

过程定义的一般形式:

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

例如定义一个平方运算:(define (square x) (* x x))

包括:过程名,形式参数,这一过程做什么(求值时做什么事情),这个定义表达式的求值使相应计算过程关联于名字 square

定义好的过程可以像基本操作一样使用:

> (square (* (+ 3 7) 10)) ; 用于计算

10000

> (+ (square 3) (* 20 (square 2))); 嵌套的多次重复使用

89

> (define (sum-of-squares x y) ; 用于定义新过程

(+ (square x) (square y)))

新定义的 sum-of-squares 又可以像内部操作一样用

> (sum-of-squares 3 4)

25

(define (f a)

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

(f 5)

136


预定义基本过程(操作)和特殊形式是构造程序的基本构件

编程中根据需要定义的过程扩大了这一构件集合

如果只看使用,完全看不出 square 是基本操作还是用户定义过程

复合过程的使用方式和威力和基本操作一样,是很好的语言特征

过程定义是分解和控制程序复杂性的最重要技术之一


1.1.5 过程应用的代换模型

复合过程确定的计算过程是(代换模型):

1. 求出各参数表达式的值(组合式求值)

2. 找到要调用的过程的定义(根据第一个子表达式的求值结果)

3. 用求出的实际参数代换过程体里的形式参数

4. 求值过程体


例:

(f 5)  用原过程体 (sum-of-squares (+ a 1) (* a 2)),代换得到

(sum-of-squares (+ 5 1) (* 5 2)) 求值实参并代入过程体,得到:

(+ (square 6) (square 10))  求值实参并代入过程体,得到:

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

(+ 36 100) 

136

为了求值1个组合式(其运算符是一个复合过程的名字,解释器的工作方式将完全按照1.1 .3节中所描述的那样, 用与以运算符名为基本过程的组合式一样的计算过程。也就是说,

解释器将对组合式的各个元素求值,而后将得到的那个过程(也就是该组合式里运算符的值) 应用于那些实际参数(即组合式里那些运算对象的值)

但是简单的代换模型,后面将给出更深入的讨论和实现。代换模型只是为了帮助直观理解过程应用的行为,它并没有反映解释器的实际工作过程,实际的解释器是基于环境实现的,后面讨论

正则序求值:完全展开而后归约的 的求值模型

应用序求值:先求值参数而后应用。(lisp采用的方法:避免对于表达式重复求值


语言表达式求值

求值过程就是表达式语义的实现

语言的表达式求值过程牵涉的问题比较多

² 要处理运算符的优先级、结合性、括号,确定计算顺序

² 子表达式的求值方式也由运算符确定


对一般的一元 / 二元运算符和函数调用

² 先求值作为运算对象的子表达式

² 而后将运算符作用,得到运算结果

² 也是递归定义的


有一批采用特殊求值规则的运算符,每个有特殊的求值方式

² 二元逻辑运算符 || &&

² 条件运算符 ?:     顺序运算符 ,

² 各种赋值运算符和增量/减量运算符


语言的基本求值规则是规范序求值

² 自定义函数都是规范序求值

² Scheme 允许自己定义按正则序求值的过程(本书中没讲)


几个特殊运算符有各自的求值规则

² || &&先求值左边运算对象,可确定结果就结束并得到结果;不能确定再求值右边运算对象,根据其结果确定整个表达式的结果

² 条件运算符 ?:  先求值条件,而后根据条件的真假选择求值一个子表达式,以该子表达式的值作为整个条件表达式的值

² 顺序运算符 ,   先求值左边子表达式,而后求值右边子表达式;以后一表达式的值作为值

² 等等


一般语言里都有一些采用不同的特殊求值规则的特殊运算符。学习任何语言,都需注意这方面的情况

另一个问题是运算对象的求值顺序

²  Scheme,一个组合式可能有多个子表达式

²  C,常规二元运算符有两个运算对象,过程可能有多个参数


它们按什么顺序求值?有规定的顺序吗?

无论是 C 还是 Scheme,都没规定运算对象的求值顺序。这意味着 假定它们一定采用某种顺序都是不可靠的

如果写的程序只有在某种特定求值顺序下才能正确工作,这种程序在实际使用中的正确性完全没保证不要写假定特定求值顺序的表达式!


语言里依赖于求值顺序的表达式

m = n ++ + n;

printf("%d, %d", n, n++);

等等。这种东西“没意思”


1.1.6   条件表达式和谓词

 X的绝对值:

写法1

(define (abs x)

  (cond ((> x 0) x)

        ((= x 0) 0)

        ((< x 0) (- x))))

如果无法匹配则返回未定义

一般性形式为: 

(cond (<p1> <e1>)

(<p2> <e2>))

p1为谓词表达式

写法2

(define (abs x)

  (cond ((< x 0) (- x))

        (else x)))

写法3

(define (abs x)

  (if (< x 0) (- x)

      x))

if表达式的一般形式:(if <predicate> <consequent> <alternative>)

三个 逻辑 符合谓词

(and <e1> ... <en>) 

(or <e1> ... <en>)

(not <e>)

过程抽象,黑箱

实现过程对上不可见,并且局部变量对上隐藏,不会和caller的参数混淆。

利用嵌套结构实现子过程局部化,只在定义子过程的过程里边课件。其他位置的过程不能调用。(块结构)

词法作用域:x由实际参数得到自己的值,自由变量。


1.1.7 牛顿法求平方根

数学里,人们关系的是说明性的描述(是什么),计算机科学里,人们关系行动性的描述(怎么做)。

要求x的平方根,猜测一个y,求出yx/y的平均值,迭代这运算。

 Scheme 实现:

² 从要求开平方的数和初始猜测值 1 开始

² 如果猜测值足够好就结束

² 否则就改进猜测值并重复这一过程


写出的过程:

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

average 很简单。还需要决定“足够好”的标准。如用:

(define (good-enough? guess x)

(< (abs (- (square guess) x)) 0.001)) 

 sqrt-iter 定义 sqrt,选初始猜测(这里用 1):

(define (sqrt x)

(sqrt-iter 1.0 x))

很容易写出对应于上面 Scheme 程序的 程序。如

int good_enough (double guess, double x)

{  return fabs(guess*guess - x) < 0.0001; }

double average (double x, double y)

{  return (x + y) / 2; }

double improve (double guess, double x) {

return average(guess, x/guess);

}

double sqrt_iter (double guess, double x) {

return good_enough(guess, x)

? guess : sqrt_iter(improve(guess,x), x);

}

double sqrt (double x) { return sqrt_iter(1., x); }

这是在 语言里做函数式程序设计(无赋值。思考题:能走多远?)

函数、语句和表达式

语言里计算过程的抽象机制是函数

函数定义与 Scheme 过程定义的不同

² 需要类型描述(因为变量有类型)

²  return 描述返回值

没有 return 就没有返回值

表达式和语句

² 表达式是有关计算的描述,运行中每个表达式都算出一个值

² 语句是命令,要求做一个动作。动作没有“值”的概念

Scheme 基于表达式,其中的每种结构都是表达式

² 计算就是求值,计算一个表达式就要求出一个值

语言(和其他常规语言)的基本结构单元是语句,表达式只是语句的组成部分,不能独立存在


1.1.8 过程作为黑箱抽象

重新考察 sqrt 过程的定义,看看能学到什么

首先,它是递归定义的,是基于自身定义的,需要考虑这种“自循环定义”是否真有意义,后面将详细讨论

sqrt 分成一些部分实现

² 每项工作用一个独立过程完成

² 构成原问题的一种分解

分解合理与否的问题值得考虑

定义新过程时,用到的过程应看作黑箱,只关注其功能,不关心其实现

例如,需要用求平方时,任何能计算平方的过程都可以用,只要求它们的使用形式相同


过程抽象的本质:

² 定义过程时,关注所需计算的过程式描述细节(怎样做),使用时只关注其说明式描述(做什么)

² 一个过程总(应该)隐藏起一些实现细节,使用者不需要知道如何写就可以用。所用过程可能是其他人写的,或是库提供的

² 过程抽象是控制和分解程序复杂性的重要手段,也是记录和重用已有开发成果的单位


过程抽象:局部名字

过程中隐藏的最简单细节是局部名。下面两个定义没区别:

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

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

过程体里的形参:

² 具体名字不重要,重要的是哪些地方用了同一个形参

² 是过程体的约束变量(概念来自数理逻辑),作用域是过程体,约束变量统一换名不改变结构的意义。其他名字是自由的

看过程 good-enough? 的定义:

(define (good-enough? guess x)

(< (abs (- (square guess) x)) 0.001)) 

这里的 必然与 square 里的 不同,否则程序执行时不可能得到所需的效果

 good-enough? 的定义里:

(define (good-enough? guess x)

(< (abs (- (square guess) x)) 0.001))

guess 和 是约束变量,<-abs 和 square 是自由(变量)

这个过程的意义正确,依赖于两个约束变量(形参)与四个自由变量的名字不同,四个自由变量(在环境里关联)的意义正确,形参与所需自由变量重名导致该变量被“捕获”(原定义被屏蔽):

(define (good-enough? guess abs)

(< (abs (- (square guess) abs)) 0.001))

自由变量(名字)的意义由运行时的环境确定,它可以是

² 内部过程或复合过程,需要应用它

² 有约束值的变量,计算中需要它的值


语言程序中名字的意义

函数里的名字可能是

² 局部参数名或局部定义的变量名等。未在局部定义的名字应该是全局定义的(变量、函数、类型等)。这里不讨论宏定义

² 同样有局部名字遮蔽外围名字的问题


语言里的名字有不同的地位和划分,除关键字外的类别有

² 每个函数里的标号名

² struct/union/enum 标记名各为一类

² 每个structunion下的成员名各为一类

² 一般标识符,包括变量名、函数名、typedef名字、枚举名


程序的名字解析是编译器的工作

² 中的名字(标识符)是静态的概念,运行时没有名字问题

² Scheme 的变量名在运行中始终存在,以支持程序的动态行为


程序里的变量定义

现在考虑 C 程序里的变量定义

     1. 为什么把一些变量定义为外部的全局变量?

     2. 为什么把一些变量定义为局部变量?

例如:需要定义一个 1000000 个元素的double数组

定义在  main  里面和外面有什么不同?

变量定义的几个原则

² 尽可能减少全局变量

² 变量定义尽可能靠近使用的位置

² 大型、唯一、公用的变量应该定义为外部全局的

² 被部分函数共享的外部变量,应考虑能否定义为 static


过程抽象:内部定义和块结构

sqrt 的相关定义包括几个过程:

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

其中 abs 和 average 是通用的,可能在其他地方定义。

注意:使用者只关心 sqrt。其他辅助过程出现在全局环境,只会干扰人的思维和工作(例如,不能再定义同名过程)

写大型程序时需要控制名字的使用,控制其作用范围(作用域)

信息的尽可能局部化是良好程序设计的重要特征

局部的东西应定义在内部。Scheme 支持过程内的局部定义,允许把过程定义放在过程里面。按这种考虑组织好的程序:

(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),由早期的重要语言ALGOL 60 引进,是一些语言里组织程序的重要手段

函数定义局部化使程序更清晰,减少了非必要的名字污染,还可能简化过程定义:由于局部过程定义在形参(x)的作用域里,因此可以直接使用(不必再作为参数传递)

按这种观点修改后的 sqrt 定义:

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

块结构对控制程序的复杂性很有价值。后来的各种新语言都为程序组织提供了一些专门机制(未必是块结构)


C  程序结构

程序的结构比较简单:

² 不支持局部函数定义(基于其他考虑)

² 这种规定限制了 C 语言的程序组织方式


程序设计语言发展中对此有两条路线:

²  Fortran  C 以及后来的 C++Java 等,都不允许过程嵌套

²  Algol 60  PascalModulaAda 等,都允许过程嵌套


允许过程的嵌套定义,益处是有更多的方式组织程序

² 根据需要建立嵌套的过程结构

² 容易做到相关信息的局部化


语言没有采纳嵌套的程序结构,主要考虑:

² 实现简单,目标程序的执行效率高

² 可能以其他方式得到信息局部化(数据抽象,面向对象技术)

语言的组织机制比较弱,语言中最高层次的机制就是函数,没有函数之上的组织机制,又不允许函数嵌套。函数都处于一个平坦的层次,以后的编程语言在这方面有很多进步


 C  语言里还可以利用程序的物理结构

² 通过 static 函数和 static 全局变量,可实现一定的信息局部化

 sqrt 实例建立一个独立文件,内容是

static double sqrt_iter (double guess, double x){...}

static double improve (double guess, double x){...}

static int good_enough (double guess, double x){...}

static double average (double x, double y){...}

double sqrt (double x) {...}

可实现多一层信息组织,但不支持多层嵌套的作用域。OO的类和嵌套类也可用于帮助组织(请分析它与多重函数嵌套的异同)


1.2  过程与它们产生的计算

一个过程也就是一种模式,表述了计算过程的局部演化方式

要真正理解程序设计,只学会使用语言功能把程序写出来还不够

² 完成同一工作有多种不同方式,应如何选择?为什么?

² 要成为程序设计专家,必须能理解所写程序蕴涵的计算,理解一个过程(procedure)产生什么样的计算进程(process

一个过程(描述)可看作一个计算的模式

² 描述一个计算的演化进程,说明其演化方式

² 对一组适当的参数,确定了一个具体的计算进程(一个计算实例,是一系列具体的演化步骤)

² 完成同一件工作,完全可能写出多个大不相同的过程

² 完成同一工作的两个不同过程导致的计算进程也可能大不相同


1.2.1  线性的迭代和递归

求阶乘的递归与迭代

线性的递归:存储信息,递归次数越多,存储的信息量越大。

迭代:状态可以用固定数目的状态变量来描述;存在着固定的规则;终止条件检测

先展开后收缩:展开过程中积累一系列计算,收缩就是完成这些计算,解释器需要维护待执行计算的轨迹,轨迹长度大致等于后续计算的次数,积累长度为线性的,计算序列的长度也为线性,称为线性递归进程

无展开/收缩,直接进行计算,计算轨迹的信息量为常量,只需维护几个变量的当前值

计算序列的长度为线性的,具有这种性态的计算进程称为线性迭代进程

迭代进程中,计算的所有信息都在几个变量里

² 可以在计算中的任何一步中断和重启计算

² 只要有这组变量的当前值,就可以恢复并继续计算

在线性递归进程中,相关变量里的信息不足以反映计算进程的情况

² 解释器需要保存一些“隐含”信息(在系统内部)

² 这种信息的量将随着计算进程的长度线性增长

看阶乘的例子

(define (factorial n)

(if (= n 1)

1

(* n (factorial (- n 1)))))

只根据当前调用 (factorial 5),无法得知外面有多少遗留下未进行的计算(不知道是从哪里开始递归到求 的阶乘)

注意区分“递归计算进程”和“用递归方式定义的过程”

² 用递归方式定义过程说的是程序的写法,定义一个过程的代码里调用了这个过程本身

² 递归计算进程,说的是计算中的情况和执行行为,反映计算中需要维持的信息的情况

常规语言用专门循环结构(for, while等)描述迭代计算

Scheme 采用尾递归技术,可以用递归方式描述迭代计算

尾递归形式和尾递归优化

² 一个递归定义的过程称为是尾递归的,如果其中的对本过程的递归调用都是执行中的最后一个表达式

² 虽然是递归定义的过程,使用的存储却不随递归深度增加。尾递归技术,就是重复使用原过程在执行栈里的存储,不另行分配

常规语言都没有实现尾递归优化,有兴趣可以自己考虑可能吗/为什么


1.2.2  树形递归

斐波那契数,Lame定理:如果欧几里得算法需要K步计算出一对整数的GCD,那么这对数中较小的那个数必然大于或者等于第kfibonacci数。

换零钱方式的统计,将总数为a现金换成n种硬币的不同方式的数目等于

将现金数a换成除了第一种硬币之外的所有其他硬币的不同方式数目,加上

将硬币a-d换成所有种类的硬币的不同方式数目,d为第一种硬币的币值


允许写实现递归进程的过程,确实有价值:

² 是某些问题的自然表示,如一些复杂数据结构操作(如树遍历)

² 编写更简单,容易确认它与原问题的关系

² 做出对应复杂递归进程的迭代进程的过程,常需要付出很多智力

换零钱不同方式,用递归过程描述很自然,它蕴涵一个树形递归进程

² 写出解决这个问题的迭代不太容易,大家自己做一做

递归描述常常比较清晰简单,但却可能是实现了一种代价很高的计算。而高效的迭代过程可能很难写。人们一直在研究:

² 能不能自动地从清晰易写的程序生成出高效的程序?

² 如果不能一般性地解决这个问题,是否存在一些有价值的问题类,

² 或一些特定的描述方式,对它们有解决的办法?


C 语言里的递归和迭代

和其他常规语言一样,通过一套迭代语句(循环语句)支持描述线性迭代式的计算

常规语言里允许以递归方式定义程序始于 Algol 60

后来的高级语言都允许递归定义的程序

Fortran  Fortran 90 开始也支持递归方式的程序

支持递归的语言实现必须采用运行栈技术,在运行栈上为过程调用的局部信息和辅助信息分配空间,带来不小开销

RISC 计算机的一个重要设计目标就是提高运行栈的实现效率

虽然 C 语言(和其他常规语言)都支持递归

但都不支持尾递归优化

即使写尾递归形式的程序,语言的运行系统仍会为每次递归调用分配新空间,程序空间开销与运行中的递归深度成线性关系


1.2.4 求幂

线性递归,分治法n/2

用反复乘的方式,求 b^8 要 次乘法,实际上可以只做 

b^2 = b • b b^4 = b^2 • b^2 b^8 = b^4 • b^4 

对一般整数 n,有

n为偶数时     b^n = (b^(n/2))^2

n为奇数时     b^n = b • b^(n-1)          请注意,n-1 是偶数

这一过程求幂所需乘法次数是 O(log n),是重大改进


1.2.6 素数检测
【计算机程序的构造和解释】第一章 构造过程抽象_第2张图片

费马小定理只说明素数能通过费马检查,并没说通过检查的都是素数。确实存在不是素数的数能通过费马检查


C 语言里的过程和计算

语言里用“函数”实现过程

² 线性递归和树形递归用递归的方式描述

² 线性迭代计算,需要用语言里的迭代控制结构(循环结构)实现


1.3  用高阶函数做抽象

人们对功能强大的程序设计语言有一个必然的要求,就是能够为公共的模式明明,建立抽象,而后直接在抽象的层次上工作。例如,

(define (cube x) (* x x x))(* x x x )好得多。

如果将过程限制为只能以数作为参数,那会严重地限制我们建立抽象的能力。

以过程作为参数或返回值的,操作过程的过程称为高阶过程


1.3.1 过程作为参数

将函数名作为函数的参数传递,类似C中的函数指针C#中的委托。

lisp里不需要检查函数的参数列表、类型。

此处的sum term next 即 为过程参数

(define (sum term a next b)

(if(>ab)

o

(+ (term a)

(sum term (next a) next b))))

考虑下面几个过程:

(define (sum-integers a b)

(if (> a b)

0

(+ a (sum-integers (+ a 1) b))))

(define (sum-cubes a b)

(if (> a b)

0

(+ (cube a) (sum-cubes (+ a 1) b))))

(define (pi-sum a b)

(if (> a b)

0

(+ (/ 1.0 (* a (+ a 2))) (pi-sum (+ a 4) b)))) 

虽然各过程的细节不同,但它们都是从参数 到参数 b,按一定步长,

对依赖于参数 的一些项求和

这几个过程的公共模式是:

(define (<pname> a b)

(if (> a b)

0

(+ (<term> a)

(<pname> (<next> a) b)))) 

许多过程有一个公共模式,说明这里存在一个有用的抽象。如果所用语言足够强大,就可以利用和实现这种抽象

Scheme 允许将过程作为参数,下面的过程实现上述抽象

(define (sum term a next b)

(if (> a b)

0

(+ (term a)

(sum term (next a) next b)))) 

其中的 term 和 next 是计算一个项和下一个 值的过程

有了 sum,前面函数都能按统一方式定义(提供适当的 term/next

(define (inc n) (+ n 1))

(define (sum-cubes a b)  (sum cube a inc b)) 

(define (identity x) x)

(define (sum-integers a b)  (sum identity a inc b)) 

(define (pi-sum a b)

(define (pi-term x) (/ 1.0 (* x (+ x 2))))

(define (pi-next x) (+ x 4))

(sum pi-term a pi-next b) )

语言里不允许以函数为参数,但允许以函数指针为参数由于有类型,以函数指针为参数的函数,要声明指针的类型

假设声明

typedef double (*MF) (double);

可定义(没用 inc,用了程序复杂一点,也更灵活):

double sum (MF f, double a, double b, double step) {

double x = 0.0;

for (; a <= b; a += step) x += f(a);

return x;

}

然后就可以定义各种使用 sum 求和的函数了。例如:

double integral (MF f, double a, double b, double dx) {

return sum(f, a + dx/2, dx, b) * dx;

}


1.3.2 lambda构造过程

类似c#lambda,直接构造一个匿名过程,通常作为参数供别人使用。

{lambda (x) (+ x 4))

前面用 sum 定义过程时都为 term  next 定义过程。如

(define (pi-sum a b)

(define (pi-term x) (/ 1.0 (* x (+ x 2))))

(define (pi-next x) (+ x 4))

(sum pi-term a pi-next b) )

这些过程只在一处使用,给予命名没有价值。最好能表达“那个返回其输入值加 的过程”,而不专门定义命名过程 pi-next

lambda 特殊形式可解决这个问题,用 lambda 写出的表达式称为lambda表达式”,求值这种表达式将得到一个匿名过程


  利用  lambda  表达式, pi-sum  可以重定义为:

(define (pi-sum a b)

(sum (lambda (x) (/ 1.0 (* x (+ x 2))))

a

(lambda (x) (+ x 4))

b))

定义积分函数 integral 也不必再定义局部函数:

(define (integral f a b dx)

(* (sum f

(+ a (/ dx 2.0))

(lambda (x) (+ x dx))

b)

dx)) 

 lambda 表达式的形式与 define 类似:

(lambda (<formal-parameters>) <body>) 

 下面两种写法等价:

(define (plus4 x) (+ x 4)) 

(define plus4 (lambda (x) (+ x 4))) 

可认为前一形式只是后一表达式的简写形式

 lambda 表达式求值得到一个过程

它可以用在任何需要过程的地方。如作为组合式的运算符:

((lambda (x y z) (+ x y (square z))) 1 2 3)

12

第一个子表达式求值得到一个过程,该过程被应用于其他参数的值

 lambda 表达式的这种直接使用主要可以

     1. 避免引入过多的过程名(如果只用一次)

     2. 直接用作过程,可能使程序清晰一些

这些似乎没表现出 lambda 表达式的本质性的价值

因为上面实例中的 lambda 表达式的内容都是静态确定的。下面很快会看到动态构造 lambda 表达式的价值

语言没有 lambda 表达式,至今为止的情况都可以用命名函数模拟


1.3.3 过程作为返回值

过程在lisp里如数据一样,可以作为参数传递。

(defineine (average-damp f)

(lambda (x) (average x (f x))))

一种语言对各种计算元素的使用可能有限制。例如:

² 语言不允许函数返回函数或数组

² C/Java/C++ 等都不允许在函数里定义函数

具有最少使用限制的元素称为语言中的“一等”元素,是语言里的“一等公民”,具有最高特权(最普遍的可用性)。常见的包括:

     1. 可以用变量命名(在常规语言里,可存入变量,取出使用)

     2. 可以作为参数传给过程

     3. 可以由过程作为结果返回

     4. 可以放入各种数据结构

     5. 可以在运行中动态地构造

Scheme(及其他 Lisp 方言)里过程具有完全的一等地位。这给实现带来困难,也带来非常强大的潜能(后面有讨论和更多例子)


你可能感兴趣的:(【计算机程序的构造和解释】第一章 构造过程抽象)