设计模式

Last Updated on 2023年7月11日

For C/C++ user

很多设计模式相关的资料都是用Java来描述的,有必要简单补充一下JavaC++OOP技术层面上的区别

  • Java不支持任何形式的运算符重载
  • Java明确区分接口和类,类只能从一个类派生,但是一个类可以实现多个接口
  • 在Java中,所有方法默认是虚(virtual)的
  • 对CPP而言,Java风格的接口可以视为一个只有pure virtual成员函数的基类,称为Interface
  • implement,is-a(inherit),has-a
    • implement: 特指继承Interface,并通过override实现其中函数的行为.
    • is-a: 特指继承普通类后,普通类和基类的关系.
    • has-a: 特指持有某个对象.

杂谈

  • 设计模式可以说就是各种各样的"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后,有两个常见的策略实现单例:
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决定.
    • ViewControll这个转发一般是异步地向controller抛出一个事件,例如controller.send(some_event)(也可以用观察者模式同步调用,Controll是observer,主动 register View内可能发出的事件即可.)
  • 从实现上看,典型的MVC使用了多个模式:
    • ViewController都是Model的观察者,在没有用户操作时(系统自动运作时),ViewController可以自动的随Model的更新而更新.
      • 例如,Model播放音乐时,View的进度条就需要自动更新,当Model播放到一半发现文件损坏时,Controll就需要停止播放,并更新GUI
    • View自身常常是按"组合模式"设计的,即整体是一个Tree,当用户或者Controller触发Event时,Event可以在树中的对象流动.
    • 在某些实现中,Controller会作提供工厂方法来创建ModelView对象
  • QT/MFC这样的GUI框架,整体的设计逻辑是Model/View,把ControlView合并在一起,以QMyWidget举例.
    • Model完全由用户给出,框架不涉及Model相关的内容.
    • QMyWidget是用户实现的某个界面元素,它将同时负责ViewControl的部分.
    • QMyWidget会在内部持有一个model引用,并主动观察它,从而能自动响应model的变化.
    • View在接收到UI事件(或model变化)时,直接会在OnXXXEvent内操作model,并更新自身.
    • 注: 在需要时,我们仍然可以手动的按MVC的风格开发,GUI框架对此没有任何限制. QT内也提供了一些按MVC风格组织的类,例如文件树ListView/TreeView

类型擦除

在运行期将一系列不同类型的实例均转换为某个 ErasedType 类型, 从而统一类型, 使得这些不同类型的实例能存储在相同的容器中.
为了使用类型擦除,需要

  1. 定义一个抽象基类, 一般称为Concpet, 用于描述接口.
  2. 定义一个模板类, 用于实现封装擦除, 一般称为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的问题主要有两个:

  1. 新插入的代码往往和继承树的核心功能无关, 例如Animal继承树可能主要服务于数据追踪系统, 而这里的make_sound()可能只会被动画系统使用(频率很低), 当需求越来越复杂, 大量与核心功能无关的代码可能让相关的class代码变得非常臃肿.
  2. 每次插入代码都相当于修改已有代码, 这可能对某些系统是不可接受的.

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);
    }
}