前言
與大多數其他書籍不同,本書不花費時間在語言機制或眾多特性上,而主要專注於軟件整體的可變性、可擴展性和可測試性。本書不假裝使用新的C++標準或特性就能區分軟件的好壞,而是清晰地表明,決定軟件好壞的是對依賴關係的管理,是我們代碼中的依賴關係決定了其優劣。因此,在C++的世界裏,這確實是一種罕見的書,因為它聚焦於更大的圖景:軟件設計。
軟件設計是管理軟件組件間相互依賴關係的藝術。它旨在最小化人為的(技術性的)依賴關係,並引入必要的抽象和妥協。
軟件架構和軟件設計僅僅是軟件開發的三層境界中的兩層。它們與實現細節這一層境界相輔相成。
1.SOLID設計原則
SOLID 原則是面向對象設計和編程的一組五個核心原則,它們共同指導我們編寫出更易維護、更靈活、更健壯的代碼。
|
原則
|
核心思想
|
要解決的問題
|
|
S 單一職責原則 (SRP) |
一個類應該有且僅有一個引起它變化的原因。
|
一個“上帝類”承擔過多職責,導致牽一髮而動全身,難以維護。
|
|
O 開閉原則 (OCP) |
軟件實體應對擴展開放,對修改關閉。
|
添加新功能時需要修改現有已測試通過的代碼,引入風險。
|
|
L 里氏替換原則 (LSP) |
子類對象必須能夠替換其父類對象,且程序行為不變。
|
繼承被誤用,子類重寫父類方法後,破壞了父類聲明的行為約定。
|
|
I 接口隔離原則 (ISP) |
客户端不應被迫依賴其不需要的接口。
|
接口過於龐大和臃腫,導致實現類被迫實現許多無關的方法。
|
|
D 依賴倒置原則 (DIP) |
高層模塊不應依賴低層模塊,二者都應依賴抽象。
|
模塊間緊密耦合,難以替換底層實現,不利於測試和擴展。
|
S:單一職責原則 (SRP)
這個原則要求一個類只負責一項明確的職責。無論名稱如何,其核心思想始終如一:只將那些真正屬於一起的東西組合在一起,而將那些並非嚴格屬於一起的東西分離開來。或者換句話説:將那些因不同原因而發生變化的事物分離開來。通過這樣做,你可以減少代碼中不同方面之間的人為耦合,並幫助你的軟件更好地適應變化。在最佳情況下,你可以在唯一的一個地方修改軟件的某個特定方面。
如下所示的抽象Document類。
//#include <some_json_library.h> // Potential physical dependency
class Document
{
public:
// ...
virtual ~Document() = default;
virtual void exportToJSON( /*...*/ ) const = 0;
virtual void serialize( ByteStream&, /*...*/ ) const = 0;
// ...
};
派生類和文檔的使用者可能因以下任何原因而需要變更:
exportToJSON()函數的實現細節發生變化,因為它直接依賴於所使用的 JSON 庫。exportToJSON()函數的簽名發生變化,因為其底層實現發生改變。Document類和serialize()函數發生變化,因為它們直接依賴於ByteStream類。serialize()函數的實現細節發生變化,因為它直接依賴於其實現細節。- 所有類型的文檔都因直接依賴於
DocumentType枚舉而需要變更。
一次變更將影響到代碼庫中相當多的地方,這構成了維護風險。單一職責原則建議我們應當分離關注點和那些並不真正屬於一起的事物,即那些非內聚的(粘合的)事物。換句話説,它建議我們將因不同原因而發生變化的事物分離成不同的變化點。
class Document
{
public:
// ...
virtual ~Document() = default;
// No more 'exportToJSON()' and 'serialize()' functions.
// Only the very basic document operations, that do not
// cause strong coupling, remain.
// ...
};
O:開閉原則 (OCP)
這個原則強調通過擴展(如繼承)來添加新功能,而非修改現有代碼。
假設我們有一個 AreaCalculator 類,它包含一個計算不同圖形面積的函數。當需要增加新的圖形時,就必須修改這個類的 calculateArea 函數,添加新的 if-else 分支。
// 違反 OCP 的設計:添加新圖形需要修改現有函數
class AreaCalculator {
public:
double calculateArea(const std::string& shapeType, ...) {
if (shapeType == "circle") {
// 計算圓形面積
} else if (shapeType == "rectangle") {
// 計算矩形面積
}
// 添加新圖形?必須在這裏修改!
}
};
遵循 OCP 的重構如下:
// 通過抽象和多態實現 OCP
class Shape {
public:
virtual ~Shape() = default;
virtual double area() const = 0; // 穩定的抽象接口
};
class Circle : public Shape {
double radius;
public:
Circle(double r) : radius(r) {}
double area() const override { return 3.14159 * radius * radius; }
};
class Rectangle : public Shape {
double width, height;
public:
Rectangle(double w, double h) : width(w), height(h) {}
double area() const override { return width * height; }
};
class AreaCalculator {
public:
// 這個函數對修改關閉:無論新增多少種圖形,都無需修改它
static double calculateArea(const Shape& shape) {
return shape.area(); // 依賴抽象,而非具體類型
}
};
現在,如果需要支持三角形,我們只需創建一個新的 Triangle 類繼承自 Shape 並實現 area() 方法即可。AreaCalculator::calculateArea 函數無需任何改動,從而實現了對擴展開放,對修改關閉。
L:里氏替換原則 (LSP)
抽象在軟件設計和軟件架構中扮演着至關重要的角色。換句話説,良好的抽象是管理複雜性的關鍵。沒有它們,良好的設計和恰當的架構是難以想象的。一個抽象表達了預期的行為,這需要被滿足。
LSP原則指導我們實現良好的抽象,它有幾個要求:
- 前置條件在子類型中不能被強化:子類型在函數中所期望的條件不能多於超類型所表達的。這將違反抽象中的預期。
- 後置條件在子類型中不能被削弱:子類型在離開函數時所承諾的條件不能少於超類型所承諾的。同樣,這將違反抽象中的預期。
- 子類型中的函數返回類型必須是協變的:子類型的成員函數可以返回一個類型,該類型本身是超類型中對應成員函數返回類型的子類型。這一特性在C++中直接有語言支持。然而,子類型不能返回超類型中對應函數返回類型的任何超類型。
- 子類型中的函數參數必須是逆變的:在成員函數中,子類型可以接受超類型中對應成員函數參數的一個超類型。這一特性在C++中沒有直接的語言支持。
- 超類型的不變式必須在子類型中得以保持:關於超類型狀態的任何預期,在所有對其成員函數(包括子類型的成員函數)的調用之前和之後,都必須始終有效。
一個經典的違反 LSP 的例子是正方形(Square)繼承矩形(Rectangle)。從數學上説,正方形是一種矩形,但在編程中,如果子類改變了父類方法的行為約定,就會出問題。
// 違反 LSP 的設計:子類改變了父類的行為契約
class Rectangle {
protected:
int width, height;
public:
virtual void setWidth(int w) { width = w; }
virtual void setHeight(int h) { height = h; }
int getArea() const { return width * height; }
};
class Square : public Rectangle {
public:
// 正方形設置寬高必須同時改變,這違反了矩形設定的“可獨立修改寬高”的約定
void setWidth(int w) override {
width = height = w;
}
void setHeight(int h) override {
width = height = h;
}
};
void processRectangle(Rectangle& rect) {
rect.setWidth(5);
rect.setHeight(4);
assert(rect.getArea() == 20); // 如果傳入 Square 對象,這裏會斷言失敗!
}
Square 雖然繼承了 Rectangle,但它修改了 setWidth 和 setHeight 的語義,導致在期望 Rectangle 行為的 processRectangle 函數中無法正確工作。
一個更好的設計是重新思考繼承關係,或者讓 Rectangle 和 Square 都繼承自一個更抽象的 Shape 基類,而不存在這種可變的設置方法。
I:接口隔離原則 (ISP)
這個原則指導我們創建小而專的接口,而不是大而全的臃腫接口。
考慮一個模擬辦公場景的接口 IWorker,它包含了工作和吃飯兩種行為。這對於人類員工是合適的,但對於機器人員工,eat() 方法就是多餘的。
// 違反 ISP 的設計:機器人被迫實現它不需要的方法
class IWorker {
public:
virtual void work() = 0;
virtual void eat() = 0; // 機器人不需要吃飯!
};
class HumanWorker : public IWorker {
public:
void work() override { /* 工作 */ }
void eat() override { /* 吃飯 */ }
};
class RobotWorker : public IWorker {
public:
void work() override { /* 工作 */ }
void eat() override { /* 空實現或拋出異常,這很糟糕! */ }
};
遵循 ISP 的重構如下:
// 遵循 ISP 的設計:將胖接口拆分為多個特定接口
class IWorkable {
public:
virtual void work() = 0;
};
class IEatable {
public:
virtual void eat() = 0;
};
class HumanWorker : public IWorkable, public IEatable {
public:
void work() override { /* 工作 */ }
void eat() override { /* 吃飯 */ }
};
class RobotWorker : public IWorkable {
public:
void work() override { /* 工作 */ }
// 不再需要實現 eat 方法
};
這樣,RobotWorker 只需要依賴它真正需要的 IWorkable 接口,代碼更加清晰,也不會被強迫實現無用的方法。
D:依賴倒置原則 (DIP)
抽象的高層類定義應該和其他抽象類放在一起。外部依賴這些抽象類。
這個原則是高層策略和底層實現細節之間解耦的關鍵。
假設有一個 NotificationService(高層模塊)直接依賴於一個具體的 EmailSender(低層模塊)來發送郵件。
// 違反 DIP 的設計:高層模塊直接依賴低層模塊
class EmailSender {
public:
void sendEmail(const std::string& message) { ... }
};
class NotificationService {
EmailSender sender; // 直接依賴具體實現
public:
void sendNotification(const std::string& msg) {
sender.sendEmail(msg); // 緊耦合,難以切換髮送方式
}
};
如果將來想改用短信發送,就必須修改 NotificationService 類。遵循 DIP 的重構如下:
// 遵循 DIP 的設計:雙方都依賴於抽象
class MessageSender { // 抽象接口
public:
virtual ~MessageSender() = default;
virtual void send(const std::string& message) = 0;
};
class EmailSender : public MessageSender { // 低層模塊依賴抽象
public:
void send(const std::string& message) override { ... }
};
class SmsSender : public MessageSender { // 新的發送方式
public:
void send(const std::string& message) override { ... }
};
class NotificationService {
std::unique_ptr<MessageSender> sender; // 高層模塊依賴抽象
public:
// 通過構造函數注入依賴(依賴注入)
NotificationService(std::unique_ptr<MessageSender> s) : sender(std::move(s)) {}
void sendNotification(const std::string& msg) {
sender->send(msg); // 無需關心具體實現
}
};
現在,NotificationService 只關心發送消息這個抽象概念,而不關心具體是通過郵件還是短信。我們可以在程序運行時(例如在 main 函數中)決定使用哪種具體的發送方式,然後“注入”給 NotificationService。這使得代碼極其靈活,也便於單元測試(可以輕鬆注入一個模擬的 MessageSender)。