游戏编程模式(四):原型模式和单例模式
原型模式
原型模式是一种对象创建型模式,它是使用原型实例指定待创建对象的类型,并且通过复制这个原型来创建新的对象。
它的工作原理很简单:将一个原型对象传给要发动创建的对象(即客户端对象),这个要发动创建的对象通过请求原型对象复制自己来实现创建过程。
在Java和C#中,对象类中的clone()方法就是原型模式的应用,在游戏开发中,考虑一个怪物生成的案例,不利用原型模式的代码将会是:
class Spawner
{
public:
virtual ~Spawner() {}
virtual Monster* spawnMonster() = 0;
};
class GhostSpawner : public Spawner
{
public:
virtual Monster* spawnMonster()
{
return new Ghost();
}
};
class DemonSpawner : public Spawner
{
public:
virtual Monster* spawnMonster()
{
return new Demon();
}
};
我们可以把clone()方法放入Monster类中,使其可以生成出一个自己的副本:
class Monster
{
public:
virtual ~Monster() {}
virtual Monster* clone() = 0;
// ……
};
class Ghost : public Monster {
public:
Ghost(int health, int speed)
: health_(health),
speed_(speed)
{}
virtual Monster* clone()
{
return new Ghost(health_, speed_);
}
private:
int health_;
int speed_;
};
将拥有clone()方法的原型类送入客户端对象(spawner类):
class Spawner
{
public:
Spawner(Monster* prototype)
: prototype_(prototype)
{}
Monster* spawnMonster()
{
return prototype_->clone();
}
private:
Monster* prototype_;
};
当然,在实际开发中,通常需要注意clone()方法具体是做浅拷贝还是深拷贝,这个点在一般的软件开发中都会遇到,这里打算只是简单介绍原型模式的基本思想,就不继续深入讨论了。
单例模式
单例模式在软件开发中出现率太过于频繁,以至于我不打算集中注意力去讨论如何使用它,而是讨论如何避免使用它。因为尽管它确实非常方便,但在游戏开发中,更应该谨慎地使用这个模式。
GoF中这样描述单例模式:
保证一个类只有一个实例,并且提供了访问该实例的全局访问点。
快速过一遍它的最简单的经典实现方案(当然此处不打算讨论线程安全的实现方案):
class FileSystem
{
public:
static FileSystem& instance()
{
// 惰性初始化(非线程安全)
if (instance_ == NULL) instance_ = new FileSystem();
return *instance_;
}
private:
FileSystem() {}
static FileSystem* instance_;
};
很明显,用单例模式的最大好处就是该单例类在任何需要的地方都可用,而无需笨重地到处传递,在很多只需要一个实例的场景中,也保证了不会因不小心创建了多个实例而造成混乱。但是,我们需要考虑它可能带来的各种麻烦事:
一、它是一个全局变量
降低代码的可读性。要理解一个单例类在某个方法中干了些啥,得追踪整个代码库来搜寻什么修改了全局变量
增加了耦合性。全局变量很容易导致不小心在某处将两块不相干的模块耦合起来
多线程不友好。将某个变量转化为全局变量时,就等于创建了一块每个线程都能访问的内存,要保证这块内存的线程安全性就会变得很困难,竞争状态、死锁、线程同步出现故障的概率将大大提升
二、实例的数量被严格约束
单例模式当然是只用来创建唯一实例的,比如日志类,为了避免日志类在众多方法中传来传去,单例模式确实是一个很好的解决方式。但是,这也使得单例类只能有唯一的一个实例,假如我们需要将日志分类记录,它将不再允许我们创建多个实例。
三、惰性初始化剥夺了控制权
在一般的软件开发中,惰性初始化确实可以帮助节省内存,只在我们需要它的时候才会占用内存。但对于游戏这种对优化要求程序非常高的应用来说,惰性初始化可能会导致降低游戏体验。例如,游玩中在达到一个高潮阶段时,可能会出现大量的画面渲染、音乐播放等需求,它们都可能是首次被调用,如果放任惰性初始化不管,此时就可能会有十万百万千万个实例被同时初始化,这将导致肉眼可见的掉帧和断续。
好,单例模式确实会带来一些问题,所以使用它就需要在一些方面做出权衡,我经常会在游戏源码中见到各种“管理器”类,开发者想要用这些类去管理其它对象。比如,怪物管理器类、粒子管理器类、声音管理器类,甚至,管理器管理器类,例:
class BulletManager
{
public:
Bullet* create(int x, int y)
{
Bullet* bullet = new Bullet();
bullet->setX(x);
bullet->setY(y);
return bullet;
}
bool isOnScreen(Bullet& bullet)
{
return bullet.getX() >= 0 &&
bullet.getX() < SCREEN_WIDTH &&
bullet.getY() >= 0 &&
bullet.getY() < SCREEN_HEIGHT;
}
...
}
像是上面这种Manager类纯属多余,这属于开发者对OOP的不熟悉,完全可以将它的功能在Bullet类本身中实现:
class Bullet
{
public:
Bullet(int x, int y) : x_(x), y_(y) {}
bool isOnScreen()
{
return x_ >= 0 && x_ < SCREEN_WIDTH &&
y_ >= 0 && y_ < SCREEN_HEIGHT;
}
...
private:
int x_, y_;
};
关于访问权限的控制,考虑两个案例,一、从基类中获取到单例对象。二、将各个单例类合并到一个单例类中,而不必真正将它们单例化:
一、从基类中获取到单例对象
很多游戏引擎中都会有GameObject基类,我们可以利用这点来从GameObject类中获取单例对象:
class GameObject
{
protected:
Log& getLog() { return log_; }
private:
static Log& log_;
};
class Enemy : public GameObject
{
void doSomething()
{
getLog().write("log!");
}
};
这保证任何GameObject之外的代码都不能接触Log对象,但是每个派生的实体确实能使用getLog()
二、合并到一个单例类中
创建一个代表整个游戏状态的Game类,让这个全局对象捎带上其它类,来减少全局变量类的数量,而不必让Log,FileSystem和AudioPlayer都变成单例:
class Game
{
public:
static Game& instance() { return instance_; }
// 设置log_, et. al. ……
Log& getLog() { return *log_; }
FileSystem& getFileSystem() { return *fileSystem_; }
AudioPlayer& getAudioPlayer() { return *audioPlayer_; }
private:
static Game instance_;
Log *log_;
FileSystem *fileSystem_;
AudioPlayer *audioPlayer_;
};
...
Game::instance().getAudioPlayer().play(VERY_LOUD_BANG);