中EN
概念页

子类沙盒

Subclass Sandbox

更新于 2026-10-13 约 9 分钟设计模式子类沙盒继承模板方法GPP

子类沙盒 (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 暴露的基类函数,形成“基类提供操作、子类组合出行为”的分工。
  1. Game Programming Patterns — subclass-sandbox
  2. Game Programming Patterns — subclass-sandbox(全文版)
  3. Robert Nystrom《Game Programming Patterns》(2014) subclass-sandbox 章,gameprogrammingpatterns.com/subclass-sandbox.html

相关页面

游戏开发命令模式Command Pattern命令模式 (Command Pattern) 把一次方法调用“物化”成对象,从而可以存储、传递、延迟执行、排队或撤销。游戏里最典型的三种用法是:可配置的按键映射、让玩家输入和 AI 共用同一套“驱动角色”的接口、以及撤销/重做。游戏开发单例与服务定位器Singleton & Service Locator单例 (Singleton) 同时承诺两件事:一个类只有一个实例,并且提供全局访问点。Nystrom 认为它在游戏里弊大于利,因为单例本质上就是全局变量。服务定位器 (Service Locator) 是更灵活的“近亲”:它同样给出全局访问点,但调用者只依赖抽象服务接口,具体实现由定位器在运行时或编译期决定,还可以退化为空服务。两者都应当慎用,首选仍是直接把对象传进去。游戏开发游戏循环与更新方法Game Loop & Update Method游戏循环 (Game Loop) 让游戏时间的推进与玩家输入和处理器速度脱钩:每一轮非阻塞地处理输入、更新状态、渲染,并跟踪真实时间来控制节奏;最常推荐的做法是“固定更新步长、可变渲染”。更新方法 (Update Method) 是它的搭档:世界维护一组对象,每个对象实现一个模拟“一帧”行为的 update(),每帧依次调用。两者和组件模式一起常被视为游戏引擎的核心。游戏开发组件模式Component Pattern组件模式 (Component Pattern) 让一个实体横跨多个领域(输入、物理、图形、AI……),同时不让这些领域彼此耦合:每个领域的代码放进自己的组件类,实体本身退化为组件的容器。它是现代引擎实体模型的基础,代价是对象变成需要正确装配的一组对象,组件之间如何通信也成了要做的设计决策。游戏开发 · UE5Gameplay 能力系统Gameplay Ability SystemGameplay 能力系统 (Gameplay Ability System, GAS) 是 UE 用来构建「Actor 可拥有并反复触发的能力」的框架:能力系统组件 (Ability System Component, ASC) 是持有者,Gameplay 能力 (Gameplay Ability) 编排执行过程,属性集 (Attribute Set) 存数值,Gameplay 效果 (Gameplay Effect, GE) 改数值和标签,Gameplay 标签 (Gameplay Tags) 做状态与互斥,Gameplay Cue 负责表现。它内建复制与客户端预测,代价是概念多、上手重,综合游戏编程模式选型:解耦与性能Choosing Game Programming Patterns本页把《Game Programming Patterns》里的 19 个模式放到同一张取舍图上:大多数模式要么为了解耦(降低改代码时需要装进脑子里的知识量),要么为了性能(利用对数据和硬件的具体假设),而这两个目标经常互相拉扯。下面先讲全书的定位和 Nystrom 的架构观,再给出按问题选模式的路线和与 UE 的对照。游戏开发字节码模式Bytecode Pattern字节码模式 (Bytecode Pattern) 把行为编码成虚拟机 (VM) 能执行的指令序列,让行为拥有数据一样的灵活性:可以热加载、与引擎核心隔离(沙盒化),还可以由非程序员编写。它是全书最复杂的模式,代价是要写编译器或创作工具,调试器也用不上了,而且这类小语言容易无节制地膨胀。游戏开发游戏脚本系统Game Scripting脚本语言是主要用来让用户控制和定制软件行为的编程语言;在游戏引擎中,它是高层、相对易用、能方便访问引擎大多数常用功能的语言,程序员和非程序员都能用它开发新游戏或给现有游戏做 mod。GEA 先区分数据定义语言和运行时脚本语言,梳理语言分类与游戏脚本的典型特征(解释执行、轻量、快速迭代、方便),介绍 QuakeC、UnrealScript、Lua、Python、Pawn,列出从"脚本回调"到"脚本驱动整个游戏"的六种架构,再以 Naughty Dog 的 DCScript 为例讲虚拟机与原生函数接口,最后是对象引用、事件收发、面向对象脚本、脚本状态机和协作式多线程脚本(GEA4 卷 II 17.游戏开发类型对象Type Object类型对象 (Type Object) 用“一个类的不同实例”来表示不同的逻辑类型:例如不为每种怪物写一个子类,而是一个 Monster 类加一个 Breed(种族)类,每个 Breed 实例描述一种怪物,怪物只持有指向自己 Breed 的引用。这样新增或修改种类只需要改数据,不必重新编译;代价是要自己管理这些类型对象,而且给每种类型定义不同的“行为”会变难。游戏开发 · UE5蓝图与 C++ 协作Blueprint & C++ InteropEpic 官方建议:多数项目混用蓝图 (Blueprint) 与 C++,以 C++ 作为基础、在其上构建蓝图类。C++ 适合“编程”——定义系统;蓝图适合“脚本”——在已有系统上定义具体行为。两者通过反射说明符互相调用:C++ 用 BlueprintCallable/BlueprintReadWrite 等暴露给蓝图,用 BlueprintImplementableEvent/BlueprintNativeEvent 让蓝图实现或覆盖 C++ 声明的函数。

输入关键词开始搜索

滚轮或双指缩放 · 拖动平移 · 双击放大 / 还原