反模式:常見的不良設計實踐

在軟體開發的世界裡,除了有那些「設計模式」能讓我們寫出更乾淨、可擴展的程式碼,也有一些「反模式 anti-pattern」,是我們經常不小心掉進去的陷阱。今天就來聊聊那些讓開發者們深受其害的反模式,看你是否曾經踩過這些坑。

大泥球 Big Ball of Mud
這個反模式可以說是最常見的。剛開始時大家都追求快速交付功能,於是程式碼變得毫無結構,像是一團亂糟糟的泥球。隨著時間推移,每次修改都變得越來越困難,改了一個地方可能就會影響到其他不相關的部分,每次修改都像拆炸彈,最後這段程式碼誰都不敢碰。

金錘子 Golden Hammer
金錘子是指開發者對某個技術或工具特別偏愛,無論遇到什麼問題,都想用它來解決。這樣的結果是,用不對的工具去解決不合適的問題,反而加大了系統的複雜度,增加開發成本。

過度設計 Overengineering
過度設計就是當開發者為了一些尚未發生的需求,或是過度預測未來的擴展性,寫了一大堆不必要的程式碼,結果專案的複雜度大幅增加,進度反而被拖慢。最終你可能永遠都用不上這些「預留的功能」。功能還沒做完,框架倒是設計了三層以上。

神物件 God Object
神物件指的是某個類別或物件擁有太多責任,幾乎控制著系統的所有邏輯。這樣做會導致這個類別變得龐大難以維護,並且非常難以測試。一旦這個類出錯,整個系統都可能受到影響。這個類負責的東西太多了,維護困難、程式碼過度耦合,沒人搞得清楚。

總結一下,軟體開發中的反模式很常見,它們在短期內看似有效,但長遠來看會讓你陷入無盡的維護困境。反模式不是錯誤的技術選擇,而是錯誤的設計思維導致的結果。了解並意識到這些常見的反模式,能幫助我們避開開發過程中的雷區。

其他參考
https://zh.wikipedia.org/zh-tw/%E5%8F%8D%E9%9D%A2%E6%A8%A1%E5%BC%8F

軟體開發常見陷阱:技術債累積過多

在軟體開發的日常工作中,技術債 Technical debt 這個詞你一定聽過,很多時候它會在不知不覺中悄悄累積,直到某天徹底爆發,讓開發團隊陷入混亂。技術債其實就像借錢一樣,當我們在開發過程中選擇快速的臨時解法、草率地修補錯誤或忽視程式碼品質,這些行為就像是向未來借來的債務。短期內我們確實能夠快速交付產品,贏得市場或是滿足客戶,但長期下來,這些技術債卻會累積成龐大的維護成本,直到某天你遇見那根壓垮駱駝的最後一根稻草。

技術債的情境

情境1. 臨時修補
為了趕進度,選擇一個「暫時可行」的解決方案,卻沒有回過頭來進行優化或重構。

情境2. 缺乏測試
為了快速交付功能,跳過了測試的步驟,導致日後發現問題時無法快速排查,維護難度大增。

情境3. 不良程式碼設計
初期沒有時間仔細設計架構,導致程式碼變得冗長、複雜,讓後期功能的擴充變得困難重重。

技術債究竟是好是壞?

技術債在實務中難以完全避免,開發團隊常常需要在交付速度與品質之間做出權衡。就像借錢一樣,有時候我們為了趕上專案進度,會選擇快速的臨時解法來解決眼前的問題,但這就像是從未來「借」來的時間。雖然暫時解了燃眉之急,但這些技術債遲早會回來找我們「還」。

實際上技術債並不是壞事,技術債是一種策略,它允許我們在資源有限的情況下,快速應對市場需求,或是優先交付核心功能。問題的關鍵在於:我們有沒有能力有效管理這些債務?而不是完全避免它。

該怎麼做?

在現實中,我們時常需要做出權衡。例如當產品急需上線,快速解法或跳過部分測試是可以接受的,只要我們之後能夠有計劃地處理這些技術債。在累積技術債的時候,要有意識地去管理,並清楚這會為未來帶來的負擔。知道什麼是「可接受的技術債」,並能適時「還債」。

有意識地累積技術債
當你選擇「借」時間時,要有清楚的紀錄,並預計什麼時候要開始還這些債。定期安排時間來重構程式碼、優化系統、完善測試,這樣可以避免技術債積壓太多。這樣你在未來就不會被技術債淹沒。

總結

技術債不是全然的壞事,有時它能幫助我們在商業上取得先機,或者優先交付更重要的功能。真正需要避免的不是技術債本身,而是『無意識地累積技術債』。懂得取捨,並在適當的時機償還技術債,才能讓我們的專案在不斷成長的同時保持靈活性。

軟體開發常見陷阱:過早優化 Premature Optimization

在軟體開發中,過早優化(Premature Optimization)是一個常見陷阱。這個陷阱常常發生在專案的早期階段,開發者過度關注細節或效能問題,試圖讓程式碼跑得更快或佔用更少資源,結果反而忽視了核心功能與可維護性。

什麼是過早優化?

就是你還沒確認程式的效能真的出現瓶頸,卻急著去優化某些部分。就好像你剛開始學跑步,還沒跑多遠就去研究如何減少鞋底跟地面的摩擦力。優化可能會帶來一點點效能提升,但問題是你不知道有沒有真的需要這樣的優化!

為什麼這是個陷阱?

  1. 浪費時間
    我想大家都很同意時間是寶貴的,你在早期花了大量時間去調整那些不會產生明顯效能差異的細節,把時間都花在微調上,核心功能卻可能還沒做好。以為自己是個高效率開發者,實際上是在繞遠路。最終拖慢開發速度,浪費大量時間在不重要的細節上,卻無法快速交付可用的功能。

  2. 程式碼變複雜
    為了達到極致效能,你可能會寫出一些讓人無法理解的程式碼。結果是什麼?日後維護時你自己或其他開發者可能會花上雙倍的時間去解讀這段程式碼。效能提升了,但維護成本也隨之上升。

  3. 預測錯誤問題/提前處理假設問題
    有些開發者喜歡預防性優化,可能會預測未來可能的效能瓶頸,並提前優化這些假設問題。結果這些瓶頸有可能永遠不會出現,反而因為其他需求變動,這些預防性優化變得毫無意義。

如何避免過度優化?

  1. 「先讓它跑起來,再讓它跑得快」的原則
    就是先求有再求好,先專注於實現核心功能,正確性比速度重要,先確保程式能夠正確執行。當系統穩定且功能完善後,如果效能真的成為問題,再進行效能優化。

  2. 使用效能分析工具
    在需要優化時,使用效能分析工具找出真正的瓶頸,與其憑感覺優化,不如用工具來幫你找出真正的瓶頸。避免浪費時間在不重要的部分,做最有效的優化。

  3. 需求導向的優化
    只有在確認系統效能不足以應對實際需求時,才需要進行優化。這樣能避免無謂的過早優化。不要因為「效能強迫症」讓自己陷入無謂的工作。

總結一下,過早優化是很多開發者在成長過程中都會經歷的階段,這很正常。我們追求更好的效能、寫出更完美的程式碼,但不要忘了,真正的目標是「解決問題」,而不是在不必要的地方過度用力。與其執著於微小的效能提升,不如專注於讓產品真正執行、幫助使用者!等到真正需要優化時再考慮效能提升。

設計原則 - 依賴反轉原則 Dependency Inversion Principle

在軟體設計中,我們經常面對類別之間的依賴關係。想像你正在開發一個龐大的系統,當中某個小功能需要變更,卻發現一改動就要牽扯到許多不相關的部分。這種情況下你是不是會開始懷疑,設計是否有問題?

這就是設計原則出現的原因,特別是「依賴反轉原則 (Dependency Inversion Principle, DIP)」,它能幫助我們建立靈活的系統,避免高耦合的噩夢。

什麼是依賴反轉原則?

依賴反轉原則是一種解決依賴問題的設計原則,依賴反轉原則強調:高層模組不應依賴於低層模組,兩者都應依賴於抽象。這聽起來可能有點抽象,但換句話說,就是我們要將具體的細節抽象化,讓不同層級的模組之間解耦不直接依賴彼此的實作,進而減少維護成本與錯誤的機會。

依賴反轉原則主要包含兩個重點:

  1. 高層模組不應該依賴於低層模組。
  2. 兩者應該依賴於抽象(介面或抽象類別)。

用最白話的方式來理解,就是「大腦不應該直接控制肌肉運動,而是應該透過神經傳導」。同樣道理,應用程式的高層邏輯不應該直接依賴底層細節,而是應該透過一層抽象來進行溝通。

依賴反轉原則的例子

我們先來看一個不遵守依賴反轉原則的例子。

錯誤的設計,違反依賴反轉原則

想像你正在設計一個點餐系統,在原始設計裡你可能會這樣實作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class CreditCard {
public:
void pay() {
// 信用卡付款邏輯
}
};

class Order {
CreditCard creditCard;
public:
void processOrder() {
creditCard.pay();
}
};

這個程式看起來沒什麼問題,但如果突然要新增其他付款方式,例如 PayPal 呢?每當系統需要支援新的付款方式時,你都得改 Order class 類別,這樣的設計很容易隨著功能擴充而變得脆弱。

在上面的範例中,Order 類別直接依賴於 CreditCard 類別,這就是違反依賴反轉原則的設計。這樣一來,只要 CreditCard 類別有變動,或者你要加入其他付款方式,Order 的實作就需要大幅改動,這導致系統的維護變得更加困難,擴充性也大打折扣。

正確的設計,遵守依賴反轉原則

我們可以透過抽象的方式解決這個問題。這裡的解決方案就是將付款方式抽象出來,讓 Order 類別不再直接依賴於具體的 CreditCard,而是依賴於一個抽象的付款介面:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class PaymentMethod {
public:
virtual void pay() = 0;
};

class CreditCard : public PaymentMethod {
public:
void pay() override {
// 信用卡付款邏輯
}
};

class PayPal : public PaymentMethod {
public:
void pay() override {
// PayPal付款邏輯
}
};

class Order {
PaymentMethod& paymentMethod;
public:
Order(PaymentMethod& method) : paymentMethod(method) {}

void processOrder() {
paymentMethod.pay();
}
};

在這個範例中,Order 類別不再依賴具體的 CreditCardPayPal,而是依賴於 PaymentMethod 這個抽象介面。這樣,如果將來要新增其他付款方式,我們只需要實作新的付款類別(實作 PaymentMethod class),而不必動到 Order 類別。這樣的設計就遵循了依賴反轉原則,達到了靈活且可擴展的目的。

為什麼這麼做?

當我們遵循依賴反轉原則,會發現系統變得更加彈性,維護性也提高了。具體實作的變動不會影響高層邏輯,這意味著在不改動核心業務邏輯的情況下,我們可以輕鬆替換或擴充底層的具體實作。

例如在付款系統中,隨著業務發展,可能會有新的付款方式不斷加入。依賴反轉原則讓我們在不改動核心訂單處理的情況下,直接新增其他付款方式,保持系統的穩定性與靈活性。

但這樣做也有一點缺點,就是會讓設計稍微複雜,特別是對於一些小型專案來說,過度抽象反而會增加開發的負擔。但隨著系統成長,這種設計方式能帶來的好處將遠大於初期的設計成本。

實際應用

依賴反轉原則在很多場景中都非常實用,特別是在大型系統或複雜應用中。例如在網路應用中,我們常常會有不同的資料存取方式,如 SQL、NoSQL 或是基於第三方的 API。透過依賴反轉原則,我們可以為高層邏輯設計一個統一的資料存取介面,而實際的資料庫選擇則可以在不同情況下自由切換,而不影響應用層邏輯。

這樣的設計原則也可以應用在許多場景,例如日誌系統、通知系統、甚至是硬體裝置的驅動層。每當你需要擴充或替換具體實作,而不想改動核心邏輯時,依賴反轉原則都是你的好幫手。

設計原則 - 介面隔離原則 Interface Segregation Principle

想像一下,你的程式裡有一個大型介面,每次新增功能都得修改這個介面,這樣不僅讓開發變得複雜,也讓維護變得困難。這就是為什麼我們需要「介面隔離原則 (Interface Segregation Principle, ISP)」。這個原則告訴我們,與其讓程式依賴一個龐大且複雜的介面,不如讓它們只依賴於它們真正需要的介面,這樣可以讓程式碼更加靈活和容易維護。

什麼是介面隔離原則?

介面隔離原則的核心概念是:不要強迫一個類別實作它不需要的介面方法。這意味著我們應該將大的介面拆分為更小、更具針對性的介面,讓類別只依賴它們所需要的功能。

想像一下,如果你是一名廚師,而你的工作清單裡除了煮飯,還包含了洗衣、修車等不相關的工作,你肯定會覺得困擾。對於軟體設計來說,大型介面就像這樣的工作清單,讓不必要的依賴進入系統,增加了複雜性。

介面隔離原則的例子

讓我們用一個具體例子來看,什麼樣的設計會違反介面隔離原則。

錯誤的設計,違反介面隔離原則

假設我們有一個 Worker 介面,裡面定義了各種工作相關的功能,包括寫程式、做簡報、煮飯等:

1
2
3
4
5
6
class IWorker {
public:
virtual void code() = 0;
virtual void cook() = 0;
virtual void present() = 0;
};

現在,如果有一個 Programmer 類別需要實作這個介面,它只需要寫程式,但仍然得實作 cookpresent 方法,這些它根本不會用到:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Programmer : public IWorker {
public:
void code() override {
// 寫程式的邏輯
}

void cook() override {
// 不需要,但必須實作
}

void present() override {
// 不需要,但必須實作
}
};

這種設計顯然是違反了 ISP 的,因為 Programmer 類別被強迫實作了它根本不需要的功能。這不僅讓程式碼冗長,還會帶來未來維護的麻煩。

正確的設計,遵守介面隔離原則

正確的設計應該將這些功能分離為多個小介面,讓類別只實作它所需要的功能。例如,我們可以這樣做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class IProgrammer {
public:
virtual void code() = 0;
};

class ICook {
public:
virtual void cook() = 0;
};

class IPresenter {
public:
virtual void present() = 0;
};

現在 Programmer 類別只需要實作 IProgrammer 介面,完全不需要理會與它無關的 cookpresent 方法:

1
2
3
4
5
6
class Programmer : public IProgrammer {
public:
void code() override {
// 寫程式的邏輯
}
};

這樣的設計遵守了介面隔離原則,讓類別只專注於它該做的事情,減少了不必要的依賴和負擔。

為什麼這麼做?

遵守 ISP 有很多好處。首先它讓程式碼更加模組化和易於理解。當介面變小、變專注時,開發者可以更容易地了解每個介面的職責,這減少了混亂。

這樣的設計讓我們更容易進行修改和擴展。假如未來需要新增功能,只需修改特定的小介面,而不會影響其他不相關的部分。這讓系統更加穩定,減少了修改時引入錯誤的風險。

遵守 ISP 讓我們可以避免不必要的依賴。當類別依賴於一個龐大的介面時,即便只使用其中一部分功能,它仍然會受到整個介面的影響。這種依賴會讓程式變得臃腫且難以維護。

然而過度拆分介面也可能帶來額外的複雜性。因此,我們在實踐 ISP 時,應該保持平衡,確保每個介面都有明確的職責,而不過度細化。

實際應用

在大型專案中,遵守介面隔離原則是保持程式碼品質和穩定性的關鍵。例如,在開發圖形使用者介面 (GUI) 時,按鈕、下拉選單等 UI 元件可能會有不同的互動方式。將這些行為拆分成小介面,能夠讓每個元件專注於它自己的功能,而不會被不必要的行為所束縛。

另一個應用場景是在大型企業軟體開發中,當需要同時面對不同的使用者需求時,我們可以透過小介面來應對不同的需求變化,確保每個功能都能獨立變動,保持靈活性。

設計原則 - 里氏替換原則 Liskov Substitution Principle

你是否曾經遇到過這樣的情況:你寫了一個子類別,卻發現當它被應用於父類別的場景時,程式突然出現了問題?這其實是違反了「里氏替換原則 (Liskov Substitution Principle, LSP)」的例子。LSP 是面向物件設計中的一個重要原則,能幫助我們在使用繼承時保持程式的穩定性。

什麼是里氏替換原則?

簡單來說,里氏替換原則強調:所有的子類別應該可以替換掉它們的父類別,而不會影響程式的正確性。也就是說,如果你的程式原本使用的是父類別,那麼它也應該能夠正常使用任何一個子類別而不會出錯。

這個原則是對繼承的一種保障:繼承不是僅僅為了重複使用父類別的程式碼,而是為了保證「子類別能夠完全取代父類別」。如果子類別的行為和父類別不同,甚至違反了父類別的預期,那麼就會破壞系統的穩定性,這就是違反 LSP 的表現。

里氏替換原則的例子

讓我們來看一個簡單的例子,理解什麼樣的設計違反了里氏替換原則。

錯誤的設計,違反里氏替換原則

假設我們有一個 Bird 類別,以及繼承它的 Penguin 類別:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Bird {
public:
virtual void fly() {
// 鳥類的飛行行為
}
};

class Penguin : public Bird {
public:
void fly() override {
throw std::logic_error("Penguins can't fly!");
}
};

在這個例子中,Penguin 類別違反了 LSP,因為當程式預期所有的 Bird 都能飛時,Penguinfly 方法卻會拋出異常,這顯然不是正確的行為。雖然企鵝是鳥類,但它不能飛,這導致了子類別無法正常替換父類別。

正確的設計,遵守里氏替換原則

要解決這個問題,我們應該重新思考類別的設計。或許「飛行」不應該是所有鳥類的共同行為,而應該是飛行鳥類的特徵。因此,我們可以將飛行行為從 Bird 類別中移除,並且將其移到專門處理飛行鳥類的類別中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Bird {
// 一般鳥類的屬性與行為
};

class FlyingBird : public Bird {
public:
virtual void fly() {
// 飛行行為
}
};

class Penguin : public Bird {
// 企鵝的行為,無需實現 fly 方法
};

經過重新設計後,現在 Penguin 類別不需要再拋出異常,因為它不會被要求飛行,而 FlyingBird 類別則專注於飛行鳥類的行為。這樣的設計遵守了 LSP,因為 Penguin 不再被強迫擁有與其不符合的行為。

為什麼這麼做?

遵守里氏替換原則能夠讓程式更具穩定性與可預測性。當你的子類別能夠無縫替換父類別時,系統能夠更有效地進行擴展,維護起來也更加容易。

不遵守 LSP 的問題在於,當子類別無法替換父類別時,往往會導致難以發現的錯誤。這些錯誤可能在某些特定情境下才會顯現出來,這使得除錯變得非常困難。

此外 LSP 的好處在於保持繼承的正確性與一致性。當我們設計一個基底類別時,我們期望所有的子類別都能遵循同樣的行為規範,而不是為每一個子類別設定不同的特例。遵守 LSP 意味著我們的設計是穩定且易於預測的,這對於大型專案來說至關重要。

當然,過度應用繼承或過於強求遵守 LSP 可能會導致程式碼設計變得複雜,因此在實踐中,我們應該根據具體需求靈活應用這一原則。

實際應用

LSP 在實際應用中是非常常見的,尤其是在框架設計或 API 開發中尤為重要。設計一個良好的框架或函式庫時,確保子類別能夠無縫替換父類別,能夠讓開發者更加輕鬆地進行擴展和修改,而不必擔心破壞現有系統。

例如在 GUI 開發中,一個基礎的按鈕類別可能會被許多不同的按鈕類別繼承(如「送出按鈕」或「取消按鈕」)。這些子按鈕應該都能被替換為基礎的按鈕類別,而不會出現意外行為。遵守 LSP 可以讓整個界面的行為更為一致,也讓開發更為順暢。

設計原則 - 開放封閉原則 Open-Closed Principle

在軟體設計中,我們經常面對需求變更的挑戰。想像一下,當客戶突然提出新需求時,你的系統卻因為架構過於死板,導致每次要修改功能都得重新調整大量程式碼。這不僅會耗費時間,還會增加出錯的機會。這時候「開放封閉原則 (Open-Closed Principle, OCP)」就能派上用場,幫助我們的程式保持靈活性,同時又能控制變更帶來的風險。

什麼是開放封閉原則?

開放封閉原則,簡單來說就是軟體應該對擴展開放,對修改封閉。也就是說我們應該設計程式,使其能夠在不修改既有程式碼的情況下,透過擴展來新增功能。

換句話說,當我們的需求改變時,應該可以透過增加新類別或新功能來解決,而不是去修改現有的邏輯。這樣的好處是,既能保護原有系統的穩定性,又能快速適應變化。

開放封閉原則的例子

讓我們來看一個不符合開放封閉原則的例子,來感受這個原則的意義。

錯誤的設計,違反開放封閉原則

假設我們在設計一個圖形繪製程式,原本只需要支援兩種圖形——圓形和方形,我們的程式可能會這樣寫:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
enum ShapeType {
Circle,
Square
};

class ShapeDrawer {
public:
void drawShape(ShapeType type) {
if (type == Circle) {
// 畫圓形的邏輯
} else if (type == Square) {
// 畫方形的邏輯
}
}
};

這段程式碼運作良好,但當需求改變,客戶希望支援更多的圖形類型時,我們就必須不斷修改 ShapeDrawer 類別,加入新的條件分支。如果圖形種類越來越多,例如三角形、橢圓與菱形等,這段程式碼會變得越來越臃腫且難以維護。

上述例子顯然違反了開放封閉原則。因為每當我們需要新增一種新圖形,都得進行程式碼修改,這不僅增加了錯誤的風險,也讓系統變得更加脆弱。理想的情況下,我們應該能在不改動既有程式碼的情況下,輕鬆地增加新圖形的繪製功能。

正確的設計,遵守開放封閉原則

為了符合開放封閉原則,我們可以運用抽象類別與多型的設計來解決這個問題:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Shape {
public:
virtual void draw() const = 0;
};

class Circle : public Shape {
public:
void draw() const override {
// 畫圓形的邏輯
}
};

class Square : public Shape {
public:
void draw() const override {
// 畫方形的邏輯
}
};

class ShapeDrawer {
public:
void drawShape(const Shape& shape) {
shape.draw();
}
};

在這個設計中,ShapeDrawer 不再依賴於具體的圖形類型,所有圖形的繪製邏輯都由各自的類別來負責。如果我們需要新增一種新圖形,例如三角形,我們只需新增一個 Triangle 類別,實作其 draw 方法,而不需要修改 ShapeDrawer 類別。這樣我們就可以輕鬆擴展功能,而不必改動既有的程式碼,符合了開放封閉原則。

為什麼這麼做?

開放封閉原則帶來的最大好處就是系統的穩定性與可擴展性。當我們能夠在不修改現有程式碼的情況下擴展功能時,就能減少意外錯誤的風險,也能保持系統的穩定性。這在大型專案或持續演進的系統中顯得重要。

同時,這樣的設計還讓我們的程式更加容易維護和理解。每個圖形的繪製邏輯都被封裝在各自的類別中,遵循單一職責原則 (Single Responsibility Principle),不會讓 ShapeDrawer 類別變得臃腫不堪。

當然,開放封閉原則也有其限制,特別是當我們過度抽象時,可能會使系統設計變得過於複雜。在小型專案中,這種設計的好處可能不那麼明顯,但隨著系統規模增大,這種方式的優勢會逐漸顯現。

實際應用

開放封閉原則廣泛應用於各種場景,尤其是在框架設計第三方擴展時非常實用。舉例來說,許多應用程式框架都提供了一系列的抽象介面,開發者可以在不修改框架核心程式碼的情況下,透過實作這些介面來擴展功能,這正是開放封閉原則的應用。

同樣地,在一些策略模式或工廠模式的應用中,我們也會看到開放封閉原則的影子。無論是新增業務邏輯、加入新的功能模組,還是替換現有功能,遵守這個原則都能讓我們的系統保持穩定且靈活。

設計原則 - 單一職責原則 Single Responsibility Principle

在寫程式的過程中,我們經常會遇到一個類別或模組負責太多事情,結果導致程式碼難以維護或修改,這種情況常常被稱為「巨石類別 (God Class)」。當我們需要對程式碼做出任何變更時,這種「巨石類別」會變得相當脆弱,因為它的每一個改動都有可能對其他功能造成影響。這時候,「單一職責原則 (Single Responsibility Principle, SRP)」便能幫助我們解決這個問題。

什麼是單一職責原則?

簡單來說,單一職責原則強調每個類別或模組應該只負責一件事,也就是說每個類別應該只專注於它的核心功能。如果一個類別有多於一個原因會導致它需要變更,那麼這個類別就違反了單一職責原則。

用白話來說,就是「一個人做好一件事」的概念。在軟體設計中,我們應該避免讓一個類別承擔太多不同的責任,這樣不僅會讓程式碼更具彈性,也更容易進行維護。

單一職責原則的例子

讓我們來看一個簡單的例子,理解什麼樣的設計違反了單一職責原則。

錯誤的設計,違反單一職責原則

我們可以先從一個不遵守單一職責原則的範例來看。假設你有一個「報告管理」類別,這個類別同時負責產生報告內容和報告的儲存:

1
2
3
4
5
6
7
8
9
10
class ReportManager {
public:
void generateReport() {
// 產生報告的邏輯
}

void saveToFile(const std::string& filename) {
// 將報告儲存至檔案
}
};

這個 ReportManager 類別明顯負責了兩件事:一是產生報告,二是將報告儲存至檔案。這樣的設計違反了單一職責原則,因為當你需要修改報告產生邏輯時,可能會影響到報告儲存的邏輯,反之亦然。

這種違反單一職責原則的設計會使程式碼變得難以維護。隨著功能增多,我們可能會發現 ReportManager 會變得越來越臃腫,最終變成「巨石類別」,任何改動都會影響整個類別的穩定性。

正確的設計,遵守單一職責原則

那麼要如何設計一個符合單一職責原則的解決方案呢?我們可以將 ReportManager 的責任拆分成兩個類別,一個專注於報告產生,另一個專注於報告儲存:

1
2
3
4
5
6
7
8
9
10
11
12
13
class ReportGenerator {
public:
void generateReport() {
// 產生報告的邏輯
}
};

class ReportSaver {
public:
void saveToFile(const std::string& filename) {
// 將報告儲存至檔案的邏輯
}
};

現在我們的 ReportGenerator 類別只專注於報告產生,而 ReportSaver 類別則負責儲存報告。這樣的設計遵守了單一職責原則,因為每個類別只負責一件事。當我們需要修改報告產生的邏輯時,並不會影響到報告儲存的功能,反之亦然。

為什麼這麼做?

單一職責原則的最大好處在於降低類別之間的耦合度。當每個類別都只負責一件事時,程式碼變更時不會影響到其他不相關的部分,這樣能夠有效降低系統的出錯機率。這也讓我們的程式碼更具可測試性,因為每個類別都只有單一的責任,單元測試時只需要關注這個類別的核心功能即可。

此外,單一職責原則還提升了系統的可維護性。隨著需求的變更,我們能夠更加輕鬆地對系統進行修改,因為我們不必擔心一個類別承擔了太多的責任,導致改動影響整個系統的穩定性。

當然,過度拆分職責可能會導致過度設計,特別是在小型專案中,每個功能都被分得太細反而會增加程式碼的複雜性。因此遵守單一職責原則時,需要根據實際需求找到平衡。

實際應用

單一職責原則在實際開發中應用廣泛,尤其是在大型專案或持續演進的系統中尤為重要。無論是類別設計還是模組拆分,遵守單一職責原則能夠有效提升系統的可擴展性與可維護性。

例如,在設計軟體框架時,我們會將不同功能的模組分開,確保每個模組只負責單一職責,這樣開發者在擴展功能時,可以很輕鬆地新增或修改特定模組,而不必擔心破壞其他模組的運作。

C++ 設計模式 - 訪問者模式 Visitor Pattern

想像你是一位探險家,來到了一座神秘的古城。這座城市有著各式各樣的建築:宏偉的宮殿、莊嚴的神廟、繁忙的市集。作為一個訪問者,你想要深入了解每個地方,但每個地方都有其獨特的探索方式。這就像程式世界中的訪問者模式,一種讓你能夠優雅地處理複雜物件結構的設計模式。準備好開始這段奇妙的探索之旅了嗎?

什麼是訪問者模式?

訪問者模式是一種行為設計模式,它允許你在不改變原有物件結構的情況下,定義對這些物件的新操作。這個模式的核心思想是將資料結構和資料操作分離。在這個模式中,訪問者會遍歷不同類型的物件,並對它們進行不同的操作。這樣的好處是可以靈活地新增行為,而不用修改原來的類別。

就像我們的探險家例子,訪問者就像是探險家,而被訪問的元素就像是城市中的各個地點。無論你去到哪裡,都能以適合該地點的方式進行探索,而不需要改變這些地點本身。

訪問者模式在繪圖軟體中的應用

讓我們透過一個繪圖軟體的例子來深入理解訪問者模式。想像我們正在開發一款繪圖軟體,裡面有各式各樣的圖形,如圓形、矩形和三角形等。這些圖形都有各自的繪圖和顯示方式,但現在我們需要新增一個功能:計算每個圖形的面積。

首先我們定義訪問者介面和圖形的基本介面,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 前向宣告
class Circle;
class Rectangle;
class Triangle;

// 訪問者基類
class Visitor {
public
virtual void visit(Circle* circle) = 0;
virtual void visit(Rectangle* rectangle) = 0;
virtual void visit(Triangle* triangle) = 0;
};

class Shape {
public:
virtual void accept(Visitor* visitor) = 0;
virtual ~Shape() {}
};

接著我們實現具體的圖形類,這裡的 CircleRectangleTriangle 類就是我們所說的”不需要修改的類”。這些類代表了我們的基本圖形,它們的主要職責是維護圖形的基本屬性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Circle : public Shape {
public:
Circle(double radius) : radius(radius) {}
void accept(Visitor* visitor) override {
visitor->visit(this);
}
double getRadius() const { return radius; }
private:
double radius;
};

class Rectangle : public Shape {
public:
Rectangle(double width, double height) : width(width), height(height) {}
void accept(Visitor* visitor) override {
visitor->visit(this);
}
double getWidth() const { return width; }
double getHeight() const { return height; }
private:
double width, height;
};

class Triangle : public Shape {
public:
Triangle(double base, double height) : base(base), height(height) {}
void accept(Visitor* visitor) override {
visitor->visit(this);
}
double getBase() const { return base; }
double getHeight() const { return height; }
private:
double base, height;
};

然後我們可以實現具體的訪問者,例如計算面積的訪問者,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 計算面積大小的訪問者
class AreaCalculator : public Visitor {
public:
void visit(Circle* circle) override {
totalArea += 3.14159 * circle->getRadius() * circle->getRadius();
}
void visit(Rectangle* rectangle) override {
totalArea += rectangle->getWidth() * rectangle->getHeight();
}
void visit(Triangle* triangle) override {
totalArea += 0.5 * triangle->getBase() * triangle->getHeight();
}
double getTotalArea() const { return totalArea; }
private:
double totalArea = 0;
};

最後客戶端可以這樣使用來取得所有圖形的面積總合,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int main() {
std::vector<Shape*> shapes = {
new Circle(5),
new Rectangle(46),
new Triangle(34)
};
AreaCalculator areaCalculator;
for (auto shape : shapes) {
shape->accept(&areaCalculator);
}
std::cout << "Total area: " << areaCalculator.getTotalArea() << std::endl;

// 釋放記憶體
for (auto shape : shapes) {
delete shape;
}

return 0;
}

執行上述程式碼,我們會得到以下輸出:

1
Total area: 108.54

透過這種方式,我們可以輕鬆地新增新的操作(如計算周長、繪製圖形等),而不需要修改現有的 CircleRectangleTriangle 類。這就是訪問者模式的核心優勢:當我們需要新增新的操作時,我們只需要建立一個新的訪問者類(比如 PerimeterCalculatorShapeDrawer),而不需要改變這些基本圖形類的結構。

這種設計使得我們的圖形結構保持穩定,同時允許我們靈活地新增新的操作。例如,如果我們之後想要新增一個計算所有圖形重心的功能,我們只需要建立一個新的 CenterOfGravityCalculator 訪問者,而不需要修改任何現有的圖形類。

訪問者模式在檔案結構分析中的應用

另一個常見的應用例子就是檔案結構分析。假設你有一個複雜的檔案系統,裡面包含了檔案(File)和資料夾(Folder)。當你想要統計資料夾內所有檔案的大小,或是對特定類型的檔案進行處理時,訪問者模式就能幫上大忙。

我們有一個檔案系統,其中包含兩種類型的元素:FileFolder。我們需要一個訪問者來遍歷這些元素並計算檔案的大小。

定義一個訪問者介面 Visitor,其中包含兩個方法,分別對 FileFolder 類別進行操作,

1
2
3
4
5
6
7
8
9
10
// 前向宣告
class File;
class Folder;

// 訪問者基類
class Visitor {
public:
virtual void visit(File* file) = 0;
virtual void visit(Folder* folder) = 0;
};

定義 FileFolder 類別,它們都會有一個 accept 方法來接受訪問者,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// 檔案類
class File {
public:
void accept(Visitor* visitor) {
visitor->visit(this);
}
int getSize() const { return size; }
private:
int size = 100; // 假設每個檔案都有大小
};

// 資料夾類
class Folder {
public:
void accept(Visitor* visitor) {
visitor->visit(this);
}

void addFile(File* file) {
files.push_back(file);
}

const std::vector<File*>& getFiles() const { return files; }
private:
std::vector<File*> files;
};

定義具體的訪問者,這個訪問者用來計算檔案總大小,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 計算大小的訪問者
class SizeVisitor : public Visitor {
public:
void visit(File* file) override {
totalSize += file->getSize();
}

void visit(Folder* folder) override {
for (auto file : folder->getFiles()) {
file->accept(this);
}
}
int getTotalSize() const { return totalSize; }
private:
int totalSize = 0;
};

最後在客戶端中使用訪問者來計算總大小,

1
2
3
4
5
6
7
8
9
10
11
int main() {
File file1, file2;
Folder folder;
folder.addFile(&file1);
folder.addFile(&file2);

SizeVisitor visitor;
folder.accept(&visitor);

std::cout << "Total size: " << visitor.getTotalSize() << std::endl;
}

透過這種方式,我們可以輕鬆地新增新的操作(如搜尋、統計、結構顯示等),而不需要修改現有的 File 和 Folder 類。這就是訪問者模式的核心優勢:當我們需要新增新的操作時,我們只需要建立一個新的訪問者類(比如 FileSearcher、FileCounter 或 StructurePrinter),而不需要改變 File 和 Folder 這些基本類的結構。
這種設計使得我們的檔案系統結構保持穩定,同時允許我們靈活地新增新的操作。例如,如果我們之後想要新增一個統計特定類型檔案數量的功能,我們只需要建立一個新的 FileTypeCounter 訪問者,而不需要修改 File 和 Folder 類。

訪問者模式的優缺點

訪問者模式就像是一把瑞士軍刀,提供了極大的靈活性。它允許我們輕鬆地新增新的操作,而無需修改現有的類結構。這不僅提高了程式碼的可維護性,還有助於遵守開閉原則。此外它還能將相關的操作集中在訪問者中,讓程式碼更加整潔和易於管理。

訪問者模式也有其缺點。它不太適合經常改變結構的系統,因為每當物件結構發生變化,你就必須更新訪問者中的方法。另外如果物件的類型數量很多,訪問者模式會變得難以維護,因為每新增一種類型都需要在訪問者中新增對應的方法。

總結

訪問者模式特別適合在物件結構穩定,但需要經常增加行為的情況下使用。透過將操作與物件分離,我們可以更輕鬆地擴展系統功能,而不會影響原有的結構。不過這個模式也有其適用範圍,過於複雜的結構變化可能會使訪問者模式的維護成本變得很高。因此選擇是否使用訪問者模式時,需要根據具體需求來權衡。

C++ 設計模式 - 原型模式 Prototype Pattern

想像一下你手上有一個心愛的玩偶,這個玩偶有著獨特的外觀、材質和手感。如果你想要再擁有一個一模一樣的玩偶,你會怎麼做呢?重新製作一個可能需要花費很多時間與精力,但如果有一個神奇的「複製器」,只要按一下按鈕,就能夠完美複製出另一個相同的玩偶,那該有多方便!這就是「原型模式(Prototype Pattern)」在軟體設計中的核心思想。這種模式讓我們能夠快速複製已經存在的物件,而不需要從頭再建立一遍。

什麼是原型模式?

原型模式是一種建立型設計模式,它允許我們透過複製現有的物件來建立新物件,而不是透過傳統的方式來構建新物件。這種方式特別適合於物件建立成本高昂或結構複雜的情境。具體來說原型模式包含了一個介面,這個介面定義了複製自身的能力。每個實現這個介面的物件都能夠建立一個自己的複本,並且複製出來的新物件與原始物件在狀態上是相同的。

在C++中,這通常是透過深拷貝或淺拷貝來實現的。深拷貝會複製物件中的所有資料,包括內部指標所指向的資源,而淺拷貝則只複製物件的基本結構,而共享內部資源。

想像你正在開發一個角色扮演遊戲。遊戲中有各種各樣的怪物,每種怪物都有自己的屬性和技能。如果每次需要新怪物時都要從頭建立,傳入很多參數跟設定數值,那將是一個繁瑣的過程。這時原型模式就能派上用場了。

再舉個例子,想像你正在開發一個圖形設計軟體,其中有些常用的圖形,例如矩形,客戶端在使用時可能需要頻繁地建立相似的矩形,而這些矩形的基本屬性是相同的(顏色、寬度、高度),為了提高客戶端的工作效率,就能夠使用原型模式快速複製產生新的矩形物件。

這個模式的基本角色包括:

  1. 原型(Prototype):宣告一個複製自己本身的介面。
  2. 具體原型(Concrete Prototype):實現複製自己本身的方法。
  3. 客戶端(Client):透過呼叫原型的複製方法來取得新的物件。

原型模式在遊戲角色建立中的應用

讓我們以遊戲開發為例,來看看原型模式的應用。一個典型的角色扮演遊戲(RPG)中,玩家可以選擇不同的職業,每個職業都有其獨特的屬性、技能和裝備。如果每次建立一個新角色都要從頭設定這些屬性,那將是非常耗時的事情。但如果我們能夠建立一個基礎角色的原型,然後透過複製這個原型來產生新角色,就能夠大大簡化這個過程。

首先我們定義一個 Prototype 介面,它包含了一個 clone() 方法,這個方法用來複製物件,

1
2
3
4
5
6
7
// 原型 Prototype
class CharacterPrototype {
public:
virtual CharacterPrototype* clone() const = 0;
virtual void showAttributes() const = 0;
virtual ~CharacterPrototype() = default;
};

接著我們建立一個具體的角色類別,例如一個戰士角色,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 具體原型 Concrete Prototype
class Warrior : public CharacterPrototype {
private:
int strength;
int defense;
std::string weapon;

public:
Warrior(int str, int def, const std::string& weap)
: strength(str), defense(def), weapon(weap) {}

CharacterPrototype* clone() const override {
return new Warrior(*this);
}

void showAttributes() const override {
std::cout << "Warrior with strength: " << strength
<< ", defense: " << defense
<< ", weapon: " << weapon << std::endl;
}
};

現在我們在客戶端可以使用這個角色原型來建立新角色,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main() {
// 建立初始角色
CharacterPrototype* originalWarrior = new Warrior(100, 50, "Sword");

// 複製角色
CharacterPrototype* clonedWarrior = originalWarrior->clone();

originalWarrior->showAttributes(); // 輸出:Warrior with strength: 100, defense: 50, weapon: Sword
clonedWarrior->showAttributes(); // 輸出:Warrior with strength: 100, defense: 50, weapon: Sword

delete originalWarrior;
delete clonedWarrior;

return 0;
}

執行上述程式碼,我們會得到以下輸出:

1
2
Warrior with strength: 100, defense: 50, weapon: Sword
Warrior with strength: 100, defense: 50, weapon: Sword

在這個例子中,我們建立了一個戰士角色作為原型,並使用原型的 clone() 方法產生了一個一模一樣的新角色。這樣,我們不需要每次建立新角色時都手動設定屬性,而是透過簡單的複製操作快速產生所需的角色。

原型模式的優缺點

原型模式的一大優點是它提供了一種高效的物件建立方式,特別是在需要多次建立類似物件的情況下,能夠節省大量時間和資源。由於原型模式直接複製現有的物件,我們可以保證新物件與原始物件具有相同的狀態,這在某些需要精確控制物件狀態的場景中尤為重要。

然而原型模式的缺點在於複製過程的複雜性。當物件的結構非常複雜或包含指標、動態分配的資源時,正確地實現深拷貝可能會變得困難。這要求開發者對物件的內部結構有非常清晰的理解,並且小心處理拷貝過程中的細節。另外如果物件中包含了不應該被複製的部分,或需要在複製後進行一些特定處理,那麼在實際應用中可能會遇到額外的挑戰。

整理來說原型模式在適合的情境下能夠極大地提高開發效率,但也需要謹慎使用,確保在複製物件時沒有引入額外的複雜度或潛在的錯誤。

總結

原型模式在我們的日常生活中也隨處可見,影印機複印文件時,原始文件就是原型,複印出來的每一份都是複製品。在細胞分裂中,新的細胞是從原有細胞複製而來的。這些例子都體現了原型模式的核心思想:透過複製現有的物件來建立新物件。

原型模式是一種強大而靈活的建立型設計模式,適合於那些需要複製大量相似物件的應用場景。透過原型模式,我們可以輕鬆地產生具有相同屬性和狀態的新物件,從而提高開發效率,降低複雜度。然而在使用時需要注意物件複製過程中的潛在風險,特別是涉及到深拷貝和資源管理時。

設計模式是解決問題的工具,而不是目的本身。靈活運用才能寫出既優雅又實用的程式碼。在軟體開發的道路上,讓我們能像原型模式一樣,在「複製」中不斷成長,創造出屬於自己的精彩作品。