类型对象
Type Object
类型对象 (Type Object) 用“一个类的不同实例”来表示不同的逻辑类型:例如不为每种怪物写一个子类,而是一个 Monster 类加一个 Breed(种族)类,每个 Breed 实例描述一种怪物,怪物只持有指向自己 Breed 的引用。这样新增或修改种类只需要改数据,不必重新编译;代价是要自己管理这些类型对象,而且给每种类型定义不同的“行为”会变难。
意图
通过创建一个类、让它的每个实例代表一种不同的对象类型,来灵活地创建新的“类”。(Game Programming Patterns — type-object)
书中的动机:RPG 里的怪物分“种族 (breed)”,种族决定起始血量和攻击文本。按常规 OOP 思路,每个种族一个 Monster 子类(Dragon 起始 230、Troll 48)。种族多到几百个、设计师又频繁调数值时,程序员就沦为“数据猴子”:收到邮件要把巨魔血量从 48 改成 52 → 改 Troll.h → 重新编译 → 提交 → 回邮件 → 重复。把“是一个 (is-a)”换成“有一个 (has-a)”种族,就把类型系统的一部分从写死的类层次搬进了运行时可定义的数据。(Game Programming Patterns — type-object)
机制
定义类型对象类和被类型化的对象类。每个类型对象实例代表一个逻辑类型;每个被类型化的对象保存一个指向描述其类型的类型对象的引用。实例特有的数据放在被类型化的对象里,同一概念类型共享的数据或行为放在类型对象里。引用同一个类型对象的对象,行为就像属于同一类型。这和子类化一样能共享数据与行为,但不需要一组写死的子类。(Game Programming Patterns — type-object)
书中的 Breed 还支持单继承:Breed 可以有父 Breed,自己没指定的属性(如生命值、攻击字符串)从父 Breed 继承,例如“巨魔弓手”“巨魔法师”继承自“巨魔”。Breed 也可以提供 newMonster() 这样的“构造函数”(即 GoF 的工厂方法 (Factory Method)),并把 Monster 的构造函数设为私有、Breed 设为友元,使它成为创建怪物的唯一途径。这样做的意义在于:构造函数只负责初始化,而游戏常常还想控制分配(自定义分配器或 对象池),newMonster() 正是放这段逻辑的地方。(Game Programming Patterns — type-object)
继承有两种实现:动态委托,每次取属性时“自己有值(非零或非空)就用自己的,否则问父 Breed”,运行时修改 Breed 也能正确反映,但要多存父指针、查找更慢;复制下来 (copy-down) 委托,在构造时把未覆盖的属性从父 Breed 拷贝下来,之后不再需要父指针,访问只是读字段,前提是 Breed 属性不再变化。(Game Programming Patterns — type-object)
作者还点明了一个有趣的事实:C++ 虚函数靠 vtable 实现,每个类一张表,每个实例持有指向它的指针。vtable 就是“种族对象”,vtable 指针就是怪物对种族的引用,所以 C++ 的类就是由编译器自动完成的、应用于 C 的类型对象模式。(Game Programming Patterns — type-object)
下图表示两层对象:每个怪物实例只存自身状态(当前血量),并指向自己的 Breed;Breed 之间可以有单继承链。
flowchart LR M1["Monster:当前血量 12"] --> B1["Breed:巨魔弓手(攻击字符串)"] M2["Monster:当前血量 30"] --> B2["Breed:巨魔法师"] M3["Monster:当前血量 25"] --> B0["Breed:巨魔(起始血量 25、攻击字符串)"] B1 -->|parent| B0 B2 -->|parent| B0
图中数值取自书中的 JSON 示例:巨魔弓手和巨魔法师的 health 填 0,表示从巨魔继承,所以设计师只改巨魔一处,三个种族都会更新。左侧对象的数量随游戏运行变化,右侧的类型对象数量由数据文件决定,而且可以在不改代码的情况下增删。
何时使用
需要定义许多不同“种类”的东西,但把种类写进语言的类型系统又太死板,特别是:事先不知道需要哪些类型(例如可下载内容里会带来新种族);希望不重新编译、不改代码就能修改或新增类型。(Game Programming Patterns — type-object)
取舍与坑(Game Programming Patterns — type-object)
- 类型对象要手动管理:以前编译器替你管理类的数据,现在要自己保证所有 Breed 都被实例化、在怪物需要时一直存在,并且每个新怪物都绑定到一个有效的 Breed。
- 很难按类型定义“行为”:子类可以重写方法做任意计算,类型对象只能把方法换成一个成员变量(存下攻击字符串,而不是用代码算出来)。绕开的办法是:预先定义一组固定行为,由类型对象里的数据来选择(比如存一个函数指针);或者干脆用解释器或 字节码模式 把行为也做成数据。作者注意到,存函数指针其实就是在自己的类型对象里重新实现 vtable;他还认为游戏越来越数据驱动,因为瓶颈已从“把玩法塞进 64K 卡带”变成“用内容填满 DVD”,脚本等高层手段用一点运行时性能换来急需的生产力。
设计决策(Game Programming Patterns — type-object)
| 决策 | 选项 | 要点 |
|---|---|---|
| 类型对象封装还是暴露 | 封装 | 复杂度藏在内部;被类型化的对象可以有选择地覆盖行为(如濒死时换攻击字符串);但每项都要写转发方法 |
| 暴露 | 外部代码可以不经实例直接使用类型对象(例如调用 Breed 的构造函数);但类型对象成为公开 API 的一部分 | |
| 对象如何创建 | 构造对象并传入类型对象 | 调用方控制内存分配 |
| 调用类型对象上的“构造函数” | 类型对象控制分配,例如保证都来自 对象池 | |
| 类型能否改变 | 不能 | 简单,好调试 |
| 能(怪物死后变僵尸) | 少创建对象,但要验证对象状态满足新类型的假设 | |
| 继承 | 无 | 简单,但会重复(五十种精灵改五十处血量) |
| 单继承 | 简单易懂,是能力与简单性之间的甜点;查属性要沿链向上走(可以在构造时把继承来的值拷进子类型,即“复制下来”) | |
| 多重继承 | 理论上最少重复,实际难以理解(“僵尸龙”同时继承“僵尸”和“龙”,哪些属性来自谁?),很少值得 |
与相邻模式的关系
- 原型模式 用另一种方式解决同一个“在对象间共享数据与行为”的问题。(Game Programming Patterns — type-object)
- 享元模式 也共享数据,但目的是省内存,共享数据不一定代表类型;类型对象关注的是组织与灵活性。(Game Programming Patterns — type-object)
- 状态模式与有限状态机 委托的是对象“此刻是什么”,类型对象委托的是对象“是什么”;允许类型改变时,类型对象就兼任了状态。(Game Programming Patterns — type-object)
代码草图(C++)
按书中示例整理:带“构造函数”和复制下来继承的 Breed,以及只能经由 Breed 创建、封装了类型对象的 Monster。
class Breed {
public:
// 复制下来委托:构造时继承未覆盖的属性,之后不再需要父指针
Breed(Breed* parent, int health, const char* attack)
: health_(health), attack_(attack) {
if (parent != NULL) {
if (health == 0) health_ = parent->getHealth();
if (attack == NULL) attack_ = parent->getAttack();
}
}
Monster* newMonster() { return new Monster(*this); } // “构造函数”工厂方法
int getHealth() { return health_; }
const char* getAttack() { return attack_; }
private:
int health_; // 起始血量
const char* attack_;
};
class Monster {
friend class Breed; // 只能经由 Breed::newMonster() 创建
public:
const char* getAttack() {
if (health_ < LOW_HEALTH) return "The monster flails weakly."; // 封装时可选择性覆盖
return breed_.getAttack();
}
private:
Monster(Breed& breed) : health_(breed.getHealth()), breed_(breed) {}
int health_; // 当前血量(实例数据)
Breed& breed_; // 类型数据
};
Monster* monster = someBreed.newMonster();
// 另一种继承实现:动态委托(Breed 需要保存 parent_)
int Breed::getHealth() {
if (health_ != 0 || parent_ == NULL) return health_; // 覆盖
return parent_->getHealth(); // 继承
}
UE 中的对应
- 数据资产 (Data Asset) → 资产管理与异步加载:非蓝图主资产 Data Asset 是“类的实例而非类”,访问更简单、更省内存,但不能被继承;道具、角色、地图等“目录型”数据建议用主资产 + Data Asset + 资产包。用一个 C++ 类的多个资产实例来表示多种道具或角色,就是类型对象;“不能被继承”则对应上表中的“无继承”选项,需要继承时要另选方案。
- Gameplay Effect 资产与 Spec → Gameplay 能力系统:GE 是运行时不可变的资产,运行时传递的是包在外面的 GE Spec(实例数据)。不可变资产相当于类型对象,Spec 相当于被类型化的实例。
- 标签作为类型词汇 → Gameplay 标签深入:Gameplay 标签可以表达“属性”(如
Character.Enemy.Zombie),并支持“父标签匹配子标签”的层级查询。它提供的是分类与查询,不是数据继承,这里只作补充参考。
GEA 的视角:类型模式与默认值(卷 II 17.3.3)
GEA 在世界编辑器与运行时之间使用的对象类型模式 (type schema) 是数据驱动的"类型对象":模式用文本文件定义某类型对象有哪些属性、各是什么数据类型、默认值和 GUI 编辑元数据(最小最大值、下拉选项),策划不需要程序员就能在编辑器中定义新类型模式。与本页的对应:
- 模式继承:有的引擎允许模式像类一样继承(顶层模式提供类型和唯一 id),派生模式还能覆盖默认值(Vehicle 默认 TopSpeed 80 英里/小时,Motorcycle 改为 100),这正是本页"类型对象之间共享数据"的继承选项。
- 默认值的传播:生成器里与默认值相同的属性干脆不写,默认值从 20 改为 30 后,所有未覆盖的旧实例自动采用新值(GEA 的兽人生命值例子)。
- GEA 把这种设计称为"生成器与原型 (archetype)",它让新对象数据可以先于运行时实现存在。详见 世界块数据格式与生成器。
出处
- Game Programming Patterns — type-object
- Game Programming Patterns — type-object(全文版)
- Robert Nystrom《Game Programming Patterns》(2014) type-object 章,gameprogrammingpatterns.com/type-object.html
- Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.3.3 节