设计模式

Last Updated on 2020年8月1日

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代码:
    1. 你不要来改我的类, 你只要按照我开放给你的接口,并实现相关的virtual就行了.
    2. 当你要写多个类时,那么就你既是类的开发者,又是类的用户,你必须彼此矛盾的为每个设计争论.
    3. 不要用非public接口(protect也不要),因为只要是非public,一般都意味着容易改变.
  • 内聚:内聚性是指代码服务功能的相关程度,代码支持的功能越贴近于同一主题,内聚程度越高.
  • 一个详细的设计模式说明/文档应当包含:
    • 名称
    • 意图: 解决什么问题,如何解决
    • 应用范围: 给出典型场景,包括不适用的典型场景
    • 结构图与类图
    • 参与者: 描述各个类的角色
    • 结果: 该模式的优点和缺点
    • 实现: 语言层面需要注意的地方.
    • 已知应用: 介绍实例
    • 相关模式: 说明这个模式和其他模式的关系,相似之处,不同之处,改进之处等.

原则

  • 将相同的部分抽象出来,将可能需要变化的部分独立出去.
  • 除了具体类的实现代码,其余代码都应该仅出现基类指针/接口,也就是说,针对抽象编程,而不是针对某个具体类进行编程.(依赖倒置)
    • 从实现上说,如果你需要依据类型信息进行分支选择,就是在针对具体类型进行编程,有必要时,就应该抽象出一个基类,消除这种分支选择.
  • 多用组合,少用继承.
    • 例如,持有一个多态接口,而不是继承多态接口一般会更好,就是说,class Foo持有一个InterfaceX *class Foo:public InterfaceX更好.
  • 当需要扩展已有对象的功能时,不要侵入已有的代码.(这一般要求已有的代码要保留好足够的开放性)

大纲

  • 工厂: 创建对象的工具
  • 策略: 使用组合替代继承,通过可调用成员实现多态.
  • 状态: 状态机的OOP实现.
  • 观察者: 基于通知实现自动化响应.
  • 装饰器,外观,代理,适配器:包装对象,改变原来的行为.
  • 模板方法: 提供框架式的代码
  • 组合: 用对象树组织对象,使集合和个体有相同的接口.
  • 命令: 将请求/操作封装为对象传递.

工厂模式

工厂模式与继承/多态基本无关,只是应用在OO体系中的一种重要编程方法,更不是一种”设计模式”,只是习惯上被称为”工厂模式”

  • 工厂模式是一种把对象创建权统一的工具,它的主要目标是:
      1. 将创建对象的权利保留在类开发者手中,而非类用户手中.使用工厂模式时,一般要把类的构造函数设为private,强迫用户使用工厂.
      1. 隐藏具体的类型信息.
  • 工厂模式的核心就是一个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
    • 如果一定需要延迟创建,可创建一个辅助类,利用辅助类的static进行构造.
    • 可以使用std::call_once.
    • 或者使用原子操作的memory fence,可参考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 Istates{

    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直接调用即可.

Reactor & Proactor

  • Reactor/Proactor一般是专用于IO开发的,这个模式很容易理解,就是事件驱动.
    • 观察者模式可以说是简化了的Reactor,观察者模式中只有一种事件”数据需要更新”,这个事件的响应就是”更新数据,并通知观察者”
  • Reactor模式下,用户代码仅关注Ready事件。
  • Proactor模式下,用户代码仅关注Done事件。
  • Reactor/Proactor模式主要服务于对性能有要求的场景,实际应用中主要是关注各个与性能相关的实现细节,例如
    • 如何监控外部事件?(如网络IO编程时,poll的选择)
    • 直接在事件循环中回调用户代码,还是在新线程调用?

装饰器,适配器,代理,外观

  • 这几个模式都用于改变原始类型class Origin的行为,但是侧重点各有不同.
  • 代理模式: ProxyOrigin,一般直接继承Origin实现,且提供完全一致的API和行为.
    • 使用这种模式一般意味着权限控制,缓存控制等额外功能.这样可以使得代码结构分层更明显,有助于解耦合.
    • 使用这种模式时,原始的Origin只负责最核心的部分,其他外围功能由Proxy提供.例如Origin只包含计算代码,而资源分配,安全检查等则交给Proxy来做.
  • 适配器模式:OriginAdapter,一般是一个独立类,内部持有一个Origin obj,负责把外部的函数调用转发到obj上.使用这种模式一般是因为两组API不同
  • 外观模式:Originwrapper,一般是一个独立类,内部持有一个Origin obj,一般会提供更加简单的接口.各种Convenience class实际就是外观模式.
  • 装饰器模式:在某些场景中,Origin可能会附带有非常多的额外属性,如果将这些额外属性直接嵌入到Origin的实现代码中,一般会使代码中充斥着if(enabled_xxx),此时就可以使用装饰器模式.
    • 使用装饰器时,有如下特性,这些特性也是实现装饰器的准则: 装饰后得到的对象总是可以和原对象互换,且使用装饰后的对象可以提供更多额外操作.
    • 在需要的时候,我们甚至可以装饰器套装饰器.
    • 在实现中,我们一般会使用一个DecoratorBase作为中间层,把全部的装饰器都划分到一个单独的继承树内.
class Origin
{
public:
    virtual void foo();
    virtual void bar();
};

class DecoratorBase : public Origin
{
public:
    DecoratorBase(class Origin* target)
    {
        raw_obj = target;
    }

protected:
    Origin* raw_obj;
};

class DecoratorA : public DecoratorBase
{
    DecoratorA(class Origin* target) : DecoratorBase(target)
    {
    }

    void foo() override
    {
        do_sth();
        raw_obj->foo();
        do_sth_more();
    }

    void bar() override
    {
        do_sth();
        raw_obj->bar();
        do_sth_more();
    }
};

class DecoratorB : public DecoratorBase
{
    DecoratorB(class Origin* target) : DecoratorBase(target)
    {
    }

    void foo() override
    {
        do_sth_b();
        raw_obj->foo();
        do_sth_b_more();
    }

    void bar() override
    {
        do_sth_b();
        raw_obj->bar();
        do_sth_b_more();
    }
};

int main()
{
    Origin raw_obj;
    DecoratorA obj_with_a(&raw_obj);
    DecoratorB obj_with_a_and_b(&obj_with_a);
    return 0;
}

命令模式

  • 简而言之,命令模式就是封装过的可调用对象,这些可调用对象主要提供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