行为模式

引言

游戏开发一直是热门的领域,掌握良好的游戏编程模式是开发人员的应备技能,本书细致地讲解了游戏开发需要用到的各种编程模式,并提供了丰富的示例。本章关于行为模式的介绍。

一旦做好游戏设定,在里面装满了角色和道具,剩下的就是启动场景。 为了完成这点,你需要行为——告诉游戏中每个实体做什么的剧本。

当然,所有代码都是“行为”,并且所有软件都是定义行为的, 但在游戏中有所不同的是,行为通常很多。 文字处理器也许有很长的特性清单, 但特性的数量与角色扮演游戏中的居民,物品和任务数量相比,那就相形见绌了。

本章的模式有助于快速定义和完善大量的行为。 类型对象定义行为的类别而无需完成真正的类。 子类沙盒定义各种行为的安全原语。 最先进的是字节码,将行为从代码中分离,放入数据文件中。

字节码

意图

将行为编码为虚拟机器上的指令,赋予其数据的灵活性。

动机

制作游戏也许很有趣,但绝不容易。 现代游戏的代码库很是庞杂。 主机厂商和应用市场有严格的质量要求, 小小的崩溃漏洞就能阻止游戏发售。

我曾参与制作有六百万行C++代码的游戏。作为对比,控制好奇号火星探测器的软件还没有其一半大小。

与此同时,我们希望榨干平台的每一点性能。 游戏对硬件发展的推动首屈一指,只有坚持不懈地优化才能跟上竞争。

为了保证稳定和性能的需求,我们使用如C++这样的重量级的编程语言,它既有能兼容多数硬件的底层表达能力,又拥有防止漏洞的强类型系统。

我们对自己的专业技能充满自信,但其亦有代价。 专业程序员需要多年的训练,之后又要对抗代码规模的增长。 构建大型游戏的时间长度可以在“喝杯咖啡”和 “烤咖啡豆,手磨咖啡豆,弄杯espresso,打奶泡,在拿铁咖啡里拉花。”之间变动。

除开这些挑战,游戏还多了个苛刻的限制:“乐趣”。 玩家需要仔细权衡过的新奇体验。 这需要不断的迭代,但是如果每个调整都需要让工程师修改底层代码,然后等待漫长的编译结束,那就毁掉了创作流程。

法术战斗!

假设我们在完成一个基于法术的格斗游戏。 两个敌对的巫师互相丢法术,直到分出胜负。 我们可以将这些法术都定义在代码中,但这就意味着每次修改法术都会牵扯到工程师。 当设计者想修改几个数字感觉一下效果,就要重新编译整个工程,重启,然后进入战斗。

像现在的许多游戏一样,我们也需要在发售之后更新游戏,修复漏洞或是添加新内容。 如果所有法术都是硬编码的,那么每次修改都意味着要给游戏的可执行文件打补丁。

再扯远一点,假设我们还想支持模组。我们想让玩家创造自己的法术。 如果这些法术都是硬编码的,那就意味着每个模组制造者都得拥有编译游戏的整套工具链, 我们也就不得不开放源代码,如果他们的自创法术上有个漏洞,那么就会把其他人的游戏也搞崩溃。

数据 > 代码

很明显实现引擎的编程语言不是个好选择。 我们需要将法术放在与游戏核心隔绝的沙箱中。 我们想要它们易于修改,易于加载,并与其他可执行部分相隔离。

我不知道你怎么想,但这听上去让我觉得有点像是数据。 如果能在分离的数据文件中定义行为,游戏引擎还能加载并“执行”它们,就可以实现所有目标。

这里需要指出“执行”对于数据的意思。如何让文件中的数据表示为行为呢?这里有几种方式。 与解释器模式对比着看会好理解些。

解释器模式

关于这个模式我就能写整整一章,但是有四个家伙的工作早涵盖了这一切, 所以,这里给一些简短的介绍。

它源于一种你想要执行的语言——想想编程语言。

比如,它支持这样的算术表达式

(1 + 2) * (3 - 4)

然后,把每块表达式,每条语言规则,都装到对象中去。数字字面量都变成对象:

简单地说,它们在原始值上做了个小封装。 运算符也是对象,它们拥有操作数的引用。 如果你考虑了括号和优先级,那么表达式就魔术般变成这样的小树:

解释器模式与创建这棵树无关,它只关于执行这棵树。 它工作的方式非常巧妙。树中的每个对象都是表达式或子表达式。 用真正面向对象的方式描述,我们会让表达式自己对自己求值。

这里的“魔术”是什么?很简单——语法分析。 语法分析器接受一串字符作为输入,将其转为抽象语法树,即一个包含了表示文本语法结构的对象集合。

完成这个你就得到了半个编译器。

首先,我们定义所有表达式都实现的基本接口:

class Expression
{
public:
virtual ~Expression() {}
virtual double evaluate() = 0;
};

然后,为我们语言中的每种语法定义一个实现这个接口的类。最简单的是数字:

class NumberExpression : public Expression
{
public:
NumberExpression(double value)
: value_(value)
{}

virtual double evaluate()
{
return value_;
}

private:
double value_;
};

一个数字表达式就等于它的值。加法和乘法有点复杂,因为它们包含子表达式。在一个表达式计算自己的值之前,必须先递归地计算其子表达式的值。像这样:

class AdditionExpression : public Expression
{
public:
AdditionExpression(Expression* left, Expression* right)
: left_(left),
right_(right)
{}

virtual double evaluate()
{
// 计算操作数
double left = left_->evaluate();
double right = right_->evaluate();

// 把它们加起来
return left + right;
}

private:
Expression* left_;
Expression* right_;
};

很优雅对吧?只需几个简单的类,现在我们可以表示和计算任意复杂的算术表达式。 只需要创建正确的对象,并正确地连起来。

你肯定能想明白乘法的实现是什么样的。

这是个优美、简单的模式,但它有一些问题。 看看插图,看到了什么?大量的小盒子,以及它们之间大量的箭头。 代码被表示为小物体组成的巨大分形树。这会带来些令人不快的后果:

Ruby用了这种实现方法差不多15年。在1.9版本,他们转换到了本章所介绍的字节码。看看我给你节省了多少时间!

  • 从磁盘上加载它需要实例化并连接成吨的小对象。

  • 这些对象和它们之间的指针会占据大量的内存。在32位机上,那个小的算术表达式至少要占据68字节,这还没考虑内存对其呢。

如果你想自己算算,别忘了算上虚函数表指针。

  • 顺着那些指针遍历子表达式是对数据缓存的谋杀。同时,虚函数调用是对指令缓存的屠戮。

参见数据局部性一章以了解什么是缓存以及它是如何影响游戏性能的。

将这些拼到一起,怎么念?S-L-O-W。 这就是为什么大多数广泛应用的编程语言不基于解释器模式: 太慢了,也太消耗内存了。

虚拟的机器码

想想我们的游戏。玩家电脑在运行游戏时并不会遍历一堆C++语法结构树。 我们提前将其编译成了机器码,CPU基于机器码运行。机器码有什么好处呢?

  • 密集。 它是一块坚实连续的二进制数据块,没有一位被浪费。
  • 线性。 指令被打成包,一条接一条地执行。不会在内存里到处乱跳(除非你的控制流代码真真这么干了)。
  • 底层。 每条指令都做一件小事,有趣的行为从组合中诞生。
  • 速度快。 综合所有这些条件(当然,也包括它直接由硬件实现这一事实),机器码跑得跟风一样快。

这听起来很好,但我们不希望真的用机器代码来写咒语。 让玩家提供游戏运行时的机器码简直是在自找麻烦。我们需要的是机器代码的性能和解释器模式的安全的折中。

如果不是加载机器码并直接执行,而是定义自己的虚拟机器码呢? 然后,在游戏中写个小模拟器。 这与机器码类似——密集,线性,相对底层——但也由游戏直接掌控,所以可以放心地将其放入沙箱。

我们将小模拟器称为虚拟机(或简称“VM”),它运行的二进制机器码叫做字节码。 它有数据的灵活性和易用性,但比解释器模式性能更好。

这听起来有点吓人。 这章其余部分的目标是为了展示一下,如果把功能列表缩减下来,它实际上相当通俗易懂。 即使最终没有使用这个模式,你也至少可以对Lua和其他使用了这一模式的语言有个更好的理解。

模式

指令集 定义了可执行的底层操作。 一系列的指令被编码为字节序列。 虚拟机 使用 中间值栈 依次执行这些指令。 通过组合指令,可以定义复杂的高层行为。

何时使用

这是本书中最复杂的模式,无法轻易地加入游戏中。这个模式应当用在你有许多行为需要定义,而游戏实现语言因为如下原因不适用时:

  • 过于底层,繁琐易错。
  • 编译慢或者其他工具因素导致迭代缓慢。
  • 安全性依赖编程者。如果想保证行为不会破坏游戏,你需要将其与代码的其他部分隔开。

当然,该列表描述了一堆特性。谁不希望有更快的迭代循环和更多的安全性? 然而,世上没有免费的午餐。字节码比本地代码慢,所以不适合引擎的性能攸关的部分。

记住

创建自己的语言或者建立系统中的系统是很有趣的。 我在这里做的是小演示,但在现实项目中,这些东西会像藤蔓一样蔓延。

对我来说,游戏开发也正因此而有趣。 不管哪种情况,我都创建了虚拟空间让他人游玩。

每当我看到有人定义小语言或脚本系统,他们都说,“别担心,它很小。” 于是,不可避免地,他们增加更多小功能,直到完成了一个完整的语言。 除了,和其它语言不同,它是定制的并拥有棚户区的建筑风格。

例如每一种模板语言。

当然,完成完整的语言并没有什么。只是要确定你做得慎重。 否则,你就要小心地控制你的字节码所能表达的范围。在野马脱缰之前把它拴住。

你需要一个前端

底层的字节码指令性能优越,但是二进制的字节码格式不是用户能写的。 我们将行为移出代码的一个原因是想要以更高层的形式表示它。 如果说写C++太过底层,那么让用户写汇编可不是一个改进方案——就算是你设计的!

一个反例的是令人尊敬的游戏RoboWar。 在游戏中,玩家 编写类似汇编的语言控制机器人,我们这里也会讨论这种指令集。

这是我介绍类似汇编的语言的首选。

就像GoF的解释器模式,它假设你有某些方法来生成字节码。 通常情况下,用户在更高层编写行为,再用工具将其翻译为虚拟机能理解的字节码。 这里的工具就是编译器。

我知道,这听起来很吓人。丑话说在前头, 如果没有资源制作编辑器,那么字节码不适合你。 但是,接下来你会看到,也可能没你想的那么糟。

你会想念调试器

编程很难。我们知道想要机器做什么,但并不总能正确地传达——所以我们会写出漏洞。 为了查找和修复漏洞,我们已经积累了一堆工具来了解代码做错了什么,以及如何修正。 我们有调试器,静态分析器,反编译工具等。 所有这些工具都是为现有的语言设计的:无论是机器码还是某些更高层次的东西。

当你定义自己的字节码虚拟机时,你就得把这些工具抛在脑后了。 当然,可以通过调试器调试虚拟机,但它告诉你虚拟机本身在做什么,而不是正在被翻译的字节码是干什么的。

它当然也不会把字节码映射回编译前的高层次的形式。

如果你定义的行为很简单,可能无需太多工具帮忙调试就能勉强坚持下来。 但随着内容规模增长,还是应该花些时间完成些功能,让用户看到字节码在做什么。 这些功能也许不随游戏发布,但它们至关重要,它们能确保你的游戏能被发布。

当然,如果你想要让游戏支持模组,那你会发布这些特性,它们就更加重要了。

示例代码

经历了前面几个章节后,你也许会惊讶于它的实现是多么直接。 首先需要为虚拟机设定一套指令集。 在开始考虑字节码之类的东西前,先像思考API一样思考它。

法术的API

如果直接使用C++代码定义法术,代码需要调用何种API呢? 在游戏引擎中,构成法术的基本操作是什么样的?

大多数法术最终改变一个巫师的状态,因此先从这样的代码开始。

void setHealth(int wizard, int amount);
void setWisdom(int wizard, int amount);
void setAgility(int wizard, int amount);

第一个参数指定哪个巫师被影响,0代表玩家而1代表对手。 以这种方式,治愈法术可以治疗玩家的巫师,而伤害法术伤害他的敌人。 这三个小方法能覆盖的法术出人意料地多。

如果法术只是默默地调整数据,游戏逻辑就已经完成了, 但玩这样的游戏会让玩家无聊得要哭。让我们修复这点:

void playSound(int soundId);
void spawnParticles(int particleType);

这并不影响游戏玩法,但它们增强了游戏的体验。 我们可以增加一些镜头晃动,动画之类的,但这足够我们开始了。

法术指令集

现在让我们把这种程序化的API转化为可被数据控制的东西。 从小处开始,然后慢慢拓展到整体。 现在,要去除方法的所有参数。 假设set__()方法总影响玩家的巫师,总直接将状态设为最大值。 同样,FX操作总是播放一个硬编码的声音和粒子效果。

这样,一个法术就只是一系列指令了。 每条指令都代表了想要呈现的操作。我们可以枚举如下:

enum Instruction
{
INST_SET_HEALTH = 0x00,
INST_SET_WISDOM = 0x01,
INST_SET_AGILITY = 0x02,
INST_PLAY_SOUND = 0x03,
INST_SPAWN_PARTICLES = 0x04
};

为了将法术编码进数据,我们存储了一数组enum值。 只有几个不同的基本操作原语,因此enum值的范围可以存储到一个字节中。 这就意味着法术的代码就是一系列字节——也就是“字节码”。

为了执行一条指令,我们看看它的基本操作原语是什么,然后调用正确的API方法。

有些字节码虚拟机为每条指令使用多个字节,解码规则也更复杂。 事实上,在x86这样的常见芯片上的机器码更加复杂。

但单字节对于Java虚拟机和支撑了.NET平台的Common Language Runtime已经足够了,对我们来说也一样。

switch (instruction)
{
case INST_SET_HEALTH:
setHealth(0, 100);
break;

case INST_SET_WISDOM:
setWisdom(0, 100);
break;

case INST_SET_AGILITY:
setAgility(0, 100);
break;

case INST_PLAY_SOUND:
playSound(SOUND_BANG);
break;

case INST_SPAWN_PARTICLES:
spawnParticles(PARTICLE_FLAME);
break;
}

用这种方式,解释器建立了沟通代码世界和数据世界的桥梁。我们可以像这样将其放进执行法术的虚拟机:

class VM
{
public:
void interpret(char bytecode[], int size)
{
for (int i = 0; i < size; i++)
{
char instruction = bytecode[i];
switch (instruction)
{
// 每条指令的跳转分支……
}
}
}
};

输入这些,你就完成了你的首个虚拟机。 不幸的是,它并不灵活。 我们不能设定攻击对手的法术,也不能减少状态上限。我们只能播放声音!

为了获得像一个真正的语言那样的表达能力,我们需要在这里引入参数。

栈式机器

要执行复杂的嵌套表达式,得先从最里面的子表达式开始。 计算完里面的,将结果作为参数向外流向包含它们的表达式, 直到得出最终结果,整个表达式就算完了。

解释器模式将其明确地表现为嵌套对象组成的树,但我们需要指令速度达到列表的速度。我们仍然需要确保子表达式的结果正确地向外传递给包括它的表达式。

但由于数据是扁平的,我们得使用指令的顺序来控制这一点。我们的做法和CPU一样——使用栈。

这种架构不出所料地被称为栈式计算机。像ForthPostScript,和Factor 这些语言直接将这点暴露给用户。

class VM
{
public:
VM()
: stackSize_(0)
{}

// 其他代码……

private:
static const int MAX_STACK = 128;
int stackSize_;
int stack_[MAX_STACK];
};

虚拟机用内部栈保存值。在例子中,指令交互的值只有一种,那就是数字, 所以可以使用简单的int数组。 每当数据需要从一条指令传到另一条,它就得通过栈。

顾名思义,值可以压入栈或者从栈弹出,所以让我们添加一对方法。

class VM
{
private:
void push(int value)
{
// 检查栈溢出
assert(stackSize_ < MAX_STACK);
stack_[stackSize_++] = value;
}

int pop()
{
// 保证栈不是空的
assert(stackSize_ > 0);
return stack_[--stackSize_];
}

// 其余的代码
};

当一条指令需要接受参数,就将参数从栈弹出,如下所示:

switch (instruction)
{
case INST_SET_HEALTH:
{
int amount = pop();
int wizard = pop();
setHealth(wizard, amount);
break;
}

case INST_SET_WISDOM:
case INST_SET_AGILITY:
// 像上面一样……

case INST_PLAY_SOUND:
playSound(pop());
break;

case INST_SPAWN_PARTICLES:
spawnParticles(pop());
break;
}

为了将一些值存入栈中,需要另一条指令:字面量。 它代表了原始的整数值。但是的值又是从哪里来的呢? 我们怎么样避免这样追根溯源到无穷无尽呢?

技巧是利用指令是字节序列这一事实——我们可以直接将数值存储在字节数组中。 如下,我们为数值字面量定义了另一条指令类型:

case INST_LITERAL:
{
// 从字节码中读取下一个字节
int value = bytecode[++i];
push(value);
break;
}

这里,从单个字节中读取值,从而避免了解码多字节整数需要的代码, 但在真实实现中,你会需要支持整个数域的字面量。

它读取字节码流中的字节作为数值并将其压入栈。

让我们把一些这样的指令串起来看看解释器的执行,感受下栈是如何工作的。 从空栈开始,解释器指向第一个指令:

首先,它执行第一条INST_LITERAL,读取字节码流的下一个字节(0)并压入栈中。

然后,它执行第二条INST_LITERAL,读取10然后压入。

最后,执行INST_SET_HEALTH。这会弹出10存进amount,弹出0存进wizard。然后用这两个参数调用setHealth()

完成!我们获得了将玩家巫师血量设为10点的法术。 现在我们拥有了足够的灵活度,来定义修改任一巫师的状态到任意值的法术。 我们还可以放出不同的声音和粒子效果。

但是……这感觉还是像数据格式。比如,不能将巫师的血量提升为他智力的一半。 设计师希望法术能表达规则,而不仅仅是数值。

行为 = 组合

如果我们视小虚拟机为编程语言,现在它能支持的只有一些内置函数,以及常量参数。 为了让字节码感觉像行为,我们缺少的是组合。

设计师需要能以有趣的方式组合不同的值,来创建表达式。 举个简单的例子,他们想让法术变化一个数值而不是变到一个数值。

这需要考虑到状态的当前值。 我们有指令来修改状态,现在需要添加方法读取状态:

case INST_GET_HEALTH:
{
int wizard = pop();
push(getHealth(wizard));
break;
}

case INST_GET_WISDOM:
case INST_GET_AGILITY:
// 你知道思路了吧……

正如你所看到的,这要与栈双向交互。 弹出一个参数来确定获取哪个巫师的状态,然后查找状态的值并压入栈中。

这允许我们创造复制状态的法术。 我们可以创建一个法术,根据巫师的智慧设定敏捷度,或者让巫师的血量等于对方的血量。

有所改善,但仍很受限制。接下来,我们需要算术。 是时候让小虚拟机学习如何计算1 + 1了,我们将添加更多的指令。 现在,你可能已经知道如何去做,猜到了大概的模样。我只展示加法:

case INST_ADD:
{
int b = pop();
int a = pop();
push(a + b);
break;
}

像其他指令一样,它弹出数值,做点工作,然后压入结果。 直到现在,每个新指令似乎都只是有所改善而已,但其实我们已完成大飞跃。 这并不显而易见,但现在我们可以处理各种复杂的,深层嵌套的算术表达式了。

来看个稍微复杂点的例子。 假设我们希望有个法术,能让巫师的血量增加敏捷和智慧的平均值。 用代码表示如下:

setHealth(0, getHealth(0) +
(getAgility(0) + getWisdom(0)) / 2);
case INST_GET_HEALTH:
{
int wizard = pop();
push(getHealth(wizard));
break;
}

case INST_GET_WISDOM:
case INST_GET_AGILITY:
// 你知道思路了吧……

正如你所看到的,这要与栈双向交互。 弹出一个参数来确定获取哪个巫师的状态,然后查找状态的值并压入栈中。

这允许我们创造复制状态的法术。 我们可以创建一个法术,根据巫师的智慧设定敏捷度,或者让巫师的血量等于对方的血量。

有所改善,但仍很受限制。接下来,我们需要算术。 是时候让小虚拟机学习如何计算1 + 1了,我们将添加更多的指令。 现在,你可能已经知道如何去做,猜到了大概的模样。我只展示加法:

case INST_ADD:
{
int b = pop();
int a = pop();
push(a + b);
break;
}

像其他指令一样,它弹出数值,做点工作,然后压入结果。 直到现在,每个新指令似乎都只是有所改善而已,但其实我们已完成大飞跃。 这并不显而易见,但现在我们可以处理各种复杂的,深层嵌套的算术表达式了。

来看个稍微复杂点的例子。 假设我们希望有个法术,能让巫师的血量增加敏捷和智慧的平均值。 用代码表示如下:

setHealth(0, getHealth(0) +
(getAgility(0) + getWisdom(0)) / 2);
LITERAL 0
GET_HEALTH

这些字节码将巫师的血量压入堆栈。 如果我们机械地将每行都这样转化,最终得到一大块等价于原来表达式的字节码。 为了让你感觉这些指令是如何组合的,我在下面给你做个示范。

为了展示堆栈如何随着时间推移而变化,我们举个代码执行的例子。 巫师目前有45点血量,7点敏捷,和11点智慧。 每条指令的右边是栈在执行指令之后的模样,再右边是解释指令意图的注释:

LITERAL 0    [0]            # 巫师索引
LITERAL 0 [0, 0] # 巫师索引
GET_HEALTH [0, 45] # 获取血量()
LITERAL 0 [0, 45, 0] # 巫师索引
GET_AGILITY [0, 45, 7] # 获取敏捷()
LITERAL 0 [0, 45, 7, 0] # 巫师索引
GET_WISDOM [0, 45, 7, 11] # 获取智慧()
ADD [0, 45, 18] # 将敏捷和智慧加起来
LITERAL 2 [0, 45, 18, 2] # 被除数:2
DIVIDE [0, 45, 9] # 计算敏捷和智慧的平均值
ADD [0, 54] # 将平均值加到现有血量上。
SET_HEALTH [] # 将结果设为血量

如果你注意每步的栈,你可以看到数据如何魔法一般地在其中流动。 我们最开始压入0来查找巫师,然后它一直挂在栈的底部,直到最终的SET_HEALTH才用到它。

也许“魔法”在这里的门槛太低了。

一台虚拟机

我可以继续下去,添加越来越多的指令,但是时候适可而止了。 如上所述,我们已经有了一个可爱的小虚拟机,可以使用简单,紧凑的数据格式,定义开放的行为。 虽然“字节码”和“虚拟机”的听起来很吓人,但你可以看到它们往往简单到只需栈,循环,和switch语句。

还记得我们最初的让行为呆在沙盒中的目标吗? 现在,你已经看到虚拟机是如何实现的,很明显,那个目标已经完成。 字节码不能把恶意触角伸到游戏引擎的其他部分,因为我们只定义了几个与其他部分接触的指令。

我们通过控制栈的大小来控制内存使用量,并很小心地确保它不会溢出。 我们甚至可以控制它使用多少时间。 在指令循环里,可以追踪已经执行了多少指令,如果遇到了问题也可以摆脱困境。

控制运行时间在例子中没有必要,因为没有任何循环的指令。 可以限制字节码的总体大小来限制运行时间。 这也意味着我们的字节码不是图灵完备的。

现在就剩一个问题了:创建字节码。 到目前为止,我们使用伪代码,再手工编写为字节码。 除非你有很多的空闲时间,否则这种方式并不实用。

语法转换工具

我们最初的目标是创造更高层的方式来控制行为,但是,我们却创造了比C++更底层的东西。 它具有我们想要的运行性能和安全性,但绝对没有对设计师友好的可用性。

为了填补这一空白,我们需要一些工具。 我们需要一个程序,让用户定义法术的高层次行为,然后生成对应的低层栈式机字节码。

这可能听起来比虚拟机更难。 许多程序员都在大学参加编译器课程,除了被龙书或者”lex“和”yacc”引发了PTSD外,什么也没真正学到。

我指的,当然,是经典教材Compilers: Principles, Techniques, and Tools

事实上,编译一个基于文本的语言并不那么糟糕,尽管把这个话题放进这里来要牵扯的东西有点多。但是,你不是非得那么做。 我说,我们需要的是工具——它并不一定是个输入格式是文本文件的编译器。

相反,我建议你考虑构建图形界面让用户定义自己的行为, 尤其是在使用它的人没有很高的技术水平时。 没有花几年时间习惯编译器怒吼的人很难写出没有语法错误的文本。

你可以建立一个应用程序,用户通过单击拖动小盒子,下拉菜单项,或任何有意义的行为创建“脚本”,从而创建行为。

我为Henry Hatsworth in the Puzzling Adventure编写的脚本系统就是这么工作的。

这样做的好处是,你的UI可以保证用户无法创建“无效的”程序。 与其向他们吐一大堆错误警告,不如主动禁用按钮或提供默认值, 以确保他们创造的东西在任何时间点上都有效。

我想要强调错误处理是多么重要。作为程序员,我们趋向于将人为错误视为应当极力避免的的个人耻辱。

为了制作用户喜欢的系统,你需要接受人性,包括他们的失败。是人都会犯错误,但错误同时也是创作的固有基础。 用撤销这样的特性优雅地处理它们,这能让用户更有创意,创作出更好的成果。

这免去了设计语法和编写解析器的工作。 但是我知道,你可能会发现UI设计同样令人不快。 好吧,如果这样,我就没啥办法啦。

毕竟,这种模式是关于使用对用户友好的高层方式表达行为。 你必须精心设计用户体验。 要有效地执行行为,又需要将其转换成底层形式。这是必做的,但如果你准备好迎接挑战,这终会有所回报。

设计决策

我想尽可能让本章简短,但我们所做的事情实际上可是创造语言啊。 那可是个宽泛的设计领域,你可以从中获得很多乐趣,所以别沉迷于此反而忘了完成你的游戏。

指令如何访问堆栈?

字节码虚拟机主要有两种:基于栈的和基于寄存器的。 栈式虚拟机中,指令总是操作栈顶,如同我们的示例代码所示。 例如,INST_ADD弹出两个值,将它们相加,将结果压入。

基于寄存器的虚拟机也有栈。唯一不同的是指令可以从栈的深处读取值。 不像INST_ADD始终弹出其操作数, 它在字节码中存储两个索引,指示了从栈的何处读取操作数。

  • 基于栈的虚拟机:

    • 指令短小。 由于每个指令隐式认定在栈顶寻找参数,不需要为任何数据编码。 这意味着每条指令可能会非常短,一般只需一个字节。
    • 易于生成代码。 当你需要为生成字节码编写编译器或工具时,你会发现基于栈的字节码更容易生成。 由于每个指令隐式地在栈顶工作,你只需要以正确的顺序输出指令就可以在它们之间传递参数。
    • 会生成更多的指令。 每条指令只能看到栈顶。这意味着,产生像a = b + c这样的代码, 你需要单独的指令将bc压入栈顶,执行操作,再将结果压入a
  • 基于寄存器的虚拟机:

    • 指令较长。 由于指令需要参数记录栈偏移量,单个指令需要更多的位。 例如,一个Lua指令占用完整的32位——它可能是最著名的基于寄存器的虚拟机了。 它采用6位做指令类型,其余的是参数。

Lua作者没有指定Lua的字节码格式,它每个版本都会改变。现在描述的是Lua 5.1。 要深究Lua的内部构造, 读读这个

  • 指令较少。 由于每个指令可以做更多的工作,你不需要那么多的指令。 有人说,性能会得以提升,因为不需要将值在栈中移来移去了。

所以,应该选一种?我的建议是坚持使用基于栈的虚拟机。 它们更容易实现,也更容易生成代码。 Lua转换为基于寄存器的虚拟机从而变得更快,这为寄存器虚拟机博得了声誉, 但是这强烈依赖于实际的指令和虚拟机的其他大量细节。

你有什么指令?

指令集定义了在字节码中可以干什么,不能干什么,对虚拟机性能也有很大的影响。 这里有个清单,记录了你可能需要的不同种类的指令:

  • 外部基本操作原语。 这是虚拟机与引擎其他部分交互,影响玩家所见的部分。 它们控制了字节码可以表达的真实行为。 如果没有这些,你的虚拟机除了消耗CPU循环以外一无所得。

  • 内部基本操作原语 这些语句在虚拟机内操作数值——文字,算术,比较操作,以及操纵栈的指令。

  • 控制流。 我们的例子没有包含这些,但当你需要有条件执行或循环执行,你就会需要控制流。 在字节码这样底层的语言中,它们出奇地简单:跳转。

    在我们的指令循环中,需要索引来跟踪执行到了字节码的哪里。 跳转指令做的是修改这个索引并改变将要执行的指令。 换言之,这就是goto。你可以基于它制定各种更高级别的控制流。

  • 抽象。 如果用户开始在数据中定义很多的东西,最终要重用字节码的部分位,而不是复制和粘贴。 你也许会需要可调用过程这样的东西。

    最简单的形式中,过程并不比跳转复杂。 唯一不同的是,虚拟机需要管理另一个返回栈。 当执行“call”指令时,将当前指令索引压入栈中,然后跳转到被调用的字节码。 当它到了“return”,虚拟机从堆栈弹出索引,然后跳回索引指示的位置。

数值是如何表示的?

我们的虚拟机示例只与一种数值打交道:整数。 回答这个问题很简单——栈只是一栈的int。 更加完整的虚拟机支持不同的数据类型:字符串,对象,列表等。 你必须决定在内部如何存储这些值。

  • 单一数据类型:

    • 简单易用 你不必担心标记,转换,或类型检查。
    • 无法使用不同的数据类型。 这是明显的缺点。将不同类型成塞进单一的表示方式——比如将数字存储为字符串——这是自找麻烦。
  • 带标记的类型:

    这是动态类型语言中常见的表示法。 所有的值有两部分。 第一部分是类型标识——一个存储了数据的类型的enum。其余部分会被解释为这种类型:

    enum ValueType
    {
    TYPE_INT,
    TYPE_DOUBLE,
    TYPE_STRING
    };

    struct Value
    {
    ValueType type;
    union
    {
    int intValue;
    double doubleValue;
    char* stringValue;
    };
    };
    • 数值知道其类型。 这个表示法的好处是可在运行时检查值的类型。 这对动态调用很重要,可以确保没有在类型上面执行其不支持的操作。
    • 消耗更多内存。 每个值都要带一些额外的位来标识类型。在像虚拟机这样的底层,这里几位,那里几位,总量就会快速增加。
  • 无标识的union:

    像前面一样使用union,但是没有类型标识。 你可以将这些位表示为不同的类型,由你确保没有搞错值的类型。

    这是静态类型语言在内存中表示事物的方式。 由于类型系统在编译时保证没弄错值的类型,不需要在运行时对其进行验证。

这也是无类型语言,像汇编和Forth存储值的方式。 这些语言让用户保证不会写出误认值的类型的代码。毫无服务态度!

  • 结构紧凑。 找不到比只存储需要的值更加有效率的存储方式。

  • 速度快。 没有类型标识意味着在运行时无需消耗周期检查它们的类型。这是静态类型语言往往比动态类型语言快的原因之一。

  • 不安全。 这是真正的代价。一块错误的字节码,会让你误解一个值,把数字误解为指针,会破坏游戏安全性从而导致崩溃。

如果你的字节码是由静态类型语言编译而来,你也许认为它是安全的,因为编译不会生成不安全的字节码。 那也许是真的,但记住恶意用户也许会手写恶意代码而不经过你的编译器。

举个例子,这就是为什么Java虚拟机在加载程序时要做字节码验证。

  • 接口:

    多种类型值的面向对象解决方案是通过多态。接口为不同的类型的测试和转换提供虚方法,如下:

    class Value
    {
    public:
    virtual ~Value() {}

    virtual ValueType type() = 0;

    virtual int asInt() {
    // 只能在int上调用
    assert(false);
    return 0;
    }

    // 其他转换方法……
    };

    然后你为每个特定的数据类型设计特定的类,如:

    class IntValue : public Value
    {
    public:
    IntValue(int value)
    : value_(value)
    {}

    virtual ValueType type() { return TYPE_INT; }
    virtual int asInt() { return value_; }

    private:
    int value_;
    };
    • 开放。 可在虚拟机的核心之外定义新的值类型,只要它们实现了基本接口就行。

    • 面向对象。 如果你坚持OOP原则,这是“正确”的做法,为特定类型使用多态分配行为,而不是在标签上做switch之类的。

    • 冗长。 必须定义单独的类,包含了每个数据类型的相关行为。 注意在前面的例子中,这样的类定义了所有的类型。在这里,只包含了一个!

    • 低效。 为了使用多态,必须使用指针,这意味着即使是短小的值,如布尔和数字,也得裹在堆中分配的对象里。 每使用一个值,你就得做一次虚方法调用。

      在虚拟机核心之类的地方,像这样的性能影响会迅速叠加。 事实上,这引起了许多我们试图在解释器模式中避免的问题。 只是现在的问题不在代码中,而是在值中。

我的建议是:如果可以,只用单一数据类型。 除此以外,使用带标识的union。这是世界上几乎每个语言解释器的选择。

如何生成字节码?

我将最重要的问题留到最后。我们已经完成了消耗和解释字节码的部分, 但需你要写制造字节码的工具。 典型的解决方案是写个编译器,但它不是唯一的选择。

  • 如果你定义了基于文本的语言:

    • 必须定义语法。 业余和专业的语言设计师小看这件事情的难度。让解析器高兴很简单,让用户快乐很难。

      语法设计是用户界面设计,当你将用户界面限制到字符构成的字符串,这可没把事情变简单。

    • 必须实现解析器。 不管名声如何,这部分其实非常简单。无论使用ANTLR或Bison,还是——像我一样——手写递归下降,都可以完成。

    • 必须处理语法错误。 这是最重要和最困难的部分。 当用户制造了语法和语义错误——他们总会这么干——引导他们返回到正确的道路是你的任务。 解析器只知道接到了意外的符号,给予有用的的反馈并不容易。

    • 可能会对非技术用户关上大门。 我们程序员喜欢文本文件。结合强大的命令行工具,我们把它们当作计算机的乐高积木——简单,有百万种方式组合。

      大部分非程序员不这样想。 对他们来说,输入文本文件就像为愤怒机器人审核员填写税表,如果忘记了一个分号就会遭到痛斥。

  • 如果你定义了一个图形化创作工具:

    • 必须实现用户界面。 按钮,点击,拖动,诸如此类。 有些人畏惧它,但我喜欢它。 如果沿着这条路走下去,设计用户界面和工作核心部分同等重要——而不是硬着头皮完成的乱七八糟工作。

      每点额外工作都会让工具更容易更舒适地使用,并直接导致了游戏中更好的内容。 如果你看看很多游戏制作过程的内部解密,经常会发现制作有趣的创造工具是秘诀之一。

    • 有较少的错误情况。 由于用户通过交互式一步一步地设计行为,应用程序可以尽快引导他们走出错误。

      而使用基于文本的语言时,直到用户输完整个文件才能看到用户的内容,预防和处理错误更加困难。

    • 更难移植。 文本编译器的好处是,文本文件是通用的。编译器简单地读入文件并写出。跨平台移植的工作实在微不足道。

      当你构建用户界面,你必须选择要使用的架构,其中很多是基于某个操作系统。 也有跨平台的用户界面工具包,但他们往往要为对所有平台同样适用付出代价——它们在不同的平台上同样差异很大。

参见

  • 这一章节的近亲是GoF的解释器模式。两种方式都能让你用数据组合行为。

    事实上,最终你两种模式会使用。你用来构造字节码的工具会有内部的对象树。这也是解释器模式所能做的。

    为了编译到字节码,你需要递归回溯整棵树,就像用解释器模式去解释它一样。 唯一的 不同在于,不是立即执行一段行为,而是生成整个字节码再执行。

  • Lua是游戏中最广泛应用的脚本语言。 它的内部被实现为一个非常紧凑的,基于寄存器的字节码虚拟机。

  • Kismet是个可视化脚本编辑工具,应用于Unreal引擎的编辑器UnrealEd。

  • 我的脚本语言Wren,是一个简单的,基于栈的字节码解释器。

子类沙盒

意图

用一系列由基类提供的操作定义子类中的行为。

动机

每个孩子都梦想过变成超级英雄,但是不幸的是,高能射线在地球上很短缺。 游戏是让你扮演超级英雄最简单的方法。 因为我们的游戏设计者从来没有学会说“不”,我们的超级英雄游戏中有成百上千种不同的超级能力可供选择。

我们的计划是创建一个Superpower基类。然后由它派生出各种超级能力的实现类。 我们在程序员队伍中分发设计文档,然后开始编程。 当我们完成时,我们就会有上百种超级能力类。

当你发现像这个例子一样有很多子类时,那通常意味着数据驱动的方式更好。 不再用代码定义不同的能力,用数据吧。

像类型对象,字节码,和解释器模式都能帮忙。

我们想让玩家处于拥有无限可能的世界中。无论他们在孩童时想象过什么能力,我们都要在游戏中展现。 这就意味着这些超能力子类需要做任何事情: 播放声音,产生视觉刺激,与AI交互,创建和销毁其他游戏实体,与物理打交道。没有哪处代码是它们不会接触的。

假设我们让团队信马由缰地写超能力类。会发生什么?

  • 会有很多冗余代码。 当超能力种类繁多,我们可以预期有很多重叠。 很多超能力都会用相同的方式产生视觉效果并播放声音。 当你坐下来看看,冷冻光线,热能光线,芥末酱光线都很相似。 如果人们实现这些的时候没有协同,那就会有很多冗余的代码和重复劳动。
  • 游戏引擎中的每一部分都会与这些类耦合。 没有深入了解的话,任何人都能写出直接调用子系统的代码,但子系统从来没打算直接与超能力类绑定。 就算渲染系统被好好组织成多个层次,只有一个能被外部的图形引擎使用, 我们可以打赌,最终超能力代码会与每一个接触。
  • 当外部代码需要改变时,一些随机超能力代码有很大几率会损坏。 一旦我们有了不同的超能力类绑定到游戏引擎的多个部分,改变那些部分必然影响超能力类。 这可不合理,因为图形,音频,UI程序员很可能不想也成为玩法程序员。
  • 很难定义所有超能力遵守的不变量。 假设我们想保证超能力播放的所有音频都有正确的顺序和优先级。 如果我们的几百个类都直接调用音频引擎,就没什么好办法来完成这点。

我们要的是给每个实现超能力的玩法程序员一系列可使用的基本单元。 你想要播放声音?这是你的playSound()函数。 你想要粒子效果?这是你的spawnParticles()函数。 我们保证了这些操作覆盖了你要做的事情,所以你不需要#include随机的头文件,干扰到代码库的其他部分。

我们实现的方法是通过定义这些操作为Superpower基类的protected方法。 将它们放在基类给了每个子类直接便捷的途径获取方法。 让它们成为protected(很可能不是虚方法)方法暗示了它们存在就是为了被子类调用。

一旦有了这些东西来使用,我们需要一个地方使用他们。 为了做到这点,我们定义沙箱方法,这是子类必须实现的抽象的protected方法。 有了这些,要实现一种新的能力,你需要:

  1. 创建从Superpower继承的新类。
  2. 重载沙箱方法activate()
  3. 通过调用Superpower提供的protected方法实现主体。

我们现在可以使用这些高层次的操作来解决冗余代码问题了。 当我们看到代码在多个子类间重复,我们总可以将其打包到Superpower中,作为它们都可以使用的新操作。

我们通过将耦合约束到一个地方解决了耦合问题。 Superpower最终与不同的系统耦合,但是继承它的几百个类不会。 相反,它们耦合基类。 当游戏系统的某部分改变时,修改Superpower也许是必须的,但是众多的子类不需要修改。

这个模式带来浅层但是广泛的类层次。 你的继承链不,但是有很多类与Superpower挂钩。 通过使用有很多直接子类的基类,我们在代码库中创造了一个支撑点。 我们投入到Superpower的时间和爱可以让游戏中众多类获益。

最近,你会发现很多人批评面向对象语言中的继承。 继承是有问题——在代码库中没有比父类子类之间的耦合更深的了——但我发现扁平的继承树比起深的继承树更好处理。

模式

基类定义抽象的沙箱方法和几个提供的操作。 将操作标为protected,表明它们只为子类所使用。 每个推导出的沙箱子类用提供的操作实现了沙箱函数。

何时使用

子类沙箱模式是潜伏在代码库中简单常用的模式,哪怕是在游戏之外的地方亦有应用。 如果你有一个非虚的protected方法,你可能已经在用类似的东西了。 沙箱方法在以下情况适用:

  • 你有一个能推导很多子类的基类。
  • 基类可以提供子类需要的所有操作。
  • 在子类中有行为重复,你想要更容易地在它们间分享代码。
  • 你想要最小化子类和程序的其他部分的耦合。

记住

“继承”近来在很多编程圈子为人诟病,原因之一是基类趋向于增加越来越多的代码 这个模式特别容易染上这个毛病。

由于子类通过基类接触游戏的剩余部分,基类最后和子类需要的每个系统耦合。 当然,子类也紧密地与基类相绑定。这种蛛网耦合让你很难在不破坏什么的情况下改变基类——你得到了(脆弱的基类问题)brittle base class problem

硬币的另一面是由于你耦合的大部分都被推到了基类,子类现在与世界的其他部分分离。 理想的情况下,你大多数的行为都在子类中。这意味着你的代码库大部分是孤立的,很容易管理。

如果你发现这个模式正把你的基类变成一锅代码糊糊, 考虑将它提供的一些操作放入分离的类中, 这样基类可以分散它的责任。组件模式可以在这里帮上忙。

示例代码

因为这个模式太简单了,示例代码中没有太多东西。 这不是说它没用——这个模式关键在于“意图”,而不是它实现的复杂度。

我们从Superpower基类开始:

class Superpower
{
public:
virtual ~Superpower() {}

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)
{
// 实现代码……
}
};

activate()方法是沙箱方法。由于它是抽象虚函数,子类必须重载它。 这让那些需要创建子类的人知道要做哪些工作。

其他的protected函数move()playSound(),和spawnParticles()都是提供的操作。 它们是子类在实现activate()时要调用的。

在这个例子中,我们没有实现提供的操作,但真正的游戏在那里有真正的代码。 那些代码中,Superpower与游戏中其他部分的耦合——move()也许调用物理代码,playSound()会与音频引擎交互,等等。 由于这都在基类的实现中,保证了耦合封闭在Superpower中。

好了,拿出我们的放射蜘蛛,创建个能力。像这样:

class SkyLaunch : public Superpower
{
protected:
virtual void activate()
{
// 空中滑行
playSound(SOUND_SPROING, 1.0f);
spawnParticles(PARTICLE_DUST, 10);
move(0, 0, 20);
}
};

好吧,也许跳跃不是超级能力,但我在这里讲的是基础知识。

这种能力将超级英雄射向天空,播放合适的声音,扬起尘土。 如果所有的超能力都这样简单——只是声音,粒子效果,动作的组合——那么就根本不需要这个模式了。 相反,Superpower有内置的activate()能获取声音ID,粒子类型和运动的字段。 但是这只在所有能力运行方式相同,只在数据上不同时才可行。让我们精细一些:

class Superpower
{
protected:
double getHeroX()
{
// 实现代码……
}

double getHeroY()
{
// 实现代码……
}

double getHeroZ()
{
// 实现代码……
}

// 退出之类的……
};

这里我们增加了些方法获取英雄的位置。我们的SkyLaunch现在可以使用它们了:

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());
}
}
};

由于我们现在可以访问状态,沙箱方法可以做有用有趣的控制流了。 这还需要几个简单的if声明, 但你可以做任何你想做的东西。 使用包含任意代码的成熟沙箱方法,天高任鸟飞了。

早先,我建议以数据驱动的方式建立超能力。 这里是你可能想那么做的原因之一。 如果你的行为复杂而使用命令式风格,它更难在数据中定义。

决策设计

如你所见,子类沙箱是一个“软”模式。它表述了一个基本思路,但是没有很多细节机制。 这意味着每次使用都面临着一些有趣的选择。这里是一些需要思考的问题。

应该提供什么操作?

这是最大的问题。这深深影响了模式感觉上和实际上有多好。 在一个极端,基类几乎不提供任何操作。只有一个沙箱方法。 为了实现功能,总是需要调用基类外部的系统。如果你这样做,很难说你在使用这个模式。

另一个极端,基类提供了所有子类也许需要的操作。 子类只与基类耦合,不调用任何外部系统的东西。

具体来说,这意味着每个子类的源文件只需要#include它的基类头文件。

在这两个极端之间,操作由基类提供还是向外部直接调用有很大的操作余地。 你提供的操作越多,外部系统与子类耦合越少,但是与基类耦合越多。 从子类中移除了耦合是通过将耦合推给基类完成的。

如果你有一堆与外部系统耦合的子类的话,这很好。 通过将耦合移到提供的操作中,你将其移动到了一个地方:基类。但是你越这么做,基类就越大越难管理。

所以分界线在哪里?这里是一些首要原则:

-
如果提供的操作只被一个或几个子类使用,将操作加入基类获益不会太多。 你向基类添加了会影响所有事物的复杂性,但是只有少数几个类受益。

让该操作与其他提供的操作保持一致或许有价值,但让使用操作的子类直接调用外部系统也许更简单明了。

  • 当你调用游戏中其他地方的方法,如果方法没有修改状态就有更少的干扰。 它仍然制造耦合,但是这是“安全的”耦合,因为它没有破坏游戏中的任何东西。

“安全的”在这里打了引号是因为严格来说,接触数据也能造成问题。 如果你的游戏是多线程的,读取的数据可能正在被修改。如果你不小心,就会读入错误的数据。

另一个不愉快的情况是,如果你的游戏状态是严格确定性的(很多在线游戏为了保持玩家同步都是这样的)。 接触了游戏同步状态之外的东西会造成极糟的不确定性漏洞。

另一方面,修改状态的调用会和代码库的其他方面紧密绑定,你需要三思。打包他们成基类提供的操作是个好的候选项。

  • 如果操作只是增加了向外部系统的转发调用,那它就没增加太多价值。那种情况下,也许直接调用外部系统的方法更简单。

    但是,简单的转发也是有用的——那些方法接触了基类不想直接暴露给子类的状态。 举个例子,假设Superpower提供这个:

    void playSound(SoundId sound, double volume)
    {
    soundEngine_.play(sound, volume);
    }

    它只是转发调用给SuperpowersoundEngine_字段。 但是,好处是将字段封装在Superpower中,避免子类接触。

方法应该直接提供,还是包在对象中提供?

这个模式的挑战是基类中最终加入了很多方法。 你可以将一些方法移到其他类中来缓和。基类通过返回对象提供方法。

举个例子,为了让超能力播放声音,我们可以直接将它们加到Superpower中:

class Superpower
{
protected:
void playSound(SoundId sound, double volume)
{
// 实现代码……
}

void stopSound(SoundId sound)
{
// 实现代码……
}

void setVolume(SoundId sound)
{
// 实现代码……
}

// 沙盒方法和其他操作……
};

但是如果Superpower已经很庞杂了,我们也许想要避免这样。 取而代之的是创建SoundPlayer类暴露该函数:

class SoundPlayer
{
void playSound(SoundId sound, double volume)
{
// 实现代码……
}

void stopSound(SoundId sound)
{
// 实现代码……
}

void setVolume(SoundId sound)
{
// 实现代码……
}
};

Superpower提供了对其的接触:

class Superpower
{
protected:
SoundPlayer& getSoundPlayer()
{
return soundPlayer_;
}

// 沙箱方法和其他操作……

private:
SoundPlayer soundPlayer_;
};

将提供的操作分流到辅助类可以为你做一些事情:

  • 减少了基类中的方法。 在这里的例子中,将三个方法变成了一个简单的获取函数。
  • 在辅助类中的代码通常更好管理。Superpower的核心基类,不管意图如何好,它被太多的类依赖而很难改变。 通过将函数移到耦合较少的次要类,代码变得更容易被使用而不破坏任何东西。
  • 减少了基类和其他系统的耦合度。playSound()方法直接在Superpower时,基类与SoundId以及其他涉及的音频代码直接绑定。 将它移动到SoundPlayer中,减少了SuperpowerSoundPlayer类的耦合,这就封装了它其他的依赖。
基类如何获得它需要的状态?

你的基类经常需要将对子类隐藏的数据封装起来。 在第一个例子中,Superpower类提供了spawnParticles()方法。 如果方法的实现需要一些粒子系统对象,怎么获得呢?

  • 将它传给基类构造器:

    最简单的解决方案是让基类将其作为构造器变量:

    class Superpower
    {
    public:
    Superpower(ParticleSystem* particles)
    : particles_(particles)
    {}

    // 沙箱方法和其他操作……

    private:
    ParticleSystem* particles_;
    };

    这安全地保证了每个超能力在构造时能得到粒子系统。但让我们看看子类:

    class SkyLaunch : public Superpower
    {
    public:
    SkyLaunch(ParticleSystem* particles)
    : Superpower(particles)
    {}
    };

    我们在这儿看到了问题。每个子类都需要构造器调用基类构造器并传递变量。这让子类接触了我们不想要它知道的状态。

    这也造成了维护的负担。如果我们后续向基类添加了状态,每个子类都需要修改并传递这个状态。

  • 使用两阶初始化:

    为了避免通过构造器传递所有东西,我们可以将初始化划分为两个部分。 构造器不接受任何参数,只是创建对象。然后,我们调用定义在基类的分离方法传入必要的数据:

    Superpower* power = new SkyLaunch();
    power->init(particles);

    注意我们没有为SkyLaunch的构造器传入任何东西,它与Superpower中想要保持私有的任何东西都不耦合。 这种方法的问题在于,你要保证永远记得调用init(),如果忘了,你会获得处于半完成的,无法运行的超能力。

    你可以将整个过程封装到一个函数中来修复这一点,就像这样:

    Superpower* createSkyLaunch(ParticleSystem* particles)
    {
    Superpower* power = new SkyLaunch();
    power->init(particles);
    return power;
    }

使用一点像私有构造器和友类的技巧,你可以保证createSkylaunch()函数是唯一能够创建能力的函数。 这样,你不会忘记任何初始化步骤。

  • 让状态静态化:

    在先前的例子中,我们用粒子系统初始化每一个Superpower实例。 在每个能力都需要自己独特的状态时这是有意义的。但是如果粒子系统是单例,那么每个能力都会分享相同的状态。

    如果是这样,我们可以让状态是基类私有而静态的。 游戏仍然要保证初始化状态,但是它只需要为整个游戏初始化Superpower一遍,而不是为每个实例初始化一遍。

    记住单例仍然有很多问题。你在很多对象中分享了状态(所有的Superpower实例)。 粒子系统被封装了,因此它不是全局可见的,这很好,但它们都访问同一对象,这让分析更加困难了。

    class Superpower
    {
    public:
    static void init(ParticleSystem* particles)
    {
    particles_ = particles;
    }

    // 沙箱方法和其他操作……

    private:
    static ParticleSystem* particles_;
    };

    注意这里的init()particles_都是静态的。 只要游戏早先调用过一次Superpower::init(),每种能力都能接触粒子系统。 同时,可以调用正确的推导类构造器来自由创建Superpower实例。

    更棒的是,现在particles_静态变量, 我们不需要在每个Superpower中存储它,这样我们的类占据的内存更少了。

  • 使用服务定位器:

    前一选项中,外部代码要在基类请求前压入基类需要的全部状态。 初始化的责任交给了周围的代码。另一选项是让基类拉取它需要的状态。 而做到这点的一种实现方法是使用服务定位器模式:

    class Superpower
    {
    protected:
    void spawnParticles(ParticleType type, int count)
    {
    ParticleSystem& particles = Locator::getParticles();
    particles.spawn(type, count);
    }

    // 沙箱方法和其他操作……
    };

    这儿,spawnParticles()需要粒子系统,不是外部系统它,而是它自己从服务定位器中拿了一个。

参见

  • 当你使用更新模式时,你的更新函数通常也是沙箱方法。
  • 这个模式与模板方法正相反。 两种模式中,都使用一系列受限操作实现方法。 使用子类沙箱时,方法在推导类中,受限操作在基类中。 使用模板方法时,基类 有方法,而受限操作在推导类中。
  • 你也可以认为这个模式是外观模式的变形。 外观模式将一系列不同系统藏在简化的API后。使用子类沙箱,基类起到了在子类前隐藏整个游戏引擎的作用。

类型对象

意图

创造一个类A来允许灵活地创造新“类型”,类A的每个实例都代表了不同的对象类型。

动机

想象我们在制作一个奇幻RPG游戏。 我们的任务是为一群想要杀死英雄的恶毒怪物编写代码。 怪物有多个的属性:生命值,攻击力,图形效果,声音表现,等等。 但是为了说明介绍的目的我们先只考虑前面两个。

游戏中的每个怪物都有当前血值。 开始时是满的,每次怪物受伤,它就下降。 怪物也有一个攻击字符串。 当怪物攻击我们的英雄,那个文本就会以某种方式展示给用户。 (我们不在乎这里怎样实现。)

设计者告诉我们怪物有不同品种,像“龙”或者“巨魔”。 每个品种都描述了一种存在于游戏中的怪物,同时可能有多个同种怪物在地牢里游荡。

品种决定了怪物的初始健康——龙开始的血量比巨魔多,它们更难被杀死。 这也决定了攻击字符——同种的所有怪物都以相同的方式进行攻击。

传统的面向对象方案

想着这样的设计方案,我们启动了文本编辑器开始编程。 根据设计,龙是一种怪物,巨魔是另一种,其他品种的也一样。 用面向对象的方式思考,这引导我们创建Monster基类。

这是一种“是某物”的关系。 在传统OOP思路中,由于龙“是”怪物,我们用DragonMonster的子类来描述这点。 如我们将看到的,继承是一种将这种关系表示为代码的方法。

class Monster
{
public:
virtual ~Monster() {}
virtual const char* getAttack() = 0;

protected:
Monster(int startingHealth)
: health_(startingHealth)
{}

private:
int health_; // 当前血值
};

在怪物攻击英雄时,公开的getAttack()函数让战斗代码能获得需要显示的文字。 每个子类都需要重载它来提供不同的消息。

构造器是protected的,需要传入怪物的初始血量。 每个品种的子类的公共构造器调用这个构造器,传入对于该品种适合的起始血量。

现在让我们看看两个品种子类:

class Dragon : public Monster
{
public:
Dragon() : Monster(230) {}

virtual const char* getAttack()
{
return "The dragon breathes fire!";
}
};

class Troll : public Monster
{
public:
Troll() : Monster(48) {}

virtual const char* getAttack()
{
return "The troll clubs you!";
}
};

每个从Monster派生出来的类都传入起始血量,重载getAttack()返回那个品种的攻击字符串。 所有事情都一如所料地运行,不久以后,我们的英雄就可以跑来跑去杀死各种野兽了。 我们继续编程,在意识到之前,我们就有了从酸泥怪到僵尸羊的众多怪物子类。

然后,很奇怪,事情陷入了困境。 设计者最终想要几百个品种,但是我们发现所有的时间都花费在写这些只有七行长的子类和重新编译上。 这会继续变糟——设计者想要协调已经编码的品种。我们之前富有产出的工作日退化成了:

  1. 收到设计者将巨魔的血量从48改到52的邮件。
  2. 签出并修改Troll.h
  3. 重新编译游戏。
  4. 签入修改。
  5. 回复邮件。
  6. 重复。

我们度过了失意的一天,因为我们变成了填数据的猴子。 设计者也感到挫败,因为修改一个数据就要老久。 我们需要的是一种无需每次重新编译游戏就能修改品种的状态。 如果设计者创建和修改品种时无需任何程序员的介入那就更好了。

为类型建类

从较高的层次看来,我们试图解决的问题非常简单。 游戏中有很多不同的怪物,我们想要在它们之间分享属性。 一大群怪物在攻击英雄,我们想要它们中的一些使用相同的攻击文本。 我们声明这些怪物是相同的“品种”,而品种决定了攻击字符串。

这种情况下我们很容易想到类,那就试试吧。 龙是怪物,每条龙都是龙“类”的实例。 定义每个品种为抽象基类Monster 的子类,让游戏中每个怪物都是子类的实例反映了那点。最终的类层次是这样的:

这里的意为“从……继承”。

每个怪物的实例属于某个继承怪物类的类型。 我们有的品种越多,类层次越高。 这当然是问题:添加新品种就需要添加新代码,而每个品种都需要被编译为它自己的类型。

这可行,但不是唯一的选项。 我们也可以重构代码让每个怪物品种。 不是让每个品种继承Monster,我们现在有单一的Monster类和Breed类。

这里意为“被……引用”。

这就成了,就两个类。注意这里完全没有继承。 通过这个系统,游戏中的每个怪物都是Monster的实例。 Breed类包含了在不同品种怪物间分享的信息:开始血量和攻击字符串。

为了将怪物与品种相关联,我们给了每个Monster实例对包含品种信息的Breed对象的引用。 为了获得攻击字符串,一个怪兽可以调用它品种的方法。 Breed类本质上定义了一个怪物的类型,这就是为啥这个模式叫做类型对象。

这个模式特别有用的一点是,我们现在可以定义全新的类型而无需搅乱代码库。 我们本质上将部分的类型系统从硬编码的继承结构中拉出,放到可以在运行时定义的数据中去。

我们可以通过用不同值实例化Monster来创建成百上千的新品种。 如果从配置文件读取不同的数据初始化品种,我们就有能力完全靠数据定义新怪物品种。 这么容易,设计者也可以做到!

模式

定义类型对象类和有类型的对象类。每个类型对象实例代表一种不同的逻辑类型。 每种有类型的对象保存对描述它类型的类型对象的引用。

实例相关的数据被存储在有类型对象的实例中,被同种类分享的数据或者行为存储在类型对象中。 引用同一类型对象的对象将会像同一类型一样运作。 这让我们在一组相同的对象间分享行为和数据,就像子类让我们做的那样,但没有固定的硬编码子类集合。

何时使用

在任何你需要定义不同“种”事物,但是语言自身的类型系统过于僵硬的时候使用该模式。尤其是下面两者之一成立时:

  • 你不知道你后面还需要什么类型。(举个例子,如果你的游戏需要支持资料包,而资料包有新的怪物品种呢?)
  • 想不改变代码或者重新编译就能修改或添加新类型。

记住

这个模型是关于将“类型”的定义从命令式僵硬的语言世界移到灵活但是缺少行为的对象内存世界。 灵活性很好,但是将类型提到数据丧失了一些东西。

需要手动追踪类型对象

使用像C++类型系统这种东西的好处之一就是编译器自动记录类的注册。 定义类的数据自动编译到可执行的静态内存段然后就运作起来了。

使用类型对象模式,我们现在不但要负责管理内存中的怪物,同时要管理它们的类型 ——我们要保证,只要我的怪物需要,所有的品种对象都能实例化并保存在内存中。 无论何时创建新的怪物,由我们来保证能初始化为含有品种的引用。

我们从编译器的限制中解放了自己,但是代价是需要重新实现一些它以前为我们做的事情。

更难为每种类型定义行为

C++内部使用了“虚函数表”(“vtable”)实现虚方法。 虚函数表是个简单的struct,包含了一集合函数指针,每个对应一个类中的虚方法。 在内存中每个类有一个虚函数表。每个类的实例有一个指针指向它的类的虚函数表。

当你调用一个虚函数,代码首先在虚函数表中查找对象,然后调用表中函数指针指向的函数。

听起来很熟悉?虚函数表就是个品种对象,而指向虚函数表的指针是怪物保留的、指向品种的引用。 C++的类是C中的类型对象,由编译器自动处理。

使用子类派生,你可以重载方法,然后做你想做的事——用程序计算值,调用其他代码,等等。 天高任鸟飞。如果我们想的话,可以定义一个怪物子类,根据月亮的阶段改变它的攻击字符串。(我觉得就像狼人。)

当我们使用类型对象模式时,我们将重载的方法替换成了成员变量。 不再让怪物的子类重载方法,用不同的代码来计算攻击字符串,而是让我们的品种对象在不同的变量中存储攻击字符串。

这让使用类型对象定义类型相关的数据变得容易,但是定义类型相关的行为变得困难。 如果,举个例子,不同品种的怪物需要使用不同的AI算法,使用这个模式就面临着挑战。

有很多方式可以让我们跨越这个限制。 一个简单的方式是使用预先定义的固定行为, 然后类型对象中的数据简单地选择它们中的一个。 举例,假设我们的怪物AI总是处于“站着不动”、“追逐英雄”或者“恐惧地呜咽颤抖”(嘿,他们不可能都是强势的龙)状态。 我们可以定义函数来实现每种行为。 然后,我们在方法中存储合适函数的引用,将AI算法与品种相关联。

听起来很熟悉?这是在我们的类型对象中实现虚函数表。

另一个更加彻底的解决方案是真正地在数据中支持定义行为。 解释器模式和字节码模式让我们定义有行为的对象。 如果我们读取数据文件并用上面两种模式之一构建数据结构,我们就将行为完全从代码中移出,放入了数据之中。

示例代码

时过境迁,游戏越来越多地由数据驱动。 硬件变得更为强大,我们发现比起能榨干多少硬件的性能,瓶颈更多于在能完成多少内容。 使用64K软盘的时代,挑战是将游戏塞入其中。 而在使用双面DVD的时代,挑战是用游戏填满它。

脚本语言和其他定义游戏行为的高层方式能给我们提供必要的生产力,同时只消耗可预期的运行时性能。 由于硬件越来越好,而大脑并非如此,这种交换越来越有意义。

在第一遍实现中,让我们从简单的开始,只构建动机那节提到的基础系统。 我们从Breed类开始:

class Breed
{
public:
Breed(int health, const char* attack)
: health_(health),
attack_(attack)
{}

int getHealth() { return health_; }
const char* getAttack() { return attack_; }

private:
int health_; // 初始血值
const char* attack_;
};

很简单。它基本上只是两个数据字段的容器:起始血量和攻击字符串。 让我们看看怪物怎么使用它:

class Monster
{
public:
Monster(Breed& breed)
: health_(breed.getHealth()),
breed_(breed)
{}

const char* getAttack()
{
return breed_.getAttack();
}

private:
int health_; // 当前血值
Breed& breed_;
};

当我们建构怪物时,我们给它一个品种对象的引用。 它定义了怪物的品种,取代了之前的子类。 在构造函数中,Monster使用的品种决定了起始血量。 为了获得攻击字符串,怪物简单地将调用转发给它的品种。

这段非常简单的代码是这章的核心思路。剩下的任何东西都是红利。

让类型对象更像类型:构造器

现在,我们可以直接构造怪物并负责传入它的品种。 和常用的OOP语言实现的对象相比这有些退步——我们通常不会分配一块空白内存,然后赋予它类型。 相反,我们根据类调用构造器,它负责创建一个新实例。

我们可以在类型对象上应用同样的模式。

class Breed
{
public:
Monster* newMonster() { return new Monster(*this); }

// Previous Breed code...
};

“模式”一词用在这里正合适。我们讨论的是设计模式中经典的模式:工厂方法。

在一些语言中,这个模式被用来构造所有的对象。 在Ruby,Smalltalk,Objective-C以及其他类是对象的语言中,你通过在类对象本身上调用方法来构建实例。

以及那个使用它们的类:

class Monster
{
friend class Breed;

public:
const char* getAttack() { return breed_.getAttack(); }

private:
Monster(Breed& breed)
: health_(breed.getHealth()),
breed_(breed)
{}

int health_; // 当前血值
Breed& breed_;
};

不同的关键点在于Breed中的newMonster()。 这是我们的“构造器”工厂方法。使用我们原先的实现,就像这样创建怪物:

这里还有一个小小的不同。 因为样例代码由C++写就,我们可以使用一个小小的特性:友类。

我们让Monster的构造器成为私有,防止了任何人直接调用它。 友类放松了这个限制,Breed仍可接触它。 这意味着构造怪物的唯一方法是通过newMonster()

Monster* monster = new Monster(someBreed);

在我们改动后,它看上去是这样:

Monster* monster = someBreed.newMonster();

所以,为什么这么做?创建一个对象分为两步:内存分配和初始化。 Monster的构造器让我们做完了所有需要的初始化。 在例子中,那只存储了类型;但是在完整的游戏中,那需要加载图形,初始化怪物AI以及做其他的设置工作。

但是,那都发生在内存分配之后。 在构造器调用前,我们已经找到了内存放置怪物。 在游戏中,我们通常也想控制对象创造这一环节: 我们通常使用自定义的分配器或者对象池模式来控制对象最终在内存中的位置。

Breed中定义“构造器”函数给了我们地方实现这些逻辑。 不是简单地调用new,newMonster()函数可以在将控制权传递给Monster初始化之前,从池中或堆中获取内存。 通过在唯一有能力创建怪物的Breed函数中放置这些逻辑, 我们保证了所有怪物变量遵守了内存管理规范。

通过继承分享数据

我们现在已经实现了能完美服务的类型对象系统,但是它非常基础。 我们的游戏最终有上百种不同品种,每种都有成打的特性。 如果设计者想要协调30种不同的巨魔,让它们变得强壮一点,他会得处理很多数据。

能帮上忙的是在不同品种间分享属性的能力,一如品种在不同的怪物间分享属性的能力。 就像我们在之前OOP方案中做的那样,我们可以使用派生完成这点。 只是,这次,不使用语言的继承机制,我们用类型对象实现它。

简单起见,我们只支持单继承。 就像类可以有一个父类,我们允许品种有一个父品种:

class Breed
{
public:
Breed(Breed* parent, int health, const char* attack)
: parent_(parent),
health_(health),
attack_(attack)
{}

int getHealth();
const char* getAttack();

private:
Breed* parent_;
int health_; // 初始血值
const char* attack_;
};

当我们构建一个品种,我们先传入它继承的父品种。 我们可以为基础品种传入NULL表明它没有祖先。

为了让这有用,子品种需要控制它从父品种继承了哪些属性,以及哪些属性需要重载并由自己指定。 在我们的示例系统中,我们可以说品种用非零值重载了怪物的健康,用非空字符串重载了攻击字符串。 否则,这些属性要从它的父品种里继承。

实现方式有两种。 一种是每次属性被请求时动态处理委托,就像这样:

int Breed::getHealth()
{
// 重载
if (health_ != 0 || parent_ == NULL) return health_;

// 继承
return parent_->getHealth();
}

const char* Breed::getAttack()
{
// 重载
if (attack_ != NULL || parent_ == NULL) return attack_;

// 继承
return parent_->getAttack();
}

如果品种在运行时修改种类,不再重载,或者不再继承某些属性时,这能保证做正确的事。 另一方面,这要更多的内存(它需要保存指向它的父品种的指针)而且更慢。 每次你查找属性都需要回溯继承链。

如果我们可以保证品种的属性不变,一个更快的解决方案是在构造时使用继承。 这被称为“复制”委托,因为在创建对象时,我们复制继承的属性到推导的类型。它看上去是这样的:

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();
}
}

注意现在我们不再需要给父品种的字段了。 一旦构造器完成,我们可以忘了父品种,因为我们已经拷贝了它的所有属性。 为了获得品种的属性,我们现在直接返回字段:

int         getHealth() { return health_; }
const char* getAttack() { return attack_; }

又好又快!

假设游戏引擎从品种的JSON文件加载设置然后创建类型。它看上去是这样的:

{
"Troll": {
"health": 25,
"attack": "The troll hits you!"
},
"Troll Archer": {
"parent": "Troll",
"health": 0,
"attack": "The troll archer fires an arrow!"
},
"Troll Wizard": {
"parent": "Troll",
"health": 0,
"attack": "The troll wizard casts a spell on you!"
}
}

:::json
{
"Troll": {
"health": 25,
"attack": "The troll hits you!"
},
"Troll Archer": {
"parent": "Troll",
"health": 0,
"attack": "The troll archer fires an arrow!"
},
"Troll Wizard": {
"parent": "Troll",
"health": 0,
"attack": "The troll wizard casts a spell on you!"
}
}

我们有一段代码读取每个品种,用新数据实例化品种实例。 就像你从"parent": "Troll"字段看到的, Troll ArcherTroll Wizard品种都由基础Troll品种继承而来。

由于派生类的初始血量都是0,所以该值从基础Troll品种继承。 这意味着无论怎么调整Troll的血量,三个品种的血量都会被更新。 随着品种的数量和属性的数量增加,这节约了很多时间。 现在,通过一小块代码,系统给了设计者控制权,让他们能好好利用时间。 与此同时,我们可以回去编码其他特性了。

设计决策

类型对象模式让我们建立类型系统,就好像在设计自己的编程语言。 设计空间是开放的,我们可以做很多有趣的事情。

在实践中,有些东西打破了我们的幻想。 时间和可维护性阻止我们创建特别复杂的东西。 更重要的是,无论如何设计类型系统,用户(通常不是程序员)要能轻松地理解它。 我们将其做得越简单,它就越有用。 所以我们在这里谈到的是已经反复探索的领域,开辟新路就留给学者和探索者吧。

类型对象是封装的还是暴露的?

在我们的简单实现中,Monster有一个对品种的引用,但是它没有显式暴露这个引用。 外部代码不能直接获取怪物的品种。 从代码库的角度看来,怪物事实上是没有类型的,事实上它们拥有品种只是个实现细节。

我们可以很容易地改变这点,让Monster返回它的Breed

class Monster
{
public:
Breed& getBreed() { return breed_; }

// 当前的代码……
};

在本书的另一个例子中,我们遵守了惯例,返回对象的引用而不是对象的指针,保证了永远不会返回NULL

这样做改变了Monster的设计。 事实是所有怪物都拥有品种是API的可见部分了,下面是这两者各自的好处:

  • 如果类型对象是封装的:

    • 类型对象模式的复杂性对代码库的其他部分是隐藏的。 它成为了只有有类型的对象才需要考虑的实现细节。

    • 有类型的对象可以选择性地修改类型对象的重载行为。 假设我们想要怪物在它接近死亡时改变它的攻击字符串。 由于攻击字符串总是通过Monster获取的,我们有一个方便的地方放置代码:

      const char* Monster::getAttack()
      {
      if (health_ < LOW_HEALTH)
      {
      return "The monster flails weakly.";
      }

      return breed_.getAttack();
      }

      如果外部代码直接调用品种的getAttack(),我们就没有机会能插入逻辑。

    • 我们得为每个类型对象暴露的方法写转发。 这是这个设计的冗长之处。如果类型对象有很多方法,对象类也得为每一个方法建立属于自己的公共可见方法。

  • 如果类型对象是暴露的:

    • 外部代码可以与类型对象直接交互,无需拥有类型对象的实例。 如果类型对象是封装的,那么没有一个拥有它的对象就没法使用它。 这阻止我们使用构造器模式这样的方法,在品种上调用方法来创建新怪物。 如果用户不能直接获得品种,他们就没办法调用它。
    • 类型对象现在是对象公共API的一部分了。 大体上,窄接口比宽接口更容易掌控——你暴露给代码库其他部分的越少,你需要处理的复杂度和维护工作就越少。 通过暴露类型对象,我们扩宽了对象的API,包含了所有类型对象提供的东西。
有类型的对象是如何创建的?

使用这个模式,每个“对象”现在都是一对对象:主对象和它的类型对象。 所以我们怎样创建并绑定两者呢?

  • 构造对象然后传入类型对象:
    • 外部代码可以控制分配。 由于调用代码也是构建对象的代码,它可以控制其内存位置。 如果我们想要UI在多种内存场景中使用(不同的分配器,在栈中,等等),这给了完成它的灵活性。
  • 在类型对象上调用“构造器”函数:
    • 类型对象控制了内存分配。 这是硬币的另一面。如果我们不想让用户选择在内存中何处创建对象, 在类型对象上调用工厂方法可以达到这一点。 如果我们想保证所有的对象都来自具体的对象池或者其他的内存分配器时也有用。
能改变类型吗?

到目前为止,我们假设一旦对象创建并绑定到类型对象上,这永远不会改变。 对象创建时的类型就是它销毁时的类型。这其实没有必要。 我们可以允许对象随着时间改变它的类型。

让我们回想下我们的例子。 当怪物死去时,设计者告诉我们,有时它的尸体会复活成僵尸。 我们可以通过在怪物死亡时产生僵尸类型的新怪兽,但另一个选项是拿到现有的怪物,然后将它的品种改为僵尸。

  • 如果类型不改变:

    • 编码和理解都更容易。 在概念上,大多数人不期望“类型”会改变。这符合大多数人的理解。
    • 更容易查找漏洞。 如果我们试图追踪怪物进入奇怪状态时的漏洞,现在看到的品种就是怪物始终保持的品种可以大大简化工作。
  • 如果类型可以改变:

    • 需要创建的对象更少。 在我们的例子中,如果类型不能改变,我们需要消耗CPU循环创建新的僵尸怪物对象, 把原先对象中需要保留的属性都拷贝过来,然后删除它。 如果我们可以改变类型,所有的工作都被一个简单的声明取代。

    • 我们需要小心地做约束。 在对象和它的类型间有强耦合是很自然的事情。 举个例子,一个品种也许假设怪物当前的血量永远高于品种中的初始血量。

      如果我们允许品种改变,我们需要确保已存对象满足新品种的需求。 当我们改变类型时,我们也许需要执行一些验证代码保证对象现在的状态对新类型是有意义的。

它支持何种继承?
  • 没有继承:

    • 简单。 最简单的通常是最好的。如果你在类型对象间没有大量数据共享,为什么要为难自己呢?
    • 这会带来重复的工作。 我从未见过哪个编码系统中设计者不想要继承的。 当你有十五种不同的精灵时,协调血量就要修改十五处同样的数字真是糟透了。
  • 单继承:

    • 还是相对简单。 它易于实现,但是,更重要的是,也易于理解。如果非技术用户正在使用这个系统,要操作的部分越少越好。 这就是很多编程语言只支持单继承的原因。这看起来是能力和简洁之间的平衡点。
    • 查询属性更慢。 为了在类型对象中获取一块数据,我们也许需要回溯继承链寻找是哪一个类型最终决定了值。 在性能攸关的代码上,我们也许不想花时间在这上面。
  • 多重继承:

    • 可以避免绝大多数代码重复。 使用优良的多继承系统,用户可以为类型对象建立几乎没有冗余的层次。 改变数值时,我们可以避免很多复制和粘贴。

    • 复杂。 不幸的是,它的好处更多地是理论上的而非实际上的。多重继承很难理解。

      如果僵尸龙继承僵尸和龙,哪些属性来自僵尸,哪些来自于龙? 为了使用系统,用户需要理解如何遍历继承图,还需要有设计优秀层次的远见。

      我看到的大多数C++编码标准趋向于禁止多重继承,Java和C#完全移除了它。 这承认了一个悲伤的事实:它太难掌握了,最好根本不要用。 尽管值得考虑,但你很少想要在类型对象上实现多重继承。就像往常一样,简单的总是最好的。

参见

  • 这个模式处理的高层问题是在多个对象间分享数据和行为。 另一个用另一种方式解决了相同问题的模式是原型模式。

  • 类型对象是享元模式的近亲。 两者都让你在实例间分享代码。使用享元,意图是节约内存,而分享的数据也许不代表任何概念上对象的“类型”。 使用类型对象模式,焦点在组织性和灵活性。

  • 这个模式和状态模式有很多相似之处。 两者都委托对象的部分定义给另外一个对象。 通过类型对象,我们通常委托了对象是什么:不变的数据概括描述对象。 通过状态,我们委托了对象现在是什么:暂时描述对象当前状态的数据。

    当我们讨论对象改变它的类型时,你可以认为类型对象起到了和状态相似的职责。