书中总结出这种 Subclass Sandbox 的设计模式
Game Design Patterns: Subclass Sandbox
这种模式要点有两点:
- 在基类中实现各种功能性方法供子类调用
- 定义沙盒接口供子类重载
书中的例子,基类中定义三个实用方法,和沙盒接口 activate:
class Superpower
{
public:
virtual ~Superpower() {}
protected:
virtual void activate() = 0;
void move(double x, double y, double z)
{
// Code here...
}
void playSound(SoundId sound, double volume)
{
// Code here...
}
void spawnParticles(ParticleType type, int count)
{
// Code here...
}
};
然后在子类中调用
class SkyLaunch : public Superpower
{
protected:
virtual void activate()
{
// Spring into the air.
playSound(SOUND_SPROING, 1.0f);
spawnParticles(PARTICLE_DUST, 10);
move(0, 0, 20);
}
};
这两个要点其实是独立的。书中主要在讨论第一点。第二点,沙盒接口的作用,一个是执行入口,另一个是调用时机可控,这些大多是和具体业务相关。
看书中的例子,很明显能看出一个问题:
void move(double x, double y, double z)
和其他两个方法playSound
,spawnParticles
并不是一类功能。假设基类抽象的是场景节点,move
是实例本身的功能,放到基类作为公用代码以及公用接口是OOP的标准用法。因此主要考虑的是,实用的功能性方法,如播放声音,播放特效等,应该放到哪里?
主要有几种方案:
-
直接写:每次在用到的地方编写一遍功能。显然这会造成代码的冗余,给维护造成麻烦。但是往往很多功能一开始写的时候就是写在用到的地方,在开发过程中逐渐由更多的地方需要同样的功能时,再考虑把功能重构到通用的地方。
-
功能单例:不同类功能封装到不同的单例。如播放音乐时调用
AudioEngine::getInstance()->playSound()
。这样做的缺点,如书中所说,是程序中多个地方造成了对AudioEngine这样的系统的引用耦合。我们可以用加一层门面来抵消这种负面效果,如加一个AudioUtils类对原始的AudioEngine进行封装。 -
超级单例:不同类功能封装到同一个单例。如一个GameUtils类或直接放到GameRuntime类中,这样与“功能单例”相比的好处是,从依赖多个系统到依赖一个系统,并且减少整个程序单例的数量,调用的时候非常方便。缺点也是十分严重,就是GameUtils会是一个非常庞大的类,一方面增加这个类本身的阅读和维护成本,另一方面对降低了重用性。
比如一个game有audio, graphics, physics功能,现在有另一个游戏要重用原有代码但是不需要physics特性,但是如果由于GameUtils依赖了physics系统,虽然在业务流程里没有任何调用任何物理相关的功能,要去除对physics系统的依赖也是很困难的。
很多大型系统里,这种通用全能的类往往会变成一个泥潭,没人敢改动和删减其中的功能,只有不断往上加新的功能。
-
超级基类:把功能封装到基类中。就是书中介绍的方法。我对这种方法持保留态度。它实际是超级单例的一种特殊实现,缺点与其类似,甚至更糟。
如果说超级单例方案造就了一个泥潭,超级基类把这坨泥潭引导每个实例中。它引入了继承这一大耦合,并且使得基类是一个会经常改动的类,这些都是反设计原则的。
最可怕的是,如果你要用基类中的功能,就需要把自己变成它的子类。如果上一种方法是你要一片树叶给你一个森林,这个方法就是要把自己变成一个森林。
一般基类中要有一些真正属于公有的代码,如引用计数,生成id,初始化等等,还有一些真正属于对象功能的方法。如果把功能方法和实用方法混在一起,对象真正的功能就会淹没在大量无关代码里,是阅读和维护代码的人很难抓住真正的功能。
但这种方法在实践中还是用的很多的。我觉得最大的好处是实用方便,大多数语言里调用基类方法比调用其他全局实例方法的语法简洁的多。这常常意味着易用性,用
playSound()
显然比AudioEngine::getInstance()->playSound()
爽快的多。特别是对初学者,或者非专业人员,比如封装给策划用的脚本,还有现在很多引擎面向的是非专业编程人员,写代码的时候感觉要什么有什么,根本不用考虑这些功能是哪里来的,反正是引擎提供的,只要专注游戏逻辑就好了。这些受众也不会太在意代码架构方面的东西。 -
消息系统:基于消息调用功能。这是依赖最低的方法。模块间的功能使用消息系统调用,如AudioEngine监听 PLAY_SOUND 消息,播放声音时发送
sendMessage(PLAY_SOUND, "a.mp3")
,不用真正依赖 audio 系统。这样只要消息接口一致,调用方和被调用方可以独立重构。如果去掉某个模块,只是对应消息无效,不会代码崩溃或者无法编译。如果不想 AudioEngine 依赖消息系统,可以用建立一个 AudioManager 监听消息和调用 AudioEngine缺点也很多。1. 这种方法调用太重,有一些很小的功能或者实用函数不适合这种模式;2. 代码流会变得更乱,不容易阅读和调试;3. 调用变成纯动态,跳过编译器检查,容易出错;4. 效率会更低