13. Type Object

13. Type Object:用运行时类型对象表示种类,让实例共享数据并可由内容扩展,通过可复位因果实验和反例证据验收。

13. Type Object

学习目标

  • 能区分类型对象与实例
  • 能解释共享定义的威力
  • 能设计类型继承与注册表

为什么"13. Type Object"从问题证据开始

你的游戏里有一百种怪物,每种有各自的属性:血量、速度、技能。最直觉的 OOP 做法是每种怪物一个类:class Goblin { ... }class Dragon { ... }——用继承表达种类差异。代码今天能跑,但两个问题浮现:类爆炸(一百种怪物 = 一百个类,新种类要写代码重编译)、策划无法参与(加怪物必须等程序员)。

本页把"无模式基线"定义为"每种怪物一个类"的代码,然后引入类型对象作为候选机制,验证它是否真的让"新增种类不动代码"。通过条件是新怪物由数据(类型对象)定义,引擎只提供读取机制——而不是种类数量决定类数量。

来源、版本与独立重写边界

本页用作者完整在线正文核对正式标题、设计分叉和时代语境,并以作者源码仓库交叉检查结构。仓库许可证明确正文、HTML与样式为 CC BY-NC-ND 4.0,示例程序等其他文件为 MIT;因此下列中文解释、图示、交互和代码均为独立教学重写,不翻译、拼接或改写受 ND 限制的原文表达。

本章机制与术语

理解 Type Object 需要四个核心概念:

  • :用运行时对象表示一种"种类"——GoblinType 存血量/速度/技能,是共享定义。
  • :具体怪物,只存自身状态(位置/当前血量),引用一个类型对象。
  • :实例从类型对象读取属性——改类型对象 = 改所有该种实例。
  • :类型对象由数据定义(文件/配置)——新种类 = 新数据,不写新类。

四个概念共同约束"用运行时类型对象表示种类,让实例共享数据并可由内容扩展":任何结论都必须回到"新增种类是否不动代码、实例是否共享定义、类型是否可动态变更"三个可观察量。

Behavioral Pattern · Type Object

Type Object — 种类是数据,实例是引用

▷ 可交互
"什么是哥布林"存成对象——改它,所有哥布林同时变类型对象(共享定义) + 实例(各自状态)GoblinType(类型对象)hp=60speed=3技能:投掷共享定义 · 一份数据 · 所有实例引用哥布林 Ax=10, y=20, hp=42哥布林 Bx=30, y=80, hp=60哥布林 Cx=50, y=15, hp=55实例只存自身状态;把 GoblinType.hp 改成 80,所有哥布林同时变强新怪物 = 新类型对象(数据文件)——引擎零改动,内容无限扩展

第 1 / 4 步 · ① 类型对象:定义怪物的共享属性(hp/speed/技能)——一份定义

类型从「类」降级为「数据对象」——种类由内容定义,实例按需引用。

💡 对着动画看:动画上方是 GoblinType 类型对象(hp=60, speed=3, 技能:投掷——一份共享定义),下方三个哥布林实例各自只存位置和当前血量,虚线指向类型对象。注意"改 GoblinType.hp 所有哥布林同时变强"——这就是共享的威力与陷阱。读"典型 OOP 回答"一节时对比:类方案把定义固化进代码,类型对象把它变成数据。

官方结构逐项深读

13. Type Object

Type Object 的主张:把"种类"从"类"降级为"对象"。传统 OOP 用类表达种类(Goblin 类),类型对象用运行时对象表达(GoblinType 实例)。种类差异从"继承关系"变成"数据差异"——种类是数据,可以存储、可以运行时创建、可以由内容文件定义。实例不再由类区分,而是由"引用哪个类型对象"区分。

Intent

意图:让"定义新种类"不写新代码。用类型对象代替类层次,种类数量从"编译期类数"变成"运行期数据条数"。策划或工具可以创建新类型对象(改数据文件),引擎的实例生成逻辑完全通用("给我这个类型,生成一个实例")。这继承了 Prototype 的"数据驱动"思想,但用"共享类型"而非"复制原型"。

Motivation

一百种怪物的问题:写一百个类,绝大部分只有属性不同(血量、速度、掉落物),行为相同(都是"攻击-被攻击-死亡")。类的差异主要是数据而非行为。动机:把"种类的数据"从代码里拿出来——一个 GoblinType 对象就够了,不必有 Goblin 类;一百个类型对象 = 一百条数据。

The typical OOP answer

传统 OOP 答案:继承树——Monster 基类,Goblin : MonsterDragon : Monster……每个子类在构造函数里设属性。问题:继承表达"种类"很笨重——种类差异是数据(数值不同),继承表达的是行为差异(方法不同);用继承表达数据差异,等于把数据固化进类,新种类必须写新类。

A class for a class

"为类建一个类":GoblinType 类描述哥布林(属性数据),Monster 实例持 GoblinType* 引用。这就把"种类"对象化了——GoblinType 是一个普通对象,可以被创建、存储、替换。Monster 类只有一个(不再有子类),所有怪物共用它,区别只在 type_ 指针指向哪个类型对象。这是类型对象的本质:实例类只有一个,种类全靠数据

The Pattern

模式结构:Type 类(存种类属性:hp/speed/skill)→ TypedObject 类(持 Type* 引用 + 自身状态)→ 读取属性时 type_->hpMonster 实例的 hp 读取走类型对象,位置等自身状态存实例。类型对象可以组织成层次(GoblinType 继承 MonsterType)表达"亚种"。引擎生成怪物 = 指定一个 Type,new 一个 Monster 挂上引用。

When to Use It

何时用类型对象:种类众多且由数据定义(怪物、道具、技能模板)、需要运行时动态创建种类(编辑器里调数值)、策划/内容团队要独立加种类。何时不用:种类很少且稳定(两个类能搞定就别建类型系统)、行为差异远大于数据差异(用继承或 Component 更合适)。判断标准:"种类数量会频繁增长吗"——会,就用类型对象。

Keep in Mind

两个注意点:类型对象要手工追踪(它也是对象——要管理生命周期、注册表、引用计数,不能裸 new 裸 delete);按类型定义行为更难(类型对象存数据容易,存"该类型怎么做"的行为难——需要函数指针或委托,复杂度上升)。类型对象擅长数据差异,行为差异要另想办法。

The type objects have to be tracked manually

类型对象是运行时对象,意味着要管理它们的生命周期:谁创建、谁持有、何时销毁。通常需要一个类型注册表map<name, Type*>):加载数据时注册所有类型,实例创建时按名字查表。销毁顺序要小心(类型对象被实例引用时不能提前销毁)。这是数据驱动系统的常见成本——资源管理从"编译期自动"变成"运行期手动"。

It's harder to define behavior for each type

类型对象存数据很容易,存行为很难:GoblinType 想定义"哥布林的攻击方式"——C++ 里类型对象不能直接挂虚函数(它本身不是类层次)。解法:函数指针(类型对象持行为回调)、行为 ID(switch 分发)、组合组件(类型对象引用行为对象)。作者提醒:如果行为差异真的很大,类型对象可能不是最佳工具——继承/Component 更适合行为多态。

Sample Code

示例代码:Breed 类(血量/速度/攻击力 + 可选"父 Breed")+ Monster 类(持 Breed* + 当前 hp)。Monster::currentHealth() 返回实例字段,Monster::maxHealth() 返回 breed_->health——静态属性走类型,动态状态走实例。创建:new Monster(goblinBreed);改类型:monster->breed_ = dragonBreed(变身!)。

Making type objects more like types: constructors

让类型对象更"像类":给类型对象加构造能力——goblinBreed->newMonster() 返回新哥布林实例(类型对象兼任工厂)。这融合了 Prototype(生成器)+ Type Object(共享类型):实例由类型创建,类型定义属性。这样"生成某类怪物"的语义完整落在类型对象上:breed.newMonster() 比裸 new Monster(breed) 更清晰。

Sharing data through inheritance

类型对象之间的继承:GoblinBreed : MonsterBreed(亚种继承基础属性)——类型对象可以形成"类型层次"表达种类间的共性。实例读取属性时沿类型链查找(breed->health,没有就查父类型)。这与类继承表达的是同一件事(种类共性),但发生在运行时数据层而非编译期类层——改父类型属性,所有亚种同时更新。

Design Decisions

四个关键设计决策:类型对象封装还是暴露类型化对象如何创建类型能否改变支持什么继承。前两者决定 API 形态,后两者决定运行时行为——下面四节展开。

Is the type object encapsulated or exposed?

封装方式:暴露(Monster 直接暴露 type_ 字段,外部可读可改——简单,但外部可以随意改类型破坏不变式)与封装(Monster 只暴露读取方法 maxHealth(),内部引用类型对象——安全,类型切换走受控接口)。作者建议:读取走封装方法(外部只问"这个怪物的最大血量",不问"它的类型是谁"),需要变身时提供 changeBreed() 受控方法。

How are typed objects created?

创建方式:类型对象兼任工厂breed.newMonster()——语义完整,实例与类型绑定清晰)与独立工厂函数createMonster(breed)——类型对象不承担创建职责,职责单一)。作者倾向前者:类型对象"知道自己怎么造实例"最自然,也方便子类型覆盖创建逻辑。但要注意:工厂方法意味着类型对象持有一份"实例构造逻辑",类型对象不再纯数据。

Can the type change?

类型能否中途改变:可变(实例可换类型——支持"变身"玩法,但引入"实例与类型不同步"的状态风险)与不可变(实例创建时定死类型——简单安全,但玩法受限)。作者建议:默认不可变(类型是实例的"出生设定"),变身玩法是特殊需求,用受控的 changeBreed() 显式实现——别让类型字段随处可改。

What kind of inheritance is supported?

类型继承程度:无继承(每个类型独立存全部属性——简单,但亚种要重复数据)与单继承(类型对象有父类型,属性沿链查找——表达亚种高效,但查找链要管理)与多继承(多个父类型——灵活,但冲突规则复杂)。作者建议:单继承够用(哥布林→食人魔这种"基础型→亚种"是最常见形态),多继承很少值得它的复杂度。

See Also

  • Prototype:原型用"复制原型"定义种类,类型对象用"共享类型"定义种类——一个复制、一个共享。
  • Flyweight:类型对象天然是享元的"固有状态"——多个实例共享一份类型数据。
  • Bytecode:类型对象定义"种类是什么",字节码定义"种类做什么"——数据与行为互补。

可迁移实现或计算骨架

initial state -> 定义 Type 类(属性数据)-> 注册类型对象(数据加载)-> 实例创建时挂类型引用 -> 属性沿类型读取
fault injection -> 每种怪物一个类
pass condition -> 新增种类只加数据(类型对象),引擎零改动
reset -> initial state

对应实现要点:Type 类存属性(可含父类型支持单继承);注册表按名管理类型对象生命周期;实例持 Type* 并只存动态状态;读取静态属性走类型、动态状态走实例。该骨架只保存实验合同;真实项目还要固定类型注册表、继承深度上限与类型热更新规则,并保留基线实现以便回退。

本章练习与节点验证矩阵

练习

问题 1:新增种类。

操作:在动画类型池中"新增"一个"食人魔"类型(只改数据)

问题 2:共享验证。

操作:修改 GoblinType.hp 为 80,观察所有哥布林实例

问题 3:类型可变实验。

操作:给一个实例调用 changeBreed(DragonType),观察属性变化

问题 4:继承实验。

操作:让 GoblinType 继承 MonsterType 基属性,改基属性观察亚种

术语复核

名词解释

本章出现的专业名词,用大白话再讲一遍。

类型对象
实例
共享

术语复核与本章回顾

掌握"13. Type Object"意味着能从"每种怪物一个类让新种类必须写代码"出发,解释类型对象、实例、共享与数据驱动四者的关系,再用"新增种类是否不动代码、实例是否共享定义、类型是否可动态变更"三个可观察量推翻或保留实现。若三个判据不能同时满足,本章仍未通过。

一句话回顾:种类从"类"降级为"对象"——GoblinType 是一份共享数据,Monster 是挂类型引用的通用实例,改数据文件就能给游戏加新怪物。

阅读导航

← 上一页:12. Subclass Sandbox · 下一页:V. Decoupling Patterns → ← 上一页:12. Subclass Sandbox · 下一页:V. Decoupling Patterns →

资料与写作方式声明

本章以Robert Nystrom《Game Programming Patterns》(游戏编程模式)权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…