[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);
欲增加紅茶機款式,第一種作法需要修改的地方:
- 增加一個新函數
- 在使用到舊函數的地方改成使用新函數(可能要增加 if 判斷式)
缺點是,呼叫端要清楚知道哪一個函數才是對的。
第二種作法則是:
- 加一個列舉元(enumerator)
- 在使用到
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);
增加新的紅茶機,需要的修改有:
- 增加一個列舉元
- 增加一個新的類別
- 修改
CreateTeaMaker
內部實作,使其俱備「製造新紅茶機」的功能 - 使用
CreateTeaMaker
代入新的參數
使用 Make
的地方,也就是呼叫端,不需要修改:一樣是呼叫 Make
來產生泡沫紅茶。
軟體專案的需求一定會變,這是鐵律。好的設計能以最小幅度,最不傷身的方式來因應需求變更。上述例子中,若能讓呼叫端的改動幅度降到最低,甚至不用變動,我認為那是比較好的作法。上述的類別實作,有機會在呼叫端不修改的情況下,加入新的功能。因此,我認為此例中,類別實作法比較優。
設計範式(Design Pattern)在眼前溜過
CreateTeaMaker
是一個 Factory Method,專門用來製造 TeaMaker
物件。這種手法很常見,人們稱之為——Factory Method pattern。
把「製造物件」跟「使用物件」拆開來,角色分明,責任分工,是降低軟體複雜度的重要技巧。複雜度低,修改的成本(苦痛指數)也相對低,也更容易因應需求變更。
簡單來說,就是要讓使用物件的人,不需要操勞如何「生成」物件。呼叫端的態度應該是:我只管用,怎麼做出來的,不關我的事。
而呼叫 MakeTea
的地方不用知道使用的物件是 CheapTeMaker
還是 AdvancedTeaMaker
,反正都會做出紅茶,這就是「多型(Polymorphism)」。
當需求來臨時
假設客戶提出一項新的需求:記錄紅茶機的使用次數。
函數實作法需要在某個地方增加計數功能,可能是在呼叫 xxx_MakeTea
的地方,然後針對不同型的紅茶機有不同的計數...
類別實作法因應此需求的改動:
- 在
TeaMaker
裡加上成員變數(Member Variable) - 呼叫
MakeTea
則計數一次 - 增加一個
GetUseCount
成員函數(Member Function),回傳總共用了多少次
這麼種作法讓呼叫端(class 的客戶)受到較少的影響。
結論
函數與類別是 C++ 程式設計的基本組件(Building Block),實作類別內的邏輯時,也常會將程式碼提取到 Local Function 以簡化類別複雜度,提高程式碼複用性。
撰寫函數或類別,把握幾個重要原則:
- 物件可以有狀態,函數不要有狀態。
- 類別應該小而精,函數要始終如一。
- 類別盡量擬物化、擬人化,函數設計成動作。
程式碼是「活的」,適時的重構,讓專案結構更紮實。累積經驗與技術,識別好與壞,用力實踐。最後還是老話一句:「說得簡單,做起來難。不做,更難。」