游戏编程模式笔记——第十三章 类型对象

“使用基类提供的操作集合来定义子类中的行为。”

动机

每个孩子都有一个超级英雄梦,但是理想很丰满,现实很骨感。玩游戏或许是令你成为超级英雄的最佳途径。因为游戏设计师从来不会说“不”,我们的超级英雄游戏目标是提供成百上千种不同的超能力以供玩家选择。
我们的计划是建立一个Superpower基类,然后,我们有一个派生类来实现各种超能力。我们将把设计文档分摊给团队中的程序员进行编码。完成时就将得到数以百计的superpower类。
这就意味着这些superpower子类几乎能够做任何事情:播放音效、产生视觉效果、与AI交互、创建和销毁其他游戏实体以及产生物理效果。它们将触及代码库的每一个角落。
如果发动我们的团队开始编写superpower类,那将会发生什么呢?

  • 这将产生大量冗余代码。
  • 游戏引擎的每个部分都将与这些类产生耦合。
  • 当这些外部系统需要改变的时候,superpower的代码很有可能遭到随机性的破坏。
  • 定义所有superpower都遵循的不变量是很困难的。

我们需要的是给每个设计superpower的游戏逻辑程序员一系列可用的基本操作函数。我们通过把这些操作设置成superpower基类的、受保护的方法来实现。把它们放在基类就能让每个Power子类简单而直接地访问这些方法。把它们设置为受保护状态(而且很可能是非虚的)来交互,以供子类调用,这正是它们存在的意义。
我们为此定义一种沙盒方法,这个是子类必须实现的抽象保护方法。在有了这些之后,为实现一种新的power,你要做的就是:

  1. 创建一个继承自Superpower的新类。
  2. 覆写沙盒函数activate()。
  3. 通过调用Superpower提供的保护函数来实现新类方法的函数体。

我们通过将基础操作提取到更高的层次来解决冗余代码问题。当我们发现在子类中存在大量重复代码时,我们就会把它向上移到Superpower中作为一个新的可用基本操作。
Superpower最终将与不同的游戏系统耦合,但我们的上百个子类则不会,它们仅与基类耦合。当这些游戏系统中的某部分变化时,对Superpower进行修改可能是必须的,但是这些子类则不应被改动。
这个设计模式会催生一种扁平的类层次架构。你的继承链不会太深,但是会有大量的类与Superpower挂钩。通过使一个类派生大量的直接子类,我们限制了该代码在代码库里的影响范围。游戏中大量的类都会获益于我们精心设计的Superpower类。

沙盒模式

一个基类定义了一个抽象的沙盒方法和一些预定义的操作集合。通过将它们设置为受保护的状态以确保它们仅供子类使用。每个派生出的沙盒子类根据父类提供的操作来实现沙盒函数。

使用情境

  • 你有一个带有大量子类的基类。
  • 基类能够提供所有子类可能需要执行的操作集合
  • 在子类之间有重叠的代码,你希望在它们之间更简便地共享代码。
  • 你希望使这些继承类与程序其他代码之间的耦合最小化。

使用须知

近些年“继承”一词容易被部分程序圈所诟病,原因之一是基类会衍生越来越多的代码。这个模式尤其受这个因素的影响。
由于子类是通过他们的基类来完成各自功能的,因此基类最终会与那些需要与其子类交互的任何系统产生耦合。当然,这些子类也与它们的基类密切相关。这个蜘蛛网式的耦合使得无损地改变基类是很困难的——你遇到了脆弱的基类问题。
而从另一个角度来说,你所有的耦合都被聚集到了基类,子类现在便与其他部分的代码划清了界限。理想状态下,你的绝大部分操作都在子类中。这意味着你的大量的代码库是独立的,并且更易于维护。
如果你仍然发现本模式正把你得基类变得庞大不堪,那么请考虑把一些提供的操作提取到一个基类能够管理的独立的类中。这里组件模式(第14章)能够提供有所帮助。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Superpower
{
public:
virtual ~Superpower() {}
protected:
virtual void activate() = 0;
void move(double x, double y, double z)
{

// Code here...
}
void playSound(SoundId sound)
{

// Code here...
}
void spawnParticles(ParticleType type, int count)
{

// Code here...
}
};

activate()就是沙盒函数。由于它是抽象虚函数,因此子类必须要重写它。这是为了让子类实现着能够明确它们该对power子类做些什么。
其他的受保护函数move()、playSound()和spawnParticles()都是所提供的操作。这些就是子类在”activate()”函数实现时能够调用的函数。
现在让我们创造一些放射性蜘蛛并创建一个power类。

1
2
3
4
5
6
7
8
9
10
class SkyLaunch : public Superpower
{
protected:
virtual void activate()
{

move(0,0,20); // Spring into the air.
playSound(SOUND_SPROING);
spawnParticles(PARTICLE_DUST, 10);
}
};

同时你可以在基类中定义角色的状态,由于可以访问状态,因此在沙盒函数可以做写实际而有趣的控制流。通过将沙盒方法变成一个可包含任意代码的成熟方法,便可具备无线潜力。

设计决策

子类沙盒是一个相当“温和”的模式。它描述了一个基本思想,但并没有给出过于详细的机制。这就意味着你每次应用它的时候,将面临一些抉择,大概包括如下的几个问题:

需要提供什么操作

一个极端看,基类不提供任何操作。为实现它你将不得不调用基类之外的系统。另一个极端是,基类为子类提供所需的所有操作。在这两种极端之间,有一个很宽阔的中间地带。如果你有一堆与外部系统耦合的继承类的话,那么就可以使用这个模式。通过把耦合提取进一个操作方法,你将它们聚集到了一个地方——基类。但是你越是这么做,基类就变得越大且越来越难以维护。
因此你该如何做出选择呢?这里有些经验法则:

  • 如果所提供的操作仅仅被一个或者少数的子类所使用,那么不必将它加入基类。这只会给基类增加复杂度,同时影响每个子类,而仅有少数子类从中受益。
  • 当你在游戏的其他模块进行某个方法调用时,如果它不修改任何状态,那么它就不具备侵入性。它仍然产生了耦合,但这是个“安全”(加引号因为存在多线程情况下也有可能出错)的耦合,因为在游戏中它不带来任何破坏。而另一个方面,如果这些调用确实改变了状态,则将与代码库产生更大的耦合,你需要对这些耦合更上心。因为此时这些方法更适合由更可视化的基类提供。
  • 如果提供的操作,其实现仅仅是对一些外部系统调用的二次封装,那么它并没有带来多少价值。在这种情况下,直接调用外部系统更为简单。
    然而,极其简单的转向调用也扔有用——这些函数通常访问基类不想直接暴露给子类的状态。例如:
    1
    2
    3
    4
    void playSound(SoundId sound)
    {

    soundEngine_.play(sound);
    }

它仅仅转向调用了Superpower中的某个soundEngine_字段。这样的好处是把这个域封装在Superpower,以免子类直接接触它。

是直接提供函数,还是由包含它们的对象提供

这个设计模式的挑战在于最终你得基类可能塞满了方法。你能够通过转移一些函数到其他类中来缓解这种情况,并与基类的相关操作中返回相应的类对象即可。
例如:

1
2
3
4
5
6
7
8
9
class Superpower
{
protected:
void playSound(SoundId sound) { /* Code... */}
void stopSound(SoundId sound) { /* Code... */}
void setVolume(SoundId sound) { /* Code... */}

// Sandbox method and other operations...
};

但是如果superpower已经变得臃肿不堪,那么我们也许想避免这样做。反而,我们创建一个SoundPlayer类来暴露这种功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class SoundPlayer
{
void playSound(SoundId sound) { /* Code... */}
void stopSound(SoundId sound) { /* Code... */}
void setVolume(SoundId sound) { /* Code... */}
};
class Superpower
{
protected:
SoundPlayer& getSoundPlayer()
{

return soundPlayer_;
}
// Sandbox method and other operations...
private:
SoundPlayer soundPlayer_;
};

把提供的操作分流到一个像这样的辅助类中能给你带来些好处:

  • 减少了基类的函数数量。
  • 在辅助类中的代码通常更容易维护。
  • 降低了基类和其他系统之间的耦合。这个例子中把音效转移到SoundPlayer中减少了Superpower对单个SoundPlayer类的耦合,SoundPlayer会自行封装其他的依赖关系。

基类如何获取其所需的状态

你得基类希望封装一些数据以对子类保持隐藏。在我们的第一个例子中,Superpower类提供了一个spawnParticles()方法。如果这个方法的实现需要一些粒子系统的对象,那么它该如何获得?

  • 把它传递给基类构造函数
    这安全地保证了每个superpower在它构造的时候能够得到一个例子系统。但是我们来看看子类:

    1
    2
    3
    4
    5
    6
    class SkyLaunch : public Superpower
    {
    public:
    SkyLaunch(ParticleSystem* particles)
    : Superpower(particles) {}
    };

    问题来了。每个子类都需要将一个构造函数来调用基类的构造函数并传入那个粒子系统参数。这样就向每个子类暴露了一些我们并不希望暴露的状态。
    这样做也存在维护负担。如果后面为基类添加另一个状态,那么我们不得不修改每个继承类的构造函数来传递它。

  • 进行分段初始化
    为了避免上述问题,我们可以把初始化拆分为两个步骤。构造函数将不带参数,仅仅负责创建对象。然后,我们调用一个直接定义在基类的函数来传递它所需的其他数据。

    1
    2
    Superpower* power = new SkyLaunch();
    power->init(particles);
  • 将状态静态化
    我们可以声明这个状态为基类私有成员,同时也是静态的。这样所有power对象都共享了particles_对象。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    class Superpower
    {
    public:
    static void init(ParticleSystem* particles)
    {

    particles_ = particles;
    }
    // Sandbox method and other operations...
    private:
    static ParticleSystem* particles_;
    };
  • 使用服务定位器
    另外一个选择是让基类把它需要的状态拉进去进行处理。一个实现方法是使用服务器定位器模式。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    class Superpower
    {
    protected:
    void spawnParticles(ParticleType type, int count)
    {

    ParticleSystem& particles = Locator::getParticles();
    particles.spawn(type, count);
    }
    // Sandbox method and other operations...
    };