#目的 使用一个基类提供的操作集合进而在子类中定义行为
#动机 每个小孩都有一个成为超级英雄的梦想,但是很不幸,宇宙射线在地球上供应不足。游戏或许是令你成为超级英雄的最佳之地。因为我们的游戏设计师从来不会说,“不”, 我们的超级英雄游戏意在提供至少十种或百种不同的能力以供玩家选择。
我们的计划是将有一个Superpower基类,然后,我们将有一个实现各个超级力量的继承类。我们将把设计文档分摊给团队中的程序员并进行编码。当我们完成的时候,我们将有数以百计的超级力量的类。
注解
当你发现自己像这个例子一样有大量的子类的时候,这意味着一种数据驱动的方法可能更适合。试着找到一种定义数据的行为的方法,而不是用大量的代码来定义不同的力量。
像模式[Type Object](./04.3-Type Object.md), Bytecode 和 Interpreter 或许能有所帮助。
我们想让玩家沉寖在一个复杂多变的世界里。无论他们小时候梦想过的什么力量,在我们的游戏里都有。这就意味着这些超级力量子类能够几乎做任何事情:播放音效,产生视觉效果,与AI交互,创建和销毁其他游戏实体以及产生物理效果。它们将涉及代码库的绝大部分内容。
让我们的团队就这么放手开始写这些子类,会发生什么呢?
-
会充满大量的冗余代码。尽管不同的力量将有所不同,我们也能料到其中必有不少冗余。他们中的多数将以同样的方式来产生视觉效果和播放音效。当你完成冰冻射线,热射线,第戎芥末射线这些射线时,会发现它们在实现上极其相似。如果人们在实现它们时没有整合起来,那么将会有大量重复的代码和付出。
-
游戏引擎的每个部分将与这些类产生耦合。在未深入了解之前,人们所写的代码会调用到那些可能与超级力量类毫无绑定关系的系统。如果我们的渲染器被组织成一些漂亮优雅的分层,只有其中的一层能够被图形引擎之外的代码使用,我们可以打赌最后将留下侵入到他们所有层的超级力量代码。
-
当这些外部系统需要改变的时候,超级力量代码将很可能被随机性地破坏。一旦我们的各种超级力量类与游戏引擎的各个零散部分产生耦合,改变这些系统无疑将影响这些超级力量类。这可不好玩,因为你的图形,音效,UI程序员可不想同时做游戏程序员的工作。
-
定义所有超级力量都遵守的约束条件很困难。例如说我们想保证所有我们超级力量播放的音效得到合理的排队和优先级处理。如果我们的百来个类都自己直接地调用音效引擎的话,这将很难实现。
我们需要的是给每个实现一个超级力量的游戏程序员一系列可用的基本元。你想要你的力量播放音效吗?那就提供给你playSound()
函数。想要粒子效果吗?这里有spawnParticles()
。我们将保证这些操作覆盖你所有的需求,这样一来你就不必滥用#include
来包含某些力量类,也不必去探究代码基的余下部分。
我们通过把这些操作设置成Superpower
基类的保护方法来实现。把它们放在基类就能让每个力量子类直接简单地访问这些方法。把它们设置为保护状态(并且可能是非虚拟的)来交互,使得它们仅仅作为子类可调用的方法而存在。
我们已经有了玩偶,现在是时候把它们置入游戏中了。为此我们定义一个沙盒方法,这是一个子类必须实现的抽象保护方法。在有了这些之后,为了实现一种新的力量,你要做的就是:
- 创建一个继承自
Superpower
的新类。 - 覆盖沙盒函数
activate()
。 - 通过调用
Superpower
提供的保护函数来实现新类方法的函数体。
我们通过尽可能地提高将可用操作的层面来解决代码冗余的问题。当我们发现在大量子类中存在重复代码,我们可以把它向上移到Superpower
中作为一个可用的新操作。
我们已经通过把耦合限制在一处来集中耦合问题。Superpower
最终将与不同的游戏系统耦合,但我们的上百个子类不会,它们仅与基类耦合。当这些游戏系统中的一个变化时,对Superpower
进行修改可能是必须的,但是这些大量的子类不应被改动。
这个设计模式会催生一种浅而宽的类层次架构。你的继承链不会深,但是会有大量的类挂在Superpower
上。通过生成一个有大量直接子类的单个类,我们在代码基里就有一只单点杠杆。我们在Superpower
中所付出的心血都将对游戏中大量的类带来益处。
注解
近来,你发现人们对面向对象语言的继承进行批判。继承是有问题的 -- 在代码基中没有比基类与子类之间更深的耦合了 -- 但是我发现宽的继承树比深的要表现的更好。
一个__基类__定义了一个抽象的__沙盒方法__和一些__提供的操作__。通过设置他们为保护状态来保证它们仅供子类使用。每个继承的__沙盒子类__针对父类提供的操作来实现沙盒函数。
沙盒模式是运用在多数代码库里甚至游戏之外的一种非常简单通用的模式。如果你有一个非虚拟的保护函数,那么你很有可能正在使用与之相类似的模式。沙盒模式在以下情况比较适用:
-
你有一个带有大量子类的基类。
-
基类能够提供所有子类可能需要执行的操作集合。
-
在子类之间有重叠的代码,你想让它们之间更容易地共享代码。
-
你想使这些继承类与程序的其他代码之间的耦合最小化。
#使用须知 “继承”一词在近代的一些程序圈子里被诟病,其中一个原因是基类会滋生越来越多的代码。这个模式尤其受这个因素的影响。
由于子类是通过它们的基类来实现剩下的游戏,基类最终会与那些需要与其子类交互的系统产生耦合。当然,这些子类也与他们的基类绑定。这个蜘蛛网式的耦合使得无损地改变基类是很困难的 -- 你遇到了脆弱的基类问题。
而从好的角度来说,你所有的耦合都被聚集到了基类,子类现在明显地与其他世界更加独立了。理想状态下,你的绝大部分操作都在子类中。这意味着你的大量的代码库是独立的,并且更易于维护。
如果你仍然发现这个模式正把你的基类浸入一大锅代码种时,请考虑把一些提供的操作提取到一个基类能够管理的独立的类中。这里组件模式能够有所帮助。
#示例 由于这是一个如此简单的设计模式,并没有多少的示例代码。这不意味着它没有用 -- 这个模式的实现关乎的是其意义而不是其复杂度。
我们将从我们的Superpower
基类开始:
class Superpower
{
public:
virtual ~Superpower() {}
protected:
virtual void activate() = 0;
void move(double x, double y, double z)
{
// Code here...
}
void playSound(SoundId sound, double volume)
{
// Code here...
}
void spawnParticles(ParticleType type, int count)
{
// Code here...
}
};
函数activate()
就是沙盒函数。由于它是虚拟和抽象的,子类必须要覆盖它。这是为了让子类使用者能够明确他们该对自己的特殊力量子类做些什么。
另外的保护函数move()
, playSound()
和spawnParticles()
都是提供的操作。这些就是子类需要在activate()
函数实现时将调用的函数。
我们没有在这个示例中实现提供的操作,但是一个实际的游戏需要有真实的代码在那儿。这个函数是Superpower
在游戏中与其他系统耦合的地方 -- move()
函数也许会调用物理引擎代码,playSound()
将与音效引擎通讯等等。由于所有的这些都是在基类的实现中,这就使得所有的耦合都封装在Superpower
自己中。
好啦,现在让我们放出放射性蜘蛛并创建一个力量。这就有一个:
class SkyLaunch : public Superpower
{
protected:
virtual void activate()
{
// Spring into the air.
playSound(SOUND_SPROING, 1.0f);
spawnParticles(PARTICLE_DUST, 10);
move(0, 0, 20);
}
};
注解
好啦,也许能够跳跃并不足以算是超能力,但是这里我尝试保持事情基础化。
这个力量把超级英雄弹向空中,播放一段恰当的音效并踢开一缕拂尘。如果所有的超级力量都如此简单 -- 仅仅是音效,粒子效果和动作的组合,那么我们就不再需要这个模式了。反而,Superpower
可以自带一个activate()
的实现,这个activate()
是访问音效ID,粒子类型和移动的部分。但是这个在仅当所有的力量基本上以同样的方式来工作仅仅在数据上有一些差异的地方才有效。让我们更详细的看一下:
class Superpower
{
protected:
double getHeroX()
{
// Code here...
}
double getHeroY()
{
// Code here...
}
double getHeroZ()
{
// Code here...
}
// Existing stuff...
};
这里我们添加了一个方法用于获取英雄的位置。我们的SkyLaunch
子类现在可以使用这些:
class SkyLaunch : public Superpower
{
protected:
virtual void activate()
{
if (getHeroZ() == 0)
{
// On the ground, so spring into the air.
playSound(SOUND_SPROING, 1.0f);
spawnParticles(PARTICLE_DUST, 10);
move(0, 0, 20);
}
else if (getHeroZ() < 10.0f)
{
// Near the ground, so do a double jump.
playSound(SOUND_SWOOP, 1.0f);
move(0, 0, getHeroZ() - 20);
}
else
{
// Way up in the air, so do a dive attack.
playSound(SOUND_DIVE, 0.7f);
spawnParticles(PARTICLE_SPARKLES, 1);
move(0, 0, -getHeroZ());
}
}
};
由于我们可以使用一些状态,现在我们的沙盒函数可以做一些实际的有趣的控制流。这里仍然仅仅是一些简单的if语句,但是你可以做任何你想做的事情。通过使沙盒函数成为一个包含任意代码的切实丰富的函数,将具备无限的潜力。
注解
起初,我建议对力量类采用数据驱动的方式。此处就是一个你决定不采用它的原因。如果你的行为是复杂和紧急的,定义数据将更困难。
#设计决策 正如你所见,子类沙盒模式是一个相当“弱化”的模式。它描述了一个基本的思想,但并没有给出过于详细的机制。这就意味着你每次应用它的时候将面临一些抉择,可能就是如下的几个问题:
##需要提供什么操作? 这是最大的问题。这深深地影响了这个模式的样貌以及它的表现如何。从小来说,基类不提供任何操作。它仅仅有一个沙盒函数。为了实现它,你将不得不调用基类之外的系统。如果从这个角度来说,说你正在用这个模式恐怕有些牵强。
而从大来讲,基类提供了子类需要的所有操作。子类仅仅与基类耦合并且不调用任何外部系统。
注解
具体来说,这意味着每个子类的源文件仅仅需要#include其基类的头文件即可。
在这两种极端之间,有一个很宽阔的中间地带。在这个空间里,一些操作由基类提供,另外一些则通过定义它的外部系统直接访问。基类提供越多的操作,子类与外部系统耦合越少,但是基类就耦合得越多。它去掉了继承类的耦合,但是它是通过把耦合聚集到基类自己来实现的。
如果你有一堆与外部系统耦合的继承类的话,那么就可以使用这个模式,通过把耦合向上移到一个提供的操作,你就把它聚集到了一个地方:基类。但是你这样做得越多,基类就变得越大和越来越难于维护。
因此你的准绳应该摆在何处?这里有一些经验法则:
-
如果所提供的操作仅仅被一个或者少数的子类使用,那么不必将它加入基类这只会给基类增加复杂度,同时将影响每个子类,而仅有少数子类从中受益。
使这个操作与其他操作保持一致或许有价值,而者使这些特殊情况的子类直接调用外部系统或许更简单清晰。
-
当你调用游戏中一些其他部分的函数的时候,如果那个函数不修改任何状态那么它就不会具备侵入性。它仍然创建了耦合,但是这是一个“安全”的耦合,因为在游戏中它不带来任何破坏。
注解
带引号的"安全"意指,即使是访问数据也能引起问题。如果你的游戏是多线程的,你可以在数据被修改的同时读取数据。如果你不小心,最终得到的将是错误的数据。
另一个令人不快的情况是如果你的游戏状态是严格准确的(许多在线游戏为了保持玩家同步),而你访问一些同步游戏状态之外的东西,则将引起非常严重的非确定性bug。
而另一方面,如果这些调用确实改变了状态,则将与代码库产生更深层次的绑定,你需要对它有更多的了解,因为这些方法更适合于成为在可见的基类中来提供。
-
如果提供的操作其实现仅仅是对一些外部系统调用的二次封装,那么它并没有带来多少价值。在这种情况下,直接调用外部系统更为简单。
然而,极其简单的转向调用也仍有用 -- 这些函数通常访问基类不想直接暴露给子类的状态。例如,让我们看看
Superpower
提供的这个:void playSound(SoundId sound, double volume) { soundEngine_.play(sound, volume); }
它仅仅在Superpower中转向调用一些soundEngine_区域。这样的好处是把这种区域封装在
Superpower
,以免子类访问它。
##是否直接提供函数,还是通过包含它们的对象来提供? 这个设计模式的挑战在于最终你的基类塞满了大量的方法。你能够通过转移一些函数到其他类中来缓解这种情况。在基类中提供的函数然后仅仅返回这些对象之一。
例如,为了使一个力量类播放音效,我们能够直接添加这些到Superpower
中:
class Superpower
{
protected:
void playSound(SoundId sound, double volume)
{
// Code here...
}
void stopSound(SoundId sound)
{
// Code here...
}
void setVolume(SoundId sound)
{
// Code here...
}
// Sandbox method and other operations...
};
但是如果Superpower
已经变得臃肿不堪,我们或许想避免这样做。反而,我们创建一个SoundPlayer
类来暴露这种功能:
class SoundPlayer
{
void playSound(SoundId sound, double volume)
{
// Code here...
}
void stopSound(SoundId sound)
{
// Code here...
}
void setVolume(SoundId sound)
{
// Code here...
}
};
然后Superpower
提供它的访问:
class Superpower
{
protected:
SoundPlayer& getSoundPlayer()
{
return soundPlayer_;
}
// Sandbox method and other operations...
private:
SoundPlayer soundPlayer_;
};
把提供的操作分流到一个像这样的辅助类中能给你带来一些东西:
-
减少基类的函数数量。在这里的例子中,我们从三个函数变成仅仅一个获取函数。
-
在帮助类中的代码通常更容易维护。像
Superpower
这样的核心基类,不论是否处于我们的意思,都因大量的依赖于它们而难于修改。通过把功能转移到一个耦合更少的第二候选类,我们可以使它的代码在不破坏的情况下更易于访问。 -
减少了基类和其他系统之间的耦合。当
playSound()
是一个直接定义在Superpower
上的函数时,无论实现中调用了什么音效代码,我们基类就直接地与SoundId
绑定了。把它转移到SoundPlayer
减少了Superpower
对单个SoundPlayer
类的耦合,SoundPlayer
会封装其他的依赖。
##基类如何获取需要的状态?
你的基类经常需要一些数据来封装和保持对子类的隐藏。在我们的第一个例子中,Superpower
类提供了一个spawnParticles()
方法。如果这个方法的实现需要一些粒子系统对象,它该如何获得?
- 把它传递到基类构造函数:
最简单的方案是让基类把粒子系统作为一个构造函数参数:
class Superpower
{
public:
Superpower(ParticleSystem* particles)
: particles_(particles)
{}
// Sandbox method and other operations...
private:
ParticleSystem* particles_;
};
这安全地保证了每个superpower
在它构造的时候有一个粒子系统。但是让我们看看一个子类:
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;
}
// Sandbox method and other operations...
private:
static ParticleSystem* particles_;
};
此处注意init()
和particles_
都是静态的。只要游戏调用Superpower::init()
稍早调用一次,所有的力量实例都可以访问粒子系统。与此同时,Superpower
实例可以通过调用正确的继承类构造函数被自由创建。
更棒的是,现在particles_
是静态变量,我们不必为每个Superpower
实例储存它,因此我们使得类占用更少的内存。
- 使用服务定位器:
之前的办法需要外部代码明确地牢记在使用基类说需的状态前将这些状态传递进去,这给周围代码的初始化工作带来了负担。另外一个选择是让基类通过把它需要的状态拉进去来处理。实现这个的一个方法是使用[服务定位器](./05.3-Service Locator.md)模式。
class Superpower
{
protected:
void spawnParticles(ParticleType type, int count)
{
ParticleSystem& particles = Locator::getParticles();
particles.spawn(type, count);
}
// Sandbox method and other operations...
};
这里,spawnParticles()
需要一个粒子系统。它从服务定位器获取了一个,而不是通过外部代码获取。
#参考