允许
- 感叹号让一些都更加激动人心! ???
每个从Monster派生的类传入初始生命并重写getAttack()来返回这个品种的攻击字符串。一些都和预想的一样,不久之后,我们的主人公就能够跑来跑去杀死各种怪物。我们继续编写代码,在我们意识到之前,就会发现有成堆的怪物派生类,从酸性史莱姆到僵尸山羊。
然后,事情很奇怪地变得缓慢起来。我们的设计师最终想要有上百个品种,我们会发现自己花费了所有的时间去编写那7行代码的小派生类。更糟糕的是——设计师要开始调整代码中已经有的品种。我们的日常工作流程变成了这样:
-
- 收到设计师的邮件,要把巨魔的攻击力从48修改成52。
-
- 签出并修改Troll.h。
-
- 重新编译游戏。
-
- 签入修改。
-
- 邮件通知。
-
- 重复上述步骤。
我们整天都很茫然,因为我们变成了填数据的猴子。我们的设计师也很茫然,因为要调整好一个数字就要花费大量的时间。我们需要的能力是在无需重新编译整个游戏的情况下,去修改品种的数值。如果设计师能在无需程序员介入的情况下创建并调整品种,那就更好了。
在一个比较高的层次上,我们要解决的问题非常简单。我们的游戏中有一堆不同的怪物,我们想要让它们共享一些特性。一个部落中的怪物想要击败主人公,我们想要它们在攻击时使用相同的文本。我们通过将它们定义成同样的“品种”来实现,那个品种决定了攻击字符串。
因为它们属于直觉上的类,因此我们决定使用派生来实现这个概念。一头龙是一只怪物,游戏中的没头龙是这个龙的“类”的实例。将每个种族定义成抽象基类Monster的派生类,让游戏中的每个怪物成为派生的品种类的实例来影射???他。我们最终会有下面这样的类层次:
游戏中每个怪物的实例,都属于一个派生的怪物类型。我们拥有的品种越多,这个类层级就越加庞大。这就是i问题的成因:添加新的品种意味着添加新的代码,每个品种必须被编译成它自己的类型。
这是可行的,但并不是唯一的选择。我们也可以将代码的架构调整成每个怪物具有一个品种。而不是为每个品种做一次从Monster的派生,我们有一个唯一的Monster类和一个唯一的品种类:
完成了。就两个类。注意这里没有任何派生。在这个系统里,游戏中的每个怪物是一个简单的Monster类的实例。Breed类包含了同一品种的所有怪物之间共享的信息:初始生命值和攻击字符串。
为了将怪物与品种关联起来,我们给每个Monster一个到包含了品种信息的Breed的引用。为了获得攻击字符串,一个怪物在只需在它的品种上调用一个方法。这个品种本质上定义了怪物的“类型”。每个品种实例是一个表述不类型概念差异的对象,即这个模式的名字:类型对象。
这个模式的特殊能力是可以在无需重新编译代码的情况下,添加新的类型。我们本质上是把硬编码的类型继承系统移动了位置,放到一个我们可以在运行时定义的数据里。
我们可以通过实例化更多的Breed实例来创建成百上千的不同品种。如果我们通过一些配置文件中的数据初始化品种,我们就能够完全在数据里定义新的怪物类型。这简单到设计师都能做!
定义一个类型对象类和一个被指定类型对象类。每个类型对象实例表示一个不同的逻辑类型。每个被指定类型的对象存储一个描述它的类型的类型对象的引用。
实例相关的数据被存储在被指定类型对象实例中,而所有同概念类型所共享的数据和行为被存储在类型对象中。引用同一个类型对象的对象会表现得好像他们是同类。这让我们能够在一个相似对象集合中共享数据和行为,很像是类派生让我们做到的事,但是无需一批硬编码的派生类。
这个模式在任何你需要定义一系列不同“种”东西,但是又不想把那些种类硬写进类型系统中的时候都有用。详细来说,只要下列任意一项成立时它就有用:
- 你不知道将来会有什么类型。(例如,我们的游戏是否需要支持下载包含怪物新品种的内容?)
- 能够在不重新编译或修改代码的情况下,修改或添加类型。
这个模式是关于把“类型”的定义从命令式???而僵硬的语言代码转移到更加灵活而且低行为式???的内存对象。灵活性是好的,但是把类型移动到数据里还是会失去些东西。
一个使用类似C++类型系统的好处是编译器自动处理所有的类登记。定义类的数据自动编译到可执行程序的静态内存分段中,就能工作了。
通过类型对象模式,我们现在不仅与责任管理内存中的怪物,还包括它们的类型——我们要保证只要有怪物需要它们,所有的品种对象就应该初始化并保留在内存中。当我们创建一个新的怪物时,我们有责任保证他由一个对合法品种的引用正确得初始化。
我们将自己从编译器的的一些限制中解放出来,但代价是我们得重新实现一些它曾为我们做的事情。
- ??? 在内部,C++ 虚方法通过一种叫做“虚函数表”(virtual function table)东西实现,简称“vtable”。一个虚函数表是一个包含了一个函数指针集合的简单结构体,每个函数指针指向类中的一个虚方法。每个类在内存中都有一个虚函数表。每个类实例都有一个指向其类虚函数表的指针。
- ??? 当你调用虚函数的时候,代码首先从对象的虚函数表中查找,然后调用存储在表里的恰当的函数。
- ??? 听起来很相似?虚函数表就是我们的品种对象,指向虚函数表的指针是怪物对其品种的引用。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_; // Starting health.
const char* attack_;
};
非常简单。它只是一个包含了两个数据字段的容器:初始生命值和攻击字符串。让我们看看怪物如何使用它:
class Monster
{
public:
Monster(Breed& breed)
: health_(breed.getHealth()),
breed_(breed)
{}
const char* getAttack()
{
return breed_.getAttack();
}
private:
int health_; // Current health.
Breed& breed_;
};
当我们构造一个怪物时,我们给它一个品种对象的引用。它定义怪物的品种,而不是用我们之前的类派生。在构造函数中,怪物使用品种来确定它的初始生命值。要获得攻击字符串,怪物只要转而调用它的品种。
这段简单的代码是这个模式的核心思想。从这里开始所有的东西都是额外奖励。
用我们现有的东西,我们直接构造了一个怪物并负责把它的品种传进去。这与大多数面向对象语言实例化对象的过程有点相反——我们通常不会分配一段空内存然后给它一个类型。反之,我们先在类上面调用了构造函数,它负责给我们一个新的实例。
我们可以将这个模式应用到类型对象上面:
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_; // Current health.
Breed& breed_;
};
关键的区别是Breed类里面的**newMonster()**函数。他是我们的“构造”工厂方法。在我们的原始实现中,创建一个怪物看起来是这样的:
-
??? 这里有另一个小区别。因为在C++实例代码中,我们可以使用一个方便的小特性:友元类。
-
??? 我们将怪物的构造函数定为私有,使得任何人都不能直接调用它。友元类绕开了这个限制,因此Breed仍然能够访问到它。这意味着创建怪物的唯一方法是通过newMonster()。
Monster* monster = new Monster(someBreed);
在修改过之后,它看起来是这样的:
Monster* monster = someBreed.newMonster();
那么,为什么要这么做呢?创建一个对象分为两步:分配内存和初始化。怪物构造函数让我们能够做所有的初始化操作。在例子里它被存进了品种里,但是整个游戏会加载图形、初始化怪物的AI然后做一些其他设定工作。
但是,这都发生在内存分配之后。我们在怪物的构造函数被调用之前,就已经有了一段内存。在游戏里,我们也希望能控制对象创建的另一方面:我们通常使用一些自定义分配器或者对象池模式来控制对象在内存中的哪个地方结束。
在Breed里定义一个“构造”函数让我们有个地方放置这个逻辑。并非简单得调用new,newMonster()函数能够在把控制权移交到初始化函数之前从一个池或者自定义堆栈里拉取。通过把此逻辑放进唯一能创建怪物的Breed里,我们保证所有的怪物都经过我们预想的内存管理体系。
我们现在已经实现了一个完全可用的类型对象系统,但是它还很基本。我们的游戏最终会有上千个种族,每一个都有一堆属性。如果一个设计师想要调整30多个巨魔品种,使他们更强一点而,她将要面对的会是一段无聊的工作。
一个有效的办法是像多个怪物通过品种共享多种特性一样,让品种之间也能够共享特性。就像我们在最初的面向对象方案那样,我们可以通过派生来实现。只是,我们不采用语言本身的派生机制,而是自己在类型对象内部实现它。
简单起见,我们只支持单继承。和一个用于基类的类一样,我们允许品种拥有一个基品种:
???? 代码
当我们构造一个品种时,我们给它一个传入一个基品种。我们可以传入NULL表示它没有祖先。
为了让它更有用,一个品种需要控制哪些特性从父类继承,哪些特性需要用它自己的。举个例子,只继承基品种中非零生命值的以及非NULL的攻击字符串。
有两种实现方式。一个是在属性每次被请求的时候执行代理调用,像这样:
???? 代码
这么做可以当品种在运行时修改后,即使不再有继承关系也能够正确执行。但另一方面,它占用更多的内存(必须保留一个指向父级的指针),而且更加慢。它必须在派生链上走一遍来查找一个属性。
如果我们确定品种的属性不会改变,一个更快的解决方案是在构造时应用继承。这被称为“复制”代理,因为我们在创建一个类型的时候把继承的属性复制到了这个类型内部。代码如下:
???? 代码
注意我们不再需要基类中的字段。一旦构造结束,我们就可以忘掉基类,因为他的属性已经被拷贝下来了。要访问一个品种的特性,现在我们只要返回它的字段。
???? 代码
又好又快!
假设游戏引擎从JSON文件创建品种。示例如下:
???? 代码
我们要写一段代码去读取每个品种项,然后用它里面的数据去创建实例。例子里巨魔基类是**“Troll”,Throll Archer和Troll Wizard**都是派生类。
因为这两个派生类的生命值都是0,所以这个值从父类继承。这意味着设计师能在Troll类中调整这个值,所有的三个品种都会一起更新。随着品种的数量和每个品种内部属性的增加,这能够节省很多时间。现在,通过一个非常小的代码段,我们完成了控制权在设计师手让他们能有效利用时间的一个开放系统。同时,我们可以不被打扰得编写其他功能。
类型对象模式让我们像在设计自己的编程语言一样射击一个类型系统。设计空间非常广阔,我们可以做很多有趣的事情。
事实上,有些事情限制了我们的美好期盼。时间和可维护性会阻止我们向任何特别复杂的方向走。更重要的是,无论我们设计了怎样的类型系统,我们的用户(通常是非程序员)需要能很容易地理解它。我们做的越简单,它就更加可用。所以,我们这里讲到的其实是个被反复践踏了的领域,把更深入的方向交给学者和爱探索的人吧。
我们的简单实现里,Monster 有一个对品种的引用,但这个引用不是公开的。外面的代码无法直接访问到怪物的品种。从核心代码????的角度来说,怪物都是无类型的,它们有品种这件事是实现细节。
我们可以做个修改,让Monster返回它的品种:
???? 代码
本书的另一个例子里,我们紧跟着进行了一个转换,返回引用而不是指针来告诉用户,永远不会返回NULL。
这么做修改了Monster的设计。这样怪物有品种这件事就在API中可见了。这对双方都有好处。
- 如果类型对象被封装:
- 类型对象模式的复杂性对代码库的其他部分不可见。它成为一个设计细节,只有有类型对象才关心它。
- 有类型对象可以选择性地重写类型对象的行为。比如说我们想把怪物濒死时的攻击字符串改掉。由于攻击字符串都是从Monster访问的,我们有个现成的位置可以写: ???? 代码 如果外部代码直接调用品种上的getAttack(),我们就没有机会插入这段逻辑。
- 我们得给类型对象暴露的所有内容提供外部访问接口。这部分很乏味。如果我们的类型对象有一大堆方法,对象类为了公开,也必须提供一一对应的一大堆方法。
- 如果类型对象被公开:
- 类型对象现在是对象公共API的一部分。通常,窄接口比宽接口更容易维护——你暴露给代码库的越少,你要面对的复杂性和维护共组就越少。
--- TODO
现在,我们假定一旦对象创建完成,就与其类型对象进行绑定,并不再改变。并不是一定要这样
。我们可以允许一个对象随时间改变类型。
回头看我们的例子。当一个怪物死的时候,设计师告诉我们他们希望尸体能够变成会动的僵尸。
我们可以通过重新产生一个带有僵尸品种的新怪,但另一个更简单的选择是获取现有的怪物并把
它的品种修改成僵尸。
- 如果类型不变:
- 无论编码还是理解起来都更简单。在概念层面上,“类型”是大多数人都不希望改变
的东西。这么做符合这条假定。 - 易于调试。如果我们在跟踪一个让怪物陷入奇怪状态的Bug时,能够直观地确定正在看
的品种肯定是怪物始终不变的品种,这件事就相对简单了。
- 如果类型改变:
- 更少的对象创建。在我们的例子里,如果类型不能改变,我们不得不在CPU循环中创建
新的僵尸怪物,把原怪物中需要保留的属性逐个拷贝过来,随后删除它。如果我们能改变类型,
所有的工作就是个简单的赋值。 - 做假定时要更加小心。对象和其类型之间存在相对紧的耦合。例如,一个品种可能假
定怪物的当前血量永远不会超过初始血量。 如果我们允许改变品种,我们需要确保现有对象能符合新类型的要求。当我们修改类型
时,我们可能会需要执行一些验证代码来保证对象现在的状态对新类型来说有意义。
####支持何种类型的派生?####
- 没有派生:
- 更简单。简单是最好的选择。如果你没有成堆的需要共享的类型对象,何必自找麻烦
呢? - 可能会导致重复劳动。我曾见过给设计师用的不支持派生的编辑系统。当你有50中精
灵,必须去50个地方把它们的血量修改成相同的数字非常无趣。
- 单继承:
- 仍然相对简单。更容易实现,但是,更重要的是,它很容易理解。如果非技术用户使
用这个系统,会动的部分越少,就越好。很多编程语言只支持单继承是有原因的。它看起来是强
大和简单之间的不错的平衡点。 - 属性查找更慢。要获得类型对象中的特定数据,我们需要在派生链中找到其类型,才
能最终确定它的值。如果我们在编写性能苛刻的代码,我们可能不想在这里浪费时间。
- 多重派生:
- 绝大多数的数据重复都能被避免。通过一个好的多继承系统,用户能够创建一个几乎
没有冗余的继承体系。比如做调整数值这件事,我们可以避免大量的复制粘贴。 - 复杂。很不幸的是,它的优点更多停留在理论上而不是实践上。多重派生很难理解或
说明。 如果我们的僵尸龙类型从僵尸和龙派生,哪些属性从僵尸获得,哪些从龙获得呢?为了
使用这个系统,用户必须理解派生图如何遍历并要有预见性地射击一个聪明的体系。 我所见到的大多数现代C++编码标准倾向于禁用多重派生,Java和C#则完全不支持。这承
认了一件不幸的事情:太难让它正确地工作以至于干脆不要用它。虽然它值得考虑,但是你很少
会希望在游戏的类型对象中使用多继承。常言道,越简单越好。
###参考###
- 这个模式引出的高级问题是如何在不同对象之间共享数据。另一个从另一个角度引出这个问题
的模式是原型
- 类型对象与享元很接近。它们都让你在实例间共享数据。享元模式倾向于节约内存,并且
共享的数据可能不会以实际的“类型”呈现。类型对象模式的重点在于组织性和灵活性。
- 这个模式与状态模式也有很多相似性。它们都把对象的部分定义交给另一个代理对象实现
。在类型对象中,我们通常代理的对象是: 宽泛地描述对象的恒定数据。在状态中,我们代理的是对象现在是什么样的,即:描述对象当前
配置的临时数据。
当我们讨论到可改变类型对象的时候,你会发现此时的类型对象兼任了状态的任务。