允许通过创建一个类来灵活地创建新“类”,每个实例都代表一种对象类型。
设想我们在开发一个奇幻RPG。我们的任务是编写部落里恶毒的怪物在追逐并试图杀死我们的勇士。怪物有些不同的特性:生命、攻击、图像、声音等等。但此例中,我们仅考虑前两项。
游戏中的每个怪物都有一个值表示当前生命。它一开始是满的,每当怪物受伤的时候,都会减掉一些。怪物们也都有一个攻击字符串。当怪物攻击主角的时候,这个文本会通过某种形式呈现给玩家。(在这里我们不考虑具体的方式。)
设计师告诉我们怪物的品种很多。比如“龙”和“巨魔”。每个品种描述游戏中的一类怪物,在同一个地下城中,任何一个品种的怪物都可以同时有好几个。
品种定义了怪物的初始生命值————龙一开始拥有比巨魔更多的生命,这使他们更难被杀死。另外定义了一个攻击字符串————表示同品种的所有怪物的攻击方式。
考虑好这个游戏设计之后,我们启动文本编辑器开始写代码。
根据上面的设计,一头龙是一种怪物,一个巨魔是另外一种,其他的品种以此类推。按面向对象的思路做,我们会有一个Monster基类:
这构成了一种“是”的关系,因为龙“是”Monster,建模时我们将Dragon定义成Monster的子类。我们知道,类派生只是在代码中实现这种概念关系的一种方式。
class Monster
{
public:
virtual ~Monster() {}
virtual const char* getAttack() = 0;
protected:
Monster(i nt starti ngHealth)
: health_(starti ngHealth)
{}
private:
int health_; // Current health.
};
公有的的**getAttack()**允许战斗代码获得怪物攻击主人公时要显示的字符串。每个派生类将会重写它来提供不同的消息内容。
构造函数是protected,它接收怪物的初始生命值作为参数。我们会从每个派生类自己的构造函数中调用它,并把这个品种的初始生命值传进去。
现在我们来看一对品种子类:
class Dragon : public Monster
{
public:
Dragon() : Monster(230) {}
virtual const char* getAttack()
{
return "The dragon breathes fi re! ";
}
};
class Troll : public Monster
{
public:
Troll() : Monster(48) {}
virtual const char* getAttack()
{
return "The troll clubs you! ";
}
};
感叹号让一些都更加激动人心!
每个从Monster派生的类都传入初始生命,并重写getAttack()来返回这个品种的攻击字符串。一切都和预想的一样,很快,主角就能够跑来跑去杀死各种怪物。我们继续编写代码,在我们意识到之前,就会发现有成堆的怪物派生类,从酸性史莱姆到僵尸山羊。
然后,工作很奇怪地慢了下来。我们的设计师最终想要有上百个品种,我们发现自己把所有的时间都投入到了编写那只有7行代码的派生类里。更糟糕的是————设计师想要调整代码中已经有的品种。我们的日常工作流程变成了下面这样:
- 收到设计师的邮件,要把巨魔的攻击力从48修改成52。
- 签出并修改Troll.h。
- 重新编译游戏。
- 签入修改。
- 邮件通知。
- 重复上述步骤。
我们开始迷茫,因为我们变成了填数据的猴子。我们的设计师也很迷茫,因为仅要调好一个数字就要花费大量的时间。我们需要一种无需重新编译整个游戏,就能修改品种数值的能力。如果设计师在无需程序员介入的情况下能创建并调整品种,那就更好了。
站在较高的层面上看,我们要解决的问题非常简单。游戏中有一堆不同的怪物,我们想让它们共享一些特性。一个部落中的怪物想要击败主角,我们要让它们在攻击时使用相同的文本。我们通过将它们定义成同样的“品种”来实现,而这个品种决定攻击字符串。
因为它们属于直觉上的类,因此我们决定使用派生来实现这个概念。一头龙是一只怪物,游戏中的每头龙是这个龙的“类”的实例。将每个种族定义成抽象基类Monster的派生类,让游戏中的每个怪物成为派生的品种类的实例来呈现它。我们最终会有下面这样的类层次:
游戏中每个怪物的实例,都属于一个派生的怪物类型。品种越多,类体系就越庞大。这就是问题的成因:添加新的品种意味着添加新的代码,每个品种必须经编译产生真正的类型。
这是可行的,但并不是唯一的选择。我们也可以将代码架构调整成每个怪物拥有一个品种。而不是让每个品种从Monster派生,一个Monster类对应一个品种类:
完成了。就两个类。注意这里没有任何派生。在这个系统里,游戏中的每个怪物是一个简单的Monster类的实例。Breed类包含了同一品种的所有怪物之间共享的信息:初始生命值和攻击字符串。
为了将怪物与品种关联起来,我们让每个Monster引用包含了品种信息的Breed。为了获得攻击字符串,一个怪物只需在它的品种上调用一个方法。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多个巨魔品种,使他们更强一点儿,她将要面对的会是一批无聊的工作。
一个有效的方法是仿照多个怪物通过品种共享多个特性的方式,让品种之间也能够共享特性。就像我们在开篇的面向对象方案那样,我们可以通过派生来实现。只是,我们不采用语言本身的派生机制,而是自己在类型对象里实现它。
简单起见,我们只支持单继承。和基类一样,品种都有一个基品种:
class Breed
{
public:
Breed(Breed* parent, i nt health, const char* attack)
: parent_(parent), health_(health), attack_(attack)
{}
int getHealth();
const char* getAttack();
private:
Breed* parent_;
int health_; // Starting health.
const char* attack_;
};
当我们构造一个品种时,我们先给它一个传入一个基品种。我们可以传入NULL表示它没有祖先。
为了让它更有用,一个品种需要控制哪些特性从父类继承,哪些特性用它自己的。举个例子,只继承基品种中非零生命值的以及非NULL的攻击字符串。
有两种实现方式。一个是在属性每次被请求的时候执行代理调用,像这样:
int Breed::getHealth()
{
// Override.
if (health_ != 0 || parent_ == NULL)
return health_;
//Inherit.
return parent_->getHealth() ;
}
const char* Breed::getAttack()
{
// Override.
if (attack_ != NULL || parent_ == NULL)
return attack_;
// Inherit.
return parent_->getAttack();
}
这么做的好处是即便在运行时修改了品种,去掉品种继承或者去掉对某个特性的继承,它能够正确工作。但另一方面,它会占用更多的内存(必须保留一个指向父级的指针),而且更慢。因为必须在派生链上走一遍来查找某个特性。
如果我们确定品种的特性不会改变,一个更快的解决方案是在构造时应用继承。这也被称为“复制”代理,因为我们在创建一个类型时把继承的特性复制到了这个类型内部。代码如下:
Breed(Breed* parent, int health, const char* attack)
: health_(health), attack_(attack)
{
// Inherit non-overridden attributes.
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 Wi zard": {
"parent": "Troll",
"health": 0,
"attack": "The troll wizard casts a spell on you! "
}
}
我们有段代码会读取每个品种项,用其中的数据创建实例。例子中巨魔基类是**“Troll”,Throll Archer和Troll Wizard**都是派生类。
因为这两个派生类的生命值都是0,所以这个值从父类继承。这意味着设计师能在Troll类中调整这个值,所有三个品种都会一起更新。随着品种的数量和每个品种内部属性的增加,这能够节省很多时间。现在,通过一个非常小的代码段,我们确保控制权能向设计师移交,完成了一个能让他们能有效利用时间的开放系统。同时,我们可以不被打扰地编写其他功能。
类型对象模式让我们像在设计自己的编程语言一样设计一个类型系统。设计空间非常广阔,我们可以做很多有趣的事情。
事实上,有些事情限制了我们的美好期盼。时间和可维护性会阻止我们向任何特别复杂的方向前进。更重要的是,无论我们设计了怎样的类型系统,用户(通常是非程序员)需要能容易地理解它。我们做的越简单,它就越可用。所以,我们这里讲到的其实是个需要反复推敲的领域,就把更深入的方向交给学者和爱探索的人吧。
我们的简单实现里,Monster有一个对品种的引用,但这个引用不是公开的。外面的代码无法直接访问到怪物的品种。从整个代码库的角度来说,怪物是无类型的,它们有品种这件事是实现细节。
我们可以做个修改,让Monster返回它的品种:
class Monster
{
public:
Breed& getBreed() { return breed_; }
// Existing code. . .
};
本书的另一个例子里,我们紧跟着进行了一个转换,返回引用而不是指针来,这让用户知道返回值永远不会是NULL。
这么做修改了Monster的设计。如此以来怪物有品种这件事就在API中可见了。不管采用哪种设计都是有优点的。
- 如果类型对象被封装:
- 类型对象模式的复杂性对代码库的其他部分不可见。它成为一个设计细节,只有有类型对象才关心它。
- 有类型对象可以选择性地重写类型对象的行为。比如说我们想把怪物濒死时的攻击字符串改掉。由于攻击字符串都是从Monster访问的,我们有个现成的位置可以写:
如果外部代码直接调用品种上的getAttack(),我们就没有机会插入这段逻辑。const char* Monster::getAttack() { if (health_ < LOW_HEALTH) { return "The monster flails weakly."; } return breed_.getAttack(); }
- 我们得给类型对象暴露的所有内容提供转发函数。这部分做起来很无聊。如果我们的类型对象类有一大堆方法,对象类为了公开,也必须提供一一对应的一大堆方法。
- 如果类型对象被公开:
- 外部代码在没有有类型对象实例的情况下就能访问类型对象。如果类型对象被封装,那么就无法在没有有类型对象包裹的情况下使用它。这样一来,调用品种方法去实例化新怪物的构造模式,就不可行了。因为用户无法直接获得品种,那么他们也就没法调用它。
- 类型对象现在是对象公共API的一部分。通常,窄接口比宽接口更容易维护————你暴露给代码库的越少,你要面对的复杂性和维护工作就越少。通过暴露类型对象,我们拓宽了对象的API,把类型对象提供的东西都包含了进来。
通过这种模式,每个“对象”现在都成了一对对象:主对象以及它所使用额类型对象。那么我们如何创建并将它们绑定起来呢?
- 构造对象并传入类型对象:
- 外部代码可以控制内存分配。因为调用代码自己负责构造这两个对象,它能够控制其内存位置。如果我们想把对象用于各种不同的内存情景(不同的分配器,分配在堆栈上等),这种设计就完全支持。
- 在类型对象上调用“构造”函数
- 类型对象控制内存分配。这是硬币的反面。如果我们不想让用户选择对象的内存位置,类型对象上的工厂方法可以做到这一点。如果我们希望所有的对象都来自同一个特定的对象池或者内存分配器时,这么做就很有用。
到目前为止,我们假定对象一旦创建完成,就与其类型对象绑定,并从不再改变。对象的类型伴随它的整个生命周期。而这并非必须。我们可以让对象动态改变类型。
回头看看我们的例子。当一个怪物死的时候,设计师希望尸体能变成会动的僵尸。我们可以通过创建一个僵尸类型的新怪物来实现这个需求,但是另一个选择把死去怪物的品种修改成僵尸。
- 如果类型不变:
- 无论编码还是理解起来都更简单。在概念层面上,“类型”是大多数人都不希望改变的东西。此方案符合这个假定。
- 易于调试。如果我们在定位一个让怪物陷入奇怪状态的Bug,能肯定怪物的品种始终不变,这件事就相对简单了。
- 如果类型改变:
- 更少的对象创建。前面的例子里,如果类型不能改变,我们得在CPU循环中创建新的僵尸怪物。把原怪物中需要保留的属性逐个拷贝过来,随后删除它。如果我们能改变类型,简单地赋个值就完事了。
- 做假定时要更加小心。对象和其类型之间存在相对紧的耦合。例如,一个品种可能假定怪物的当前血量永远不会超过初始血量。如果我们允许改变品种,我们需要确保现有对象能符合新类型的要求。当我们修改类型时,我们可能会需要执行一些验证代码来保证对象现在的状态对新类型来说有意义。
- 没有派生:
- 更简单。简单是最好的选择。如果你没有成堆的需要共享的类型对象,何必自找麻烦呢?
- 可能会导致重复劳动。我曾见过给设计师用的不支持派生的编辑系统。当你有50种精灵,必须去50个地方把它们的血量修改成相同的数字,这就非常无趣。
- 单继承:
- 仍然相对简单。更容易实现,但更重要的是,它很容易理解。如果非技术用户使用这个系统,要操作的部分越少就越好。很多编程语言只支持单继承是有原因的。它看起来是强大和简洁之间不错的平衡点。
- 属性查找会更慢。要获得类型对象中的特定数据,我们需要在派生链中找到其类型,才能最终确定它的值。如果我们在编写性能苛刻的代码,我们可能不想在这里浪费时间。
- 多重派生:
- 绝大多数的数据重复都能被避免。通过一个好的多继承系统,用户能够创建一个几乎没有冗余的继承体系。比如做调整数值这件事,我们可以避免大量的复制粘贴。
- 复杂。很不幸的是,它的优点更多停留在理论上而不是实践上。多重派生很难理解或说明。 如果我们的僵尸龙类型从僵尸和龙派生,哪些属性从僵尸获得,哪些从龙获得呢?为了使用这个系统,用户必须理解派生图如何遍历并要有预见性地设计一个聪明的体系。 我所见到的大多数现代C++编码标准倾向于禁用多重派生,Java和C#则完全不支持。这承认了一件不幸的事情:太难让它正确工作以至于干脆不要用它。虽然它值得考虑,但是你很少会希望在游戏的类型对象中使用多继承。常言道,越简单越好。
- 这个模式引出的高级问题是如何在不同对象之间共享数据。另一个从另一个角度引出这个问题
的模式是原型
-
类型对象与享元很接近。它们都让你在实例间共享数据。享元模式倾向于节约内存,并且共享的数据可能不会以实际的“类型”呈现。类型对象模式的重点在于组织性和灵活性。
-
这个模式与状态模式也有很多相似性。它们都把对象的部分定义交给另一个代理对象实现。在类型对象中,我们通常代理的对象是: 宽泛地描述对象的恒定数据。在状态中,我们代理的是对象现在是什么样的,即:描述对象当前配置的临时数据。
当我们讨论到可改变类型对象的时候,你会发现此时的类型对象兼任了状态的任务。
===============================
[上一节](04.2-Subclass Sandbox.md)
[下一节](05-Decoupling Patterns.md)