[C++设计模式] 深入理解面向对象设计原则
文章目录
在了解为什么需要设计模式之后,我们再来深入理解关于面向对象的设计原则。
在软件开发中,变化是永恒的主题。当需求发生变化时,设计的灵活性和可扩展性便成为系统能否持续演进的关键。设计模式的核心就在于利用面向对象设计原则,帮助开发者构建既能应对变化又具有高度复用性的系统。
本文将从面向对象设计的必要性入手,逐步分析其设计原则和应用,并探讨如何将这些原则上升为设计经验。
面向对象设计:为什么?
变化是复用的天敌
在软件开发中,“变化”无处不在:
- 用户需求不断更新。
- 新技术不断涌现。
- 系统规模不断扩大。
复用性和灵活性往往因变化而受到挑战。面向对象设计的目标在于通过一系列原则和模式,隔离变化点,让系统的核心逻辑在变化中保持稳定。
面向对象的价值:抵御变化
面向对象设计通过封装和抽象,将变化控制在可预见的范围内:
- 封装变化点:将容易变化的部分封装起来,不影响其他模块。
- 抽象接口:通过接口定义不变的部分,隐藏具体实现的变化。
重新认识面向对象
理解“隔离变化”
面向对象的核心思想是隔离变化:让容易变化的部分独立,不干扰稳定的部分。
宏观角度来看,面向对象的构建方式更能适应软件的变化,能将变化所带来的影响减为最小。
各司其职
微观角度来看,每个类、对象都应该承担明确的职责。职责越单一,变化的影响范围越小,系统的可维护性越高。由于需求所导致的新增类型不应该影响原来类型的实现,即为各司其职。
对象是什么?
**语言层面:**对象封装了代码和数据。
**规格层面:**对象是一系列可以被直接使用的公共接口。
**概念层面:**对象是某种拥有责任 (功能) 的抽象。
对象是数据和行为的封装体。它不仅包含数据,还定义了操作这些数据的方法。对象的使命在于协同完成任务,同时隐藏实现细节。
面向对象设计原则
核心:高内聚,低耦合。
单一职责原则 (SRP)
定义:一个类只应该有一个引起其变化的原因。即,每个类的职责应当单一。
目的:降低类的复杂性,减少因为职责过多而导致的变更影响。
示例代码:一个打印报表的系统。
#include <iostream>
#include <string>
using namespace std;
// 负责生成报表数据
class ReportGenerator {
public:
string generateReport() {
return "Report Content";
}
};
// 负责打印报表
class ReportPrinter {
public:
void printReport(const string& reportContent) {
cout << "Printing Report: " << reportContent << endl;
}
};
int main() {
ReportGenerator generator;
ReportPrinter printer;
// 生成并打印报表
string report = generator.generateReport();
printer.printReport(report);
return 0;
}
为何实现SRP:
ReportGenerator
只负责生成报表,不负责打印。ReportPrinter
只负责打印报表,不关心生成逻辑。- 任何一方变化(如改变打印逻辑)都不会影响另一方。
开放封闭原则 (OCP)
定义:软件实体(类、模块、函数)应该对扩展开放,对修改封闭。
目的:通过扩展实现新功能,而不是通过修改现有代码。
示例代码:不同类型图形的绘制。
#include <iostream>
#include <vector>
using namespace std;
// 图形基类
class Shape {
public:
virtual void draw() const = 0; // 抽象方法:绘制
virtual ~Shape() = default;
};
// 圆形实现
class Circle : public Shape {
public:
void draw() const override {
cout << "Drawing Circle" << endl;
}
};
// 矩形实现
class Rectangle : public Shape {
public:
void draw() const override {
cout << "Drawing Rectangle" << endl;
}
};
// 渲染器
void renderShapes(const vector<Shape*>& shapes) {
for (const auto& shape : shapes) {
shape->draw(); // 动态绑定
}
}
int main() {
Circle circle;
Rectangle rectangle;
vector<Shape*> shapes = {&circle, &rectangle};
renderShapes(shapes);
return 0;
}
为何实现OCP:
- 添加新形状(如三角形)只需继承
Shape
,无需修改现有代码。 renderShapes
函数可以处理任何实现了Shape
的新类,扩展性强。
里氏替换原则 (LSP)
定义:子类必须能够替换掉基类而不影响程序正确性。
目的:保证继承关系的正确性,避免引入不必要的兼容性问题。
示例代码:一个计算四边形面积的系统。
#include <iostream>
using namespace std;
// 基类:四边形
class Quadrilateral {
public:
virtual double area() const = 0; // 抽象方法:计算面积
virtual ~Quadrilateral() = default;
};
// 子类:矩形
class Rectangle : public Quadrilateral {
protected:
double length, width;
public:
Rectangle(double l, double w) : length(l), width(w) {}
double area() const override {
return length * width;
}
};
// 子类:正方形
class Square : public Rectangle {
public:
Square(double side) : Rectangle(side, side) {}
};
int main() {
Rectangle rect(5, 3);
Square square(4);
Quadrilateral* quad = ▭
cout << "Rectangle Area: " << quad->area() << endl;
quad = □
cout << "Square Area: " << quad->area() << endl;
return 0;
}
为何实现LSP:
Square
作为Rectangle
的子类,可以正常替代Rectangle
,满足面积计算逻辑。- 程序运行时无需额外判断对象类型,继承关系清晰。
依赖倒置原则 (DIP)
定义:高层模块不依赖于低层模块,二者都依赖于抽象。
目的:减少模块间的耦合,增强系统灵活性。
示例代码:日志记录系统。
#include <iostream>
#include <string>
using namespace std;
// 日志接口
class ILogger {
public:
virtual void log(const string& message) = 0;
virtual ~ILogger() = default;
};
// 文件日志实现
class FileLogger : public ILogger {
public:
void log(const string& message) override {
cout << "Logging to file: " << message << endl;
}
};
// 数据库日志实现
class DatabaseLogger : public ILogger {
public:
void log(const string& message) override {
cout << "Logging to database: " << message << endl;
}
};
// 高层模块
class App {
ILogger& logger; // 依赖抽象 (引用在初始化列表中进行定义)
public:
App(ILogger& log) : logger(log) {}
void run() {
logger.log("Application started");
}
};
int main()
{
FileLogger fileLogger;
App app(fileLogger); // 注入文件日志
app.run();
return 0;
}
为何实现DIP:
App
不依赖具体日志实现(如文件或数据库),依赖抽象接口(ILogger& logger在构造时候的指向)
。- 日志实现的变化(如添加网络日志)不会影响
App
。
接口隔离原则 (ISP)
定义:类不应该依赖于它不需要的接口。
目的:避免因为不相关的接口导致类的复杂性增加。
示例代码:打印机接口设计。
#include <iostream>
using namespace std;
// 通用打印机接口
class IPrinter {
public:
virtual void print() = 0;
virtual ~IPrinter() = default;
};
// 多功能打印机接口
class IMultiFunctionPrinter : public IPrinter {
public:
virtual void scan() = 0;
virtual void fax() = 0;
};
// 普通打印机
class BasicPrinter : public IPrinter {
public:
void print() override {
cout << "Basic printing..." << endl;
}
};
// 多功能打印机
class MultiFunctionPrinter : public IMultiFunctionPrinter {
public:
void print() override {
cout << "Printing..." << endl;
}
void scan() override {
cout << "Scanning..." << endl;
}
void fax() override {
cout << "Faxing..." << endl;
}
};
int main() {
BasicPrinter printer;
printer.print();
return 0;
}
为何实现ISP:
BasicPrinter
只实现其需要的IPrinter
,而不是实现不需要的scan
和fax
。- 避免强迫子类实现不相关的方法,保持接口简洁。
举一个生活中的例子:
可以将接口隔离原则比作电器的遥控器。想象一下,你有一个电视遥控器,如果这个遥控器上不仅可以控制电视的音量和频道,还包含了洗衣机、冰箱和空调的控制按钮,那么它将变得非常复杂和难以使用。对于用户来说,电视遥控器应该只包含与电视控制相关的按钮,而不应该包含其他设备的控制按钮。这就是接口隔离原则的目的:只提供类所需的接口,避免额外的、不需要的复杂性。
优先使用对象组合,而不是类继承
定义:通过组合(has-a 关系)来复用代码,而不是通过继承(is-a 关系)来扩展功能。
目的:避免类继承导致的紧耦合,提升灵活性和扩展性。
可参考3.4依赖倒置原则 (DIP)app
高层类的实现
示例代码:文件读取器的实现。
#include <iostream>
#include <string>
using namespace std;
// 文件读取器基类
class FileReader {
public:
virtual string read() const = 0; // 抽象方法:读取文件
virtual ~FileReader() = default;
};
// 文本文件读取器
class TextFileReader : public FileReader {
public:
string read() const override {
return "Reading text file...";
}
};
// 文件处理器
class FileProcessor {
FileReader& reader; // 使用组合,而非继承
public:
FileProcessor(FileReader& reader) : reader(reader) {}
void process() {
cout << reader.read() << endl;
cout << "Processing file..." << endl;
}
};
int main() {
TextFileReader textReader;
FileProcessor processor(textReader);
processor.process();
return 0;
}
为何优先使用组合:
FileProcessor
使用FileReader
的接口,通过组合实现功能,而不是继承。- 如果需要扩展新的读取方式(如 JSON 文件读取器),只需实现
FileReader
接口,无需修改FileProcessor
。
封装变化点
定义:将可能发生变化的部分封装起来,与稳定的部分隔离。
目的:减少变化对系统其他部分的影响。
示例代码:支付系统的实现。
#include <iostream>
#include <string>
using namespace std;
// 支付接口
class PaymentMethod {
public:
virtual void pay(double amount) const = 0;
virtual ~PaymentMethod() = default;
};
// 信用卡支付
class CreditCardPayment : public PaymentMethod {
public:
void pay(double amount) const override {
cout << "Paying $" << amount << " using Credit Card." << endl;
}
};
// PayPal支付
class PayPalPayment : public PaymentMethod {
public:
void pay(double amount) const override {
cout << "Paying $" << amount << " using PayPal." << endl;
}
};
// 支付处理器
class PaymentProcessor {
PaymentMethod& method; // 封装支付方式
public:
PaymentProcessor(PaymentMethod& method) : method(method) {}
void processPayment(double amount) {
method.pay(amount);
}
};
int main() {
CreditCardPayment creditCard;
PayPalPayment paypal;
PaymentProcessor processor(creditCard);
processor.processPayment(100);
return 0;
}
为何封装变化点:
- 支付方式是容易变化的部分,通过抽象
PaymentMethod
隔离变化。 - 添加新的支付方式(如数字货币支付)时,无需修改
PaymentProcessor
。
针对接口编程,而不是针对实现编程
定义:依赖于抽象接口,而不是具体实现。
目的:通过接口定义稳定的行为,屏蔽具体实现的变化。
示例代码:日志记录系统。
#include <iostream>
#include <string>
using namespace std;
// 日志接口
class ILogger {
public:
virtual void log(const string& message) const = 0;
virtual ~ILogger() = default;
};
// 文件日志实现
class FileLogger : public ILogger {
public:
void log(const string& message) const override {
cout << "Logging to file: " << message << endl;
}
};
// 控制台日志实现
class ConsoleLogger : public ILogger {
public:
void log(const string& message) const override {
cout << "Logging to console: " << message << endl;
}
};
// 应用程序类
class Application {
ILogger& logger; // 依赖于接口
public:
Application(ILogger& logger) : logger(logger) {}
void run() {
logger.log("Application is running");
}
};
int main() {
ConsoleLogger consoleLogger;
FileLogger fileLogger;
Application app(consoleLogger);
app.run();
return 0;
}
为何针对接口编程:
Application
仅依赖于ILogger
接口,屏蔽了具体日志实现的细节。- 更换日志实现(如文件日志、网络日志)时无需修改
Application
。
总结
面向对象设计的八个原则相辅相成,构成了一套强大的设计体系:
原则 | 主要目标 |
---|---|
单一职责原则 (SRP) | 每个类专注于单一职责,降低复杂性和变化影响。 |
开放封闭原则 (OCP) | 通过扩展增加功能,对已有代码不做修改。 |
里氏替换原则 (LSP) | 确保子类能替代基类,保持程序正确性。 |
依赖倒置原则 (DIP) | 高层模块依赖抽象,而非具体实现。 |
接口隔离原则 (ISP) | 精简接口,避免强迫类实现不需要的方法。 |
优先使用对象组合,而不是继承 | 通过组合增强灵活性,避免继承导致的耦合。 |
封装变化点 | 隔离容易变化的部分,减少变化对系统的影响。 |
针对接口编程,而不是实现编程 | 依赖抽象接口,屏蔽实现细节。 |
通过遵循这些原则,可以显著提升代码的复用性、灵活性和可维护性,同时构建一个高内聚、低耦合的系统。
四、面向接口设计
产业发展的标志之一是接口标准化。
例如:
- 汽车行业:标准化零件设计。
- 支付行业:支付接口的统一。
在软件开发中,接口设计的标准化也是实现系统协同与复用的关键。
示例:标准化接口
class Payment {
public:
virtual void processPayment(double amount) = 0;
};
class CreditCardPayment : public Payment {
public:
void processPayment(double amount) override {
cout << "Processing credit card payment of $" << amount << endl;
}
};
class PayPalPayment : public Payment {
public:
void processPayment(double amount) override {
cout << "Processing PayPal payment of $" << amount << endl;
}
};
void processTransaction(Payment& paymentMethod, double amount) {
paymentMethod.processPayment(amount);
}
五、将设计原则提升为设计经验
- 不断实践:通过实际项目理解设计原则的重要性。
- 积累模板:将设计原则总结为可复用的模板或模式。
- 分享经验:通过团队交流与讨论,让设计经验得到传承和优化。
总结
面向对象设计原则是构建灵活、可扩展系统的基石。通过遵循这些原则,开发者可以有效隔离变化、降低复杂性,同时提升代码的复用性和维护性。在设计模式的学习中,将原则与实际代码结合,才能真正理解面向对象设计的力量。
该专栏之后的文章会对设计模式进行逐个讲解,尽量做到详细通俗,但请保证有一定的语言基础。