子类沙盒
Subclass Sandbox
子类沙盒 (Subclass Sandbox) 让基类提供一组受保护的“提供的操作”和一个抽象的沙盒方法,派生类只用这些操作来实现沙盒方法。这样大量派生类(例如几十种超能力)只耦合到基类,不直接接触引擎的各个系统;代价是基类会越来越臃肿,变成脆弱基类。
意图
用基类提供的一组操作,在子类中定义行为。(Game Programming Patterns — subclass-sandbox)
机制
基类定义一个抽象的沙盒方法和若干提供的操作,设为 protected,表明它们是给派生类用的;每个派生的沙盒子类用这些操作实现沙盒方法。(Game Programming Patterns — subclass-sandbox)书中的例子是 Superpower 基类提供 move()、playSound()、spawnParticles(),各种超能力子类只重写 activate()。提供的操作通常是 protected 且非虚的,表明它们就是给子类调用的。
书中的动机:游戏要有上百种超能力,每种一个子类,它们几乎会碰到引擎的每个角落。放任不管会出现四个问题(Game Programming Patterns — subclass-sandbox):大量重复代码(冰冻射线、热射线、第戎芥末射线其实差不多);引擎各部分都与这些类耦合,甚至绕过渲染器分层;外部系统一改,某些超能力就坏;难以定义所有超能力都遵守的不变量(例如所有音效都要经过排队和优先级处理)。把操作收进基类后,重复代码可以不断上提为新的高层操作,耦合也集中到了基类一处。
这会形成浅而宽的类层次。作者承认继承本身问题很多,基类与子类之间的耦合是代码库里最深的耦合,但他认为宽的继承树比深的好用;而且对基类的投入能惠及大量子类。作者也提醒:子类非常多时,往往说明数据驱动更合适,可以考虑 类型对象、字节码模式 或解释器模式;反过来,如果行为复杂且是命令式的,用数据就难以表达,这正是需要子类沙盒的理由。如果所有超能力都只是“音效 + 粒子 + 位移”的组合,基类直接读字段实现 activate() 就够了,根本不需要这个模式。(Game Programming Patterns — subclass-sandbox)
下图表示耦合关系:所有子类只指向基类,只有基类直接连到外部系统。
flowchart TB S1[SkyLaunch] --> B["Superpower 基类:activate() 抽象 + protected 提供的操作"] S3["其他几十种超能力子类"] --> B B --> Sys1["物理"] B --> Sys2["音频"] B --> Sys3["粒子"]
这样耦合集中到了基类一处,派生类本身与外界隔离,代价是基类同时依赖所有系统。
何时使用
有一个基类和许多派生类;基类能提供派生类需要的全部操作;派生类之间行为重叠,希望更容易共享代码;希望尽量减少派生类与程序其他部分的耦合。只要代码里有非虚的 protected 方法,你可能就已经在用类似的东西了。(Game Programming Patterns — subclass-sandbox)
取舍与坑
- 脆弱基类问题 (brittle base class problem):基类会耦合到任何一个派生类需要接触的系统,派生类又紧贴基类,结果基类很难改动。另一面是大部分行为都在派生类里,与外界隔离得很好。基类变成“一锅杂烩”时,把部分操作拆到独立类里,见 组件模式。(Game Programming Patterns — subclass-sandbox)
- 提供哪些操作(经验法则):一个极端是基类什么都不提供(那就谈不上用了这个模式),另一个极端是基类提供一切,每个子类源文件只需
#include基类头文件。提供得越多,子类与外部的耦合越少,基类的耦合越多。只有一两个子类用的操作不值得放进基类;会修改状态的外部调用耦合更深,更值得收进基类(只读调用是“安全”的耦合,但打引号是因为多线程下读也可能读到修改中的数据,严格确定性的联网游戏若读到同步状态之外的东西还会引发不确定性 bug);只是简单转发的操作价值不大,除非它能把基类的私有状态(如soundEngine_)藏起来。(Game Programming Patterns — subclass-sandbox) - 直接提供方法,还是经由辅助对象:基类方法太多时,可以返回一个
SoundPlayer之类的辅助对象。这能减少基类的方法数,辅助类更容易维护,也降低了基类与其他系统的耦合。(Game Programming Patterns — subclass-sandbox) - 基类如何拿到所需状态:通过构造函数传入(每个派生类构造函数都得转发,暴露了不想暴露的状态);两阶段初始化(容易忘记调用
init(),可以用工厂函数封装);做成静态状态(只需初始化类一次,也省内存,但仍有单例的许多问题:大量对象共享同一状态,只是它不再全局可见);或者通过 单例与服务定位器 中的服务定位器自己去取。(Game Programming Patterns — subclass-sandbox) - 与模板方法 (Template Method) 互为镜像:两者都用一组原语操作实现一个方法;子类沙盒里方法在派生类、原语在基类,模板方法正好相反。也可以把它看作外观 (Facade) 模式的变体:基类把整个引擎藏在一个简化的 API 后面。(Game Programming Patterns — subclass-sandbox)
- 用 游戏循环与更新方法 中的更新方法时,
update()往往就是沙盒方法(Game Programming Patterns — subclass-sandbox);命令类很多时,给它们一个提供高层便捷方法的基类,命令模式 的execute()也就成了沙盒方法(Game Programming Patterns — command)。
代码草图(C++)
按书中示例整理(基类的提供操作与“静态状态”方案合在一起展示):
class Superpower {
public:
virtual ~Superpower() {}
static void init(ParticleSystem* particles) { particles_ = particles; } // 方案:静态状态
protected:
virtual void activate() = 0; // 沙盒方法
void move(double x, double y, double z) { /* 调用物理... */ } // 提供的操作
void playSound(SoundId sound, double volume) { /* 调用音频... */ }
void spawnParticles(ParticleType type, int count) { /* 使用 particles_... */ }
double getHeroX() { /* ... */ return 0; }
double getHeroY() { /* ... */ return 0; }
double getHeroZ() { /* ... */ return 0; }
private:
static ParticleSystem* particles_;
};
class SkyLaunch : public Superpower {
protected:
virtual void activate() {
if (getHeroZ() == 0) {
// 在地面:跃向空中
playSound(SOUND_SPROING, 1.0f);
spawnParticles(PARTICLE_DUST, 10);
move(0, 0, 20);
} else if (getHeroZ() < 10.0f) {
// 接近地面:二段跳
playSound(SOUND_SWOOP, 1.0f);
move(0, 0, getHeroZ() + 20);
} else {
// 高空:俯冲攻击
playSound(SOUND_DIVE, 0.7f);
spawnParticles(PARTICLE_SPARKLES, 1);
move(0, 0, -getHeroZ());
}
}
};
// 方案:两阶段初始化封装进工厂函数,避免忘记 init()
Superpower* createSkyLaunch(ParticleSystem* particles) {
Superpower* power = new SkyLaunch();
power->init(particles);
return power;
}
注:书中两阶段初始化方案里的 init() 是实例方法,静态状态方案里的 init() 是静态方法,二者是并列的不同选项;上面为了紧凑放在同一段里。
UE 中的对应
- Gameplay Ability → Gameplay 能力系统:写能力时,主体逻辑写在重写的
ActivateAbility里,并调用基类UGameplayAbility提供的CommitAbility(实际扣除消耗并开始冷却)、EndAbility(必须调用,否则系统认为能力仍在运行)等操作,主要工作再交给 Ability Task。可以把它看作子类沙盒:派生能力通过基类提供的操作与能力系统交互。“忘了调EndAbility就会卡住”也说明了这种基类契约的脆弱之处。 - 蓝图可覆盖的 C++ 函数 → 蓝图与 C++ 协作:
BlueprintNativeEvent和BlueprintImplementableEvent让蓝图子类实现 C++ 基类声明的函数,再配合BlueprintCallable暴露的基类函数,形成“基类提供操作、子类组合出行为”的分工。
出处
- Game Programming Patterns — subclass-sandbox
- Game Programming Patterns — subclass-sandbox(全文版)
- Robert Nystrom《Game Programming Patterns》(2014) subclass-sandbox 章,gameprogrammingpatterns.com/subclass-sandbox.html