緒論
我們已經介紹過資料抽象,這是一種構造系統的方法學,它能夠使程式中的大部分描述與其所操作的資料物件的具體表示無關,比如一個有理數程式的設計與有理數的實現相分離。這裡的關鍵是構築資料抽象屏障——在有理數的例子中即有理數的建構函式(make_rat
)和獲取有理數分子分母的選擇函式(numer
、denom
)——它能將有理數的使用方式與其藉助於表結構的具體表示形式隔離開。
資料抽象屏障是控制複雜性的強有力工具,然而這種型別的資料抽象還不夠強大有力。從一個另一個角度看,對於一個資料物件可能存在多種有用的表示方式,且我們希望所設計的系統能夠處理多種表示形式。比如,複數就可以表示為兩種幾乎等價的形式:直角座標形式(實部和虛部)和極座標形式(模和幅角)。有時採用直角座標更方便,有時採用幅角更方便。我們希望設計的過程能夠對具有任意表示形式的複數工作。
我們從簡單的複數例項開始,看看如何為複數設計出直角座標表示和極座標表示,而又維持一種抽象的“複數”資料物件的概念。做到這一點的方式就是基於通用型選擇函式(real_part
、img_part
、magnitude
、angle
)來定義複數的算術運算(add_complex
、sub_complex
、mul_complex
和div_complex
),使這些選擇函式能訪問一個複數的各個部分,無論複數採用的是什麼表示方式。採用這種方法設計的複數系統如下圖所示:
上圖中包含兩種不同的抽象屏障。“水平”抽象屏障所扮演的角色如我們在有理數中講的相同,他們將“高層”操作與“底層”表示隔離開。與此同時,還存在一道“垂直”屏障,它使我們能夠隔離不同的設計,並且還能夠安裝其他的表示方式。
2.4.1 複數的表示
複數表示為有序對有兩種可能表示方式:直角座標形式(實部和虛部)和極座標形式(模和幅角)。我們將複數集合設想為一個帶有兩個座標軸(“實”軸和“虛”軸)的兩維空間,如下圖所示。按照這一觀點,複數\(z = x + iy\)(其中\(i^2=-1\))可看作這個平面上的一個點,其中的實座標是\(x\)而虛座標為\(y\)。在這種表示下,複數的加法就可以歸結為兩個座標相加:
當需要乘兩個複數時,更自然的考慮是採用複數的極座標形式,此時複數用一個模和一個幅角表示(上圖中的\(r\)和\(A\))。兩個複數的乘積也是一個向量,得到它的方式是模相乘,幅角相加。
雖說複數的兩種不同表示方式適合不同的運算,但從開發人員角度來看,資料抽象原理希望所有複數操作都應該可以使用,而無論計算機所用的具體表示形式是什麼。例如我們常常需要取得一個複數的模,即使它原本採用的是複數的直角座標表示;同樣我們也常常需要得到複數的實部,即使它採用的是極座標形式。
我們沿用之前有理數的設計策略,假定所有複數運算的實現都基於如下四個選擇函式:real_part
、img_part
、magnitude
和angle
;還要假定有兩個構造複數的過程:make_from_real_imag
根據實部和虛部返回一個(基於某種表示的)複數,make_from_mag_ang
根據模和幅角描述返回一個(基於某種表示的)複數。這些過程的性質是。對於任何複數\(z\)(不管其基於何種表示方式),下面兩者:
make_from_real_imag(real_part(z), imag_part(z))
和
make_from_mag_ang(magnitude(z), angle(z))
產生出的複數都應該等於\(z\)(且保持原來的表示方式)。
我們可以利用這些建構函式和選擇函式來刻畫“抽象資料”,從而實現複數的算術。正如上面公式中所述,複數的加法和減法採用實部和虛部方式描述,而乘法和除法採用模和幅角的方式描述。
def add_complex(z1, z2):
return make_from_real_imag(real_part(z1) + real_part(z2), imag_part(z1) + imag_part(z2))
def sub_complex(z1, z2):
return make_from_real_imag(real_part(z1) - real_part(z2), imag_part(z1) - imag_part(z2))
def mul_complex(z1, z2):
return make_from_mag_ang(magnitude(z1) * magnitude(z2), angle(z1) + angle(z2))
def div_complex(z1, z2):
return make_from_mag_ang(magnitude(z1) / magnitude(z2), angle(z1) - angle(z2))
為了完成這一複數包,我們必須選擇一種表示方式。我們假定有兩個程式設計師Ben和Hacker。Ben選擇了複數的直角座標形式,Alyssa選擇了複數的極座標形式。對於選擇直角座標形式的Ben而言,此時實部和虛部的獲取是直截了當的,但為了得到模和幅角,或需要在給定模和幅角下構造複數時,他利用了下面的三角關係:
這些公式建立起實部和虛部對偶\((x, y)\)與模和幅角對偶\((r, A)\)之間的聯絡。Ben基於這種表示給出了下面這幾個選擇函式和建構函式:
import math
def real_part(z):
return z[0]
def imag_part(z):
return z[1]
def magnitude(z):
return math.sqrt(real_part(z) ** 2 + imag_part(z) ** 2)
def angle(z):
return math.atan2(imag_part(z), real_part(z))
def make_from_real_imag(x, y):
return [x, y]
def make_from_mag_ang(r, a):
return [r * math.cos(a), r * math.sin(a)]
下列是我們對Ben的直角座標表示方法的測試結果:
complex_1 = make_from_real_imag(math.sqrt(3)/2, 1/2) # (sqrt(3)/2, 1/2)
complex_2 = make_from_real_imag(1/2, math.sqrt(3)/2) # (1/2, sqrt(3)/2)
print(add_complex(complex_1, complex_2))
# [1.3660254037844386, 1.3660254037844386], 對應(sqrt(3)/2 + 1/2, sqrt(3)/2 + 1/2)
print(mul_complex(complex_1, complex_2))
# [6.123233995736765e-17, 0.9999999999999998],對應(0, 1)
但在另一邊,對於選擇了複數的極座標形式的Alyssa而言,選取模和幅角的操作直截了當,但必須透過三角關係去得到實部和虛部。Alyssa基於複數的極座標形式所給出的選擇函式和建構函式如下:
def real_part(z):
return magnitude(z) * math.cos(angle(z))
def imag_part(z):
return magnitude(z) * math.sin(angle(z))
def magnitude(z):
return z[0]
def angle(z):
return z[1]
def make_from_real_imag(x, y):
return [math.sqrt(x**2 + y**2), math.atan2(y, x)]
def make_from_mag_ang(r, a):
return [r, a]
下列是我們對Alyssa的極座標表示方法的測試結果:
complex_1 = make_from_mag_ang(1, math.pi/6) # (1, pi/6)
complex_2 = make_from_mag_ang(1, math.pi/3) # (1, pi/3)
print(add_complex(complex_1, complex_2))
# [1.9318516525781366, 0.7853981633974483], 對應(sqrt(6) + sqrt(2))/2, arctan(sqrt(3)/2 + 1/2, sqrt(3)/2 + 1/2))
print(mul_complex(complex_1, complex_2))
# [1, 1.5707963267948966] 對應(1, pi/2)
資料抽象的規則保證了add_complex
、sub_complex
、mul_complex
和div_complex
的同一套實現對於Ben的表示或者Alyssa的表示都能正常工作。
2.4.2 帶標誌資料
認識資料抽象的一種方式是將其看作“最小允諾原則”的一個應用。在 2.4.1 節中我們可以選擇採用Ben的直角座標表示形式或者Alyssa的極座標表示形式,由選擇函式和建構函式形成的抽象屏障,使我們可以把為自己所用資料物件選擇具體表示形式的事情儘量往後推,而且還能保持系統設計的最大靈活性。
最小允諾原則還可以推進到更極端的情況,我們可以在完成了對選擇函式和建構函式的設計,並決定了同時使用Ben的表示和Alyssa的表示之後,依然維持所用表示方式的不確定性。如果要在同一個系統中包含這兩種不同表示,那麼就需要採用一種方式將極座標形式的資料和直角座標形式的資料區分開。
完成這種區分的一種方式,就是在每個複數裡包含一個型別標誌部分——符號rectangular
或者polar
,我們在操作複數時可以藉助此標誌來確定使用的選擇函式。
為了能對帶標誌資料進行各種操作,我們假定有過程type_tag
和contents
,它們分別從資料物件中提取型別標誌和實際內容(在複數的例子中即極座標或者直角座標)。還要假定一個過程attach_tag
,它以一個標誌和實際內容為引數,生成出一個帶標誌的資料物件。實現這些的直接方式就是採用普通的表結構:
def attach_tag(type_tag, contents):
return [type_tag, contents]
def type_tag(datum):
if isinstance(datum, list):
return datum[0]
else:
raise ValueError("Bad tagged dataum -- TYPE-TAG", datum)
def contents(datum):
if isinstance(datum, list):
return datum[1]
else:
raise ValueError("Bad tagged dataum -- CONTENTS", datum)
利用這些過程,我們就可以定義出謂詞is_rectangular
和is_polar
,它們分別辨識直角座標的和極座標的複數:
def is_rectangular(z):
return type_tag(z) == "rectangular"
def is_polar(z):
return type_tag(z) == "polar"
有了型別系統之後,Ben和Alyssa現在就可以修改自己的程式碼,使他們的兩種不同表示能共存於一個系統中了。當Ben構造一個複數時,總為它加上標誌,說明採用的是直角座標:
def real_part_rectangular(z):
return z[0]
def imag_part_rectangular(z):
return z[1]
import math
def magnitude_rectangular(z):
return math.sqrt(real_part_rectangular(z)**2 +
imag_part_rectangular(z)**2)
def angle_rectangular(z):
return math.atan2(imag_part_rectangular(z),
real_part_rectangular(z))
def make_from_real_imag_rectangular(x, y):
return attach_tag("rectangular", [x, y])
def make_from_mag_ang_rectangular(r, a):
return attach_tag("rectangular", [r * math.cos(a), r * math.sin(a)])
Alyssa構造複數時,總將其標誌設為極座標:
def real_part_polar(z):
return magnitude_polar(z) * math.cos(angle_polar(z))
def imag_part_polar(z):
return magnitude_polar(z) * math.sin(angle_polar(z))
def magnitude_polar(z):
return z[0]
def angle_polar(z):
return z[1]
def make_from_real_imag_polar(x, y):
return attach_tag("polar",
[math.sqrt(x**2 + y**2),
math.atan2(y, x)])
def make_from_mag_ang_polar(r, a):
return attach_tag("polar", [r, a])
每個通用型選擇函式都需要考慮到可能存在的兩種複數表示情況,故它需要先檢查引數的標誌,然後呼叫處理該類資料的適當過程。例如為了得到一個複數的實部,real_part
需要透過檢查確定是使用Ben
的real_part_rectangular
還是Alyssa
的real_part_polar
。在這兩種情況下,我們都用contents
提取出原始的無標誌資料,並將它送給所需的直角座標過程或極座標過程:
def real_part(z):
if is_rectangular(z):
return real_part_rectangular(contents(z))
elif is_polar(z):
return real_part_polar(contents(z))
else:
raise ValueError("Unknown type -- REAL-PART", z)
def imag_part(z):
if is_rectangular(z):
return imag_part_rectangular(contents(z))
elif is_polar(z):
return imag_part_polar(contents(z))
else:
raise ValueError("Unknown type -- IMAG-PART", z)
def magnitude(z):
if is_rectangular(z):
return magnitude_rectangular(contents(z))
elif is_polar(z):
return magnitude_polar(contents(z))
else:
raise ValueError("Unknown type -- MAGNITUDE", z)
def angle(z):
if is_rectangular(z):
return angle_rectangular(contents(z))
elif is_polar(z):
return angle_polar(contents(z))
else:
raise ValueError("Unknown type -- ANGLE", z)
在實現複數算術運算時,我們仍然可以採用取自2.4.1節的同樣過程add_complex
、sub_complex
、mul_complex
和div_complex
,因為它們所呼叫的選擇函式都是通用型的,對任何表示都能工作,例如過程add_complex
仍然是:
def add_complex(z1, z2):
return make_from_real_imag(real_part(z1) + real_part(z2),
imag_part(z1) + imag_part(z2))
最後,我們還必須選擇是採用Ben的表示還是Alyssa的表示構造複數。一種合理的選擇是,手頭有實部和虛部時,建構函式的返回採用直角座標表示;手頭有模和幅角時,建構函式的返回採用極座標表示:
def make_from_real_imag(x, y):
# 手頭有實部和虛部時,建構函式的返回採用直角座標表示
return make_from_real_imag_rectangular(x, y)
def make_from_mag_angle(r, a):
# 手頭有模和幅角時,建構函式的返回採用極座標表示
return make_from_mag_ang_polar(r, a)
下面是我們對這樣的複數系統進行的測試結果:
complex_1 = make_from_mag_ang_polar(1, math.pi/6) # (1, pi/6)
complex_2 = make_from_mag_ang_polar(1, math.pi/3) # (1, pi/3)
print(add_complex(complex_1, complex_2))
# ['rectangular', [1.3660254037844388, 1.3660254037844386]], 對應(sqrt(3)/2 + 1/2, sqrt(3)/2 + 1/2)
實際上,這樣得到的複數系統所具有的結構如下所示:
可見這一系統已經分解為三個相對獨立的部分:複數算術運算、Alyssa的極座標實現和Ben的直角座標實現。極座標或直角座標的實現可以是Ben和Alyssa獨立工作寫成的東西,這兩部分又被第三個程式設計師作為基礎表示,用於在抽象建構函式和選擇函式的介面(interface)之上實現各種複數算術過程。
2.4.3 資料導向的程式設計和可加性
檢查一個資料項的型別,並據此去呼叫某個適當過程稱為基於型別的分派。在系統設計中,這是一種獲得模組性的強有力策略。而在另一方面,像2.4.2節那樣實現的分派有兩個明顯的弱點。第一個弱點是,其中的這些通用型介面過程(real_part
、imag_part
、magnitude
和angle
)必須知道所有的不同表示。舉例來說,假定現在希望為前面的複數系統增加一種表示,我們就必須將這一新表示方式標識為一種新型別,而且要在每個通用介面過程裡增加一個子句,檢查這一新型別。
第二個弱點是,即使這些獨立的表示形式可以分別設計,我們也必須保證在系統裡不存在兩個名字相同的過程。正因如此,Ben和Alyssa必須去修改原來在2.4.1節中給出的那些過程的名字。
位於這兩個弱點之下的基礎問題是,上面這種實現通用型介面的技術不具有可加性。在每次增加一種新表示形式時,使用通用選擇函式的人都必須修改他們的過程,而那些做獨立表示的介面的人也必須修改程式碼以避免名字衝突問題,這非常不方便,且容易引進錯誤。
現在我們需要的是一種能夠將系統設計進一步模組化的方法。一種稱為資料導向的程式設計的程式設計技術提供了這種能力。在這種程式設計技術中,我們可以將處理針對不同型別的一些公共通用操作視為處理一個二維表格,其中一維包含著所有的可能操作,另一維就是所有的可能型別。在前一節開發的複數系統裡,我們也可以將同樣的資訊組織為一個表格,如下圖所示:
在我們上面所實現的複數系統中,採用的方式是用一些過程做為複數算術與兩個表示包之間的介面,並且讓這些過程中的每一個去做基於型別的顯式分派。而資料導向的程式設計則意味著我們可以把這一介面實現為一個過程,讓它用操作名和引數型別的組合到表格中查詢,以便找出應該呼叫的適當過程。
參考
- [1] Abelson H, Sussman G J. Structure and interpretation of computer programs[M]. The MIT Press, 1996.