Last Updated on 2023年7月11日
For C/C++ user
很多设计模式相关的资料都是用
Java
来描述的,有必要简单补充一下Java
和C++
在OOP
技术层面上的区别
- Java不支持任何形式的运算符重载
- Java明确区分接口和类,类只能从一个类派生,但是一个类可以实现多个接口
- 在Java中,所有方法默认是虚(virtual)的
- 对CPP而言,
Java
风格的接口可以视为一个只有pure virtual
成员函数的基类,称为Interface
- implement,is-a(inherit),has-a
- implement: 特指继承
Interface
,并通过override
实现其中函数的行为. - is-a: 特指继承普通类后,普通类和基类的关系.
- has-a: 特指持有某个对象.
- implement: 特指继承
杂谈
- 设计模式可以说就是各种各样的"OOP最佳实践方法". 一个设计模式对应了一种组织代码的风格,对应了一种解决问题的策略.
- 设计模式可以说是程序员的"行话",使用"行话"有利于交流,但是并不一定有利于问题的解决,简单优雅的解决问题总是最重要的,不要本末倒置,避免过度设计.无论何时,总应该使用简单的工具,而不是"感觉更好"的工具,只在真实需要的时候才使用更复杂的设计.
- 设计模式不是简单的"编程规范",而是由{问题, 场景, 编程规范}复合而成的. 一个设计模式必然有其要解决的问题,只给出编程方法而不考虑问题及场景的, 肯定不是一个好的设计.
- 一个好OO程序员应该把其他程序员都当成傻瓜,就能写出好的OO代码
- 内聚:内聚性是指代码服务功能的相关程度,代码支持的功能越贴近于同一主题,内聚程度越高.
- 耦合:不同实体之间共享的数据/信息/代码越多,则耦合程度越高.
- 一个详细的设计说明/文档应当包含:
- 名称
- 意图: 解决什么问题,如何解决
- 应用范围: 给出典型场景,包括不适用的典型场景
- 结构图与类图
- 参与者: 描述各个类的角色
- 结果: 该模式的优点和缺点
- 实现: 语言层面需要注意的地方.
- 已知应用: 介绍实例
- 相关模式: 说明这个设计和已有其他模式的关系,相似之处,不同之处,改进之处等.
原则
- 将相同的部分抽象出来,将可能需要变化的部分独立出去.
- 除了具体类的实现代码,其余代码都应该仅出现基类指针/接口,也就是说,针对抽象编程,而不是针对某个具体类进行编程.(依赖倒置)
- 从实现上说,如果你需要依据类型信息进行分支选择,就是在针对具体类型进行编程,有必要时,就应该抽象出一个基类,消除这种分支选择.
- 多用组合,少用继承.
- 例如,持有一个多态接口,而不是继承多态接口一般会更好,就是说,
class Foo
持有一个InterfaceX *
比class Foo:public InterfaceX
更好.
- 例如,持有一个多态接口,而不是继承多态接口一般会更好,就是说,
- 当需要扩展已有对象的功能时,不要侵入已有的代码.(这一般要求已有的代码要保留好足够的开放性)
大纲
- 工厂: 创建对象的工具
- 策略: 使用组合替代继承,通过可调用成员实现多态.
- 状态: 状态机的OOP实现.
- 观察者: 基于通知实现自动化响应.
- 装饰器,外观,代理,适配器:包装对象,改变原来的行为.
- 模板方法: 提供框架式的代码
- 组合: 用对象树组织对象,使集合和个体有相同的接口.
- 命令: 将请求/操作封装为对象传递.
工厂模式
工厂模式与继承/多态基本无关,只是应用在OO体系中的一种重要编程方法,更不是一种"设计模式",只是习惯上被称为"工厂模式"
- 工厂模式是一种把对象创建权统一的工具,它的主要目标是: 将创建对象的权利保留在类开发者手中,而非类用户手中,保证对象创建都有统一的入口. 使用工厂模式时,一般要把类的构造函数设为
private
,强迫用户使用工厂. - 工厂模式的核心就是一个
T * Create(arg)
函数(或T * FactoryMethod(arg)
),依应用场景不同,可以分为简单工厂
,抽象工厂
,工厂方法
简单工厂和抽象工厂:
- 简单工厂是指不使用继承/多态特性的工厂, 一般可以直接实现为一个静态的
T * Create()
或者class Creator{T * Create()}
- 可以参考<
>书的 1.11对象池
, 来实现线程安全的的简单工厂,其中的第一个1.11.0的 version3 版本就足够好了
- 可以参考<
- 抽象工厂是指需要创建
creator
实例才能使用的工厂,通过class Creator{virtual T * Create()}
实现的工具.- 有时候,多个简单工厂需要按固定的组合使用,此时就可以使用抽象工厂把这些工厂函数封装在一个类内.
- 使用抽象工厂后
Creator * creator
具备更好的可扩展性,用户可以替换掉默认的creator
,类开发者也能提供多个不同类型的具体creator
供用户选用.
工厂方法:
- 在开发框架时,有的类也会额外提供创建对象的成员函数,这些成员函数一般被称为工厂方法.
- 通常而言,工厂方法是
virtual
的,可以供用户扩展.
class Process{
virtual BaseType * CreateObj();
void Exec(){
auto p_obj=CreateObj(); // 此时,调用的一般是派生类的`CreateObj()`,`p_obj`一般也是BaseType的派生类.
DoThings(p_obj);
}
}
单例模式
- 单例是一种特殊的工厂,它用于产生一个全局唯一的对象.
- 在C++11之前,高性能且线程安全的单例模式是比较难实现的,要么需要严格控制函数调用的时机,要么需要使用全局变量.
- 在C++ 11后,有两个常见的策略实现单例:
- 一般情况下可以直接使用
static
- Double-Checked Locking is Fixed In C++11
- 一般情况下可以直接使用
T & GetInstance(){
static T obj
return obj;
}
// 或者
struct TCreator(){
TCreator(){
p_obj = new T();
}
T * p_obj;
};
T* GetInstance()
{
static TCreator tc;
return tc.p_obj;
}
策略模式
- 理想的
virtual
成员函数应当是纯虚的,每个派生类都有完全不同的实现.不是这种理想状况时,就应该优先考虑策略模式. - 策略模式使用可调用的
handler
成员替代virtual
函数,主要优势:- 代码可复用, 我们不用重复的为每个派生类编写功能相同的代码,直接为
InterfaceX *
赋值即可 - 代码可扩展, 直接继承
InterfaceX
就能实现新的行为,不影响已有代码,且可以直接应用于新代码 - 可以动态修改,
InterfaceX *
的多态是运行期的,可以动态的替换.
- 代码可复用, 我们不用重复的为每个派生类编写功能相同的代码,直接为
- NOTE:
- 在C这样的语言中,可以通过函数指针实现弱的策略模式.
状态模式
- 状态机的主要特点是:
- 输入是无法预测的,必须对所有可能的输入产生响应.(响应可以是什么也不做,或者报警)
- 响应输入后,状态可能会产生改变.
- 状态模式的典型实现:
- 有一个
StateMachine
类,它负责持有全局的信息,接受所有外界的输入,只有这个类是暴露给用户的,用户不需要感知到"状态". - 有一个抽象基类
IState
和若干个XXXState
实现具体状态. IState
也需要能够支持响应任意输入- 具体类一般需要读取
StateMachine
内存储的全局信息,所以IState
一般会持有一个StateMachine
实例的引用. XXXState
彼此之间完全独立,负责执行具体的响应
- 有一个
- 在传统的状态机实现中,每一个
action_x
函数内都是一个巨大的switch-case
,根据当前的状态选择要执行的操作,经过状态模式解耦之后,各个状态只需要负责好自己的响应即可.添加新的具体State
也变得容易. - 状态模式可以近似看做是策略模式的一种变体.
StateMachine
其实就是在动态的切换自己持有的`stratety.
// 每一个action都可能触发状态变化,如果状态不变,那么就返回`this`,否则,返回一个new出来的新状态.
// 这种动态方案的代码结构更加简洁,也更易于扩展,相对的,会带来一些额外的new/delete开销.
class StateMachine;
class IState
{
public:
IState(StateMachine * machine):the_machine(machine){}
virtual IState * do_action_i();
virtual IState * do_action_j();
// 省略很多... do_action
private:
StateMachine * the_machine;
};
class StateMachine
{
public:
StateMachine();
// 仅需要把操作转发给current_state即可.
void action_i()
{
auto new_state = current_state->do_action_i();
ChangeStateTo(new_state);
}
void action_j()
{
auto new_state = current_state->do_action_j();
ChangeStateTo(new_state);
}
// ... 省略很多 acion_x()
void ChangeStateTo(IState * new_state){
if(new_state != current_state){
delete current_state;
current_state=new_state;
}
}
private:
IState* current_state = nullptr;
};
class DefaultState: public IState{
DefaultState(StateMachine * machine):IState(machine){}
IState * do_action_i() override
{
printf("Not supported");
return this;// state not change
}
IState * do_action_j() override
{
printf("action j has done");
return new ExampleState(the_machine);
}
// .... 省略很多 do_action_x()
}
StateMachine::StateMachine(){
current_state = DefaultState(this);
}
class ExampleState : public IState
{
// ... 省略
};
观察者模式:
- 典型场景:
Subject
负责主动刷新状态,再根据更新后的数据来通知Observer
- 这里的通知通常就是
subject
直接调用observer.update()
- 调用
obverser.update()
时,可以附带数据,用于传递数据,这种风格称为push
- 这里的通知通常就是
- 在现代的实现中,很多时候不需要
Observer
类,而是直接把可调用对象注册到subject
上,subject
直接调用即可.
装饰器,适配器,代理,外观
- 这几个模式都用于在运行期动态改变对象的行为.
命令模式
- 简而言之,命令模式就是封装过的可调用对象,这些可调用对象主要提供
exec()
,undo()
这样的接口.- 使用命令模式时,我们只需要知道它会干某件事,但是我们并不关心它干的是什么事.
- 命令模式一般会把函数和它需要的数据都封装起来.
模板方法模式:
- 由开发者A提供整体的算法流程,由开发者B实现部分可能不同的细节.
- 例如:基类提供框架,派生类提供部分API的细节.
- 例如:sort中可以由用户提供一个compare函数.
组合模式
- 为
class T
添加一个vector<T *> child_list
成员,使之可以按树状结构组织.- 例如,只需要
class Menu
一个类就可以同时代表"菜单"以及"菜单"中的item.
- 例如,只需要
child_list.size()
可以用于区分叶节点和中间节点,从而能让我们做一些区分的操作.- 例如:
class Menu
中,如果child_list
为空,则它是一个MenuItem
,反之,它是一个子菜单,我们据此就可以执行不同的操作,这是一种常用的设计.
- 例如:
MVC模式
- MVC模式广泛运用于带有GUI的系统中,有些GUI组件天生就适合用MVC来组织,例如媒体播放器.
- 想要理解MVC模式,就必须想象出三个独立的开发者,因为这个模式对于独立开发者而言意义比较模糊.
Model
开发者负责提供基础的API
,这些API
的具体何时调用需要由Controll
开发者控制;View
开发者负责提供更新界面的API,这些API的具体何时调用由Control
控制;View
也能收到用户触发的UI事件,但是View
并不负责进行响应,View
只把这个事件转发出去,具体要做什么由Controll
决定.View
向Controll
这个转发一般是异步地向controller
抛出一个事件,例如controller.send(some_event)
(也可以用观察者模式同步调用,Controll
是observer,主动 registerView
内可能发出的事件即可.)
- 从实现上看,典型的MVC使用了多个模式:
View
和Controller
都是Model
的观察者,在没有用户操作时(系统自动运作时),View
和Controller
可以自动的随Model
的更新而更新.- 例如,
Model
播放音乐时,View
的进度条就需要自动更新,当Model
播放到一半发现文件损坏时,Controll
就需要停止播放,并更新GUI
- 例如,
View
自身常常是按"组合模式"设计的,即整体是一个Tree
,当用户或者Controller
触发Event
时,Event
可以在树中的对象流动.- 在某些实现中,
Controller
会作提供工厂方法来创建Model
和View
对象
- QT/MFC这样的GUI框架,整体的设计逻辑是
Model/View
,把Control
和View
合并在一起,以QMyWidget
举例.Model
完全由用户给出,框架不涉及Model
相关的内容.QMyWidget
是用户实现的某个界面元素,它将同时负责View
和Control
的部分.QMyWidget
会在内部持有一个model
引用,并主动观察它,从而能自动响应model
的变化.View
在接收到UI事件(或model变化)时,直接会在OnXXXEvent
内操作model
,并更新自身.- 注: 在需要时,我们仍然可以手动的按
MVC
的风格开发,GUI框架对此没有任何限制. QT内也提供了一些按MVC
风格组织的类,例如文件树
和ListView/TreeView
类型擦除
在运行期将一系列不同类型的实例均转换为某个 ErasedType 类型, 从而统一类型, 使得这些不同类型的实例能存储在相同的容器中.
为了使用类型擦除,需要
- 定义一个抽象基类, 一般称为Concpet, 用于描述接口.
- 定义一个模板类, 用于实现封装擦除, 一般称为Model.
例如,下面的例子中就可以把不同的动物Erase成Animal
class AnimalConcept {
public:
virtual const char *see() const = 0;
virtual const char *say() const = 0;
};
template <typename ConcreteType>
class AnimalModel : public AnimalConcept {
ConcreteType *m_animal;
public:
AnimalModel(ConcreteType *animal) : m_animal(animal) {}
const char *see() const { return m_animal->see(); }
const char *say() const { return m_animal->say(); }
};
template <typename ConcreteType>
std::shared_ptr<AnimalConcept> EraseToAnimal(ConcreteType *animal) {
return std::make_shared<AnimalModel>(animal);
}
class Cat {
public:
const char *see();
const char *say();
};
class Dog {
public:
const char *see();
const char *say();
};
void main(){
auto p_c = new Cat();
auto p_d = new Dog();
std::vector<std::shared_ptr<AnimalConcept>> = {EraseToAnimal(p_c),EraseToAnimal(p_d)}
}
Visitor
Visitor 是 Interface 的一种变形实现, 当想要为某一个继承树中的每个类型都实现特化的Interface时, 就可以使用Visitor模式.
例如, 有一个继承树Animal,Cat,Dog,Cow,Sheep
, 我们希望为每一个Concrete类型都添加一个make_sound()
, 那么可以在基类中定一个一个void make_sound() = 0;
, 然后在每个派生类中实现.
使用Interface的问题主要有两个:
- 新插入的代码往往和继承树的核心功能无关, 例如
Animal
继承树可能主要服务于数据追踪系统, 而这里的make_sound()
可能只会被动画系统使用(频率很低), 当需求越来越复杂, 大量与核心功能无关的代码可能让相关的class代码变得非常臃肿. - 每次插入代码都相当于修改已有代码, 这可能对某些系统是不可接受的.
Visitor模式的核心意图是: 将所有新逻辑都集合到一个新的Visitor Class中
class IVisitor {
public:
virtual void Visit(Cat * cat) = 0;
virtual void Visit(Dog * dog) = 0;
virtual void Visit(Cow * cow) = 0;
virtual void Visit(Sheep * sheep) = 0;
}
class MakeSoundVisitor : public IVisitor{
public:
void Visit(Cat * cat){
printf("%s meow\n",cat->name());
}
void Visit(Dog * dog){
printf("%s woof\n",dog->name());
}
void Visit(Cow * cow){
printf("%s moo\n",cow->name());
}
void Visit(Sheep * sheep){
printf("%s baa\n",sheep->name());
}
}
Dispatch
在核心完成后, 剩下的工作主要是完成Dispatch功能, 也就是如何根据IVisitor * v;IAnimal * m
触发相关的调用.
这里有两类策略
一类是由Visitor自身负责Dispatch (self dispatch), 这一般用于我们完全无法修改Animal
继承树的情况, 例如Animal
继承树是第三方库提供的. 此时我们需要为IVisitor添加一个void Visit(Animal * animal);
的接口, 在其中使用dynamic_cast
来判断类型, 然后调用相关的Visit
函数.
也就是
void IVisitor::Visit(Animal * animal){
if(auto p = dynamic_cast<Cat *>(animal)){
Visit(p);
}else if(auto p = dynamic_cast<Dog *>(animal)){
Visit(p);
}else if(auto p = dynamic_cast<Cow *>(animal)){
Visit(p);
}else if(auto p = dynamic_cast<Sheep *>(animal)){
Visit(p);
}else{
assert(false);
}
}
在这种场景下,当我们有IVisitor * v;IAnimal * m
的指针时,通过v->Visit(m)
的方式来触发调用.
另一类是由继承树负责Dispatch, 这种场景下, 我们只需要修改继承树一次即可, 首先, 基类中需要添加一个void Accept(IVisitor * v) = 0;
的接口, 然后在每个派生类都需要添加下面的代码.
void Accept(IVisitor * v) override {
v->Visit(this);
}
在这种场景下,当我们有IVisitor * v;IAnimal * m
的指针时,通过m->Accept(v)
的方式来触发调用, 这种方式一般被称为双重分发(double dispatch), 因为m->Accept
首先查了一次m
的虚函数表,这是第一次dispatch, 在进入Accept
后,v->Visit(this)
又查了一次v
的虚函数表, 这是第二次dispatch.
Double dispatch 添加的代码很简单, 且仅需要修改继承树一次, 是较为流行的一种方式.
Popular usage
Visitor模式常常和使用对象树的系统搭配使用,例如UI系统,AST等等, 因为在这些系统中不但常常需要为很多核心类型添加新功能, 而且新添加的功能往往和核心类型自身的核心意图无关,这天生就和Visitor模式的意图相吻合.
例如AST的printer需要每个类型都提供自己独立的print/parse逻辑, 但是这些逻辑是和AST自身的核心设计无关的, 因此使用Visitor模式可以很好的解决这个问题.
除此之外, Visitor在这种场景还有一个优势, 那就是容易描述递归结构, 例如
// Self dispatch
void SomeVisitor::Visit(NodeX * node){
for(auto child : node->children()){
Visit(child);
}
}
// Double dispatch
void SomeVisitor::Visit(NodeX * node){
for(auto child : node->children()){
child->Accept(this);
}
}