OOP 学习笔记(11)——创建型模式

OOP 学习笔记(11)——创建型模式

Contents

创建型模式

单例(Singleton)模式

需求:全局计数器

设计一个全局计数器,程序任意位置调用 addCount() 均可以使得计数器 $+1$。

调用 getCount() 返回计数器数值。

如何实现这两个需求。

分析:全局变量

int count = 0;
void addCount()
{
    count++;
}
int getCount()
{
    return count;
}

但这样的设计,用户容易访问到 count 变量使得其存在被随意修改的可能。

并且,我们最好应该避免全局变量。

分析:静态成员

class Counter
{
    static int count = 0;
public:
    void addCount()
    {
        count++;
    }
    int getCount()
    {
        return count;
    }
};

Counter counter;
counter.addCount();
cout << counter.getCount() << endl;

如此实例化的方式显然很麻烦。

分析:静态方法

class Counter
{
    static int count = 0;
public:
    static void addCount()
    {
        count++;
    }
    static int getCount()
    {
        return count;
    }
};

Counter::addCount();
cout << Counter::getCount() << endl;

这样似乎很完美。

新需求:多种计数器

如果我们需要有多种 Counter,那么很容易想到利用虚函数和继承的方法实现。

也就是说会出现:

class BaseCounter
{
public:
    static virtual void addCount() = 0;
    static virtual int getCount() = 0;
};

但这样是会编译错误的!

换句话说,静态方法加上虚函数不可行的!

静态方法不可以是虚函数!

分析:根本目的

我们想要使用静态方法的根本目的在于:

  • 无论何处调用,访问的是相同的函数和变量
  • 无需多次实例化

单例模式

单例模式要求:

  • 单例类只能有一个实例。
  • 单例类必须自主创建唯一实例。
  • 单例类必须可以提供这一唯一实例。

实现关键:

  • 构造函数不开放(private)。
  • 通过静态方法、枚举返回单例类对象。

实现:本类作为静态成员

class Counter
{
    Counter(const Counter &) = delete;
    void operator=(const Counter &) = delete;

    int count;
    Counter()
    {
        count = 0;
    }
    static Counter _instance; // 全局唯一实例
public:
    static Counter &instance() // 唯一方法获取实例
    {
        return _instance;
    }
    void addCount()
    {
        count++;
    }
    int getCount()
    {
        return count;
    }
};
Counter Counter::_instance;

使用时只须:

Counter &c = Counter::instance();
c.addCount();
cout << c.getCount() << endl;

单例模式封装了全局性变量,只有一次实例化,也没有依赖静态访问方法,满足上面的需求。

优化:惰性初始化(Lazy Initialization)

我们希望单例模式在使用时自动构造单例实例。

只须将 _instance 这个成员改为:

static Counter &instance()
{
    static Counter _instance;
    return _instance;
}

这样,函数内静态变量在第一次执行到其定义时才会被构造。

也就是说,第一次调用 instance() 才会实例化单例。

:这是考虑到一些较为复杂的类可能需要长时间构造,程序刚开始就构造可能导致程序卡顿之类问题。

问题:意外删除

上面的实现保证了实例不会被重复构造,但实际上不能避免意外删除。

考虑下面代码:

Counter &c = Counter::instance();
delete &c; // 可以执行
c.addCount(); // 运行时错误

(引用和实例有着同样的实际地址)

所以还应该将析构函数也设置为私有

实现:单例模式 $+$ 虚函数

#include <iostream>

using namespace std;

class BaseCounter
{
public:
    virtual void addCount() = 0;
    virtual int getCount() = 0;
};

class SimpleCounter : public BaseCounter
{
    // ...单例相关逻辑...
    int count;
    SimpleCounter()
    {
        count = 0;
    }
public:
    virtual void addCount()
    {
        count++;
    }
    virtual int getCount()
    {
        return count;
    }
};

class NotSimpleCounter : public BaseCounter
{
    // ...相关逻辑
}

void doStuff(BaseCounter *counter)
{
    counter->addCount();
    cout << counter->getCount() << endl;
}

int main()
{
    doStuff(&SimpleCounter::instance());
    doStuff(&NotSimpleCounter::instance());
    doStuff(&SimpleCounter::instance());
    return 0;
}

尚存在缺陷

  • 单例相关逻辑较多,需要在派生类中分别实现单例代码,尤其是每个单例派生类的 instance()

奇特的递归模板模式(CRTP)

全称为 Curiously Recurring Template Pattern

template <class Derived> // 模板参数为派生类类型
class Singleton
{
    Singleton(const Singleton &) = delete;
    void operator=(const Singleton &) = delete;
protected:
    Singleton() {}
    virtual ~Singleton() {}
public:
    static Derived &instance() // 关键部分
    {
        static Derived _instance;
        return _instance;
    }
};

class SimpleCounter : public BaseCounter, public Singleton<SimpleCounter>
{
    friend class Singleton<SimpleCounter>; // 友元声明使得 Singleton 类可以访问其私有的构造函数
    // ...只须实现计数器逻辑
};

这里采用的是 CRTP 加上多重继承

注意,不可将 Singleton 类的实现逻辑实现在 BaseCounter 类中,单例功能与其他功能剥离。

否则 BaseCounter 将成为模板类,无法脱离模板参数存在。

代码合法原因

  • 尽管 Singleton 利用到了 SimpleCounter,但其中不直接、间接包含 SimpleCounter 类,其大小不依赖于 SimpleCounter,所以实际上不需要其完整定义。
  • 在模板类 Singleton 被实例化时,SimpleCounter 类与 Singleton 类的定义实际上已经给出。

关于 CRTP

CRTP 是实现多台的另一种方式,实际上并不只是单例模式才会使用到:

  • 利用 C++ 模板,让编译器生成重复代码。

其本质上还是编译器多态(静态多态):

  • 在编译期确定实际被调用的函数。

关于单例模式

存在着一定争议。

优点

  • 实现似乎较为简单。
  • 以相对安全形式提供可供全局访问的数据。

缺点

  • 难以完全正确实现,安全隐患在各种特殊情况下可能仍然存在,防不胜防。
    • 该设置为 deleteprivate 的构造、析构函数都应该正确设置,朴素的实现方法也未必能保证线程安全。
  • 违反了面向对象单一职责原则。
  • 滥用这种方法会使得实际依赖关系变得隐蔽。
    • 开发者无需在意类之间关系,只需引用单例即可。

工厂(Factory)模式

需求:泡茶程序

根据需求提供绿茶或红茶。

class Tea
{
    // ...
};
class GreenTea : public Tea
{
    // ...
};
class BlackTea : public Tea
{
    // ...
};
GreenTea *orderGreenTea()
{
    GreenTea *greenTea = new GreenTea();
    greenTea->addIngredients();
    greenTea->brew();
    greenTea->pour();
    return greenTea;
}
BlackTea *orderGreenTea()
{
    BlackTea *blackTea = new BlackTea();
    blackTea->addIngredients();
    blackTea->brew();
    blackTea->pour();
    return blackTea;
}

可见代码中的大量冗余。

优化:抽象出公共逻辑

当然也可以稍作修改:

Tea *orderTea(const string &type)
{
    Tea *tea = nullptr;
    if (type == "GreenTea")
        tea = new GreenTea;
    else
        if (type == "BlackTea")
            tea = new BlackTea;
    tea->addIngredients();
    tea->brew();
    tea->pour();
    return tea;
}

这种构造过程可能多次用到,也可以进一步抽象。

工厂模式

Tea 类中添加一个 factory 静态方法。

class Tea
{
    // ...
public:
    static Tea *factory(const string &type);
};

// ...子类定义...

Tea *Tea::factory(const string &type)
{
    if (type == "GreenTea")
        return new GreenTea;
    else
        if (type == "BlackTea")
            return new BlackTea;
        else // ...其他可能
}

这也就是工厂模式

单独工厂类

当构造逻辑过于复杂,或者有必要进行分离时,可以把工厂放在单独类中。

class TeaFactory
{
public:
    void setMilk(int amount)
    {
        // ...
    }
    void setSugar(int amount)
    {
        // ...
    }
    Tea *createTea(const string &type)
    {
        Tea *tea = nullptr;
        if (type == "GreenTea")
            tea = new GreenTea;
        else if (type == "BlackTea")
            tea = new BlackTea;
        else // ...其他可能
        if (milkAmount > 0) tea->addMilk(...);
        if (sugarAmount > 0) tea->addSugar(...);
        // ...其他属性配置
    }
};

实际上抽象来看就是:

21

工厂模式用途

  • 包装复杂的构造逻辑。

  • 为重载的构造函数提供描述性名称。

    • 一些类可能具有多个重载的构造函数,可以改写为工厂模式以使用描述性名称,方便实例化操作。
    • 比如复数从平面坐标构造和极坐标构造两种方式,这两种方式参数类型恰好相同,使用工厂模式更方便也更直观。
  • 对象构造需要用到当前函数体无法访问的信息。
    • Box2D 是一个物理引擎,继承了大量物理力学、运动学的计算,并将物理模拟过程封装到类对象中。

    • 只须调用引擎中相关对象、函数,就可模拟实现各种物理运动。

    • 使用时物理情境下设置物体,引擎即可自行按照物理规则模拟。

    • 其中类型包括 b2Bodyb2Worldb2BodyDef,分别表示刚体对象、物理场景以及 b2Body 对象拥有的属性。

    • 其中 b2Body 的构造函数为私有,必须使用工厂模式构造:

    b2World *world = new b2World(gravity(0.0f, -10.0f), ...);
    b2BodyDef bodyDef;
    bodyDef.position.Set(0.0f, 4.0f);
    // ...
    b2Body *body = world->createBody(&bodyDef);
    

    其构造需要使用到 bodyDef 中的内容。

  • 需要集中管理被构造对象的生命周期。

    • 上述例子中 b2World 维护了所有 b2Body 的列表,这一列表是私有的,可以用于管理其生命周期。
    • 而且使用这样的方法,可以使得实例化 b2Body无需给出所有属性,并可以方便地构造多个属性相同、类似的对象
    • 除此之外,Box2D 自己实现了小块内存分配器,手动管理内存,人工进行实例化反而会与自动内存分配冲突。
    • 也就是说工厂模式可以避免用户误用系统的内存分配机制,让创建过程不暴露给用户。

抽象工厂(Abstract Factory)模式

工厂模式局限

工厂模式目的是构造单个类对象。

如果我们需要构造多个类对象,而且有特定组合方式?

需求:编译器前端

一个实例:编译器前端。

  • 编译器前端负责由代码构建统一格式的中间表示。
  • 通常来说,需要三个步骤,语法分析、语义分析、中间代码生成。
  • 对于每种语言来说,每个步骤的实现不同,同种语言应该使用同种实现。
  • 编译器前端的框架相同,只须替换是三个步骤不同实现即可。
  • 不妨假设有两种语言 C++ 和 Java。

设计

三个步骤类:

  • 语法分析:基类 LexercppLexerJavaLexer
  • 语义分析:基类 ParserCppParserJavaParser
  • 中间代码生成:基类 GeneratorCppGeneratorJavaGenerator

框架实现:

class Compiler
{
    string type;
public:
    Compiler(string _type) : type(_type) {}
    LexResult *lex(Code *input)
    {
        Lexer *lexer;
        if (type == "cpp")
            lexer = new CppLexer;
        else if (type == "java")
            lexer = new JavaLexer;
        return lexer->lex(input);
    }
    // ...其他两部分类似
};

框架实现细节

上述工厂模式实现合理,但是有较多代码重复,并且如果需要增加新语言,需要在每个步骤都加上 if 分支。

我们如何在当前框架下减少代码重复。

分析:添加一层抽象

设计一个基类,抽象统一语言所需的所有步骤:

class AbstractLanguageFactory
{
public:
    virtual Lexer *createLexer() = 0;
    virtual Parser *createParser() = 0;
    virtual Generator *createGenerator() = 0;
};

class CppFactory : public AbstractLanguageFactory
{
public:
    Lexer *creater()
    {
        return new CppLexer;
    }
    // ...其他两部分类似
};
class JavaFactory : public AbstractLanguageFactory
{
    // ...类似
};

则上述 Compiler 类可以改写为:

class Compiler
{
    AbstractLanguageFactory *factory;
public:
    Compiler(AbstractLanguageFactory *_factory) : factory(_factory) {}
    LexResult *lex(Code *input)
    {
        Lexer *lexer = factory->createLexer();
        return lexer->lex(input);
    }
    // ...其他两部分类似
};

调用时只需:

int main()
{
    CppFactory *cppFactory = new CppFactory();
    Compiler *cppCompiler = new Compiler(cppFactory);

    Cod *code = ...
    LexResult = *lex = cppCompiler->lex(code);
    // ...
}

抽象工厂模式

对应术语与类:

  • 抽象工厂:AbstractLanguageFactory
  • 具体工厂:CppFactoryJavaFactory
  • 抽象产品:LexerParserGenerator
  • 具体产品:CppLexerJavaParser 等。

抽象工厂模式,即将同类具体产品包装为一个具体工厂,以抽象工厂形式传递给上层代码。

上层代码只需要关心抽象工厂和抽象产品,无需知道具体工厂、产品是哪些。

22

抽象工厂模式是工厂模式的升级版,用于创建一组相关或相互依赖的对象。

总结

创建型模式

创建型模式是将对象的创建与使用进行划分,规避复杂对象创建带来的资源消耗,满足特殊情况的创建要求。

上面的三种设计模式分别有不同的创建需求,因此有了不同的实现。

设计模式总结

日常任务实际上往往是多种设计模式的综合。

比如策略模式实例化可能过于复杂,可以采用抽象工厂模式进行策略创建的封装。

比如数据存储采用迭代器模式,而数据结构又可以采用适配器模式。

总的来说,设计模式存在的目的是为了面向未来、面向变化

  • 进行代码复用。
  • 支持功能拓展。
  • 降低维护代价。
  • 方便外部调用。

理解与应用也应该将重点放在设计思路而非具体实现代码,也不应该过度设计框架,避免过于精巧设计给实现带来不便。

 

点赞 0

No Comments

Add your comment