What & How & Why

C++设计模式 第一周

本页内容是 Boolan C++ 开发工程师培训系列的笔记。
因个人水平有限,我撰写的笔记不免出现纰漏。如果您发现错误,请留言提出,谢谢!


设计模式与面向对象

人们在解决问题的过程中,常常会采取两种方式:分而治之抽象分治的核心概念是将大问题分解为一系列规模较小的问题,然后各个击破。而抽象则是针对问题本身的特点进行观察,最大程度的提取该问题和其他问题的共同性,以方便以后类似问题的解决。换而言之,抽象对于分治来说,更加强调复用性。抽象和复用性并不是单纯的存在于软件设计中,而是作为人类思考、进步的一种哲学指导思想存在的——人类最伟大的抽象工程之一就是数学定理和公式。由此可见,抽象和复用性在人类生活中占据着无与伦比的重要地位。这同样适应于软件设计:在软件工程中,一个主要衡量代码设计质量的指标,就是其本身的复用性

C++ 中通过面向对象的中的继承和多态来体现抽象的思想FIXME(对象组合)。举例来说,如果希望设计一个画图类,那么无论是话什么图形,都可以通过继承一个通用的父类 Shape 来获得画图中一些可以共用的,或者说可以重用的属性和方法,因为所有的图形都可以被归纳为形状。在这一点上,我个人认为分治是强调了解决单个独立问题的一种处理方式;而抽象更着重于多个问题的特性提取,从而可以制造出泛化的轮子去解决将来可能遇到的类似问题。而设计模式,则是前人积累的经验,一些对于实际问题的抽象经验。在项目中合理地运用设计模式可以完美地解决很多问题,因为每种模式在现实中都有相应的原理来与之对应,每种模式都描述了一个在我们周围不断重复发生的问题,以及该问题的核心解决方案,这也是设计模式能被广泛应用的原因。

面向对象的设计原则

上面谈到C++中面向对象是抽象思想赖以实现的原则。抽象的最大的特点是重用性,也就是它本身的优势:抵御变化。为了达到这个目的,我们需要明确一个概念:并不是所有的面向对象都可以称为抽象。只有在向对象设计中保证了各个功能模块各司其职,并相互不影响的情况下,抽象思想才可以实现。也就是说,最理想的情况下,每个模块的责任是不重复的;而我们对系统添加新的功能,只应该通过添加新的模块去实现。只有这样,才能最大限度的隔离变化,抵御变化带来的影响。为了做到这一点,面向对象设计中提出了一系列的基本原则作为设计的思想,来确保设计出来的系统具有抽象思想的特性。这些原则如下:

1.依赖倒置原则Dependence Inversion Principle):

  • 高层模块(稳定)不应该依赖于低层模块(变化),二者都应该依赖于抽象(稳定)。
  • 抽象(稳定)不应该依赖于实现细节(变化),实现细节应该依赖于抽象(稳定)。

总的来说,就是不管自身是否稳定,所依赖的对象必须是稳定的,这样才能抵御“变化”。

2.开放封闭原则(Open Close Principle):

  • 对扩展开放,对更改封闭
  • 类模块应该是可扩展的,但是不可修改

也就是说,在程序需有新需求(变化)的时候,不能去修改原有的模块,而是添加新的模块来实现。换句话说,是为了使程序具有良好的扩展性,易于维护和升级。

3.单一职责原则Single Responsibility Principle):

  • 一个类应该仅有一个引起它变化的原因
  • 变化的方向隐含着类的责任

这一条原则在强调类的功能必须专一。如果一个类包含了过多的功能,也就意味着这个类需要去承担更多的责任;一旦发生变化,该类需要修改的几率大大的增加。而同时,包含过多的内容意味着类的逻辑将更加复杂,可读性会更低。

4. Liskov替换原则Liskov Substitution Principle):

  • 子类必须能够替换它们的基类(IS-A)
  • 继承表达类型抽象

里氏替换原则强调了什么是真正的复用(也就是继承的真正含义IS-A)。只有当派生类可以替换掉基类,且软件单位的功能不受到影响时,基类才能真正被复用,而派生类也能够在基类的基础上增加新的行为。

5. 接口隔离原则(Interface Segregation Principle):

  • 不应该强迫客户程序依赖它们不用的方法。
  • 接口应小而完备。

该原则实际上在强调一点:使用多个隔离的接口,比使用单个接口要好。换句话说就是,我们需要尽量降低类之间的耦合度,以及相互之间的依赖度。

6.合成复用原则Composite Reuse Principle

  • 类继承通常为“白箱复用”,对象组合通常为“黑箱复用”。
  • 继承在某种程度上破坏了封装性,子类父类耦合度高。
  • 而对象组合则只要求被组合的对象具有良好定义的接口,耦合度低。

使用复合代替继承作为类之间的关系可以有效的降低类之间的耦合度。

7.封装变化点

  • 使用封装来创建对象之间的分界层,让设计者可以在分界层一侧进行修改,而不会对另一侧产生不良的影响,从而实现层次间的松耦合。


8. 针对接口编程,而不是针对实现编程

  • 不将变量类型声明为某个特定的具体类,而是声明为某个接口。
  • 客户程序无需获知对象的具体类型,只需要知道对象所具有的接口。
  • 减少系统中各部分的依赖关系,从而实现“高内聚,松耦合”的类型设计方案。


以上的规则,实际上在强调抽象和重复性的重要性,以及保证这个重要性的前提:模块化。模块化不仅是软件设计成功的前提,在人类社会的其他行业中,具有模块化标准的产业,也同样是行业内的翘楚。

设计模式的总览

设计模式在 GOF 进行了详细的分类。如果按照使用的目的来分类,设计模式可以分为如下几种:

  • 创建型(Creational)模式:将对象的部分创建工作延迟到子类或者其他对象。这类型模式分离了对象的创建和对象的使用。
  • 结构型(Structural)模式:通过累积成或者对象组合获得更灵活的结构,因对需求变化对对象的结构带来的冲击。
  • 行为型(Behavioral)模式:通过累积成或者对象组合来划分类与对象之间的职责,从而应对需要变化对多个有交集的对象带来的冲击。

如果按照范围来看则可以分为两种:

  • 以类模式来处理类与子类的静态关系,通常针对继承来说。
  • 以对象模式来处理对象之间的动态关系,通常针对于组合关系来说。

不过在实际的应用中,可以将这些设计模式从变化的角度来进行分类:

组件协作 单一职责 对象创建 对象性能 接口隔离 状态变化 数据结构 行为变化 领域问题
Template Method Decorator Factory Method Singleton Facade Memento Composite Command Interpreter
Strategy Bridge Abstract Factory Flyweight Proxy State Iterator Visitor
Observer / Event Prototype Mediator Chain of Resposibility
Builder Adapter



相信这么多设计模式,估计你也跟我一样看花眼了吧m(。那么有没有一个正确的方法知道应该如何正确使用对应的设计模式呢?

答案是重构获得模式Refactoring to Patterns)。我们知道,设计模式是前人总结出来的,对特定的稳定和变化的关系作出的特定的抽象方法。只有用对地方,设计模式才能正确的应对需求变化,提高复用性。如何使用设计模式需要我们清楚的认识类之间的关系,通过结构化的方式来区分哪些模块需要稳定,哪些模块需要变化。而这样的认识,必须基于我们对自己的项目有清楚的了解的基础上。因此,重构获得模式提倡根据需求来制定设计模式成为了目前公认的最好的使用设计模式的方法。开始的时候我们不必拘泥于任何设计模式,因为我们永远都不知道需求会如何变化。只有程序达到了一定规模,我们对程序有着结构上的了解,并可以从现有代码中发现不使用指定设计模式的问题之后,才是应用设计模式的最好时机,同是也是重构的最佳时机。

关于重构的书籍,有两本值得推荐:重构-改善既有代码的设计重构与模式

除了理论上意外,还具有一些具体的技巧,比如:

  • 静态绑定转换到动态绑定
  • 早绑定转换到晚绑定
  • 继承关系转为组合关系
  • 编译时依赖转换到运行时依赖
  • 紧耦合转换到松耦合

设计模式:组件协作

现代软件专业分工之后的第一个结果是“框架与应用程序的划分”。“组件协作”模式通过晚期绑定,来实现框架应用程序之间的松耦合,是二者之间协作时常用的模式。属于组件协作模式的设计模式有三种:Template MethodStrategy Observer / Event

4.1.Template Method


Template Method 适用于软件构建过程中框架处于稳定状态,但细节或者子步骤(应用层)需要大量改变的需求的情况。这样的情况从具体的实现上来看是像下图这样的:

<html>

<img src=“/_media/programming/cpp/boolan_cpp/ds_binding.svg” width=“900”/>

</html>

如上图,框架开发者负责开发1, 3,5 的内容,而应用开发者需要开发 2, 4 的内容。整个软件的流程按 1-5 的顺序走。那么这个程序要如何设计呢?

按传统的结构化设计来看(左边),如果要实现一个指令序列,那么这个整体的命令序列是要通过应用开发者去完成的;但在我们先前提到的情况中,框架端是没有变化的(稳定的);也就是说,我们知道,整个软件的流程是不变的,唯一变化的只有应用开发者负责开发的部分。如果由应用层开发者来实现整个流程,那么有两个问题将会出现: -

  1. 应用层开发者必须明白框架开发者的开发内容。
  2. 框架开发者如果需要改变流程,必须与应用层开发者进行沟通。

上面这两个问题会大大的增加软件开发的成本和可维护性。我们不禁想到,有没有可能将整体流程部分直接交给框架开发者直接制定,使得应用开发者不用承担不必要的负担?

Template Method 正是应此而生的。

传统结构化设计,我们通过应用端对框架端进行调用,我们称之为早绑定Early Binding ),比如如下的设计:

/* Library & Framework Dev */
class Library {
public:
    void Step1() { //... }
    void Step3() { //... }
    void Step5() { //... }
};

/* Application */
class Application {
public:
    bool Step2(){ //... }
    void Step4(){ //... }
};
/* Main workflow */
int main()
{
    Library lib();
    Application app();
    lib.Step1();
    if (app.Step2()) { lib.Step3(); }
    for (int i = 0; i < 4; i++) {
        app.Step4();
    }
    lib.Step5();
}
我们可以看到,因为整个程序的流程实现是交给应用层开发者的,因此流程是变化的,对于框架开发者来说也是不可控的。

而相比之下,如果将流程交由框架开发者来实现,也就是框架开发者在流程中反过来调用应用开发者创造的内容,我们称这种方法为晚绑定Late Binding)。我们来看看代码是怎么实现的:
/* Libary & FrameWork with template method */
class Library {
public:
    //Stable 
    void Run() {
        Step1();
        //variation -> virtual funtion call (step 2)
        if (Step2()) { Step3(); }
        for (int i = 0; i < 4; i++){
            Step4(); //variation -> virtual funtion call (step 4)
        }
        Step5();
    }
    virtual ~Library(){ }
protected:
    void Step1() {.....}
    void Step3() {.....}
    void Step5() {.....}
    virtual bool Step2() = 0; //variation
    virtual void Step4() = 0; //variation
};
/* Application */
class Application : public Library {
protected:
    virtual bool Step2(){...}
    virtual void Step4() {...}
};
int main()
    {
        Library* pLib=new Application();
        lib->Run();
        delete pLib;
    }
}
框架开发者将 run() 函数,也就是整个程序的运行流程继承到了框架类中。由于框架开发者并不知道应用层开发者具体会如何设计第二步和第四步;因此将这两个方法定义成了虚函数;应用层开发者只需要在子类中通过多态重写这两个虚函数即可。相信你也看出好处在哪里了;应用层开发者只需要专注自己的开发内容即可,并不用去深入考虑整个流程,以及继承的内容是什么。而且,在将来维护的时候,只需要针对应用层的内容进行维护即可。

明白了这些,我们来看一看 Template Method 的具体定义吧:

定义一个操作中的算法的骨架(稳定),而将一些步骤延迟(变化)到子类中。Template Method 使得子类可以在不改变(复用)一个算法的结构即可重新定义(Override 重写)该算法的某些特定步骤。
——《设计模式》GoF


一个稳定的框架,一个稳定的算法(工作流程),加上一些需求有变化的应用,通过 Template Method ,就可以让应用层的内容去复用这个算法(流程)了。前面三者是条件,最后一个是结果。这就是Template Method 的作用。

<html>

<img src=“/_media/programming/cpp/boolan_cpp/template_method.svg” width=“450”/>

</html>

总结:

  • Template Method 是一种非常基础性的设计模式,在面向对象的系统中,有着大量的应用。他用最简洁的机制(虚函数的多态性)为很多应用程序的框架提供了灵活的扩展点,是代码复用方面的基本实现结构。
  • 除了可以灵活对应子步骤的变化外,“不要调用我,让我来调用你”的反向控制结构是 Template Method 的典型应用。
  • 在具体实现方面,被 Template Method 调用的虚方法可以具有实现,也可以没有任何实现(抽象方法、纯虚方法),一般推荐将他们设置为 Protected 方法。

Strategy

在软件设计的过程中,某些对象使用的算法会经常发生改变。如果将这些算法改变的所有可能性都集成到对象中,那么可以想象到的是,对象会变得异常臃肿;并且,一些对象根本不会用到的算法,也会占用资源,造成性能负担。这种情况下,我们就需要考虑使用 Strategy 模式。

我们来看一个具体的例子:我们需要设计一个计算税的系统,该系统需要支持不同国家的税率算法。按照结构化程序的思路,我们会专门为该系统设计一个总的 CalculateTax() 方法,然后在该方法内部进行不同国家的条件判断,以调用对应的税收算法:

enum TaxBase {
    CN_Tax,
    US_Tax,
    DE_Tax,
};
/* Tax Algorithm */
class SalesOrder {
    TaxBase tax;
public:
    double CalculateTax() {
        if (tax == CN_Tax) { .... }
        else if (tax == US_Tax) { .... }
        else if (tax == DE_Tax) { .... }
    } 
};
这种方法看上去并没有什么太大的问题。但如果我们以是否具有拓展性的标准(也就是带着时间轴去看)去看待这种实现方法,我们会发现一些问题。

假设我们需要该系统增加对新的国家的税率计算支持,如果按照上面的实现方法,我们需要修改所有的类:

  • 添加对应的判断条件到 TaxBase 中。
  • 添加对应的税率算法到 SalesOrder 中。

只有完善了这些新的信息,我们的系统才能具有对新国家的税率的计算能力。然而,根据面向对象的设计原则,我们这里修改了好几个类(模块) 的功能。也就是说,我们没有在不修改原有模块的基础上进行拓展;在这一点上我们违反了开放封闭原则

而从另一个角度上看,添加的这些算法,在一定程度上造成了对象的臃肿。原来的每个对象需要搭载 3 个算法,而添加新的国家以后,每个对象则需要搭载 4 个算法。在计算机中,我们每一次运算都要写入内存;如果代码较为精简,可能只需要写入一次CPU的高速缓存就能完成;但如果代码较为臃肿,则可能要分成好几次来写入,甚至会用上外部存储器空间。因此上述的设计,导致了因为空间的浪费而造成的效率低下;再者,如果对所有对象都执行 if-else 判断,会产生额外的计算负担。

因此,对于这样的情况,我们可以采用如下的设计方法,也就是 Strategy 模式。我们先来看一幅图,看看 Strategy 模式是如何对待这样的情况的:

<html>

<img src=“/_media/programming/cpp/boolan_cpp/strategy_compare.svg” width=“1000”/>

</html>

从上图可以明确的看出, Strategy 模式通过对一个 TaxStrategy 基类的重写来定义针对不同国家的税率算法;而程序的执行类 SalesOrder 只需要包含 TaxStrategy 就可以实现对不同税率算法的调用了。而相比起结构化的实现方法,添加新的国家税率算法,只是添加一个新的对 TaxStrategy 重写的子类而已。可以看出来,在 Strategy 模式下, SalesOrderTaxStrategy 一直都是稳定的;变化的只有具体的,代表着不同国家税率的子类。

具体的代码如下:

/* Tax Algorithm base class */
class TaxStrategy {
public:
    virtual double Calculate(const Context& context) = 0;
    virtual ~TaxStrategy() {}
};
/* Tax Algorithm */
class CNTax : public TaxStrategy {
public:
    virtual double Calculate(const Context& context) { .... }
};
class USTax : public TaxStrategy {
public:
    virtual double Calculate(const Context& context) { .... }
};
class DETax : public TaxStrategy {
public:
    virtual double Calculate(const Context& context) { .... }
};
/* if we wanna add a new tax calculate algorithm... */
class FRTax : public TaxStrategy {
public:
    virtual double Calculate(const Context& context) { .... }
};
/* stable */
class SalesOrder {
private:
    TaxStrategy* strategy;
public:
    //we have the concrete Tax Type (Nation) here
    SalesOrder(StrategyFactory* strategyFactory) {
        this->strategy = strategyFactory->NewStrategy();
    }
    ~SalesOrder() { delete this->strategy; }
    public double CalculateTax() {
        Context context();
        //Calculate() will call the corresponding Algorithm
        double val = strategy->Calculate(context); 
    } 
};

明白了以上的内容,再来看看 Strategy 模式的定义:

定义一系列算法,把它们一个个封装起来,并且使他们可以互相替换(变化)。该模式可以使算法可以独立于那些使用它的客户程序(稳定的)进行变化(扩展,子类化)。
——《设计模式》GoF

也就是说,具体的算法是变化的,但抽象的算法是稳定的。如果要维持调用这些算法的客户程序的稳定,最好的办法就是用客户程序调用抽象的算法;然后使用抽象算法的具体实现来进行功能的拓展。

<html>

<img src=“/_media/programming/cpp/boolan_cpp/strategy_uml.svg” width=“650”/>

</html>

总结:

  • Strategy 及其子类为组件提供了一系列可重用的算法,从而可以使得类型在运行时根据需要在各个算法之间进行很方便的切换。
  • Strategy 模式提供了条件判断语句作为实现的另一种选择。但条件语句作为结构型设计的一种典型手法,带来的负面效果就是高耦合,以及运行的负担。因此,对包含有许多条件判断语句的代码,并且预知到该条件有可能在未来变更,通常都需要 Strategy 模式。
  • 如果Strategy对象没有实例变量,那么各个上下文可以共享同一个 Strategy 对象,从而节省对象的开销。

Observer

在软件构建的过程中,有时候我们需要为某一些对象建立一种“通知依赖关系”——即一个对象(目标对象)的状态如果发生改变,那么所有依赖该对象的对象(观察者)都将得到通知。因为这里存在依赖的关系,按照先前提到的面向对象设计原则,我们应该知道,如果这样的依赖关系过于紧密,会使软件抵御变化的能力大大降低。而观察者模式(Observer)则是针对该种情况设计出的一种可以将依赖关系弱化并稳定的设计模式。

我们来考虑一个例子:按如下代码所示,我们目前拥有两个类:FileSplitter, 和 MainForm。这两个类实现了对文件的切割功能; MainForm 负责整个应用的流程,而 FileSplitter 则是具体文件分割的实现方法:

class FileSplitter
{
    string m_filePath;
    int m_fileNumber;
public:
    FileSplitter(const string& filePath, int fileNumber) :
        m_filePath(filePath), 
        m_fileNumber(fileNumber),
    void split(){
        //...read file
        //...split the file and write
        for (int i = 0; i < m_fileNumber; i++) {
            //...
        }
    }
};
class MainForm : public Form
{
    TextBox* txtFilePath;
    TextBox* txtFileNumber;
public:
    void Button1_Click() {
        string filePath = txtFilePath->getText();
        int number = atoi(txtFileNumber->getText().c_str());
        FileSplitter splitter(filePath, number);
        splitter.split();
    }
};
现在我们需要添加一个显示进度条的功能。按照常理来说,我们应该直接去调用控件类;因为 MainForm 是我们的流程,因此,我们在 MainForm 的类中添加一个 ProgressBar 的对象(假设 ProgressBar 是我们要调用的具体控件类):
ProgressBar* m_progressBar;
因为具体的文件分割操作是由 FileSplitter 类实现的,因此文件的分割进度信息我们需要到该类里去获取。而获取该类内部信息的话,最容易想到的就是在 FileSplitter 类中再建立一个 ProgressBar 的对象,然后用这个对象通过一定的操作去获取当前分割的值。而查看 FileSpilitter 后,我们发现,具体分割的操作其实是由 FileSplitter 类的成员函数 split() 实现的;因此,我们可以在 split() 的分割循环中不停的更新分割的进度,然后交给 m_progressBar
for (int i = 0; i < m_fileNumber; ++i) {
    //...
    float progressValue = m_fileNumber;
    //get current progress, handle it to m_progressBar
    progressValue = (i + 1) / progressValue;
    m_progressBar->setValue(progressValue);
}
然后在 MainForm 中,对 FileSplitter 初始化,就可以源源不断的得到的进度信息了:
FileSplitter splitter(filePath, number, progressBar);
功能实现了;但仔细思考一下,以上的设计依赖关系是什么样的?

没错,在这个例子中,无论是主流程 MainForm ,还是功能类 FileSplitter ,都依赖了一个具体的控件类 ProgressBarProgressBar 是一个变化的类(进度条的实现可以是各种各样的形式);而在此处,功能类 FileSplitter 显然是稳定的;因此,我们的设计实际上依赖了一个变化的元素,这明显的违反了依赖倒置原则

当然我们可以像以前一样去找 ProgressBar 的抽象父类;但问题又在于, ProgressBar 的抽象父类没有具体的方法可以供其对象调用。这就意味着我们不能使用该对象去接收当前的进度值。

如果考虑一下 ProgressBar 在系统里扮演了什么样的角色,我们会发现,它其实扮演了一个通知的角色。也就是说,比起使用一个具体的空间对象,我们可以使用一个抽象的通知来代替它的角色。

可以想到的是,不管进度条长啥样,它代表的始终是一个进度。因此,我们可以将进度条抽象为一个一定范围的数值;将这个数值交给不同的控件,就可以实现不同的进度条样式了:
class IProgress {
public:
    virtual void DoProgress(float value) = 0;
    virtual ~IProgress() {}
};
class FileSplitter {
    //....
    IProgress*  m_iprogress; 
    //....
};
可以看到的是, IProgress 中有一个 DoProgress 的方法。我们可以通过在具体的实现流程中对这个方法进行重写,达到想要的进度条效果:
class MainForm : public Form, public IProgress {
    //....
    virtual void DoProgress(float value) {
        progressBar->setValue(value);
    }
};

通过以上的改动,我们已经成功的实现了依赖导致原则:功能类 FileSplitter 再也没有依赖具体的控件类实现了;因此功能类 FileSplitter 实现了功能独立,可以用于任意的控件类的使用。具体的结果可以如下所示:

<html>

<img src=“/_media/programming/cpp/boolan_cpp/toobserver.svg” width=“900”/>

</html>

上面的修改只支持了一个流程(观察者)。假如我们有多个观察者需要接收通知(比如要把进度结果通知给不同的进度条),应该怎么做呢?

很简单;前面我们通过抽象对象 m_iprogress 来传递通知;因为 m_iprogress 一次只能传递一个通知,因此我们将其改为一个存储指针的 List 即可达到效果:

List<IProgress*>  m_iprogressList;
当然,为了实现对这些通知的管理,我们需要再设计一些函数:
void addIProgress(IProgress* iprogress) {
    m_iprogressList.push_back(iprogress);
}
void removeIProgress(IProgress* iprogress) {
    m_iprogressList.remove(iprogress);
}
通过上述的改写,我们实际上实现了如下图所示的结果:

<html>

<img src=“/_media/programming/cpp/boolan_cpp/ntoob.svg” width=“600”/>

</html>

具体的实现代码如下:

class IProgress {
public:
    virtual void DoProgress(float value)=0;
    virtual ~IProgress() {}
};
class FileSplitter {
    string m_filePath;
    int m_fileNumber;
    List<IProgress*>  m_iprogressList; // 抽象通知机制,支持多个观察者
public:
    FileSplitter(const string& filePath, int fileNumber) :
        m_filePath(filePath), 
        m_fileNumber(fileNumber) {
    }
    void split() {
        for (int i = 0; i < m_fileNumber; i++) {
            //...
            float progressValue = m_fileNumber;
            progressValue = (i + 1) / progressValue;
            onProgress(progressValue); //发送通知
        }
    }
    void addIProgress(IProgress* iprogress) {
        m_iprogressList.push_back(iprogress);
    }
    void removeIProgress(IProgress* iprogress) {
        m_iprogressList.remove(iprogress);
    }
protected:
    virtual void onProgress(float value) {
        List<IProgress*>::iterator itor=m_iprogressList.begin();
        while (itor != m_iprogressList.end() )
            (*itor)->DoProgress(value); //更新进度条
            itor++;
        }
    }
};
class MainForm : public Form, public IProgress {
    TextBox* txtFilePath;
    TextBox* txtFileNumber;
    ProgressBar* progressBar;
public:
    void Button1_Click( ){
        string filePath = txtFilePath->getText();
        int number = atoi(txtFileNumber->getText().c_str());
        ConsoleNotifier cn;
        FileSplitter splitter(filePath, number);
        splitter.addIProgress(this); //订阅通知
        splitter.addIProgress(&cn); //订阅通知
        splitter.split();
        splitter.removeIProgress(this);
    }
    virtual void DoProgress(float value) {
        progressBar->setValue(value);
    }
};
class ConsoleNotifier : public IProgress {
public:
    virtual void DoProgress(float value) {
        cout << ".";
    }
};
再来看看观察者模式的定义:

定义对象间一种一对多(变化)的依赖关系,以便当一个对象(Subject)的状态发生改变时,所有依赖于它的对象都得到通知并自动更新。
——《设计模式》GoF



<html>

<img src=“/_media/programming/cpp/boolan_cpp/observer.svg” width=“1000”/>

</html>

总结:

  • 使用面向对象的抽象,Observer 模式使得我们可以独立地改变目标与观察者,从而使二者之间的依赖关系达到松耦合。
  • 目标发送通知时,无需制定观察者,通知(可以携带通知信息作为参数)会自动传播。
  • 观察者自己决定是否需要订阅通知,目标对象对此一无所知。
  • Observer 模式是基于事件的 UI 框架中非常常用的设计模式,也是 MVC 模式的一个重要组成部分。

设计模式:单一职责

在软件组件的设计中,继承的概念是很重要的。一个类是否需要继承,是需要根据其是否真正符合继承的含义来决定的。盲目的继承意味着责任划分的不清晰;很多时候,错误的继承会随着需求的变化导致子类急剧的膨胀;而膨胀的元凶则是代码的冗余。因此,我们在软件设计中,必须明确模块的责任。

有两种设计模式特别强调了责任的界限:装饰模式(Decorator)和桥模式(Bridge)。

Decorator

某些情况下我们可能会错误的使用继承。由于继承具有为类型引入静态的潜质(必须在编译阶段指定类型),导致很多时候我们的扩展方式都缺乏灵活性(不能去动态的决定)。更糟的是,随着这些子类的增多,以及需要更多功能的扩展,接下来的子类会膨胀到一个惊人的程度。

来看一个具体的例子:

我们知道流(Stream)分为很多种类,比如文件流(FileStream)、网络流(NetworkStream )、内存流(MemoryStream )等等;而对于流来说,基本的操作有读(read())、写(write()),找(seek)等等。那么如果我们想对每种不同流实现流的基本操作,那么普通的办法应该是通过不同的流对父类 Stream 的继承,来分别重写指定的操作:

/* Base Class with operations */
class Stream {
public:
    virtual char Read(int number) = 0;
    virtual void Seek(int position) = 0;
    virtual void Write(char data) = 0;
    virtual ~Stream() {}
};

class FileStream:public Stream {
public:
    virtual char Read(int number) { .... } // read FileStream
    virtual void Seek(int position) { .... } //  seek FileStream
    virtual void Write(char data) { .... } // write FileStream
};

class NetworkStream :public Stream {
public:
    virtual char Read(int number) { .... } // read NetworkStream 
    virtual void Seek(int position) { .... } // seek NetworkStream 
    virtual void Write(char data) { .... } // write NetworkStream 
};

class MemoryStream :public Stream {
public:
    virtual char Read(int number) { .... } // read MemoryStream 
    virtual void Seek(int position) { .... } // seek MemoryStream 
    virtual void Write(char data) { .... } // wr MemoryStream 
};
那么此时的继承关系应该是这样的:

<html>

<img src=“/_media/programming/cpp/boolan_cpp/dec_inher_11.svg” width=“500”/>

i </html>

如果有一天,我们想对这些个类做添加一个加密操作;如果通过继承来扩展功能,那么结构图就会变成这样:

<html>

<img src=“/_media/programming/cpp/boolan_cpp/dec_inher_22.svg” width=“500”/>

</html>

如果再有一天,我们又想添加一个缓冲操作,如果又通过继承来扩展功能,那么结构图就会更复杂:

<html>

<img src=“/_media/programming/cpp/boolan_cpp/dec_inher_33.svg” width=“700”/>

</html>

如果再有一天我们希望对我们主体类同时使用加密和缓冲拓展……
反正我是不想画了……而如你肉眼所见,仅仅添加了两个功能,我们的新子类就膨胀到了令人发指的地步。

造成这样的原因是因为我们错误的使用了继承;换句话说,我们模糊了各个类的责任。对于三个主体类(文件流、网络流、内存流),继承关系是正确的;因为他们确实反映了与 StreamIs-a 关系。但反观我们后来加上去的加密和缓冲,这些拓展性的功能是否真的需要用到继承呢?

来看一下具体的代码(以加密为例子):

class CryptoFileStream :public FileStream{
public:
    virtual char Read(int number){
        //addtional crypto OP...
        FileStream::Read(number); // read FileStream
    }
    virtual void Seek(int position){
        //addtional crypto OP...
        FileStream::Seek(position);// seek File Stream
        //addtional crypto OP...
    }
    virtual void Write(byte data){
        //addtional crypto OP...
        FileStream::Write(data);//write File Stream
       //addtional crypto OP...
    }
};
class CryptoNetworkStream : :public NetworkStream{
public:
    virtual char Read(int number){
        //addtional crypto OP...
        NetworkStream::Read(number);
    }
    virtual void Seek(int position){
        //addtional crypto OP...
        NetworkStream::Seek(position);
        //addtional crypto OP...
    }
    virtual void Write(byte data){
        //addtional crypto OP...
        NetworkStream::Write(data);
        //addtional crypto OP...
    }
};
通过查看上面的代码,我们发现基本操作下的绝大部分的代码都是在做重复做同样的工作;也就是说,如果按照这样的结构来写程序,代码的冗余会非常高。找到了重复点,那么我们有没有办法来消除这些重复?

我们接着仔细观察,这些操作的不同在哪里?
FileStream::Seek(position);
NetworkStream::Seek(position);
可以看到的是,除了调用的类不一样,方法 seek() 是一模一样的;这说明了一个问题:对于拓展的功能使用继承来添加的话,是会有重复的,因为拓展的功能在严格意义上并不能代替基类;只有在拓展的功能与主体类组合的情况下,才能代替基类

那么我们可以尝试着将继承的关系改为组合的关系试试,于是就有了:
class CryptoNetworkStream {
NetworkStream *stream; //switch inheritance to combination
.....
};
既然类中有了对应的主体类的对象,很显然我们可以通过该对象去调用对应的基本操作,于是就有了:
class CryptoNetworkStream {
    NetworkStream *stream; //switch inheritance to combination
    virtual void Seek(int position) {
        stream -> seek(position);
    }
.....
};
对比之前通过继承来实现的调用 NetworkStream::Seek(position),你会发现实际上面的代码做的是同一件事;但我们的子类 CryptoNetworkStream 就不用再苦哈哈的从主体类那里继承基础操作了,子类的体积是不是变小了许多?

我们接着将所有的加密子类都替换成上面的组合方式调用。当你替换完成的时候,你又会发现一件有趣的事情:
class CryptoFileStream {
    FileStream *stream; //switch inheritance to combination
    virtual void Seek(int position) {
        stream -> seek(position);
};
class CryptoNetworkStream {
    NetworkStream *stream; //switch inheritance to combination
    virtual void Seek(int position) {
        stream -> seek(position);
};
看到这里,我们突然明白了:上下的两个 seek(),不就是多态的体现吗?而调用他们的对象,差别仅在于主体类的差异。那么这里我们完全可以在这里用多态处理这个对象;也就是将编译时的静态转化成了运行时的动态。于是,上面的程序可以改成:
class CryptoFileStream {
    Stream *stream; //new Filestream
    virtual void Seek(int position) {
        stream -> seek(position);
};
class CryptoNetworkStream {
    Stream *stream; //new NetworkStream
    virtual void Seek(int position) {
        stream -> seek(position);
};
以上的修改将所有的变化都丢到了运行时;也就是说,这几个看似不同的类,在编译阶段是完全等同的。他们唯一的不同,是在 Run time 决定的。因此,我们实际上就可以把上述的这种为了添加扩展功能而设计的众多子类合并到一起了。而因为我们这里有虚函数的存在,因此必须继承基类 Stream指定接口规范
class CryptoStream : public Stream {
    Stream *stream; //...
public:
    virtual void Seek(int position) {
        stream -> seek(position);
};
这就是装配模式的一大特点:既继承基类,又组合基类(这两个是完全不同的概念)。到此,我们成功的将加密操作做成了一个独立的模块;而这个模块在对之后主体类应用的过程中,呈现出了一种相加的状态:
FileStream* s1 = new Filestream(); //main class
CryptoStream* s2 = new CryptoStream(s1); // add option to main class

注:拓展功能类同样需要构造函数。



而拓展操作独立的好处在于,我们可以对任意的主体类进行叠加式的操作,而这样叠加式的操作避免了像继承那样的大量重复性操作。这样的操作,称之为运行时装配

根据 GoF 的经典重构理论来说,如果某一个类它的多个子类都具有同样字段的时候,那么应该将这个字段晚上提。比如我们的两个拓展功能类加密和缓冲,内部都有这么一段:

Stream* stream;
我们可以将这一段提到基类 Stream;但又出现了一个问题;我们的主体类并不需要这一段。为此,我们需要设计一个中间类Decorator):
DecoratorStream: public Stream {
protected:
    Stream* stream;//...
    DecoratorStream(Stream * stm):stream(stm) {}
};
做好这个中间类以后,以后所有的拓展功能类,以及需要通过组合基类来实现操作的子类,都可以继承这个类了。而经过这么一段折腾,我们的类关系实际上变成了这样:

<html>

<img src=“/_media/programming/cpp/boolan_cpp/after_dec.svg” width=“700”/>

</html>

到此,我们将所有的类功能都独立了出来,而我们的主题类也不用再依赖拓展功能类,就可以直接进行编译了。是不是太奇妙了!!这就是 Decorator 模式的威力所在啊!而我们的拓展附加类要使用的时候,需要加上我们的主体类;这也很好的体现了拓展功能的 Decorator 属性。作为装饰品,当然要有主体了对不对?

同时可以看出来的是,通过这样的设计模式,我们对整个系统的改进是巨大的;乘法和加法的数量级永远不是在一个等级上的。

说到这里,我们也能看出来装配模式到底可以解决什么样的问题了:

在某些情况下,我们可能会“过度的使用继承来扩展对象的功能”,由于继承为类型引入静态特质,使得这种扩展方式缺乏灵活性;并且随着子类的增多(扩展功能的在增多),各种子类的组合(扩展功能的组合)会导致子类的膨胀。这样的情况下就需要用到装配模式。

来看一看定义:

动态(组合)地给一个对象增加一些额外的指责。就增加功能而言,Decorator 模式比生成子类(继承)更为灵活(消除重复代码、减少子类个数)。
——《设计模式》GoF

这个结果跟我们上面优化前后的对比结果是不是一致呢?

<html>

<img src=“/_media/programming/cpp/boolan_cpp/decoretor.svg” width=“900”/>

</html>

总结:

  • 通过采用组合而非继承的手法,Decorator 模式实现了在运动时动态扩展对象功能的能力,而且可以根据需要扩展多个功能。同时,使用装配模式避免了使用集成带来的“灵活性差”和“多子类衍生的问题”。
  • Decorator 类在接口上变现为 is-a Component 的继承关系,即 Decorator 类集成了 Component 类所具有的接口。但在实现上又表现为 has-a Component 的组合关系,即 Decorator 类又使用了另外一个 Component 类。
  • Decorator 模式的目的并非解决“多子类衍生的多继承”问题,Decorator 模式应用的重要点在于解决“主体类在多个方向上的扩展功能”——是为“装饰”的含义。

Bridge

单一职责类型的设计模式主要解决的是责任划分的问题。我们在 Decorator 模式中已经讨论了不恰当继承会导致子类快速膨胀的问题。该问题会出现一个衍生问题:某些类型的实现逻辑使得其自身具有往不同方向变化的趋势,这样的情况应该如何应对?

细想一下这个问题实际和先前讨论的问题非常相似:上一节谈到的子类快速膨胀的后果,就是因为没有明确模块责任而导致的。而在这里,类型的实现逻辑需要往不同方向变化则表明了该类型需要实现多个功能,而这些功能可能按自身的意义分为好几个范畴不同的部分。如果把这些功能都混淆在一起,在以后的调用中很可能会出现问题(调用很可能只需要一部分功能的变化)。因此,我们在设计中,不但需要强调功能与主体的分离,而且需要将不同类型的功能区别看待。桥模式 Bridge 正好可以解决这样的问题。

还是从一个例子开始吧。假设我们需要开发一套即时通讯软件,该软件需要在不同的平台上部署(PC,移动端),而每个平台都要求有类似功能的具体实现。考虑到每个平台的功能需求相同,但实现不同,我们首先想到的就是针对不同的平台继承不同的基类,然后再进行拓展功能。这样的类关系可以表现为下图:

<html>

<img src=“/_media/programming/cpp/boolan_cpp/birdge_class_1.svg” width=“400”/>

</html>

有一天我们希望在每个平台上都开发出两个不同的版本。两个版本具有不同的功能,但用到的基础功能是一致的。因此,我们的设计可能就会演变为下图所示:

<html>

<img src=“/_media/programming/cpp/boolan_cpp/bridge_class_2.svg” width=“600”/>

</html>

这个结构是不是很熟悉?我们希望开发的两个版本只是基于公用的基础功能实现了不同的功能组合,但这样的设计给出的结果却是将新增的功能组合作为了主体类的一部分。再来看看实现:

/* Base class */
class Messager {
public:
    virtual void Login(string username, string password)=0;
    virtual void SendMessage(string message)=0;
    virtual void SendPicture(Image image)=0;

    virtual void PlaySound()=0;
    virtual void DrawShape()=0;
    virtual void WriteText()=0;
    virtual void Connect()=0;
    
    virtual ~Messager(){}
};
/* platform Implementation */
class PCMessagerBase : public Messager{
public:
    virtual void PlaySound() { .... }
    virtual void DrawShape() { .... }
    virtual void WriteText() { .... }
    virtual void Connect(){ .... }
};
class MobileMessagerBase : public Messager{
public:
    virtual void PlaySound() { .... }
    virtual void DrawShape() { .... }
    virtual void WriteText() { .... }
    virtual void Connect(){ .... }
};

class PCMessagerLite : public PCMessagerBase {
public:
    virtual void Login(string username, string password) { PCMessagerBase::Connect(); .... }
    virtual void SendMessage(string message) { PCMessagerBase::WriteText(); .... } 
    virtual void SendPicture(Image image) { PCMessagerBase::DrawShape(); .... }
};
class PCMessagerPerfect : public PCMessagerBase {
public:
    virtual void Login(string username, string password) { PCMessagerBase::Connect(); .... }
    virtual void SendMessage(string message) { PCMessagerBase::WriteText(); .... } 
    virtual void SendPicture(Image image) { PCMessagerBase::DrawShape(); .... }
};
class MobileMessagerLite : public MobileMessagerBase {
public:
    virtual void Login(string username, string password) { MobileMessagerBase ::Connect(); .... }
    virtual void SendMessage(string message) { MobileMessagerBase ::WriteText(); .... } 
    virtual void SendPicture(Image image) { MobileMessagerBase ::DrawShape(); .... }
};
class MobileMessagerPerfect : public MobileMessagerBase {
public:
    virtual void Login(string username, string password) {
        MobileMessagerBase::PlaySound();
        MobileMessagerBase::Connect();
    }
    virtual void SendMessage(string message) {
        MobileMessagerBase::PlaySound();
        MobileMessagerBase::WriteText();
    }
    virtual void SendPicture(Image image) {
        MobileMessagerBase::PlaySound();
        MobileMessagerBase::DrawShape();
    }
};
可见的是,上面的代码有大量的重复。对于不同平台的不同版本在实现中,唯一不同的就是他们继承的类不同。按照我们在装配模式中学到的经验,这是可以通过继承转组合化简的,也就是把我们需要的不同版本(不同基础功能的组合)作为一个扩展功能来看待。因此,上面的一大堆子类也可以按照装配模式的方法来精简,于是可以化简成:
class MessagerLite {
MessagerImp* messagerImp;
public:
    virtual void Login(string username, string password) { messagerImp->Connect(); .... }
    virtual void SendMessage(string message){ messagerImp->WriteText(); .... }
    virtual void SendPicture(Image image) {  messagerImp->DrawShape(); .... }
};
class MessagerPerfect {
MessagerImp* messagerImp;   
public:
    virtual void Login(string username, string password) {
        messagerImp->PlaySound();
        messagerImp->Connect();
        ....
    }
    virtual void SendMessage(string message) { 
        messagerImp->PlaySound();
        messagerImp->WriteText();
        ....
    }
    virtual void SendPicture(Image image){
        messagerImp->PlaySound();
        messagerImp->DrawShape();
        ....
    }
};
到这里,添加不同功能组合的计划已经被实现了。不过回到先前的源代码中,我们发现有这么一段:
class PCMessagerBase : public Messager { ... }
仔细看一下,这里是有问题的。 Messager 是一个抽象类,里面全是纯虚函数;而 PCMessagerBase 中只重写了其中的一部分虚函数。按照虚函数的概念,纯虚函数是必须被重写的;因此我们得想一个办法让这里正确继承下去。这一个点,就是桥模式在解决问题上不同于装配模式的地方了。
那要怎样组织类关系才合理呢?我们不妨来理顺一下思路:

  1. Lite 版本 和 Perfect 版本是通过 Messager 中的函数的前三个函数重写来实现的。
  2. 而上述这三个函数的实现,又包含了对后面四个函数的重写:比如 Login() 需要依赖 Connect() 来实现。

因此,我们明白一件事:Lite 版本 和 Perfect 版本的实现是不区分平台的。只有当他们内部的功能函数需要具体化的时候,才会考虑平台的问题。也就是说,Lite 版本 和 Perfect 版本的实现,实际上算是对 Messager 的一种重新诠释,而这个诠释的具体实现,是要交给不同的平台去应对的。

有了这个思路,我们可以做几件事来改进我们的程序了:

  1. Lite 版本 和 Perfect 版本的实现就是重写 Messager 中的前三个函数,其他的函数都是需要在不同平台中做具体实现的,因此可以把这些函数都独立出来,作为平台类的父类。
  2. 接下来,Lite 版本 和 Perfect 版本的实现可以直接继承重写 Messager ,其内部实现就是通过 Messager 的对象调用前三个函数实现多态。
  3. 再者,我们分离出来的函数组成的类 MessagerImp 负责对先前三个函数的实现;因为有不同的平台要求,因此也通过继承多态到子类去做具体实现。


做完这几步,我们发现我们将实现和抽象又一次的分开了,先前通过直接继承的复杂关系,到此也理清楚了。

总的来说,这就是一个抽象类会有自身的诠释,而诠释的过程需要交给具体的其他函数去实现。回头看看本节概述中关于桥模式所针对的问题的描述,也就明白了。在这里,变化的有两种:一种是抽象类的子类的变化;另外一种是子类中具体实现的函数的变化。把这两者分开后,两边的责任都非常明确。接着再以组合的方式拼到一起,我们就能得到一个清晰的程序结构,和更加简洁高效的代码。这就是桥模式所带给我们的。

来看看桥模式的定义:

将抽象部分(业务功能)与实现部分(平台实现)分离,使他们都可以独立地变化。
——《设计模式》GoF



<html>

<img src=“/_media/programming/cpp/boolan_cpp/bridge1.svg” width=“1000”/>

</html>

总结:

  • Bridge 模式使用“对象间的组合关系”解耦了抽象和实现之间固有的绑定关系,使得抽象和实现可以沿着各自的维度来变化。所谓抽象和实现沿着各自维度的变化,即“子类化”他们。
  • Bridge 模式有时候类似于多继承方案,但是多继承方案往往违背单一指责原则(即一个类只有一个变化的原因),复用性较差。Bridge 模式是比多继承方案更好的解决方法。
  • Bridge 模式的应用一般在“两个非常强的变化维度”,有时一个类也有多于两个的变化维度,这是可以使用 Bridge 的扩展模式。




~~DISQUS~~