确保一个类只有一个实例,并为其提供一个全局访问入口。
(摘自《游戏编程模式》)
在GoF中对单例模式的描述中,它通常弊大于利。我的实际编码过程很喜欢使用单例,因为它让代码更加整洁,且访问函数时很便捷。然而,同样要注意的是,单例模式具有很多缺点缺点,我们需要知道如何在使用中避免这些弊端的影响。
单例模式的特点和实现
单例模式的特点
- 确保一个类只有一个实例
- 提供一个全局指针以访问唯一实例
- 如果我们没有使用单例,那么其单例静态实例是不会被创建的(尽在第一次调用时创建)
- 单例实例会在第一次调用时进行初始化
- 单例可以被继承,并且可以通过虚函数重载来实现不同条件下的实现方法(多态)
单例实现
class Singleton
{
public:
static Singleton& Instance()
{
//C++ 11保证一个局部静态变量的初始化只进行一次
static Singleton* instance=new Singleton();
return *instance;
}
}
利用单例继承来进行跨平台代码编写
class FileIOSystem
{
public:
static FileIOSystem& Instance()
{
#if PLAYFORM==WINDOWS
static FileIOSystem* instance=new WinFileIOSystem();
#elif PLAYFORM==PLAYSTATION4
static FileIOSystem* instance=new PS4FileIOSystem();
#endif
return *instance;
}
virtual string Read()=0;
virtual void Write(string val)=0;
}
class WinFileIOSystem : FileIOSystem
{
public:
virtual string Read(){ /*Windows文件读操作相关代码*/}
virtual void Write(string val){ /*Windows文件写操作相关代码*/}
}
class PS4FileIOSystem : FileIOSystem
{
public:
virtual string Read(){ /*PS4文件读操作相关代码*/}
virtual void Write(string val){ /*PS4文件写操作相关代码*/}
}
单例模式的缺陷
它是一个全局变量
我们想要编写一个单例模式代码,我们最主要的目的其实就是方便访问和在模块中要求只有一个单例。然而,单例模式是靠全局变量实例实现的。作为一个全局变量,它带来了不少的缺点。
- 全局变量意味着无论在那一行代码,只要在同一个或引用了相关namespace,都能够访问并修改其属性。这导致了我们对其的维护十分困难。我们不知道在哪会错误获取或设错了某一个值导致Bug的出现。
- 全局代码促进了耦合。全局代码导致了跨模块的访问变的十分便利,然而程序员在享受其带来便利的同时,也破坏了模块的独立性。
- 多线程中全局变量的访问需要花功夫避免线程同步出现的死锁、条件竞争等问题。
项目开发后期它会显得“多余”
项目开发初期,我们面对的需求较少,使用单例模式编写的接口可以很好的满足需求。然而随着项目实现的功能不断增多,从前实现的单例可能会需要更多的实例。例如:日志系统的开发,当我们前期个人开发时,单例可以满足个人日志debug的需求;然而后期发展到多人协作后,单例的日志系统使得所有开发者的debug日志都由一个日志系统维护,这时候日志文件就成了巨大的“垃圾场”。这是我们在项目前期不使用单例模式就可以避免的
非延迟初始化和继承特性二者不可兼得
上文我们叙述了延迟初始化的好处,横看成岭侧成峰,这里我们也得指出延迟初始化的坏处。单例规模不同,初始化需要的时间也不同。当我们在第一次使用时对单例进行初始化,而这时恰好是用户体验的关键时刻,这就会照成卡顿。因此,有些时候,我们会将单例写成如下:
class Singleton
{
public:
static Singleton& Instance()
{
return instance;
}
private:
static Singleton instance;
}
//在程序启动后,便会自动初始化静态变量instance
Singleton Singleton::instance;
然而,我们前面也展示了单例模式借用继承和虚函数实现的多态!而且Instance()还会显得十分多余——为什么我不直接访问静态变量呢?
正确的处理单例模式
让代码更紧凑
让代码更紧凑,避免更多的单例出现。事实上,很多单例模式出现的原因就是将本应该在一个类的功能分解成实例类和实例Manager类——这虽然很符合人的逻辑,但是这也无形让代码结构变得更加松散。
断言限制成为单例
除了单例模式,我们还可以通过断言限制某个类仅能初始化一个实例。代码如下:
class Singleton
{
public:
Singleton()
{
assert(!instantiated_);
instantiated_=true;
}
private:
static bool instantiated_=false;
}
上述的Singleton类的构造函数只能调用一次,若第二次访问时,则会出错。
另类方法实现便捷访问
便捷访问是我们使用单例模式重要的原因。但我们可以通过一下途径去实现单例模式的便捷访问。
- 将实例作为参数传递进去。这样就使得在函数这个作用域中方便访问,但不至于作用至全局。
- 通过继承访问。若想要更便利的访问某个类,则可以作为其子类——子类访问父类是方便的。因此,我们可以让父类包含一个单例,其子类则通过访问父类所包含的单例,这样就避免了大多数类实例直接访问单例的问题。
将所有的单例封装在一个大的单例模式中
通过整合,我们可以减少单例的数量。如下代码所示:
class Game
{
public:
static Game& Instance()
{
return instance_;
}
Log& GetLog(){return *log;}
FileSystem& GetFileSystem(){return *fileSystem;}
AudioSystem& GetAudioSystem() {return *audioSystem;}
private:
static Game instance_;
Log *log;
FileSystem *fileSystem;
AudioSystem *audioSystem;
}
//访问 AudioSystem
Game.Instance.GetAudioSystem();
这样编写的代码就避免了再将Log、FileSystem、AudioSystem编写成单例的问题。
总结
- 在《游戏编程模式》中建议,单例模式不值得使用!
- 建议使用单例模式的情景:整个软件仅需一个实例,且具有一定的独立性——尽管在软件不断开发中不能够保证这一点会不会变,如果变了,代码修改起来将非常困难。