1. 程式人生 > >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 方言)裡,過程具有完全一等地位。這給語言實現帶來一些困難,也為程式設計提供了極大潛力。後面有更多的討論