01. 设计模式概述
一、什么是设计模式
设计模式(Design Pattern)是一套被反复使用的、多数人知晓的、经过分类编目的代码设计经验的总结,使用设计模式是为了可以重用代码,让代码更容易被他人理解并且提高代码的可靠性。
设计模式一般包含 模式名称、问题、目的、解决方案、效果 等组成要素,其中关键要素是 模式名称、问题、解决方案 和 效果。
- 模式名称(Pattern Name):通过一两个词来为模式命名,以便我们更好地理解模式并方便开发人员之间的交流,绝大多数模式都是根据其功能或模式结构来命名的。
- 问题(Problem):描述了应该在何时使用模式,它包含了设计中存在的问题以及问题存在的原因。
- 解决方案(Solution):描述了一个设计模式的组成部分,以及这些组成部分之间的相互关系、各自的职责和协作方式,通过解决方案通过 UML 类图和核心代码进行描述。
- 效果(Consequence):描述了模式的优缺点以及在使用模式时应权衡的问题。
二、设计模式的分类
虽然 GOF 设计哦是只有 23 个,但它们各具特色,每个模式都为某一类可重复的设计问题提供了一套解决方案。根据它们的用途,设计模式可分为 创建型(Creational)、结构性(Structural)和 行为型(Behaviroal)3 种。其中,创建型模式 主要用于 如何创建对象。结构性模式 主要用于 描述如何实现类或对象的组合。行为型模式 主要用于 描述类与对象怎样交互以及怎样分配职责。在 GOF 23 种设计模式中,包含 5 种创建型模式、7 种结构性模式 和 11 种行为型模式。
【1】、创建型模式(5 种)
这些设计模式提供了一种在创建对象的同时隐藏创建逻辑的方式,而不是使用 new 运算符直接实例化对象。这使得程序在判断针对某个给定实例需要创建哪些对象时更加灵活。包括 单例模式、工厂方法模式、抽象工厂模式、原型模式、建造者模式。
模型名称 | 定义 |
---|---|
单例模式 (Signletion Pattern) |
确保一个类只有一个实例,而且自行实例化并向整个系统提供这个实例 |
工厂方法模式 (Factory Method Pattern) |
定义一个用于创建对象的接口,让子类决定将哪一个类实例化 |
抽象工厂模式 (Abstract Factory Pattern) |
提供一个创建一系列相关或相互依赖对象对象的接口,而无需指定它们具体的类 |
原型模式 (Prototype Pattern) |
使用原型实例指定创建对象的种类,并且通过赋值这些原型创建新的对象 |
建造者模式 (Builder Pattern) |
将一个复杂对象的构建与它的表示分离,使得同样的构建构成可以创建不同的表示 |
除了 GOF 设计模式中 5 种创建型模式外,通常将一种非 GOF 模式的 简单工厂模式 作为其它工厂模式的基础。
模型名称 | 定义 |
---|---|
简单工厂模式 (Simple Factory Pattern) |
定义一个工厂类,它可以根据参数的不同返回不同类的实例,被创建的实例通常具有共同的父类 |
【2】、结构型模式(7 种)
这些设计模式关注类和对象的组合。继承的概念被用来组合接口和定义组合对象获得新功能的方式。包括 适配器模式、桥接模式、组合模式、装饰模式、外观模式、享元模式、代理模式。
模式名称 | 定义 |
---|---|
适配器模式 (Adapter Pattern) |
将一个接口转换成客户希望的另一个接口,使接口不兼容的那些类可以一起工作 |
桥接模式 (Bridge Pattern) |
将抽象部分与其实现部分分离,使它们都可以独立地变化 |
组合模式 (Composite Pattern) |
组合多个对象形成树形结构以表示具有 “整体一部分” 关系的层次结构 |
装饰模式 (Decorator Pattern) |
动态地给一个对象增加一些额外地职责 |
外观模式 (Facade Pattern) |
外部与一个子系统的通信通过一个统一的外观对象进行,为子系统中的一组接口提供一个一致的入口 |
享元模式 (Flyweight Pattern) |
运用共享计数有效的支持大量细粒度对象的复用 |
代理模式 (Proxy Pattern) |
给某一个对象提供一个代理,并由代理对象控制对原对象的引用 |
【3】、行为型模式(11 种)
这些设计模式关注对象之间的通信。包括 模板方法模式、命令模式、访问者模式、中介者模式、观察者模式、迭代器模式、解释器模式、备忘录模式、状态模式、策略模式、责任链模式***。
模式名称 | 定义 |
---|---|
职责链模式 (Chain of Responsibility Pattern) |
避免将请求者与接收者耦合在一起,让多个对象都有机会接收请求,将这些对象连接成一条链,并且沿着这条链传递请求,直到有对象处理它为止 |
命令模式 (Command Pattern) |
将一个请求封装成一个对象,从而可用不同的请求对客户端进行参数化。对请求排队或者记录请求日志,以及支持可撤销的操作 |
解释器模式 (Interpreter Pattern) |
定义一个语言的文法,并且建立一个解释器来解释该语言中的句子 |
迭代器模式(Iterator Pattern) | 提供一种方法来访问聚合对象,而不用暴露这个对象的内部表示 |
中介者模式 (Mediator Pattern) |
用一个中介对象(中介者)来封装一系列的对象交互,中介者使各对象不需要显示地相互引用,匆从而使其耦合松散,并且可以独立地改变它们之间地交互 |
备忘录模式 (Memento Pattern) |
在不破坏封装的前提下,捕获一个对象的内部状态,并在该对象之外保存这个状态,这样可以在以后将对象恢复到原先保存的状态 |
观察者模式 (Observer Pattern) |
定义对象之间的一种一对多依赖关系,使得每当一个对象状态发生改变时,其相关依赖对象皆得到通知并被自动更新 |
状态模式 (State Pattern) |
允许一个对象在其内部状态改变时改变它的行为,对象看起来似乎改变了它的类 |
策略模式 (Strategy Pattern) |
定义一系列算法类,将每一个算法封装起来,并让它们可以相互替换,使得算法的变化可独立于使用它的客户 |
模板方法模式 (Template Method Pattern) |
定义一个操作中算法的框架,而将一些步骤延迟到子类中,使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤 |
访问者模式 (Visitor Pattern) |
提供一个作用于某对象结构中的各元素的操作表示,使得可以在不改变各元素的类的前提下定义作用于这些元素的新操作 |
三、设计模式的七大原则
此外,设计模式还有七大原则,包括 单一职责原则、开闭原则、里氏替换原则、依赖倒置原则、接口隔离原则、合成复用原则、迪米特法则(又称 最少知道原则)。这些原则为设计模式提供了指导和约束,有助于设计出更加灵活、可维护和可扩展的软件系统。
【1】、单一职责原则(Single Responsibility Principle, SRP)
一个类应该只有一个引起变化的原因。换句话说,每个类应该只有一个职责,如果有多个职责,那么这些职责应该被分离到不同的类中。这样,当需求改变时,只有一个类需要修改。
单一职责原则的核心思想是:一个类不能太“累”!在软件系统中,一个类(大到模块,小到方法)承担的职责越多,它被复用的可能性就越小,而且一个类承担的职责过多,就相当于将这些职责耦合在一起,当其中一个职责变化时,可能会影响其他职责的运作。因此要将这些职责进行分离,将不同的职责封装在不同的类中,即将不同的变化原因封装在不同的类中;如果多个职责总是同时发生改变,则可将它们封装在同一类中。
class Rectangle
{
private:
double width;
double height;
public:
Rectangle(double width, double height) : width(width), height(height) {}
double GetArea(void)
{
return width * height;
}
};
// 圆形类,只负责圆形的相关操作
class Circle
{
private:
double radius;
public:
Circle(double radius) : radius(radius) {}
double GetArea(void)
{
return M_PI * radius * radius;
}
};
#include <iostream>
#include <cmath>
using namespace std;
int main(void)
{
// 创建矩形对象,并计算面积
Rectangle rect(5.0, 10.0);
cout << "Rectangle area: " << rect.GetArea() << endl;
// 创建圆形对象,并计算面积
Circle circle(7.0);
cout << "Circle area: " << circle.GetArea() << endl;
return 0;
}
【2】、开闭原则(Open Closed Principle, OCP)
软件实体(类、模块、函数等)应当是可扩展但不可修改的(对提供方而言,扩展开放,对使用方而言,修改关闭)。当软件需要变化时,尽量通过 扩展软件实体 的行为来实现变化,而 不是通过修改已有的代码 来实现变化。也就是说,你应该能够在不修改原有代码的情况下添加新的功能。这通常通过抽象和接口来实现,允许用户通过继承和多态来扩展行为。用抽象构建框架,用实现扩展细节。
在很多面向对象编程语言中都提供了接口、抽象类等机制,可以通过它们定义系统的抽象层,再通过具体类来进行扩展。如果需要修改系统的行为,无须对抽象层进行任何改动,只需要增加新的具体类来实现新的业务功能即可,实现在不修改已有代码的基础上扩展系统的功能,达到开闭原则的要求。
class Shape
{
public:
virtual void draw(void) = 0;
};
class Rectangle : public Shape
{
public:
void draw(void) override
{
cout << "绘制矩形" << endl;
}
};
class Circle : public Shape
{
public:
void draw(void) override
{
cout << "绘制圆形" << endl;
}
};
class Triangle : public Shape
{
public:
void draw(void) override
{
cout << "绘制三角形" << endl;
}
};
// 绘制图形的类
class GraphEditor
{
public:
void drawShap(Shape * shape)
{
shape->draw();
}
};
#include <iostream>
using namespace std;
int main(void)
{
Rectangle rectangle;
Circle circle;
Triangle triangle;
GraphEditor graphEditor;
graphEditor.drawShap(&rectangle);
graphEditor.drawShap(&circle);
graphEditor.drawShap(&triangle);
return 0;
}
【3】、里氏替换原则(Liskov Substitution Principle, LSP)
里氏替换原则指明 所有引用基类(父类)的地方必须能透明地使用其子类地对象,即 子类必须能够替换其基类。这一原则强调的是基类和子类之间的行为应该是一致的,以便在软件系统中能够无缝地使用子类来替代基类,而不会破坏系统的正确性。
由于使用基类对象的地方都可以使用子类对象,因此在程序中尽量使用基类类型来对对象进行定义,而在运行时再确定其子类类型,用子类对象来替换父类对象。
在运用里氏代换原则时,应该将父类设计为抽象类或者接口,让子类继承父类或实现父接口,并实现在父类中声明的方法。程序运行时,子类实例替换父类实例,可以很方便地扩展系统的功能,无须修改原有子类的代码,增加新的功能可以通过增加一个新的子类来实现。
任何基类可以出现的地方,子类一定可以出现,即子类可以拓展父类的功能,但不能改变父类原有的功能。换句话说,子类继承父类时,除添加新的方法完成新增功能外,尽量不要重写父类的方法。如果通过重写父类的方法来完成新的功能,这样写起来虽然简单,但是整个继承体系的可复用行会比较差。
假设我们有一个基类 Shape
表示形状,它有一个虚函数 getArea()
用于计算面积。然后我们有个派生类:Rectangle
(矩形)。
class Shape
{
public:
virtual double getArea(void) = 0;
};
class Rectangle : public Shape
{
private:
double length;
double width;
public:
Rectangle(void) : length(0), width(0) {}
Rectangle(double length, double width) : length(length), width(width) {}
double getArea(void) override
{
return length * width;
}
double getLength(void)
{
return length;
}
void setlength(double length)
{
this->length = length;
}
double getWidth(void)
{
return width;
}
void setWidth(double width)
{
this->width = width;
}
};
现在,如果我们增加一个派生类 Square
(正方形),但它继承 Rectangle
类而不是直接实现 Shape
,并修改了父类中的设置长度和宽度的方法。
class Square : public Rectangle
{
public:
Square(void) : Rectangle(0, 0) {void}
Square(double side) : Rectangle(side, side) {}
void setLength(double length)
{
Rectangle::setlength(length);
Rectangle::setWidth(length);
}
void setWidth(double width)
{
Rectangle::setlength(width);
Rectangle::setWidth(width);
}
};
然后,我们在主函数中创建 Rectangle
类和 Square
的对象,并调用 set()
方法设置长和宽,然后调用 getArea()
方法求面积。
#include <iostream>
int main(void)
{
Rectangle rect;
Square square;
rect.setlength(10);
rect.setWidth(15);
cout << "Rectangle area: " << rect.getArea() << endl;
square.setLength(10);
cout << "Square area: " << square.getArea() << endl;
return 0;
}
如果此时我们把 rect
对象类型也从父类类型 Rectangle
换成子类类型 Square
,会发现 rect
对象的 getArea()
方法的得到的值发生改变。这违反了里氏替换原则。为了修复这个问题,我们可以将 Square
类直接实现 Shape
接口,确保其属性和行为保持正方形的特性。
【4】、依赖倒置原则(Dependency Inversion Principle, DIP)
依赖于抽象而不是依赖于具体。换句话说,高级模块不应该依赖于低级模块,两者都应该依赖于抽象。同时,抽象不应该依赖于细节,细节应该依赖于抽象,换言之,要针对接口编程,而不是针对实现编程。这有助于减少类之间的耦合度。
依赖倒转原则要求在程序代码中传递参数时或在关联关系中,尽量引用层次高的抽象层类,即使用接口和抽象类进行变量类型声明、参数类型声明、方法返回类型声明,以及数据类型的转换等,而不要用具体类来做这些事情。为了确保该原则的应用,一个具体类应当只实现接口或抽象类中声明过的方法,而不要给出多余的方法,否则将无法调用到在子类中增加的新方法。
在实现依赖倒转原则时,需要针对抽象层编程,而将具体类的对象通过 依赖注入(Dependency Injection,DI)的方式注入其他对象中。依赖注入 是指当一个对象要与其他对象发生依赖关系时,通过抽象来注入所依赖的对象。常用的注入方式有 3 种:构造注入、设值注入(Setter() 方法注入)和 接口注入。
- 构造注入:是指通过构造函数来传入具体类的对象。
- 设值注入:是指通过 Setter() 方法来传入具体类的对象。
- 接口注入:是指通过实现在接口中声明的业务方法来传入具体类的对象。这些方法在定义时使用的是抽象类型,在运行时再传入具体类型的对象,由子类对象来覆盖父类对象。
// 抽象接口
class IReceiver
{
public:
virtual string getInfo(string message) = 0;
};
// 具体实现类A
class Email : public IReceiver
{
public:
string getInfo(string message)
{
return "email message: " + message;
}
};
// 具体实现类B
class WeChat : public IReceiver
{
public:
string getInfo(string message)
{
return "wechat message: " + message;
}
};
// 高级模块,依赖于抽象接口而不是具体实现
class Person {
public:
void receive(IReceiver & receiver)
{
cout << receiver.getInfo("Hello, world") << endl;
}
};
#include <iostream>
using namespace std;
int main(void)
{
// 客户端代码,创建具体实现类的对象,并通过高级模块使用它们
Person person;
Email email;
WeChat wechat;
person.receive(email);
person.receive(wechat);
return 0;
}
【5】、接口隔离原则(Interface Segregation Principle, ISP)
客户端不应该被强制依赖于它们不使用的接口。换句话说,一个接口应该小而完备,只做一件事情。如果接口过于庞大,那么应该将其拆分成更小的接口,以便客户端只需要知道和使用它们感兴趣的方法。
// 定义一个打印接口
class IPrinter
{
public:
virtual void print(string data) = 0;
};
// 定义一个复印接口
class ICopier
{
public:
virtual void copy(string data) = 0;
};
// 定义一个多功能一体机,它同时实现了打印和复印接口
class MultiFunctionMachine : public IPrinter, public ICopier
{
public:
void print(string data) override
{
cout << "Printing: " << data << endl;
}
void copy(string data) override
{
cout << "Copying: " << data << endl;
}
};
#include <iostream>
#include <memory>
using namespace std;
// 客户端代码,展示了如何使用这些接口
int main(void)
{
// 创建一个多功能一体机实例
// shared_ptr是一个智能指针,它会自动管理对象的生命周期
// 初始化方法如下:shared_ptr<T> ptr = make_shared<T>(args...);
shared_ptr<MultiFunctionMachine> machine = make_shared<MultiFunctionMachine>();
machine->print("machine: Hello, World!");
machine->copy("machine: Hello, World!");
// 一个客户端只需要打印功能
shared_ptr<IPrinter> printer = machine;
printer->print("printer: Hello, World!");
// 另一个客户端只需要复印功能
shared_ptr<ICopier> copier = machine;
copier->copy("copier: Hello, World!");
return 0;
}
【6】、合成复用原则(Composite Reuse Principle, CRP)
合成复用原则就是在一个新的对象里通过 关联关系(包括组合关系和聚合关系)来使用一些已有的对象,使之成为新对象的一部分;新对象通过委派调用已有对象的方法达到复用功能的目的。简言之:复用时要尽量使用组合/聚合关系(关联关系),少用继承。
在面向对象设计中,可以通过两种方法在不同的环境中复用已有的设计和实现,即通过 组合/聚合关系 或通过 继承关系,但首先应该考虑使用 组合/聚合,组合/聚合可以使系统更加灵活,降低类与类之间的耦合度,一个类的变化对其他类造成的影响相对较少。其次才考虑 继承,在使用继承时,需要严格遵循里氏代换原则,有效使用继承会有助于对问题的理解,降低复杂度,而滥用继承反而会增加系统构建和维护的难度以及系统的复杂度,因此需要慎重使用继承复用。
通过继承来进行复用的主要问题在于继承复用会破坏系统的封装性,因为继承会将基类的实现细节暴露给子类,由于基类的内部细节通常对子类来说是可见的,所以这种复用又称 “白箱” 复用。如果基类发生改变,那么子类的实现也不得不发生改变。从基类继承而来的实现是静态的,不可能在运行时发生改变,没有足够的灵活性。而且继承只能在有限的环境中使用(如类没有声明为不能被继承)。
由于组合或聚合关系可以将已有的对象(也可称为成员对象)纳入新对象中,使之成为新对象的一部分,因此新对象可以调用已有对象的功能,这样做可以使得成员对象的内部实现细节对于新对象不可见,所以这种复用又称为 “黑箱” 复用。相对继承关系而言,“黑箱” 复用的耦合度相对较低,成员对象的变化对新对象的影响不大,可以在新对象中根据实际需要有选择性地调用成员对象的操作。合成复用可以在运行时动态进行,新对象可以动态地引用与成员对象类型相同的其他对象。
一般而言,如果两个类之间是 Has-A
的关系,应使用 组合或聚合;如果是 Is-A
关系,可使用 继承。Is-A
是严格的分类学意义上的定义,意思是一个类是另一个类的 “一种”;而 Has-A
则不同,它表示某一个角色具有某一项责任。
// 定义一个复用的组件接口
class IComponent
{
public:
virtual void operation() = 0;
};
// 实现组件接口的具体类
class ConcreteComponentA : public IComponent {
public:
void operation() override
{
cout << "ConcreteComponentA operation." << endl;
}
};
class ConcreteComponentB : public IComponent {
public:
void operation() override
{
cout << "ConcreteComponentB operation." << endl;
}
};
// 使用组合的类,复用ConcreteComponentA和ConcreteComponentB的功能
class Composite
{
private:
IComponent * componentA;
IComponent * componentB;
public:
Composite(IComponent * componentA, IComponent * componentB)
: componentA(componentA), componentB(componentB) {}
// 执行组合操作,复用组件的操作
void performOperations()
{
componentA->operation();
componentB->operation();
}
};
#include <iostream>
using namespace std;
int main(void)
{
// 创建具体组件对象
IComponent * componentA = new ConcreteComponentA();
IComponent * componentB = new ConcreteComponentB();
// 通过组合创建复合对象
Composite composite(componentA, componentB);
// 执行复合操作,复用组件的功能
composite.performOperations();
// 清理资源
delete componentA;
delete componentB;
return 0;
}
【7】、迪米特法则(Law of Demeter, LoD)或最少知识原则(Principle of Least Knowledge)
一个对象应该对其他对象保持最少的了解。也就是说,一个类应该尽量减少对其他类的依赖和交互,只与直接相关的类进行通信。这有助于降低系统的复杂性,提高模块间的独立性。
如果一个系统符合迪米特法则,那么当其中某一个模块发生修改时,就会尽量少地影响其他模块,扩展会相对容易。这是对软件实体之间通信的限制。迪米特法则要求限制软件实体之间通信的宽度和深度。迪米特法则可降低系统的耦合度,使类与类之间保持松散的耦合关系。
每个对象或多或少都会与其它对象有耦合关系,只要两个对象直接有耦合关系,我们就说这两个对象之间是朋友关系。耦合的关系有很多,例如:依赖、关联、组合、聚合等。其中,我们称出现在成员变量,方法形参,方法返回值的类为直接朋友,而出现在局部变量的类不是直接的朋友。也就是说,默认的类最好不要以局部变量的形式出现在类的内部。
在迪米特法则中,对于一个对象,其 “朋友” 包括以下几类:
- 当前对象本身(this)。
- 以参数形式传入到当前对象方法中的对象。
- 当前对象的成员对象。
- 如果当前对象的成员对象是一个集合,那么集合中的元素也都是朋友。
- 当前对象所创建的对象。
任何一个对象,如果满足上面的条件之一,就是当前对象的 “朋友”,否则就是 “陌生人”。在应用迪米特法则时,一个对象只能与直接朋友发生交互,不要与 “陌生人” 发生直接交互,这样做可以降低系统的耦合度,一个对象的改变不会给太多其他对象带来影响。
迪米特法则要求在设计系统时,应该尽量减少对象之间的交互。如果两个对象之间不必彼此直接通信,那么这两个对象就不应当发生任何直接的相互作用;如果其中一个对象需要调用另一个对象的方法,可以通过第三者转发这个调用。简言之,就是通过引入一个合理的第三者来降低现有对象之间的耦合度。
在将迪米特法则运用到系统设计中时,要注意以下几点:在类的划分上,应当尽量创建松耦合的类,类之间的耦合度越低,就越有利于复用,一个处在松耦合中的类一旦被修改,不会对关联的类造成太大波及;在类的结构设计上,每个类都应当尽量降低其成员变量和成员函数的访问权限;在类的设计上,只要有可能,一个类应当设计成不变类;在对其他类的引用上,一个对象对其他对象的引用应当降到最低。
// 定义一个提供读取功能的类
class Reader
{
public:
string readBook(void)
{
return "Reading book...";
}
};
// 定义一个提供打印功能的类,该类不直接依赖Reader类
class Printer
{
public:
void print(string message)
{
cout << message << endl;
}
};
// 定义一个Person类,该类作为中介者,协调Reader和Printer的交互
class Person
{
private:
Reader reader;
Printer printer;
public:
void readAndPrint(void)
{
string message = reader.readBook();
printer.print(message);
}
};
#include <iostream>
using namespace std;
int main(void)
{
Person person;
person.readAndPrint();
return 0;
}
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 震惊!C++程序真的从main开始吗?99%的程序员都答错了
· 【硬核科普】Trae如何「偷看」你的代码?零基础破解AI编程运行原理
· 单元测试从入门到精通
· 上周热点回顾(3.3-3.9)
· winform 绘制太阳,地球,月球 运作规律