游戏编程模式笔记——第十四章 组件模式

通过创建一个类来支持新类型的灵活创建,其每个实例都代表一个不同的对象类型。

动机

设想我们在开发一款奇幻RPG游戏。我们的任务是为凶狠的怪物群编写代码,它们会追杀我们主角。怪物具有一系列属性:生命值、攻击力、图形效果、声音表现等,但我们仅以生命值和攻击力为例。
设计师告诉我们怪物的种类繁多,怪物的种类决定着怪物的初始生命值以及攻击字符串。

经典的面向对象方案

我们得到了一个Monster基类,其他怪物从Monster派生(参考继承里面的is a结构)。
很快事情陷入了泥沼。设计师最终设计了上百个种族,我们发现自己的时间几乎投入到了编写那短短7行代码长的派生类以及反复地重新编译。更糟糕的是——设计师想要调整代码中已经有的种族。

一种类型一个类

站在较高的层面上看,我们要解决的问题非常简单。游戏中有一堆不同的怪物,我们想让它们共享一些特性。我们通过将它们定义成相同的“种类”来实现,而这个种类就决定了其攻击的伤害表现。
游戏中每只怪物实例都将属性某一种派生的怪物种族。种族越多,类继承树就越大。这显然是个问题:添加新的种族意味着添加新的代码,而且每个种族不得不按照自己的类型来编译。
这么做是奏效的,但并非唯一的选择。我们可以重构我们的代码,使得每个怪物都“has a”种类。我们仅声明单个Monster类和单个Breed类,而不是从Monster派生出各个种族。
就两个类。注意这里没有任何派生,在这个系统里,游戏中的每个怪物是一个简单的Monster类的实例。Breed类包含了同一种族的所有怪物之间共享的信息:初始生命值和攻击字符串。
为了将怪物与种族关联起来,我们让每个Monster实例化一个包含了其种族信息的Breed对象的引用。为了获得攻击字符串,一个怪物只需要在这个引用上调用一个方法。Breed类本质上定义了怪物的“类型”。每个种族实例都是一个对象,代表着不同的概念类型,而这个模式的名字就是:类型对象。

类型对象模式

定义一个类型对象类和一个持有类型对象类。每个类型对象的实例表示一个不同的逻辑类型。每个持有类型对象类的实例引用一个描述其类型的类型对象。
实例数据被存储在持有类型对象的实例中,而所有同概念类型所共享的数据和行为被存储在类型对象中。引用同一个类型对象的对象之间能表现出“同类”的性状。这让我们可以在相似对象集合中共享数据和行为,这与类派生的作用有几分相似,但却无需硬编码出一批派生类。

使用情境

当你需要定义一系列不同“种类”的东西,但又不想把那些种类硬编码进你的类型系统时,本模式都适用。尤其是当下面任何一项成立的时候:

  • 你不知道将来会有什么类型
  • 你需要在不重新编译或修改代码的情况下,修改或添加新的类型。

使用须知

这个模式旨在将“类型”的定义从严格生硬的代码语言转移到灵活却弱化了行为的内存对象中。灵活性是好的,但是把类型转移到数据里仍有所失。

类型对象必须手动追踪

使用类型对象模式,我们不但要负责管理内存中的怪物,还要管理他们的类型,我们得保证只要有怪物存在,其对应的种族对象就应该被实例化并驻留于内存。一旦创建新的怪物,我们就必须确保它是一个有效种族示例的引用来进行正确的初始化。

为每个类型定义行为更困难

当我们改用类型对象的时候,我们用成员变量代替了方法重写。不再是派生出怪物类然后重写父类的方法来异化攻击字符串,而是定义另一个种族来存储攻击字符串。
这使得通过类型对象去定义类型相关的数据非常容易,但是定义类型相关的行为却很难。如假设不同的怪物种类需要采用不同的AI算法,那么使用这种模式将面临很大的挑战。
有几种方法可以跨越这个限制。一个简单的方法是创建一个固定的预定义行为集合,让类型对象的数据从中任选其一。我们可以定义函数来实现每种行为,然后我们可以在种类里放一个指向特定方法的指针与AI算法关联。
另一个更强大、更彻底的解决方法是支持在数据中定义行为。解释器模式和字节码模式都可以编译代表行为的对象。如果我们能读取数据文件并提供给上述任意一种来实现,行为定义就完全脱离了出来。而被放进数据文件内容中。

示例

首先从Breed类开始:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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_;
};

我们来看看怪物如何使用它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Monster
{
public:
Monster(Breed& breed)
: health_(breed.getHealth()),
breed_(breed) {}

const char* getAttack()
{

return breed_.getAttack();
}
private:
// Current health.
int health_;
Breed& breed_;
};

当我们构造一个怪物时,我们给它一个种族对象的引用。由此定义怪物的种族,取代之前的派生关系。
这段简单的代码是这个模式的核心思想。以下内容则都是额外的好处。

构造函数:让类型对象更加像类型

我们通常不会分配一段空内存然后给它一个类型。面向对象的思想是调用类自身的构造函数,由它负责为我们创建新的实例。
我们可以将这个模式应用到类型对象上面:

1
2
3
4
5
6
7
8
9
class Breed
{
public:
Monster* newMonster()
{

return new Monster(*this);
}
// Previous Breed code...
};

使用它们的类:(其实就是工厂模式)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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()函数。它是一个“构造器”工厂方法。在我们的原始实现中,创建一个怪物的过程是这样的:

1
Monster* monster = new Monster(someBreed);

在修改过后,它看起来是这样的:

1
Monster* monster = someBreed.newMonster();

为什么要这么做呢?创建一个对象分为两步:分配内存和初始化。Monster的构造函数让我们能够做所有的初始化操作。在例子中所做的仅仅是保存了一个种族的引用,但如果是完整的游戏,还需要加载图形、初始化怪物AI并进行其他设定工作。
但是这都发送在内存分配之后。我们在怪物的构造函数被调用前,就已经获得一种用于容纳它的内存。在游戏里,我们也希望能控制对象创建的这一环节:通常使用一些自定义内存分配器或者对象池模式来控制对象在内存中存在的位置和时机。
在Breed里定义一个“构造函数”让我们有地方实现这套逻辑。取代简单new操作的是,newMonster()函数能在控制权被移交至初始化函数前,从一个池或者自定义堆栈里获取内存。把此逻辑放进唯一能创建怪物的Breed里,就保证了所有的怪物都由我们预想的内存管理体系经手。

通过继承共享数据

我们的游戏最终会有上千个种族,每个都包含大量属性,调整一个种类,设计师可能需要面临海量的数据。
一个有效的方法是仿造多个怪物通过种族共享特性的方式,让种族之间也能够共享特性。就像我们在开篇的面向对象那样,我们可以通过派生来实现。只是,我们不采用语言本身的派生机制,而是自己在类型里实现它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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来表示它没有祖先。
为使其更实用,子种族需要明确哪些特性从父类继承,哪些特性由自己重写和特化。以我们的例子打比方,子种族只继承基种族中的非零生命值以及非NULL的攻击字符串。
实现方式有两种,一种是在属性每次被请求的时候执行代理调用,像这样:

1
2
3
4
5
6
7
8
9
10
int Breed::getHealth()
{
// Override.
if(health_ != 0 || parent == NULL)
{
return health_;
}
// Inherit.
return parent_->getHealth();
}

这么做的好处是,即使在运行时修改了种类、去掉种类继承或者去掉对某个特性的继承,它仍能够正常运作。但另一方面,它会占用更多的内存(必须保留一个指向父级的指针),而且更慢。因为为查找某个特性,它必须在派生链上进行遍历。
如果我们能够确保基种类的属性不会改变,那么一个更快的解决方法是在构造时采用继承。这也被称为“复制”代理,因为我们在创建一个类型时把继承的特性复制到了这个类型内部。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Breed(Breed* parent, int health, const char* attack) 
: health_(health), attack_(attack)
{
// Inherit non-overriden attributes.
if (parent != NULL)
{
if(health == 0) health_ = parent->getHealth();

if(attack == NULL)
{
attack_ = parent->getAttack();
}
}
}

注意我们不再需要基类中的属性了。一旦构造结束,我们就可以忘掉基类,因为它的属性已经被拷贝了下来。要访问一个种族的特性,现在只需要返回它自身的字段。

1
int getHealth() { return health_; }

随着种族数量和每个种族内部属性的增加,这能够节省很多时间。

设计决策

类型对象应该封装还是暴露

Monster类有一个对种类的引用,但这个引用不是公开的。外部代码无法直接访问到怪物的种族。从代码库的角度来说,怪物实时上是无类型的,而他们持有种类这个事只是个实现细节。
我们可以做个修改,让Monster类返回它的种族:

1
2
3
4
5
6
class Monster
{
public:
Breed& getBreed() { return breed_; }
// Existing code...
};

这么做改变了Monster的设计。如此每只怪物都有所属种类这件事就在API中可见了。不管采用哪种设计都是有优点的。
如果类型对象被封装

  • 类型对象模式的复杂性对代码库的其他部分不可见,它成为了持有类型对象才需关心的实现细节。
  • 持有类型对象的类可以有选择性地重写类型对象的行为。如果外部代码调用了种族上的API,我们就没有办法重写类型对象的行为了。我们得给类型对象暴露的所有内容提供转发函数。这部分工作是枯燥的。

如果类型对象被公开

  • 外部代码在没有持有类型对象类实例的情况下就能访问类型对象。
  • 类型对象现在是对象公共API的一部分。通过暴露类型对象,我们拓宽了对象的API,把类型对象提供的所有东西都包含了进来。

持有类型对象如何创建

  • 构造对象并传入类型对象
    外部可以控制内存分配。因为调用代码自己负责构造这两个对象,所以它能够控制其内存位置,如果我们想把对象用于不同的内存情景(不同的分配器、分配在堆栈上等),这种设计就完全支持。
  • 在类型对象上调用“构造”函数
    类型对象控制内存分配。如果我们不想让用户选择对象的内存位置,则类型对象上的工厂可以做到这一点,如果我们希望确保所有的对象都来自同一个特定的对象池或者内存分配器,那这么做就很有用。

类型能否改变

目前为止,我们假定对象一旦创建完成,就与其类型对象绑定,并从不再改变。对象的类型伴随着它的整个生命周期。而这并非必须。我们可以让对象动态改变类型。
类型不变

  • 无论编码还是理解起来都更简单。在概念层面上,“类型”是大多数人都不希望改变的东西。此方案正是基于这一假定。
  • 易于调试。

类型可变

  • 减少对象创建。如果我们能改变类型,简单赋值就完事了,否则只能创建新的实例。
  • 做约束时要更加小心。对象和其类型之间存在相对紧的耦合,如果允许改变种族,那么我们就需要确保现有对象能符合新类型的要求。当我们修改类型时,我们可能会需要执行一些验证代码来保证对象现在的状态对新类型来说有意义。

支持何种类型的派生

没有派生

  • 简单
  • 可能会导致重复劳动。

单继承

  • 仍然相对简单。它看起来是强大和简介之间不错的平衡点。
  • 属性查找会更慢。要获得类型对象中的特定属性我们需要在派生链中找到其类型。

多重派生

  • 能避免绝大多数的数据重复。通过一个好的多继承系统,用户能够创建一个几乎没有冗余的继承体系。我们可以避免大量的复制粘贴。
  • 复杂。多重派生难以理解或说明。