jim yeh on 六月 19th, 2014

最近在工作上重構過去別人開發的程式,它是系統目前正在運作的程式,但由於程式長達 3000 多行,維護和除錯都變得很困難。程式中因應業務邏輯的需要,用了許多 if 判斷及 for 迴圈的控制,使得程式變得很不容易閱讀。在這種情況下,同人使用了幾個招式,利用它們可以把一層層繁複的迴圈和條件判斷式剥開,把程式重構成能夠清楚表達功能 what 的意圖,有別於之前在 how 的層次中容易使人迷惑在繁複的實作細節。這幾個招式可以幫我們對系統輕易地分而治之,以下同人就分享幾個用來清楚表達程式意圖的重構招式。

一、防衛子句

有時候我們在程式看到用 if-then-else 實作檢查執行條件的控制,其實多半是更適合使用防衛子句。防衛子句是在一開始進入某個方法時,在還沒進入程式的主要控制流程前,就讓不符合流程的允入條件的狀況,直接離開方法。但受到結構化程式架構的影響,為了讓方法中的控制流程只能有一個入口和一個出口,很多人都習慣用 if-then-else 的寫法來控制流程的例外控制。然而這樣會讓程式碼容易變得凌亂;程序多種流程分歧的結果是好幾層的 if-then-else 滿天飛,增加了閱讀與理解程式意圖的困難。

《Kent Beck 的實作模式》很清楚地解釋防衛子句和 if-then-else 適用不同的情境,它提到:

if-then-else 表達的是『可供選擇的多個等重要的控制流』。防衛子句則適合用來表達另一種情形,即『其中一個控制流比其他的更重要』。

防衛子句的好處在於把程式主要的流程和例外的情況分開,這樣可以更清晰地表達程式的主體和例外狀況的區別,而不會讓所有的流程都混淆在一起,而且這樣也比較容易呼應使用案例的合約形式(前置條件)。當然,也許有人覺得應該用一致的方式來表達各種程式路徑,來做到程式流程的共用性,因此以結構化程式結構的寫法會設了一些特殊含義的旗標來控制程式的流程。但問題是這樣的寫法通常只有作者才會看的懂,甚至過了一段時日之後,連作者自己也會忘記自己當初程式為什麼要這樣寫了。

Kent Beck 建議程式設計者不要為了守舊而要不懂得變通,他提到過去有一條程式設計的戒律是每個副程式都應該有一個入口和一個出口。這是為了防止在同一個副程式的多個地方跳進跳出而導致混淆。但由於過去的程式語言大量使用全域變數,採取這樣的戒律有它的道理,但在現代像 Java 的程式語言,方法大多較小,而且通常使用局部變數,就沒有必要再守舊。輕率沿襲這條程式設計的俗例,會妨礙防衛子句的使用。[1]遵從 Kent Beck 建議,當同人看到很長的又很多層的 if-then-else 的程式片段時,我通常會試著把它改寫防衛子句的寫法。

二、問題領域的背景脈絡

運用防衛子句之後,程式碼就會很容易讓人分辨主流程和例外處理而不會相互混淆,這會使程式碼顯示出業務流程本身的內聚性。但程式碼當中可能會參考很多共用的變數或方法,包括系統環境參數、限制資料的條件、以及資料的存取與控制等,這些變數和方法我們可以將它們從程式當中萃取出來,識別出問題領域的背景脈絡類別。同人通常會以 XXXContext 來命名,把原來在程式中出現的上述變數和方法,搬到 XXXContext 中,並且視需要對 XXXContext 進行 extract Method 的重構手法。當你這樣做以後,你會發現關於系統參數、限制條件及資料存取控制等背景脈絡會變成一個整體性而清楚的概念,而且也解除了許多原來程式存在的耦和性

三、宣告式語意

XXXContext 最大的好處是促成了宣告式語意的實現,同人常用兩種宣告式語意來簡化迴圈中深層的條件判斷式。一種是名之 XXXValidation 的驗證,而另一種則是名之為 XXXVerification 的確認,兩者的差異在於前者是驗證符合交易的條件,而後者則是確認交易後的流程處理。所以一般化的程式架構大概是長得像下面的範例:

if (null == request) {
  return null;
}

XXXContext ctx = new XXXContext(conn);
XXXResult result = new XXXResult();

try {
  new XXXValidation(ctx, request)
    .validate(new XXXIdIsVaild())
    .validate(new XXXQtyIsEnought())
    .end();
} catch (XXXValidationException ex) {
  result.add(VAL_ERR_CODE, ex.getErrorCode());
  return result;
}

XXXCondition condition=ctx.getCondition(request);
List<XXXModel> models = dao.query(condition);

try {
  List<XXXData> content = new XXXVerification(ctx, request)
    .verify(new XXXIsNotExpiration())
    .verify(new XXXRemainedQtyGTZero())
    .select(new XXXTransform(), models);
} catch (XXXVetificationException ex) {
  result.add(VER_ERR_CODE, ex.getErrorCode());
  return result;
}

result.add(DATA, content);

return result;

上面這段程式顯示從 request 訊息檢查代碼與數量,若代碼合法且數量足夠則從透過 dao 從資料來源中取得資料,然後再選取未逾期且餘額超過的資料,並放到 result 回傳。過程中若驗證或確認有錯誤發生,則拋出異常以防衛子句的寫法來區隔主流程和異常流程,以免除用 if 判斷回傳值而影響程式的簡明風格。其中在 validate()、verify()、以及 select() 的第一項引數都是 command 或 action 物件,它們的呼叫界面是傳入 XXXContext 的實例 ctx、交易訊息的實例 request、以及傳入 select() 的第二項引數資料串列的每一項成員 XXXModel 的實例,以進行延遲運算來得到資料過濾及映射的結果。

這種宣告式語意的寫法之前曾經有文章提到過,所以這篇文章就不再花篇輻多加介紹。你應該可以體會到,用以上這樣的模式來重構 legacy code,程式的意圖會很清楚地浮現出來,也會直接具體地關連到問題領域業務邏輯的語言,而不是一大串的 if-then-else 或是 for 迴圈的語法,這有助於不同觀點的相互溝通。你可以從主要流程中具體明白程式在做什麼,而如果需要瞭解實作的細節,則可以進一步去參考那些很簡單的物件化之小函式。由於不同關切點的分離,程式變得更簡單而且容易測試,對後續的程式維護甚至想要增加功能的需求來說,都會得到莫大的助益呀。



附註  
  1. 博碩文化編譯(2013),《Kent Beck 的實作模式》,p.103。[]
     

2 Responses to “清楚表達程式意圖的重構招式”

  1. [...] 語言整合查詢(LINQ)是非常有用的設計概念,它扭轉我們過去用指令式編程的習慣,轉而以宣告式編程的設計典範,讓程式碼變得簡潔,也提高設計的抽象能力。尤其是對於迴圈當中一再出現類似的條件判斷,卻苦於迴圈的特性讓我們沒有很好的策略來將變動的部分封裝起來,只能任由讓程式碼因應需求改變而愈來愈複雜,增加開發及維護的困難。但如果我們把指令式編程的開發慣例換成宣告式編程的思維模式,我們就會發現問題將變得很簡單。 [...]

  2. [...] 當然,上面這一段程式碼還可以從巢狀 if 的寫法改成板狀 if,再加上防衛子句,程式碼會變得比較清晰可讀,但無可避免的,在程式碼當中還是免不了要處理非主流程的分支處理。這樣的缺點是增加每一個服務的實作,這些非主要流程的分支處理都無法省略,無形之中將會分散編程者聚焦程度與增加人為錯誤的疏失。 [...]

Leave a Reply

You can use these tags: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong> <pre class="">