設計模式的原則與種類(上)
關於軟體設計模式,有幾個設計原則需要去遵守。或著說當我們自己在寫程式碼時,常常可以藉由檢視這幾種原則來思考自己寫出來的程式碼是不是還有空間可以改善。
最常見的設計原則叫作 SOLID 原則:
原則 | 中文名稱 | |
---|---|---|
S | Single Responsibility Principle (SRP) | 單一職責原則 |
O | Open–Closed Principle (OCP) | 開放封閉原則 |
L | Liskov Substitution Principle (LSP) | 里氏替換原則 |
I | Interface Segregation Principles (ISP) | 介面隔離原則 |
D | Dependency Inversion Principle (DIP) | 依賴反向原則 |
我們一開始看到這些原則時肯定毫無概念,甚至覺得又要記好多專有名詞好麻煩。其實我跟大家一樣,覺得背這些東西不知道會不會用到,所以我其實都不會背。
這些原則我覺得其實看過稍微有印象就可以了,他們想表達的中心思想不外乎就是那幾個。我們以後再更深入討論個別的設計模式時,這些原則都會再次看到,到時我們就會有「啊原來就是在說這個原則啊~」的感覺!
在這篇中,我們會討論前面兩個原則,也就是 SRP 還有 OCP。在下一篇中,我們才會再把剩下的講完~
接下來我們就來看看什麼是 SOLID 原則吧!
上面是這個原則的定義。但這是什麼意思呢?
讓我們來舉一個例子:
假如我們有一個類別,這個類別負責計算一個班上學生們某個科目的通過率。只要學生通過一個特定分數,那麼就算通過。
class PassRateCalculator { public: float calculatePassRate(); };
而我們有兩個科目,國文和數學,兩個科目都利用這個類別 PassRateCalculator
來計算通過率,三者的關係如下圖:
假如兩個科目的通過分數都是一樣的,那這樣不會有什麼問題。但是假如今天老師突然想要將數學的通過分數進行調整,那麼這樣就會有問題了!
因為我們就會同時更改到國文和數學的 calculatePassRate()
這個函數,導致國文的通過率也會跟著改變!這就違反了一開始的定義「一個模組應該而且只有一個理由會使其改變」,因為現在這個模組可能會被兩個理由改變,也就是數學和國文。
解決方法就是應該國文和數學各自有自己的計算模組,關係如下圖:
那麼在程式碼中要如何實作呢?其實有幾種方法,第一種最簡單也是最直覺的就是分別建立數學和國文的類別,並分別實作不同的計算方式。
這樣的缺點就是當我們有五個或是十個甚至更多科目時,類別的數目也會迅速的增加。這對於程式碼的維護還有未來的可塑性是非常差的。
class MathPassRateCalculator // 數學類別 { public: float calculatePassRate(); }; class ChinesePassRateCalculator // 國文類別 { public: float calculatePassRate(); };
第二種方法是我們可以傳入參數來控制不同的計算方式,比如說:
class PassRateCalculator { public: float calculatePassRate(string subject) { if (subject == "國文") .... else if (subject == "數學") .... } };
這樣的缺點同樣也會因為科目的增加而讓我們類別內部的程式碼變得更加複雜而且不容易維護。
第三種才是我們需要學習的!但同時我認為這也是最不直覺的,就是我們需要另外建立 PassRateHandler
這個類別。在這個類別中,我們則會實作 calc(threshold)
的函數。當我們需要使用時,就可以透過 PassRateHandler
來實作不同的計算方式!
class PassRateHandler { public: float calc(int threshold) { .... } } int main() { PassRateHandler math; math.calc(60); PassRateHandler chinese; chinese.calc(85); return 0; }
誒但看到這邊聰明的你們可能就會想了,這樣真的跟一開始的有不一樣嗎?
邏輯上其實是有的!但是因為我們舉的這個例子太簡單了,所以看不出太大的差別。但在現實生活中,每個科目除了要計算通過率之外,可能還會需要紀錄總學生人數、學生出席率、權重分配等等。因此我們就會多寫出很多不同的 Handler
來管理不同的職責。而這也和一開始的觀念互相呼應:「一個模組應該而且只有一個理由會使其改變」。
這篇英文文章利用做早餐舉了一個更為複雜的例子,有興趣的可以去看看,相信看完之後一定會更加了解!
開放封閉原則 OCP
看完這句大家是不是跟我一樣有看沒有懂。但沒關係,他的概念其實非常簡單!
白話來說就是一段好的程式碼唯一會被修改到的機會就是當我們發現程式碼有缺陷或是有錯誤需要被修正的時候。當我們在未來想要為這段程式碼新增任何功能,或是改變其特性的時候,我們並不需要去動到原本已經存在的程式碼。
這麼做的好處是什麼呢?顯而易見的當然就是我們只需要去擔心我們新增程式碼的品質與可行性,並針對新增的程式碼寫出相對應的測試。我們並不需要去擔心今天我們新增的功能會不會影響到原本程式碼的品質、或是破壞現有的功能。
讓我們來看看一個例子:
假設一家公司生產的產品有三個標籤:名字、大小、還有顏色。而顏色有紅綠藍,大小有小中大。
enum class Color { red, green, blue }; enum class Size { small, medium, large }; struct Product { string name; Color color; Size size; };
假設我們被要求寫出一段程式碼,用來針對公司產品的大小與顏色進行過濾。
一般人可能會寫出這樣的程式碼:
struct ProductFilter { vector<Product*> by_color(vector<Product*> items, const Color color) { Items result; for (auto& i : items) if (i->color == color) result.push_back(i); return result; } vector<Product*> by_size(vector<Product*> items, const Size size) { Items result; for (auto& i : items) if (i->size == size) result.push_back(i); return result; } };
這樣的程式碼是可以達到我們的目標沒錯,但這樣的程式碼並沒有符合 OCP 原則。試想一下,如果以後我們被要求增加過濾名字的功能,我們是不是要跑去 ProductFilter
裡面並新增 by_name()
的函數。
那我們應該要怎麼寫才可以讓程式碼符合 OCP 呢?更好的寫法應該是像這樣:
struct Specification { virtual ~Specification() = default; virtual bool is_satisfied(Product* item) const = 0; }; struct ColorSpecification : Specification { Color color; ColorSpecification(const Color color) : color(color) {} bool is_satisfied(Product *item) const override { return item->color == color; } }; struct SizeSpecification : Specification { Size size; SizeSpecification(const Size size) : size(size) {} bool is_satisfied(Product* item) const override { return item->size == size; } }; struct BetterFilter { vector<Product*> filter(vector<Product*> items, Specification &spec) { vector<Product*> result; for (auto& p : items) if (spec.is_satisfied(p)) result.push_back(p); return result; } };
在這段程式碼中,我們新增了一個 Specification
的類別,而所有的過濾標準都是這個 Specification
的子類別,並且強制實作 is_satisfied()
這個函數。
如此一來,如果我們在未來想要加入更多的過濾標準的話,我們完全不需要去動到原本就已經存在的程式碼。比如說如果要針對名字過濾,那我們就可以新增一個 NameSpecification
的類別並繼承 Specification
。
這樣的程式碼就是符合 OCP 原則的程式碼!也就是新增功能時並不會動到原本就已經存在的程式碼,唯一會改變的機會只有修復錯誤的時候。