官术网_书友最值得收藏!

1.2 功能的抽象

為了能解決大型軟件項目,必須要將其拆分成更小的部分。其中一種可以將問題拆分成較小部分的方法是:將其分解為一組可以相互協作的功能。這一方法被稱為功能抽象(functional abstraction),或者叫作過程抽象(procedural abstraction)。

讓我們通過一個簡單的例子來理解編寫函數其實是一個抽象的實例。假設你要寫一個需要計算某個值的平方根的程序,你知道應該怎么做嗎?你是否知道求平方根的具體算法并不重要,因為Python數學模塊(math)中提供了平方根函數。

import math
...
answer = math.sqrt(x)

你可以很有信心地使用sqrt函數來求平方根,因為你知道它會做什么,即使你可能并不知道它是如何完成計算的。在這里,你只關注了sqrt函數的某些方面(做什么),而忽略了另一些細節(如何完成)。這就是抽象。

分離一個組件的能夠做什么和它會怎么去完成這一任務的關系,是一種特別強大的抽象形式。如果我們將一個函數想象成一個服務,那么使用這個函數的程序就可以被稱為服務的客戶端(client),并且這個函數被實際執行的代碼就可以被稱為服務的實現(implement)。在客戶端工作的程序員只需要知道這個函數的功能即可,并不需要知道該函數工作的任何過程細節。因此對于客戶端使用者來說,這個函數就像一個能執行所需操作的神奇的黑盒子。類似地,這個函數的實現者也不需要關心應該如何使用該函數,可以自由地專注于這個函數應該如何完成其任務的各種細節,而不用在意實際調用函數的位置和原因。

為了實現這種清晰的分離,客戶端使用者和實現者必須對要完成的功能達成一致,也就是說,他們必須對客戶端代碼和具體實現之間的接口有共同的理解。這個接口就像是一種將函數的兩個不同角度的視圖分開的抽象屏障。圖1.1所示為用圖形化的方式呈現Python字符串分割方法(或字符串模塊中的等效的分割函數)。圖里顯示了這個方法/函數需要一個字符串作為必需參數,以及另一個字符串作為可選參數,最后返回一個字符串。使用這個split方法/函數的客戶端使用者并不需要關心它的工作方式(也就是框內的內容),只需要知道應該如何使用它。因此,我們需要的是仔細描述函數將做什么,而不必描述函數將會如何完成它的工作。這種描述被稱為規范(specification)。

圖1.1 split功能的黑箱接口示意圖

很明顯,描述函數的調用方式是規范的一個重要部分。也就是說,我們需要知道函數的名稱、需要什么參數以及函數返回的內容。這些信息也可以被稱為函數的簽名(signature)。除了簽名,規范還需要精確描述函數的功能。我們需要知道調用函數所提供的參數與結果是如何相關的。這種關聯信息,很多時候是以非正式的格式完成。例如,假設你正在編寫Python數學模塊(math)中的平方根函數。讓我們來看看下面這個函數的規范。

def sqrt(x):
    """Computes the square root of x"""

這并不是一個很好的函數規范。這種非正式格式描述的問題在于它們往往并不完整,且含糊不清。要知道,一個良好的規范應當可以使客戶端使用者和實現者(即使他們是同一個人)僅僅依靠函數的規范,就能夠完成各自的任務。這也是抽象過程如此有用的原因。如果這個函數計算x的平方根,但沒有返回結果怎么辦?純理論上來說,這也是符合規范的;但這樣的話,這個方法對客戶端使用者來說沒有任何用處。同樣,sqrt(16)可以返回-4嗎?如果函數的實現只適用于浮點數,但客戶端使用了整數作為參數調用該函數,應該怎么辦?如果導致了程序崩潰,那是誰的錯?如果客戶端使用負數作為參數調用此函數會發生什么?它會返回一個負數,還是會直接崩潰?如果客戶端使用字符串作為參數調用此函數會發生什么?可見,這種簡單、非正式格式的描述,并沒有告訴我們如何理解這個函數。

這可能聽起來像是挑刺。因為通常每個人都“理解”平方根功能該做些什么。所以,如果我們有任何疑問,都可以通過查看該函數的源代碼或通過實際使用來證明我們的假設(例如嘗試計算sqrt(-1)并查看會發生什么)。但是,做這些事情就會打破客戶端使用者和實現者之間的抽象隔離。而強制客戶端程序員去理解函數的細節實現,也就意味著他或她必須去厘清這些代碼的所有細節并進行處理,從而失去抽象的優勢。另一方面,如果客戶端程序員只是依賴于代碼實際執行的結果(通過嘗試它),他或她就有可能做出一些實現者無法預期的假設。例如,實現者發現了計算平方根的更好方法,因此修改了代碼實現,那么客戶端使用者對某些“邊緣”行為的假設可能就不再正確。但如果保持抽象隔離,客戶端代碼和實現代碼都可以隨意修改,因為抽象隔離能夠確保程序繼續正常運行。這種理想的情況被稱為實現獨立性(implementation independence)。

希望下面這個令人刻骨銘心的反例,可以讓你深刻地認識到在大型編程時,組件的精確規范是多么重要。美國航空航天局1999年的火星氣候軌道飛行任務,由于假設與實現不匹配而造成了巨大的損失。原因很簡單,只是因為一個模塊需要使用英制單位來獲得信息,但被假設為可以用公制單位。在大多數情況下,仔細定義的規范是絕對必要的。因為只要規范沒有被明確地說明或者被嚴格地遵守,意想不到的災難就會出現。

所以很顯然,我們需要一種比非正式描述更好的東西來更好地表述規范。函數規范通常包含先驗條件和后置條件。先驗條件是關于調用函數時計算狀態的假設。后置條件則是關于函數完成后的真實情況的陳述。下面就是sqrt函數包含先驗條件和后置條件的示例規范:

def sqrt(x):
    """Computes the square root of x.
    pre: x is an int or a float and x >= 0
    post: returns the non-negative square root of x"""

先驗條件用來陳述實現中所做的任意假設,尤其是關于函數參數的假設。為了完整地描述,它一般會使用這個參數的正式名稱(在這個例子里是x)來描述參數。后置條件就需要描述函數實現代碼中使用輸入參數完成了什么。前后條件加在一起,對函數的描述就成為客戶端與實現之間的一種契約。如果客戶端使用者保證在調用函數時滿足先驗條件,那么實現者就保證在函數結束時也將滿足后置條件。因此,這種使用先驗條件和后置條件來描述系統中的模塊的方式也被稱為契約式設計(design by contract)。

先驗條件和后置條件是程序斷言(assertion)的一種特定的文檔類型。斷言,是一段關于計算狀態的聲明,并且在程序中的特定點處,這個計算狀態為真。在函數執行之前,先驗條件必須為真,并且后置條件也必須為真。稍后,我們將會看到程序在其他地方非常有價值的文檔化斷言。

如果你讀得足夠仔細,你可能會覺得上面sqrt函數的示例中的后置條件有點不對勁,因為它描述了這個函數應該做什么。嚴格來說,斷言不應該說明函數的作用,而是應該說明程序中給定的點,現在什么是真的。因此,將后置條件表示為post:RETVAL ==√x可能更加正確,其中RETVAL用來表示函數的返回值。盡管嚴格來說,這樣描述不太準確,但大多數程序員都傾向于像我們這個例子中一樣,提供不太正式的后置條件。鑒于這種非正式風格更受歡迎,而且信息量也沒有變少,我們在后面將繼續使用這種“返回這個、那個和其他”的形式來表述后置條件。當然,如果你堅持應該嚴格使用完美的斷言的話,可以做一些必要的改變。

現在,我們可以發現一個關于規范中先驗條件和后置條件的重要觀點:規范的重點在于它提供了對函數或其他組件的簡潔和精確的描述。如果規范都模糊不清,或者比實際中實現的代碼更長、更復雜的話,我們什么都得不到。數學符號往往是簡潔而精確的,所以它們通常在規范中非常有用。實際上,一些軟件工程方法采用完全正式的數學符號來描述所有系統組件,即形式化方法(formal method),這樣可以允許用數學的方式陳述和證明程序的屬性,提高了開發過程的精確性。在最好的情況下,這樣也可以證明程序的正確性,也就是程序的代碼忠實地實現了它的規范。然而,使用這種方法需要相當強大的數學功底,既然它還沒有在整個行業中通用,那么我們目前將繼續使用這種不太正式的規范,只在需要、合適且有用的時候使用眾所周知的數學和編程符號。

另一個重要的觀點就是:在代碼中放置規范。在Python里,開發人員有兩種方法可以將注釋放入代碼中:常規注釋(用前導的#符號表示)和文檔字符串(模塊頂部、緊跟在函數名或類名之后的字符串表達式)。文檔字符串將和它們附著的各種對象一起被打包,從而方便之后在運行時隨時查看。正是由于文檔字符串也被同時用于實現Python內部幫助文檔,以及被PyDoc標準文檔模塊用來自動創建API文檔,因此它可以成為一個特別好的媒介來定義規范。一般來說,對客戶端程序員有用的信息,應當包含在文檔字符串中;而僅供函數實現者使用的信息,應該使用內部注釋。

契約式設計的基本思想是:如果在調用函數時滿足函數的先驗條件,則在函數結尾的后置條件也必須滿足。如果不能夠滿足先驗條件,則萬事皆休。這就產生了一個有趣的問題:當不能滿足先驗條件時,這個方法應該做些什么?從規范的角度來看,在這種情況下這個方法做什么都無所謂,可以說是“松了一口氣”。但如果你是實現者,你可能會想忽略掉不滿足的先驗條件。這樣做的話,有可能會意味著執行這個函數會導致程序立即崩潰;也有可能代碼雖然能夠繼續運行,但會產生一些無意義的結果。不論是哪一種結果,其實都不太好。

一個更好的應對方法是:采用防御性編程實踐。因為有未滿足的先驗條件,說明程序里存在錯誤,所以你應該能夠檢測到這種錯誤并處理它,而不是無動于衷地忽略這種情況。但是這個應對方法應該怎么實現呢?例如,我們可以讓它輸出錯誤信息。因此在sqrt函數里就可能會有下面這樣的代碼:

def sqrt(x):
    ...
    if x < 0:
       print 'Error: can't take the square root of a negative'
    else:
       ...

輸出錯誤消息的弊端是調用程序無法知道出現了什么問題。例如,這個錯誤消息可能只會出現在程序生成的報告里,甚至可能會被忽視掉。在實際項目中,很可能會在圖形程序里調用通用庫,如sqrt函數,在這樣的情況下,這個錯誤消息就根本不會出現在任何地方。

在大多數的情況下,方法被設計成向外輸出消息的形式并不太合適(除非這個函數的規范里定義了需要輸出某些東西)。更理想的狀況是:函數能以某種方式表示發生了錯誤,并且能夠讓客戶端程序決定如何處理這個問題。對于某些程序,遇到錯誤的正確響應可能會終止程序并輸出錯誤消息;而在其他情況下,程序也許能夠從錯誤中恢復并繼續運行。這樣的選擇結果應該只能由客戶端做出。

一個方法可以通過多種方式發出錯誤信號,例如返回一個超出范圍的結果。下面就是這樣的一個例子:

def sqrt(x):
    ...
    if x < 0:
       return -1
    ...

既然sqrt函數的規范明確表示返回值不能為負,那么值-1就可以用來指示錯誤。客戶端代碼可以憑借它返回的結果來確定是否正常。另一種方法是,使用一個全局(global,程序的所有部分都能訪問)變量來記錄錯誤。客戶端代碼在每次操作后,都會去檢查這個全局變量的值,來確認是否存在錯誤。

當然,用這種特殊的檢測方法來“檢查錯誤”有一個問題:客戶端程序可能由于決策結構而不斷地去檢查錯誤以致變得混亂。例如客戶端的代碼邏輯可能會變成下面這樣:

x = someOperation()
if x is not OK:
    fix x
y = anotherOperation(x)
if y is not OK:
    abort
z = yetAnotherOperation(y)
if z is not OK:
    z = SOME_DEFAULT_VALUE

可以看出,每次操作之后的錯誤檢查,已經多到模糊了原始算法的意圖的地步。

大多數現代編程語言都包含異常處理(exception handling)機制,它為程序運行過程中出現的錯誤提供了一種優雅的處理方法。異常處理背后的基本思想是程序錯誤不會直接導致“崩潰”,而是將程序的控制權轉移到一個被稱為異常處理程序(exception handler)的特殊部分。這個異常處理程序特別有用的是:讓客戶端程序不必顯式地去檢查是否發生了錯誤。實際上,客戶端只需要說:“如果出現任何錯誤,這里是我想要執行的代碼。”然后,在運行時系統會確保在發生錯誤時調用適當的異常處理程序。

在Python里,運行時的錯誤會生成異常對象(exception object)。程序使用try語句來捕獲和處理這些錯誤。例如,取負數的平方根會導致Python生成ValueError(值錯誤異常)——這是一個Python的通用Exception(異常)類的子類。如果客戶端程序沒有處理此異常,程序將會終止。下面就是交互時發生的情況:

>>>sqrt(-1)
Traceback (most recent call last):
  File "<stdin>", line 1, in ?
ValueError: math domain error
>>>

當然,程序里也可以“捕獲”try語句中引起的異常:

>>> try:
...    sqrt(-1)
... except ValueError:
...    print "Ooops, sorry."
...
Ooops, sorry.
>>>

當執行到try下方縮進的這條語句的時候,如果發生錯誤,Python會查看錯誤是否與任一except子句中列出的類型匹配。如果有,就執行第一個匹配的except語句塊。但是,如果沒有匹配到任何一個except子句,則程序將停止并顯示錯誤消息。

要利用異常處理來驗證先驗條件,我們只需要在判斷決策中驗證先驗條件,并且生成適當的異常對象。這稱為引發異常(raising an exception),由Python里的raise語句完成。raise語句非常簡單:raise <expr>。其中<expr>是生成包含有關錯誤信息的異常對象的表達式。當執行raise語句時,它會讓Python解釋器中斷當前操作并將控制權轉移到異常處理程序。如果找不到合適的處理程序,則程序終止。

Python庫中的sqrt函數會通過這樣的檢查來確保其參數為非負數,并且參數具有正確的類型(int或float)。因此,sqrt函數的代碼可以包含下面這些檢查語句:

def sqrt(x):
    if x < 0:
        raise ValueError('math domain error')
    if type(x) not in (type(1), type(1L), type(1.0)):
        raise TypeError('number expected')
    # compute square root here

請注意,用這些條件檢查是不需要else語句的。因為當raise語句執行時,它有效地終止了該函數,所以只有滿足先驗條件時才會執行“計算平方根”的部分。

通常情況下,檢測到先驗條件違規時引發何種類型的異常并不重要。重要的是,錯誤應該被盡可能早地診斷出來。Python提供了一個將斷言直接嵌入代碼的語句——assert(斷言)。它輸入一個布爾表達式(Boolean expression),當表達式計算結果不為True時,就會引發AssertionError異常。使用assert語句使得強制執行先驗條件變得特別容易。

def sqrt(x):
    assert x >= 0 and type(x) in (type(1), type(1l), type(1.0))
    ...

正如你所見,assert語句是一種將斷言直接插入代碼的非常簡便的方法。它非常有效地將先驗條件(和其他斷言)的文檔轉換為額外的測試,有助于確保程序依照規范正常運行。

這種防御性編程有個潛在的缺點:它增加了程序執行的額外開銷。因為每次調用函數的時候,都會額外消耗幾個CPU周期來檢查先驗條件。然而,鑒于現代處理器的速度不斷提高以及錯誤程序的潛在危險不斷增加,這通常是值得付出的代價。另外,assert語句還有一個好處:可以在需要的時候關閉斷言檢查。在命令行執行Python的時候,加上-O開關就可以讓解釋器跳過所有斷言的檢查。也就是說,我們可以在程序測試時使用斷言,然后在認為系統能夠正常工作并投入到生產環境的時候將其關閉。

當然,在測試過程中檢查斷言,而在生產系統中將其關閉,就好像在有安全網的時候,在距離地面大約3米的地方練習一個特技,然后在刮風的日子里、沒有安全網的情況下,在離地面大約30米的地方表演這個特技。在測試期間捕獲錯誤非常重要,但在系統使用時繼續捕獲它們更為重要。因此,我們的建議是隨時隨地地使用斷言并隨時保持打開斷言檢查。

你可能已經學會了一種非常流行的程序設計方法——自上而下的設計(top-down design)。自上而下的設計本質上是通過功能抽象來將應用程序的大問題分解為更小、更易于管理的組件。例如,假設你正在開發一個幫助你老師進行評分的程序。你的老師希望這個程序能夠輸入一組考試成績,并且輸出一份總結學生表現的報告。具體而言,程序輸出的報告里應該包含以下有關的統計信息。

?高分,這是數據集中的最大數字。

?低分,這是數據集中的最小數字。

?平均值,這是數據集的“平均”分數。它通常被表示為,并且由下面這個公式計算得到:

即所有的得分(xi表示第i個得分)之和除以統計的分數個數(n)。

?標準差,這是衡量分數分布情況的指標。標準偏差s可以由下面這個公式算出:

在這個公式中,是平均值,xi表示第i個數據值,n是數據值的數量。這個公式看起來很復雜,但計算起來并不難。表達式是單個元素與平均值的“偏差”的平方。分數的分子也就是所有數據值的偏差(平方之后)的和。

在剛開始編寫這個程序的時候,你可以開發一個包含以下功能的簡單算法:

Get scores from the user
Calculate the minimum score
Calculate the maximum score
Calculate the average (mean) score
Calculate the standard deviation

假設你正在與朋友合作開發此程序,你可以將這個算法劃分為多個部分,并且每個部分都能夠與程序里的其他部分協作。然而,在開始真正的工作之前,你需要一個更完整的設計來確保每個人開發的部件都能夠組合在一起,并且解決整個問題。通過自上而下的設計,你可以把算法中的每一行當作一個單獨的函數來編寫。這個設計也將會包含每個方法的規范。

一個顯而易見的解決方案是:把考試成績存儲在列表中,然后把這個列表作為參數,傳遞到各個方法里去。使用這種解決方案的話,可以參考下面的設計示例:

# stats.py
def get_scores():
    """Get scores interactively from the user
    post: returns a list of numbers obtained from user"""
def min_value(nums):
    """ find the minimum
    pre: nums is a list of numbers and len(nums) > 0
    post: returns smallest number in nums"""
def max_value(nums):
    """ find the maximum
    pre: nums is a list of numbers and len(nums) > 0
    post: returns largest number in nums"""
def average(nums):
    """ calculate the mean
    pre: nums is a list of numbers and len(nums) > 0
    post: returns the mean (a float) of the values in nums"""
def std_deviation(nums):
    """calculate the standard deviation
    pre: nums is a list of numbers and len(nums) > 1
    post: returns the standard deviation (a float) of the values
          in nums"""

有了這些方法的規范,你和你的朋友就應該能夠輕松地分配這些方法并且很快地完成這個程序。讓我們實現其中一個方法,看看它應該是什么樣的。如std_deviation方法的實現示例:

def std_deviation(nums):
    xbar = average(nums)
    sum = 0.0
    for num in nums:
        sum += (xbar - num)**2
    return math.sqrt(sum / (len(nums) - 1))

可以看到,這段代碼的運行依賴于average函數。由于我們已經定義了這個方法,因此可以放心地在這里直接使用它,從而避免了重復工作。這里還使用了簡化了的+=(加法賦值)運算符;這是求和的簡寫方式。也就是說x +=y語句和x=x+y語句會產生相同的結果。

程序的其余部分將留給你來完成。如你所見,在這個程序里自上而下的設計和方法的規范齊頭并進。所以,在設計確定了必要的功能時,規范保證了正式且確定的設計。因此,程序的各個部分可以被單獨處理。你肯定能輕而易舉地完成這個程序。

為了使規范有效,規范必須同時說明客戶端和實現者的期望。任何在客戶端可見的影響都應在后置條件中被描述出來。例如,假設std_deviation函數是下面這樣實現的:

def std_deviation(nums):
    # This is bad code. Don't use it.
    xbar = average(nums)
    n = len(nums)
    sum = 0.0
    while nums != []:
        num = nums.pop()
        sum += (xbar - num)**2
    return math.sqrt(sum / (n - 1))

這段代碼中使用了Python列表的pop方法。對nums.pop()的調用將會返回列表中的最后一個數字,并從列表中刪除該項。之后循環繼續,直到處理完列表中的所有元素。這個版本的std_deviation能夠返回正確的值,因此它似乎符合先驗條件和后置條件指定的契約。但是,作為參數傳遞的列表對象nums是可變的,而且對列表的任何修改都將對客戶端可見。所以,這段代碼的使用者會非常驚訝,因為他們會發現調用std_deviation (examScores)會導致examScores中的所有值被刪除!

這類函數調用和程序其他部分之間的相互影響被稱為副作用(side effects)。在這個例子里,刪除examScores中的元素是調用std_deviation函數的副作用。一般來說,應當避免方法中的副作用,但完全不準修改又禁止得太嚴格了。有些方法就是需要產生副作用,列表類中的pop方法就是一個很好的例子。它被用來獲取一個值,然后就像副作用一樣,從列表中刪除這個元素。因此,有一個至關重要的事情需要注意,方法中的任何副作用都應在其后置條件中指出。由于std_deviation的后置條件并沒有包含會修改nums的任何內容,因此這個代碼實現隱性地違反了契約。方法的可見效果應該只是在后置條件中描述的那些。

順便說一下,輸出消息或將信息放在文件中也是副作用的例子。正如上面提到的,方法通常不應該輸出任何東西,除非這是它聲明的功能中的一部分,這也是一個(可能)未注明副作用的特殊情況。

主站蜘蛛池模板: 南川市| 太保市| 广丰县| 荆州市| 凌源市| 威远县| 乌苏市| 云和县| 儋州市| 仲巴县| 大厂| 巴里| 岳阳市| 通渭县| 仙居县| 江源县| 永安市| 诸暨市| 信丰县| 周宁县| 崇仁县| 安国市| 乐亭县| 呈贡县| 玉林市| 花垣县| 湘潭市| 长宁区| 荔浦县| 新安县| 呼伦贝尔市| 义乌市| 永宁县| 类乌齐县| 札达县| 扬中市| 定边县| 绍兴县| 南澳县| 镇安县| 彰武县|