條件語句之refactoring - 變條件為子類別

作者:石一楹
校稿:朱子

Martin Fowler在《refactoring》中講到了這個問題,但是它在最開始的類別裡面就有 type 這樣的類別屬性(校註1)。實際情況下,如果已經明確地指定了類型屬性,就不太可能不會直接去實作類別。我在這裡所描述的,是我認為最可能出現的實際情況,所以 refactoring 的方法和過程與 Martin Fowler 的完全不同,大家可以相互參考。

一、 程式碼味道(Code Smell)

Refactoring 一般發生於你需要增加新功能之時,由於新功能加入是如此之困難,你會想到是否有必要改變原來的設計和程式結構,從而使得功能的加入變得更加簡單。當然,在這裡,你要記住兩頂帽子,一頂是修改,一頂使功能增加,千萬不要同時戴上兩頂帽子

但是,一個很好的習慣是經常對你的程式碼進行審查,聞一聞其中的味道,是否有點異樣。大量的 case、if-else-if-else或類似的 switch 敘述就是一種異味。如果在你的程式碼中出現了很長的case語句,並且這些 case 或 if-else-if-else 語句都具有相同的結構,或者都是對常數整數或常數字串進行判斷,那麼,這樣的條件需要變成子類別(校註2)。

二、 壞味道的結果

如果這些條件之下不影響行為,那麼這相當於一個列舉型態,你可以使用 Martin Fowler 的「把類型程式碼變成類別」的 refactoring 方法(校註3)。

但是,如果在不同的條件之下帶有不同的行為,那麼這些不同的行為必定帶有各自的資料結果,顯然這些資料結構將全部保存在條件語句所在方法的類別中。你的類別將日益增長,類別的介面將越來越臃腫。更嚴重的是,當你需要增加新功能的時候,不能僅僅依靠增加一個類別就能夠實現,而是需要找到這樣的 switch 敘述出現的地方,直接修改來源程式碼。對來源程式碼的修改甚至會引起介面的變化,從而影響該類別原來的介面。

三、 一個實際的例子 

下面的例子取自 Delphi 的一個片段。我在程式碼中省去了多執行緒相關的內容和其他細節。同時,這裡展示的程式碼也並非最後的結果,因為還有後續的 refactoring 使之成為 state + strategy 的混合體。

系統有一個 TPeer 類別,代表 P2P 系統中的一個類別,這個類別監聽所有的 Socket 事件,並將它交給一個 TProtocol 的物件來處理。

這裡的 dealwithDiscovery,dealwithRegist 以及 dealwithUnRegist 是前一步 refactoing 的結果。它們需要其他方法來輔助,所以在 TProtocol 類別中,包含了大量不同情況下的資料結構和相關的操作。

聞一下這裡的味道,如果你的程式出現這樣的程式碼,那麼 TProtocol 類別的長度可想而知,它做了許多不同的事情,承擔不同的責任,實現不同的行為。更麻煩的是,如果我現在想要處理一個其他的協議,如 "Test",那麼你必須找到這段程式碼,修改 if 語句,在最後的 else 之前,加上一個新的判斷:

 if method = ’Test’ then begin
   dealwithTest(fromPeer, header, EOSXMLParser);

顯然,這樣的結果是我們無法承受的,它至少違反了我們以下的直覺和原則: 

總而言之,這是一段傳統程式碼。

四、解決方案 

實質的問題乃 if 語句判斷這些不可變的字串,實際上是在對各種類型建模,我們在物件導向的系統中,往往使用類別(Class)來實現類型(Type)。

1. 使用 FactoryMethod 樣式,封裝構建過程

TProtocolFactory = class
private
  constructor create;
public
  class function getInstance:TProtocolFactory; 
  function CreateProtocol(name:String):TProtocol; 
end; 


{ TProtocolFactory } 

constructor TProtocolFactory.create;
begin
  inherited create; 
end;

function TProtocolFactory.CreateProtocol(name: String): TProtocol;
begin
  result := TNullProtocol.getInstance; //(Null Object,我將在其他文章中講到)
end;

class function TProtocolFactory.getInstance: TProtocolFactory;
const fInstance:TProtocolFactory = nil;
begin
  if (fInstance = nil) then fInstance := TProtocolFactory.create;
  result := fInstance;
end;

該工廠方法是一個 Singleton。其次增加新的測試該單元。

2. 為第一個 if 建立子類別

上面的第一個 if 表示 DiscoveryPeer 的處理,使用方法名加上 TProtocol,得到子類別的名稱為:

'T' + 'DiscoveryPeer' + 'Protocol'..

所以這個子類別的名稱為 TDiscoveryPeerProtocol,注意在進行 Refactoring 的時候,要小心地選擇你所使用的名字。

TDiscoveryPeerProtocol = class(TProtocol)
end;

3. 使用 Push Down Field 和 Push Down Method 把原來在 TProtocol 和處理 DiscoveryPeer 相關的資料及行為移至 TDiscoveryPeerProtocol 類別。

3.1. 建立新類別,移動欄位和方法

TDiscoveryPeerProtocol = class(TProtocol)
private
  //私有資料和方法
protected
  //保護方法
public
  procedure dealwithDiscovery(fromPeer:Tpeer;header:TmsgHeader; 
    EOSXMLParser:TEOSXMLParser);
end;

3.2. 重新命名,把 dealwithDiscovery 方法改名為 process

TDiscoveryPeerProtocol = class(TProtocol)
private
  //私有資料和方法
protected
  //保護方法
public
  procedure process(fromPeer:Tpeer;header:TmsgHeader; EOSXMLParser:TEOSXMLParser);
end;

3.3. 修改工廠方法

原來是:

function TProtocolFactory.CreateProtocol(name: String): TProtocol; 
begin
  result := TNullProtocol.;(Null Object,我將在其他文章中講到) 
end; 

改為:

function TProtocolFactory.CreateProtocol(name: String): TProtocol; 
begin
  if method='DiscoveryPeer' then begin
    result := TDiscoveryPeerProtocol.create;
  end else
    result := TNullProtocol.getInstance;
    // (Null Object,我將在其他文章中講到) 
end;

3.4. 去掉對該 if 的判斷

rocedure TProtocol.process(fromPeer : TP2PPeer;msgHeader:TMsgHeader;
  EOSXMLParser:TEOSXMLParser);
var
  method:String; 
begin 
  method := header.getMethod(); 
  if method = 'DiscoveryPeer' then begin 
    dealwithDiscovery(fromPeer, header, EOSXMLParser); 
  end else if method='RegistPeer' then begin
    dealwithRegist(fromPeer, header, EOSXMLParser);
  end else if method='UnRegistPeer' then begin
    dealwithUnRegist(fromPeer, header, EOSXMLParser);
  end else
    exit;
end; 

變為:

procedure TProtocol.process(fromPeer : TP2PPeer;msgHeader:TMsgHeader;
  EOSXMLParser:TEOSXMLParser); 
var
  method:String; 
begin 
  method := header.getMethod(); 
  if method='RegistPeer' then begin 
    dealwithRegist(fromPeer, header, EOSXMLParser); 
  end else if method='UnRegistPeer' then begin 
    dealwithUnRegist(fromPeer, header, EOSXMLParser); 
  end else 
    exit;
end;

3.5. 修改客戶程式碼,使用工廠方法

var 
aProtocol:TProtocol; 

aProtocol := TProtocolFactory.getInstance.CreateProtocol(header.getMethod()); 
aProtocol.process(fromPeer, header, EOSXMLParser); 

測試程式碼,保證原來所有的功能完全正確。 

4. 對每一個 if 分枝做 3.1-3.3 過程,3.4 不再需要修改。每完成一個分支,進行測試 

5. 結果,Factory Method 如下

function TProtocolFactory.CreateProtocol(peer:TP2PPeer;name: String): 
  TP2PProtocol; 
begin 
  if name = 'RegistPeer' then
    result := TRegistPeerProtocol.create(peer) 
  else if name = 'DiscoveryPeer' then 
    result := TDiscoveryPeerProtocol.create(peer) 
  else if name = 'UnReigstPeer' then 
    result := TUnRegistPeerProtocol.Create(peer) 
  else
    result := TNullProtocol.getInstance; 
end; 

同時, 原來 TProtocol 中 Process 方法變為:

procedure TProtocol.process(fromPeer : TP2PPeer;msgHeader:TMsgHeader;
  EOSXMLParser:TEOSXMLParser); 
var
  method:String; 
begin 
  method := header.getMethod(); 
end; 

事實上,process 已經不做任何工作了,所以我們可以把它聲明為抽象:

procedure process(aThread:TThread;fromPeer:TP2PPeer;
  EOSXMLParse:TEOSXMLParse);virtual;abstract;

測試。

6. 現在,我們具有如下的類別層次

如果你需要增加新的行為,現在所需要做的就是從 TProtocol 中繼承一個子類別,然後在 FactoryMethod 中加入新的實例過程。

現在,唯一出現 if-then-else 的地方是工廠方法,我們可以滿意於這樣的結果。但其實若使用 Delphi 的 MetaClass,則可以排除這樣的判斷結構,在 Java 中的實作則更簡單,可以參考 www.erptao.org 上的《參數化類別》。

要注意的是,如果把 client 的 TPeer 算進去,實際上很容易實現了state/strategy 樣式,我將在其他地方論述。

 

校註

  1. bloodGroup。
  2. 可以使用 strategy(315) 或 state(305) 設計樣式。
  3. 像血型這樣的數值型態(或者列舉型態)程式碼並不會影響類別行為,換句話說,改變血型並不會影響人的行為。

關於作者:

石一楹是一個專注於物件導向領域的系統設計師,目前的工作是在中國大陸一家 ERP 公司任職 CTO。他住在浙江杭州,有一個兩個月大的女兒。外號「石破天」(Shi Potian),可以於各論壇中發現這位(金庸筆下)大俠的影蹤。

校稿作業:

朱子乃點空間「UML專欄」編輯,另對這篇文章負責校稿、修訂及編排等工作。