设计模式
1 7 项基本原则
在学习 23 种设计模式前,需要对 7 大设计原则进行了解,经典的设计模式中都或多或少的使用了这些设计原则,可以说设计模式是基于这些原则来实现的。
1.1 单一职责原则
单一职责原则(Single Responsibility Principle),通俗的说,即一个类只负责一项职责。假设某个类负责两项不同的职责 P1 和 P2,则当职责 P1 发生改变时,可能会导致正常运行的职责 P2 发生故障。
该原则能降低类的复杂度,提高代码可读性和系统的可维护性。
在工作中,可能会由于业务逻辑足够简单,从而违背该原则,因此根据实际情况而定,不用过于死板。
1.2 开放-关闭原则
开闭原则(Open-Closed Principle)表示软件实体(类、模块、函数等)是可以被扩展的,但不可被修改。如果软件能满足该原则,它有两项优点:
- 软件拥有很强的适应性和灵活性,能扩展已存在的系统来提供新的功能
- 已存在的模块、特别是重要的抽象模块,不需要被修改,软件有很强的稳定性和持久性
不可能所有的模块都遵守 OCP 原则,但尽可能不去修改已经写好的代码,而是去扩展它
1.3 里氏替换原则
里氏替换原则(Liskov Substitution Principle)的重点在面向对象编程时(继承),子类可以扩展父类功能,但不能改变父类的原有功能。要保证修改前后,功能的一致性和可用性。
程序运行时父类可以被子类替换,但反过来可能会导致异常。
1.4 依赖倒转原则
依赖倒转(Dependence Inversion Principle)的核心思想是面向接口编程。高层模块不应该依赖与底层模块,进一步说,抽象不应该依赖于细节,细节应该依赖于抽象。下面举例说明:
class Book{
public String getContent(){
return "这是一个有趣的故事";
}
}
class Mother{
public void say(Book book){
System.out.println("妈妈开始讲故事");
System.out.println(book.getContent());
}
}
public class Client{
public static void main(String[] args){
Mother mother = new Mother();
mother.say(new Book());
}
}
上述代码中,Mother 模块只能读书,如果希望读取报纸、杂志等其他物品时,则需要修改 Mother 类的接口:
class Newspaper{
public String getContent(){
return "这个一则重要的新闻";
}
}
这是由于 Book 和 Mother 的耦合度太高。
下面看另一个例子:
interface IReader{
public String getContent();
}
class Newspaper implements IReader {
public String getContent(){
return "这个一则重要的新闻";
}
}
class Book implements IReader{
public String getContent(){
return "这是一个有趣的故事";
}
}
class Mother{
public void say(IReader reader){
System.out.println("妈妈开始讲故事");
System.out.println(reader.getContent());
}
}
public class Client{
public static void main(String[] args){
Mother mother = new Mother();
mother.say(new Book());
mother.say(new Newspaper());
}
}
这样修改后,无论提供哪一种读物,实现了 IReader 接口后就可以被 Mother 类读取。这个案例中,高层模块代表 Mother 类负责业务逻辑,低层模块则是 Book、Newspaper 实现细节。
通过依赖倒转原则,能降低类的耦合性,降低修改程序的风险
1.5 接口隔离原则
接口隔离原则(Interface Segregation Principle)强调一个类对另一个类的依赖应该建立在最小的接口上。即一个实现类,不应该依赖它不需要的接口。
- 建立单一接口,尽可能细化接口,接口中的方法尽可能少
- 接口设计过小会导致设计复杂化,因此适度衡量
1.6 迪米特法则
迪米特法则(Law Of Demeter)又称为最少知道原则,简单来说:一个类不要依赖太多其他类,尽量减少依赖关系,做到低耦合、高内聚。
一个对象应该对其他对象保持最少的了解。通俗的讲:只和直接朋友通信。
两个对象之间有耦合关系,就能认为这两个对象之间是朋友关系。耦合的方式有依赖、关联、组合、聚合等。
其中,我们称出现成员变量、方法参数、方法返回值中的类为直接的朋友,而出现在局部变量中的类则不是直接的朋友。
因此,陌生的类不要作为局部变量的形式出现在类的内部。
1.7 组合/聚合/复用原则
该原则(Composite/Aggregate Reuse Principle)提出在实际开发中,尽量使用聚合/组合,而不要直接类继承。
举例:
---
title: Extend Example
---
classDiagram
Employee <|-- Sales
Employee <|-- Manager
Employee <|-- Worker
上例中,简单的继承基类 Employee,将基类的实现细节暴露给子类。该做法封装性差,当基类的实现发生了变化,则子类也不得不进行修改。
---
title: Aggregation Example
---
classDiagram
Employee o-- Role
Role <|.. Sales
Role <|.. Manager
Role <|-- Worker
class Role["«interface» Role"]
将其更改为上图所示,增加接口,Employee 和 Role 之间使用聚合关系实现,当 Employee 类发生改变时,对其他类的影响较少。
聚合/组合的细节对客户端来说是不可见的,能更好的降低耦合度
2 23 种设计模式
总体来说,设计模式可以分为三大类:
创建型模式(5种):工厂方法模式、抽象工厂模式、单例模式、建造者模式、原型模式
结构型模式(7种):适配器模式、装饰器模式、代理模式、外观模式、桥接模式、组合模式、享元模式
行为型模式(11种):策略模式、模板方法模式、观察者模式、迭代子模式、责任链模式、命令模式、备忘录模式、状态模式、访问者模式、中介者模式、解释器模式
由于工作中很难覆盖到所有的使用场景,因此会挑选出常用的几种设计模式作为参考
首先从创建型模式开始:
2.1 单例模式
确保类只有一个实例,并提供一个全局的访问点。它拥有饿汉式、懒汉式、双重校验锁、枚举等多种实现方法,其中不同的方法有不同的优缺点。
适合需要控制实例数量以节省系统资源的情况,同时能确保共享资源的独占访问,避免冲突,统一管理等。
其中 JAVA 语言更推荐枚举方式创建,由于它天然线程安全,且能防止序列化和反射攻击:
public enum Singleton {
INSTANCE;
public void doSomething() {
// ...
}
}
而 C++ 通常利用静态局部变量的特性来实现单例模式,这种方式天然线程安全:
class Singleton {
public:
static Singleton& getInstance() {
static Singleton instance;
return instance;
}
// 删除拷贝构造函数和赋值操作符,防止拷贝和赋值
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
private:
Singleton() {}
};
可以看这篇:Java单例模式的7种写法
2.2 工厂模式
创建模式等几种较为基础,后续有精力补充吧。
简单描述下,工厂模式大致有简单工厂、抽象工厂、静态工厂等,为了方便批量创建对象,能屏蔽创建细节。将对象创建和使用分离,增加程序的灵活性和可维护性。
2.3 建造者模式
将一个复杂对象的构建创建分为多个模块构造,使同样的构造过程能创建不同的表示。当需要构建的对象具有复杂的内部结构,并且创建过程依赖于多个独立的步骤时,建造者模式非常适用。
结构型模式
2.4 适配器模式
将一个类的接口转换成另一个接口,使原本由于接口不兼容而不能一起工作的类可以一起工作。
2.5 外观模式
提供一个统一的接口,用来访问子系统中的一组接口。即定义一个高层接口,让子系统更加容易使用。
2.6 装饰者模式
当需要在运行时动态地给对象添加功能,而这些功能又可以动态地撤销时,装饰者模式非常适用。
简单来说,
① 首先定义一个基类接口
② 根据上述接口定义一个含有基本功能的基类
③ 再根据接口实现一个抽象装饰类,抽象是为了定义更多的具体装饰类;且构造的入参为基类接口,则能将基类传入进行装饰操作
④ 定义具体的装饰类,使用时,可以将②的基类传入装饰类参与构造,则能运行装饰类的能力,也能运行基类的能力,完成装饰
// 抽象组件:用户
public interface User {
void action();
}
// 具体组件:普通用户
public class RegularUser implements User {
@Override
public void action() {
System.out.println("普通用户执行操作");
}
}
// 抽象装饰者:用户装饰器
public abstract class UserDecorator implements User {
protected User user;
public UserDecorator(User user) {
this.user = user;
}
@Override
public void action() {
user.action();
}
}
// 具体装饰者:管理员装饰器
public class AdminDecorator extends UserDecorator {
public AdminDecorator(User user) {
super(user);
}
@Override
public void performAction() {
super.performAction();
System.out.println("管理员额外操作");
}
}
// 客户端代码
public class Client {
public static void main(String[] args) {
User regularUser = new RegularUser();
regularUser.performAction();
User adminUser = new AdminDecorator(regularUser);
adminUser.performAction();
}
}
2.7 代理模式
代理模式分为三类:静态代理、动态代理、CGLIB代理。其中动态代理、CGLIB 代理这些概念在 JAVA 语言中常见,而 C++ 中不常用。
思考后会发现,代理模式与装饰模式非常相似,都通过包装一个对象来实现对其功能的增强或控制。
但两者设计的目的和应用场景是不同的:
- 装饰者模式:用于动态地为对象添加功能,强调功能的增强和组合。这些功能可以灵活组合,强调的是功能的增强,且客户端通常会意识到对象被装饰了。
- 代理模式:用于控制对对象的访问,强调访问的控制和权限管理。并且客户端通常不会意识到代理的存在。
行为模式
2.8 策略模式
定义一系列算法,把他们封装起来,并使他们能够相互替换。但容易导致类膨胀。
例如业务中使用图像识别时,不同格式的图片编码方式不同,通常需要使用相应的解码库来处理:
// 抽象策略类:定义图片处理的接口
class ImageStrategy {
public:
virtual ~ImageStrategy() = default;
virtual void processImage(const std::string& imagePath) = 0;
};
// 具体策略类:WebP处理策略
class WebPStrategy : public ImageStrategy {
public:
void processImage(const std::string& imagePath) override {
// 具体的WebP图片处理逻辑
}
};
// 上下文类:图片读取器
class ImageReader {
private:
std::map<std::string, ImageStrategy*> strategyMap;
public:
ImageReader() {
// 初始化策略映射
strategyMap[".png"] = new PNGStrategy();
strategyMap[".webp"] = new WebPStrategy();
}
~ImageReader() {
// 释放策略对象
}
void readImage(const std::string& imagePath) {
// 根据文件扩展名选择对应的策略
}
};
2.9 观察者模式
定义对象间的一对多依赖,当一个对象状态改变时,所有依赖它的对象都得到通知并被自动更新。
业务中,当一张图片被读取成功时,则会通知所有的模型开始解析,如:
// 抽象观察者类
class IObserver {
public:
virtual ~IObserver() = default;
virtual void Update(const cv::Mat& image) = 0;
};
// 抽象被观察者类
class ISubject {
public:
virtual ~ISubject() = default;
virtual void Attach(std::shared_ptr<IObserver> observer) = 0;
virtual void Detach(std::shared_ptr<IObserver> observer) = 0;
virtual void Notify() = 0;
};
// 具体被观察者类:图片读取器
class ImageLoader : public ISubject {
public:
void Attach(std::shared_ptr<IObserver> observer) override {
observers_.push_back(observer);
}
void Detach(std::shared_ptr<IObserver> observer) override {
observers_.remove(observer);
}
void Notify() override {
for (const auto& observer : observers_) {
observer->Update(image_);
}
}
bool LoadImage(const std::string& image_path) {
// 读取图片
Notify();
return true;
}
private:
std::list<std::shared_ptr<IObserver>> observers_;
cv::Mat image_;
};
// 具体观察者类:模型1(类别分类)
class ClassificationModel : public IObserver {
public:
void Update(const cv::Mat& image) override {
// 模型处理逻辑
}
};
// 具体观察者类:模型2(人脸识别)
class FaceRecognitionModel : public IObserver {
public:
void Update(const cv::Mat& image) override {
// 模型处理逻辑
}
};
假设根据模型分为不同的线程处理,则能充分的利用资源,而不会导致某个模型任务未完成,而导致整个流程阻塞。
由于业务的模型部署在嵌入式设备中,各个模型的执行时间差距不大,执行时间短暂,阻塞时间尚在容忍范围内;若根据模型来区分图片的执行进度,势必引入更多的控制位标志图片是否进行推理。
因此权衡后,业务关注图片的整体推理进度,某模型任务失败,则该图片任务重新执行,故采用上述模式,解耦模型处理逻辑。
2.10 模板方法模式
定义一个操作的骨架,并将一些步骤延迟到子类中,使得子类可以在不改变算法结构的前提下,重新定义算法的某些特定步骤。