首页 > 代码库 > SICP学习笔记及题解—构造过程抽象(三)
SICP学习笔记及题解—构造过程抽象(三)
主要内容
高阶过程:以过程为参数和/或返回值的过程
lambda 表达式
let 表达式
用过程作为解决问题的通用方法
- 求函数的 0 点
- 求函数的不动点
返回过程值
过程是语言里的一等公民 (first-class object)
1.3.1高阶过程
过程是抽象,一个过程描述了一种对数据的复合操作,如求立方过程:(define (cube x) (* x x x))
换个方式,也可以总直接写组合式:(* 3 3 3), (* x x x), 不定义过程,总基于系统操作描述,不能提高描述的层次,虽然也能计算立方,但程序里没建立立方的概念, 将常用公共计算模式定义为过程并命名,就是在程序里建立概念.
过程抽象起着建立新概念的作用。基于定义好的过程编程,就是基于更高级的新概念思考问题,非常重要.
不过上面定义的过程(define (cube x) (* x x x))只能以数值作为参数也限制了建立抽象的能力。例如在一些计算模式里,在某个(某些)地方可以使用不同的过程要建立这类计算模式的抽象,就需要以过程作为参数或返回值
高阶过程:以过程作为参数或返回值,操作过程的过程
- 高阶过程是非常有用的抽象工具
- 语言允许定义高阶过程,就能更好地支持描述复杂的程序,因为它们为定义抽象提供了更多的可能性
- 常规语言也提供了一定的定义高阶过程的能力
- 一些语言里函数提供了过程参数
- C/C++ 函数可以指向函数的指针参数,用于传操作性的实参
但这些功能的能力都比较有限, Java、C# 等引进 lambda 表达式,是为了在这方面有所前进
需要高阶过程的一类情况
- 一些计算具有相似的模式,只是其中涉及的几个操作不同
- 要利用这种公共模式,就需要把这几个操作参数化
- 具有参数化操作的过程,就是高阶过程(一类情况)
下面引入高阶抽象
虽然细节有许多差异,但它们有共同的模式:从某参数 a 到参数 b,按一定步长,对依赖于 a 的一些项求和. 把其中的公共模式较严格地写出来(标明变化的部分):
(define (<pname>ab) (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 是计算一个项和计算下一a值的过程.
上面的求和几个函数都能基于 sum 定义(只需提供具体的 term 和 next)
(define (inc n) (+ n 1)) // 求a到b的立方和 (define (cube n) (* n n n )) (define (sum-cubes a b) (sum cube a inc b)) (define (identity x) x) // 求a到b的累加和 (define (sum-integers a b) (sum identity a inc b)) (define (pi-sum a b) // pi-sum (define (pi-term x) (/ 1.0 (* x (+ x 2)))) (define (pi-next x) (+ x 4)) (sum pi-term a pi-next b) )
以过程作为参数
如果某个抽象真有用,那么一定能用它形式化很多概念。例如,可以用sum 实现数值积分,公式是
其中 dx 是很小的步长值
我们可以将这一过程直接定义如下:
(define (integral f a b dx)
(define (add-dx x)(+ x dx))
(* (sum f (+ a (/ dx 2.0)) add-dx b)dx))
结果如下
C 语言里不允许把函数为参数,但允许函数指针参数,由于有类型,函数指针参数也要声明指针的类型..具体参考C语言的相关资料.
前面用 sum 时都为 term 和 next 定义了专门过程。如
(define (pi-sum a b) // pi-sum
(define (pi-term x) (/ 1.0 (* x (+ x 2))))
(define (pi-next x) (+ x 4))
(sum pi-term a pi-next b) )
这些过程只用一次,为它们命名没有实际的价值,最好是能表达”那个返回其输入值加 4 的过程”等概念,而不专门定义pi-next 等命名过程.
这实际上是一个数学问题: f(x) = sin x + x, 说了两件事:描述了一个函数,并给它取了名字, 实际上,定义函数和给它命名是两件事, 应该作为两件事,分开做,考虑如何说一个函数但不命名.由此便引出对lambda的构造过程
1.3.2 lambda构造过程
从上面的一段关于名字的讨论中,可以看出我们需要一种可以匿名命名过程的技术,而Scheme 恰好可以通过 lambda 特殊形式处理这个问题,求值”lambda表达式”得到一个匿名过程.
lambda 表达式把一段计算参数化,抽象为一个(匿名)过程
比如,下面几个表达式都计算距离,有共同的计算模式
<span style="font-size:12px;">(sqrt (+ (* 5 5) (* 8 8))) (sqrt (+ (* 11 11) (* 17 17))) (sqrt (+ (* 128 128) (* 213 213)))</span>可以考虑其共性的抽象,建立相应的过程对象, 描述相应过程对象的lambda表达式:
(lambda (x y) (sqrt (+ (* x x) (* y y))))求值这个表达式,得到”求距离的过程对象”.
利用 lambda 表达式重新定义 pi-sum:
(define (pi-sum a b) (sum (lambda (x) (/ 1.0 (* x (+ x 2)))) a (lambda (x) (+ x 4)) b))两个 lambda 表达式:(lambda (x) (/ 1.0 (* x (+ x 2)))) ,带有一个参数 x 的 lambda 表达式, 表达式的体是 (/ 1.0 (* x (+ x 2))), 以及(lambda (x) (+ x 4)),带有一个参数 x 的 lambda 表达式, 表达式的体是 (+ x 4).
用同样技术定义积分函数 integral,也不再定义局部函数:
<span style="font-size:12px;">(define (integral f a b dx) (* (sum f ; sum term (+ a (/ dx 2.0)) ; a (lambda (x) (+ x dx)) ; next b) ; b dx)) </span>lambda 表达式的思想源自数学家 A. Church 提出的 λ-演算. Church 当时也在研究计算的抽象表述问题, 他在 1940 年代提出了 λ-演算,其工作的精髓是提出了一套数学记法和规则,表示函数的描述与函数应用, 已证明,λ-演算是与图灵机等价的计算模型, λ-演算可以看作抽象的编程语言,它是编程语言理论研究的一个重要基础和工具,在计算机科学领域具有重要地位.
可以看出, Scheme 里 lambda 表达式的一般形式:
(lambda (<formal-parameters>) <body> )
形式上与 define 类似,有形式参数表和体(但没有过程名), lambda 是特殊形式,其参数不求值
实际上,用 define 定义过程只是一种简写形式,下面两种写法等价:
(define(plus4x)(+x4)) ; 定义一个过程并用 plus4 命名 (define plus4 (lambda (x) (+ x 4))) ;给 plus4 关联一个过程对象 ;一般说: (define (<name> <formals>) <body>) ;相当于 (define <name> (lambda (<formals>) <body>))Scheme 允许前一写法只是为了易用,没有任何功能扩充.
求值 lambda 表达式建立一个新过程, 建立的过程可以用在任何需要用过程的地方, 在 Scheme 语言里,过程也是对象.就像整数、字符串等,都是对象, 程序运行中可以建立各种对象, 求值 lambda 表达式是建立过程对象的唯一方式
由于 lambda 表达式的值是过程,因此可以作为组合式的运算符:
((lambda (x y z) (+ x y (square z))) 1 2 3)
12
第一个子表达式求值得到一个过程, 得到的过程应用到其他参数的值(组合式的求值规则).
前面几个例子用 lambda 表达式作为过程的参数
- 组合式求值时先求值各参数表达式
- 对作为参数的 lambda 表达式求值得到相应过程,它们被约束到形参,然后在过程里面用
这样直接使用 lambda 表达式,主要作用是
- 不引入过程名,简单(如果只用一次)
- 直接描述过程,有可能使程序更清晰
这些还没表现出 lambda 表达式的本质价值
- 前面实例里的 lambda 表达式都是静态确定的
- 下面会看到在更复杂的环境中 lambda 表达式的价值
C 等常规语言没有 lambda 表达式,但至今为止的情况都可以用命名函数模拟.
Lambda 表达式的应用
设要定义一个复杂函数,例如:
如果直接定义,有些子表达式需要多次计算
- 如果子表达式计算非常耗费资源,就会带来很大资源浪费
- 多次出现同一表达式,造成代码依赖,给程序维护修改带来困难
一种技术是引进临时变量保存中间信息:
程序中常出现这类情况,需要引进辅助变量记录一段程序计算出的中间值,而在Scheme 里的一种解决方法:定义一个内部辅助函数
#lang planet neil/sicp (define (f x y) (define (square n) (* n n)) (define (f-helper a b) (+ (* x (square a)) (* y b) (* a b))) (f-helper (+ 1 (* x y)) (- 1 y))) (f 2 3)
这里的技术是:
- 把各公共表达式抽象为辅助过程的参数,定义辅助函数,它基于这些参数描述所需计算,从公共子表达式的值算出整个表达式的值
- 在函数调用中以各公共表达式作为对应实参,安排好过程返回值对更复杂的情况,也可以考虑多层分解
当然前面辅助函数可以用一个 lambda 表达式代替。定义:
#lang planet neil/sicp (define (f x y) (define (square x) (* x x)) ( (lambda (a b) (+ (* x (square a)) (* y b) (* a b))) (+ 1 (* x y)) (- 1 y)))结果:
为lambda 表达式引进两个参数,以公共表达式作为应用的对象
技术:
- 1, 用一个 lambda 表达式作为运算符,其中公共表达式抽象为参数
- 2, 把实际的公共表达式作为运算对象
但是这种写法不太清晰(lambda 表达式较长,与参数的关系不易看清.因此便引入了let表达式机制.
1.3.3 Let表达式
程序中经常需要这种结构,Scheme 引进 let 结构(是上述 lambda 表达式的变形)用于引进辅助变量,传递中间结果.
过程 f 可重新定义为:
let 引进了两个局部变量并分别约束值,let 体用这些变量完成计算
比较:
- let 的局部变量约束在前计算在后,更符合阅读习惯,更清晰
- 用 lambda 时计算表达式在前,实参/形实参约束关系不容易看清
let 表达式的一般形式:前面是一组变量/值表达式对,表示建立约束关系
(let ((<var1><exp1>) …… ( <varn> <expn> ) ) <body>)读作:令 <var1> 具有值 <exp1>,且 <var2> 具有值 <exp2> ……且<varn> 具有值 <expn> 做 <body>
let是 lambda 表达式的一种应用形式加上语法外衣,等价于:
((lambda ( <var1> …<varn> ) <body>) <exp1> …… <expn>)在let 结构里,为局部变量提供值的表达式在let之外计算,其中只能引用本 let 之外的变量.
简单情况里看不出这种规定的意义, 如果出现局部变量与外层变量重名,就会看到这一规定的意义
例如:
(let ((x 3) (y (+ x 2))) (* x y))let头部的变量约束中把y约束到由外面的x求出的值,不是约束到这个 let 里面的 x 的值, 如果外面的 x 约束值是 5,表达式的值将是 21
1.3.4 作为通用方法的过程
高阶过程的功能:
- 表示高级的计算模式,其中把一些操作参数化
- 解决的是一族问题
- 用一组适当过程实例化,得到一个具体的过程
- 把具体计算过程用于适当的数据,得到一个具体计算
- 可以同时做过程参数的实例化和提供被操作数据
- 也可以分步提供,为程序的分解和设计提供了自由度
下面讨论两个更有趣的高阶函数实例,研究两个通用问题的解决方法:
- 找函数的 0 点
- 找函数的不动点
基于这两个方法定义的抽象过程可用于解决许多具体问题.
找方程的根 (函数的零点)
问题:找区间里方程的根:区间 [a, b],若 f(a) < 0 < f(b),[a, b] 中必有 f 零点(中值定理)
折半法:取区间中点 x 计算 f(x)
- 如果 f(x) 是根(在一定误差的意义下),计算结束
- 否则根据 f(x) 的正负将区间缩短一半
- 在缩短的区间里继续使用折半法
易见,上面操作做一次,区间长度减半假设初始区间的长度为 L,容许误差为 T,所需计算步数为 O(log(L/T)).是对数时间算法.而且不难定义一个 Scheme 过程实现这个算法.
实现折折半法求零点的过程:
(define (search f neg-point pos-point) (let ((midpoint (average neg-point pos-point))) (if (close-enough? neg-point pos-point) midpoint (let ((test-value (f midpoint))) (cond ((positive? test-value) (search f neg-point midpoint)) ((negative? test-value) (search f midpoint pos-point)) (else midpoint))))))
这里定义的是核心过程:
- 参数应该是被处理函数 f 以及它的一个负值点和一个正值点,只有保证参数正确,才能得到正确结果
- 过程实现折半计算的基本过程,以尾递归方式定义
编程原则:
- 总采用功能分解技术,最高层的过程实现算法框架
- 把具有独立逻辑意义的子计算抽象为子过程调用
- 过程的实现另行考虑
需要一个子过程判断区间足够小(谓词):评价标准可根据需要确定
(define (close-enough? x y)
(< (abs (- x y)) 0.001))
把判断区间满足要求的方法抽象为过程,另行定义,优点:
- 可以单独研究判断的技术,选择适当的方法
- 容易通过替代的方法,独立改进程序中的重要部分
用户提供的区间可能不满足 search 的要求(两端点函数值异号)。可以定义一个包装过程,只在参数合法时调用 search:
<span style="font-size:12px;">(define (half-interval-method f a b) (let ((a-value (f a)) (b-value (f b)) ) (cond ((and (negative? a-value) (positive? b-value)) (search f a b)) ((and (negative? b-value) (positive? a-value)) (search f b a)) (else (error “Values are not of opposite sign” a b)) ) ))</span>error 是发错误信号的内部过程,它逐个打印各参数(任意多个)
使用(实例):求 pi 的值(sin x 在 2 和 4 之间的零点):
用折半法求一个函数的根
很多问题可以归结到求函数 0 点(求函数的根),数值计算情况,都可以用 half-interval-method, 函数可以事先定义,也可以直接用 lambda 描述.
函数的不动点
定义:x 是函数 f 的不动点,如果将 f 作用于 x 得到的还是 x. f的不动点就是满足等式f(x) = x的那些 x.
显然,并非每个函数都有不动点。反例很多,如(lambda (x) (+ x 1)).
存在一些有不动点的函数,从某些初值出发,反复应用这个函数,可以逼近它的一个不动点, 即使有不动点,也未必满足具有这种性质, 可能与初值选择有关。有这样的函数,从一些初值出发可以达到不动点,从另一些初值出发达不到不动点
下面只考虑有不动点的函数,而且假设知道合适的初值。在这种情况下考虑不动点的计算问题
有些函数存在不动点,但简单地反复应用上述函数却不能达到不动点.
x 的平方根可看作 f(y) = x/y的不动点
考虑用下面求平方根过程:
(define (sqrt x)
(fixed-point (lambda (y) (/ x y)) 1.0 ))
它一般不终止(产生的函数值序列不收敛),因为:
控制振荡的一种方法是消减变化的剧烈程度。因为问题的答案必定在两值之间,可考虑用它们的平均值作为下一猜测值:
(define (sqrt x)
(fixed-point (lambda (y) (average y (/ x y)))
1.0) )
试验说明,现在计算收敛,能达到不动点(得到平方根)
1.3.5 过程作为返回值.
上面减少振荡的方法称为平均阻尼技术.
(lambda (y) (average y (/ x y))) 是(lambda (y) (/ x y)) 的派生函数,可称为(lambda (y) (/ x y)) 的平均阻尼函数.
从函数构造其平均阻尼函数的操作很有用,构造方法具有统一模式,能不能定义过程完成这一构造?
- 不动点计算中的平均阻尼是一种通用技术
- 做的事情就是求函数值和参数值的平均值
- 而从给定函数 f 求其平均阻尼函数,却是基于 f 定义另一过程
在 Scheme 里函数用过程表示,上面工作要求定义一个高阶过程,它需要在执行中创建并返回一个新过程,这是个新问题.这应该是有用的程序技术,因为增强了表述计算进程的能力.
在 Scheme 里很容易构造新的过程对象并将其返回,只需用 lambda 表达式描述过程的返回值。
最简单的模式: (define (g f …) (lambda (…) …) )
下面过程计算出与 f 对应的平均阻尼过程:
(define (average-damp f)
(lambda (x) (average x (f x))))
注意:average-damp以过程 f 为参数,返回基于 f 构造的另一过程,实现的是一种从过程到过程的变换对任何函数实参,它都能返回这个实参函数的平均阻尼函数
在计算中生成新过程(对象)是前面没遇到过的新问题,实际上这是lambda 表达式最重要的作用, 常规语言(Java/C++/C#)引入 lambda 表达式的目的也在于此.
把 (average-damp f) 构造的过程作用于 x,平方根函数可以重新定义
注意新定义的特点:基于两个通用过程,它们分别求不动点和生成平均阻尼函数,两个通用过程都可以用于任意函数,具体函数用 lambda 表达式直接构造
许多问题可以归结为求不动点.
下面用牛顿法求根作为返回 lambda 表达式的另一应用,一般牛顿法求根牵涉到求导,设要求 g 的根, 牛顿法说 g(x) = 0 的解是下面函数的不动点
要用这个公式,需要能求出给定函数 g 的导函数,求导是从一个函数(原函数)算出另一函数(导函数)。在 Scheme 里实现,就是构造新过程.可以在 Scheme 做符号求导,现在考虑一种”数值导函数“,g(x) 的数值导函数是D g(x) = (g(x + dx) – g(x)) / dx
做数值计算,可以用一个很小的数值作为 dx,如 0.00001.
把 dx 定义为全局变量(也可以作为参数):( define dx 0.00001)
生成”导函数”的过程定义为:
(define(derivg)
(lambda (x)
(/ (- (g (+ x dx)) (g x))
dx) ) )
生成的过程是 g 的数值导函数, 用 deriv 可以对任何函数求数值导函数,如例:
可以把牛顿法定义为一个求不动点的函数:
;牛顿法 过程抽象过程
(define (newton-transform g)
(lambda (x) (- x (/ (g x) ((deriv g) x)))))
; 过程抽象
(define (newtons-method g guess)
(fixed-point (newton-transform g) guess))
newton-transform 从函数 g 构造出另一个Scheme 过程,它能计算g 的导函数的近似值。这也是构造新过程.这个牛顿法求根函数可以用于任何函数
求导函数的操作是数值计算,得到原函数的数值导函数。对同样dx 不同函数的误差不同。不同 dx 也可能导致不同的计算误差
Scheme 的优势是符号计算,操作各种符号形式的表达式, 可以用符号表达式表示代数表达式。在 Scheme 里很容易实现符号求导.
基于 newton-method 也可以计算平方根, x 的平方根可以看作函数 (lambda (y) (- (* y y) x)) 的 0 点, 基于这一观点定义的求平方根过程
从初始值 1.0 出发求函数的不动点, 类似的,x 的立方根是函数 (lambda (y) (- (* y y y) x)) 的 0 点, 同样可以基于这一观点求 x 的立方根
上面用两种不同方法求平方根,都是把平方根表示为另一种更一般的计算方法的实例:
- 作为一种不动点搜索过程(搜索)
- 采用牛顿法,而牛顿法本身也是一种不动点计算
- 都把求平方根看作是求一个函数在某种变换下的不动点
1.3.6 抽象和以及过程
这两种方法有共同模式,可以进一步推广为一个通用过程(
define (fixed-point-of-transform g transform guess)
(fixed-point (transform g) guess))
上面过程的意义是基于一个实现某种变换的过程 transform,求某个函数 g 经某种变换得到的函数的从一个猜测出发的不动点, 只要一个计算可以表达为这种模式,都可以基于这个高阶过程描述, 只需提供适当的实现变换的 transform.
基于前面高阶过程,可以写出平方根函数的另外两个定义:
在编程中,要注意发现和总结遇到的有用抽象,正确识别并根据需要加以推广,以便用于更大范围和更多情况.
- 注意在一般性和使用方便性
- 利用所用语言的能力(不同语言构造抽象的能力不同)
- 库是这方面的范例。函数式和 OO 语言提供了更大的思考空间
语言对各种计算元素的使用可能有限制。如:
- C 语言不允许函数返回函数或数组
- C/Java/C++ 等都不允许数组和函数的赋值
使用限制最少的元素称为语言中的”一等”元素,是语言的”一等公民”,具有最高特权(最普遍的可用性)。常见的包括:
- 可以用变量命名(在常规语言里,可存入变量,取出使用)
- 可以作为参数传给过程
- 可以由过程作为结果返回
- 可以放入各种数据结构
- 可以在运行中动态地构造
在 Scheme(及其他 Lisp 方言)里,过程具有完全一等地位。这给语言实现带来一些困难,也为编程提供了极大潜力。后面有更多的讨论
SICP学习笔记及题解—构造过程抽象(三)