游戏编程模式笔记——第六章 单例模式

”确保一个类只有一个实例,并为其提供一个全局访问入口。“
尽管单例模式的出发点是好的,但在GOF对单例模式的描述中,它通常弊大于利。他们一再强调应当谨慎使用该模式——然而当其应用于游戏产业中时,这一点却往往被忽略了。
与任何模式一样,在不合适的地方使用单例模式,就像药不对症,不过首先,我们来看看模式本身。
确保一个类只有一个实例
在对系统文件进行操作时,如果我们调用一个方法创建文件,又调用另外一个方法删除这个文件,那么我们的封装类就必须知悉,并确保他们不会互相干扰。为了实现这点,对封装类的调用必须能够知道之前的每一步操作。如果使用者能够自由地创建这个类的实例,那么一个实例就无法知道其他实例所做的操作。而单例模式则提供了在编译期就能确保某个类只有一个实例的做法。
提供一个全局指针以访问唯一实例
除了创建一个单独的实例外,它还提供一个全局的方法以便获取该实例。这样一来,任何模块在任何地方都能得到这个实例了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class FileSystem
{
public:
static FileSystem& instance()
{

//Lazy initialize
if(instance_ == NULL)
{
instance_ = new FileSystem();
}
return *instance_;
}
private:
FileSystem() {}
static FileSystem* instance_;
};

以下是更现代的版本:

1
2
3
4
5
6
7
8
9
10
11
class FileSystem
{
public:
static FileSystem& instance()
{

static FileSystem *instance = new FileSystem();
return *instance;
}
private:
FileSystem() {}
};

C++11保证一个局部静态变量的初始化只进行一次,哪怕实在多线程的情况下也是如此。所以你有一个现代C++编译器的话,这份代码是线程安全的,而之前的例子却不是。
使用情境
除了不会因为初始化多个实例而将事情弄糟,它还具备一些其他的优良特性。

  • 如果我们不使用它,就不会创建实例——节省CPU周期以及内存空间。
  • 它在运行时初始化。
  • 你可以继承单例。这是一个强大但是经常被经常忽视的特性。
    比如FileSystem,我们可以继承它并为不同平台定义派生类。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    class FileSystem
    {
    public:
    static FileSystem& instance();
    virtual ~FileSystem() {}
    virtual char* read(char *path) = 0;
    virtual void write(char* path, char* text) = 0;
    private:
    FileSystem() {}
    };

    class PS3FileSystem : public FileSystem
    {
    public:
    virtual char* read(char* path)
    {

    // Use Sony file IO API...
    }
    virtual void Write(char *path, char *text)
    {

    // Use Sony file IO API...
    }
    };

这里巧妙的地方在于如何创建实例:

1
2
3
4
5
6
7
8
FileSystem& FileSystem:: instance()
{
#if PLATFORM == PLAYSTATION3
static FileSystem *instance = new PS3FileSystem();
#elif ...
// Other Platforms
#endif
}

我们可以通过FileSystem::instance()来访问文件系统,而不必和任何平台相关的代码发生耦合。

后悔使用单例的原因

它是一个全局变量

  • 它们令代码晦涩难懂
  • 全局变量促进了耦合
  • 它对并发不友好

单例就是一个全局状态——它只是被封装到了类中而已。

它是个画蛇添足的解决方案

便利的访问,是使用单例模式的主要原因。比如日志类,将Log类的实例传递给每个函数会扰乱函数签名,并分散代码意图。把Log类变为单例能解决问题,但是不能够创建多个日志器了。但是一旦需要加入日志过滤的需求,通过将日志分割成不同的文件才解决这个问题,除了要修改log不能有多个实例的问题,同时需要修改每个调用点。

延迟初始化剥离了你的控制

游戏通常需要仔细地控制内存在堆中的布局来防止碎片化。如果我们的音频系统在初始化时分配了内存,我们需要知道初始化发生的时间,以便让我们控制它在堆中的内存布局。

那么我们该怎么做

看你究竟是否需要类

保姆类有时是有用的,不过这通常反映出他们对OOP不熟悉。如果可以,你只需要将这些功能移动到它所帮助的类中去就可以了。毕竟面向对象就是让对象自己管理自己。

将类限制为单一实例

为实例提供便捷的访问方式。

通用的原则是,在保证功能的情况下将变量限制在一个狭窄的范围内。对象的作用域越小,我们需要记住它的地方越少。让我们考虑下代码库访问一个对象的其他途径:

  • 传递进去。 将这个对象当作一个参数传递给需要它的函数。
  • 在基类中获取它。 把Log定义在GameObject中,这样所有派生自GameObject的对象都可以范围它。(子类沙盒)
  • 通过其他全局对象访问它。
  • 通过服务定位器来访问。定义一个类专门用来给对象做全局访问。