[156] 函數或類別

[156] 函數或類別

先前寫過 C++ 類別的設計手法,其中一個是「滿足條件才能生成物件」。其實,需求若只是產生 TheTea,使用函數(Function)即可滿足,有需要寫成類別嗎?本文以此例探討:To class, or not to class.

以函數實作

把先前的 TeaMaker 類別實作貼過來:

class TeaMaker
{
  TeaMaker(Tea, Sugar, Ice);
  TheTea Make();
};

相應的函數可實作成:

TheTea MakeTea(Tea, Sugar, Ice);

同樣是接收三個輸入參數,產生輸出(TheTea),上述函數實作滿足了「當前」的需求。一般來說,呼叫函數不需要先建物件,再呼叫其成員函數,比使用類別來得輕鬆。那麼,有必要設計成類別嗎?

跟許多軟體開發問題的答案一樣:不一定。看似廢話,但有深究的必要。

擴充與封裝

市面上的泡沐紅茶機不只一種,如何滿足多種紅茶機的需求?以函數的方式實作,作法之一是:一個函數對應一種款式,在多種款式中,「任君」挑選適合的函數。:

TheTea Cheap_MakeTea(Tea, Sugar, Ice);
TheTea Advanced_MakeTea(Tea, Sugar, Ice);

另一種寫法是用 enum 來表列紅茶機款式,呼叫端透過輸入參數挑選欲使用的紅茶機:

enum TeaMaker {
    kCheapTeaMaker,
    kAdvancedTeaMaker,
};

TheTea MakeTea(TeaMaker, Tea, Sugar, Ice);

概念相同,只是用法不同。這兩種函數實作,常見的呼叫端用法:

if (money is not issue)
    return Advanced_MakeTea(tea, sugar, ice);
else
    return Cheap_MakeTea(tea, sugar, ice);
if (money is not issue)
    return Advanced_MakeTea(kAdvancedTeaMaker, tea, sugar, ice);
else
    return Cheap_MakeTea(kCheapTeaMaker, tea, sugar, ice);

欲增加紅茶機款式,第一種作法需要修改的地方:

  1. 增加一個新函數
  2. 在使用到舊函數的地方改成使用新函數(可能要增加 if 判斷式)

缺點是,呼叫端要清楚知道哪一個函數才是對的。

第二種作法則是:

  1. 加一個列舉元(enumerator)
  2. 在使用到 MakeTea 函數的地方代入新的列舉元(可能要增加 if 判斷式)

缺點是,MakeTea 內部實作越來越複雜,而且會動到原本已經測試過的程式碼。而且,經常因為函數長度太長或過於複雜,會將邏輯切成小塊,提取成函數,最終變成與第一種類似。

上述作法,少了「間接層」,相對容易實作。使用上較為直覺,容易理解(呼叫函數,給必要的參數)。但這是最好的方法嗎?答案一樣是:不一定。

以類別實作紅茶機繼承體系

接下來,同樣的需求以類別來實作,我刻意搞得較複雜,來凸顯許多人覺得 C++(或物件導向)糟糕的地方,然後說明複雜背後的意義(意境):

class TeaMaker
{
  TeaMaker();
  virtual TheTea Make(Tea, Sugar, Ice) = 0;
};

class CheapTeaMaker : public TeaMaker
{
  CheapTeaMaker();
  virtual TheTea Make(Tea, Sugar, Ice) override;
};

class AdvancedTeaMaker : public TeaMaker
{
  AdvancedTeaMaker();
  virtual TheTea Make(Tea, Sugar, Ice) override;
};

enum TeaMaker {
    kCheapTeaMaker,
    kAdvancedTeaMaker,
};

TeaMaker* CreateTeaMaker(TeaMaker);

注意,我把原本建構式(Constructor, ctor)裡的參數移到 Make

紅茶機的類別設計:一個 TeaMaker 為 Base class,有一個純虛擬函數(Pure Virtual Function)。CheapTeaMaker, AdvancedTeaMaker 皆繼承自 TeaMaker 並實作 MakeTea 函數。

以下是此類別的用法:

/// Client
auto tea_maker = CreateTeaMaker(kAdvancedTeaMaker);
auto the_tea = tea_maker->MakeTea(tea, sugar, ice);

/// Another place in your code base
auto tea_maker = CreateTeaMaker(kCheapTeaMaker);
auto the_tea = tea_maker->MakeTea(tea, sugar, ice);

增加新的紅茶機,需要的修改有:

  1. 增加一個列舉元
  2. 增加一個新的類別
  3. 修改 CreateTeaMaker 內部實作,使其俱備「製造新紅茶機」的功能
  4. 使用 CreateTeaMaker 代入新的參數

使用 Make 的地方,也就是呼叫端,不需要修改:一樣是呼叫 Make 來產生泡沫紅茶。

軟體專案的需求一定會變,這是鐵律。好的設計能以最小幅度,最不傷身的方式來因應需求變更。上述例子中,若能讓呼叫端的改動幅度降到最低,甚至不用變動,我認為那是比較好的作法。上述的類別實作,有機會在呼叫端不修改的情況下,加入新的功能。因此,我認為此例中,類別實作法比較優。

設計範式(Design Pattern)在眼前溜過

CreateTeaMaker 是一個 Factory Method,專門用來製造 TeaMaker 物件。這種手法很常見,人們稱之為——Factory Method pattern。

把「製造物件」跟「使用物件」拆開來,角色分明,責任分工,是降低軟體複雜度的重要技巧。複雜度低,修改的成本(苦痛指數)也相對低,也更容易因應需求變更。

簡單來說,就是要讓使用物件的人,不需要操勞如何「生成」物件。呼叫端的態度應該是:我只管用,怎麼做出來的,不關我的事。

而呼叫 MakeTea 的地方不用知道使用的物件是 CheapTeMaker 還是 AdvancedTeaMaker,反正都會做出紅茶,這就是「多型(Polymorphism)」。

當需求來臨時

假設客戶提出一項新的需求:記錄紅茶機的使用次數。

函數實作法需要在某個地方增加計數功能,可能是在呼叫 xxx_MakeTea 的地方,然後針對不同型的紅茶機有不同的計數...

類別實作法因應此需求的改動:

  1. TeaMaker 裡加上成員變數(Member Variable)
  2. 呼叫 MakeTea 則計數一次
  3. 增加一個 GetUseCount 成員函數(Member Function),回傳總共用了多少次

這麼種作法讓呼叫端(class 的客戶)受到較少的影響。

結論

函數與類別是 C++ 程式設計的基本組件(Building Block),實作類別內的邏輯時,也常會將程式碼提取到 Local Function 以簡化類別複雜度,提高程式碼複用性。

撰寫函數或類別,把握幾個重要原則:

  1. 物件可以有狀態,函數不要有狀態。
  2. 類別應該小而精,函數要始終如一。
  3. 類別盡量擬物化、擬人化,函數設計成動作。

程式碼是「活的」,適時的重構,讓專案結構更紮實。累積經驗與技術,識別好與壞,用力實踐。最後還是老話一句:「說得簡單,做起來難。不做,更難。」