第一章 继承
1.类
C和C++最大的区别就在于“类”,类不仅是语法糖,而是用函数链接的数据,对象则是指定类的实例化,保有自身数据的同时和其他同类的对象分享自己的功能。类中数据的部分与C的构造差别不大,唯一的新东西是三个访问等级:公开、保护、私有。另外,类中的东西默认是私有,而结构体中的东西则是默认公开。如下的数据结构,类和结构体的数据完全相同,但注意我们显式地增加了“公开”关键字来使类的数据能从外部被访问。
struct Point3d
{
float x;
float y;
float z;
}
class Point3d
{
public:
float x;
float y;
float z;
}5
正如我们之后会看到的,不同平台之间的编译器不仅代码生成规则是差异巨大的,在性能表现上也是同理。尽管如此,如上的结构体和类生成了同样的序列并将在不同平台上有同样的行为。试着让相互关联的函数和数据作为同一数据结构的一部分呢?于是“类”这个语法糖的独特性就出现了。它允许我们清晰的表达我们想在一组数据中实行怎样的行为。例如,获得数组的长度时,obj ToCamera.Length()远比Length(objToCamera)更符合直觉。但这只是表象,C++的编译器会在内部将成员函数调用重写为C函数,更改之处在于,如下函数中,第一个被传入的是指向对象的指针参数。这是被编译器修改前后的同一成员函数。
Vector3d objToCamera = object.GetPos() — camera.GetPos();
//This function call..
float fDist = objToCamera.Length();
//It would be transformed to something like this:
float fDist = Vector3d_Length(&objToCamera);
这并非无足轻重的区别,而是我们实现强大面向对象编程能力的基础,更是工作时的好伙伴。C++的强大之处从何而来?我们来看继承的概念。
2.继承
继承允许我们简单的创造已有类的新类变体,不需更改原类且省时省力。继承帮助我们用符合直觉的方式呈现某种概念。例如,我们可能刚写好一个普通敌人的类,它管理场景中敌人的动画、跟踪射线的击中位置、运行AI等。
class Enemy
{
public:
void SelectAnimation() ;
void RunAI();
// Many more functions...
private:
int m_iHitPoints;
// Many more member variables here
};
当要写个关卡结束时的boss时,我们要做个艰难的决定:我们想要重用敌人类的许多功能,但boss又比普通敌人做得更多。或许可以复制粘贴这些代码,然而,这将破坏整洁、封装好的类,更会在之后导致更多维护问题。于是继承登场了,我们可以创造新继承了敌人类的Boss类,于是它接受了已有的敌人功能,在此之上,更能覆盖指定语句来给Boss独特的行为,例如,我们覆写AI。
class Boss : public Enemy
{
public:
void RunAI();
}
这时,敌人类即是“基类”,Boss是子类。现在,我们可以同时使用这两个类:
Enemy enemy1;
Boss boss;
// Tell the enemy and boss to do their thing
enemy .RunAI() ;
boss.RunAI();
类中公开的变量和函数是公开给所有使用这个类的人的;被保护的部分只可被此类和派生类使用;私有部分只被此类可用,派生类不可用。有时,我们不想完全覆写某个基类中的函数,而是仅添加一些功能。例如,我们或许想要Boss类仍像敌人一样行为,但又在此之上做额外的AI计算,RunAI()的使用就会变成这样。
void Boss: :RunAI()
{
// First run the generic AI of an enemy
Enemy::RunAI();
// Now do the real boss AI on top of that
//...
}
可观察到,调用被子类重写的基类函数时,多了基类名和两个冒号的前缀,"::"是C++的作用域标识符,它指定了某个函数、变量或类的住址。这个代码中,我们指定了要调用的是基类的RunAI(),而不是当前的Boss类中的。继承很好用,又比如游戏的结尾处要增加一个非常特殊的Boss,超级无敌大Boss。派生类不仅能继承直接基类,更能覆写它所有基类的公开和保护的函数。这里,我们覆写另一个敌人类的函数。
class SuperDuperBoss : public Boss
{
public:
void RunAI();
};
谈论类之间的继承常使文字错综复杂,简直就像解释和家族远亲之间的关系。类图表仿照了家族树,言简意赅的表述了信息。
3.多态和虚函数
虽然截至目前的示例都简洁有力,但用在项目里,代码却总是臃肿局促。败笔之一是,指向对象的指针一定要是和对象同类的。听起来奇怪,就好像要用float来指向int一样。请听后文分解。
如果,在敌人/Boss例子中,我们有20个不同的敌人和5个不同的Boss,且我们要调用所有关卡中敌人的ExecuteFrame()函数,我们不得不跟踪所有敌人单元,即是存储敌人和Boss的列表,再遍历它们,这有些麻烦。要是能只存敌人列表且不限于类地一次性调用所有的ExecuteFrame()函数,岂不美哉?这就是多态的能力:把某个类的对象当作另一个类的对象。以下是个小例子:
// We can create an object of type Enemy:
Enemy * pEnemy1 = new Enemy;
// And we can create an object of type Boss:
Boss * pBoss = new Boss;
// We can also create a pointer of type Enemy to refer to
// the boss!
Enemy * pEnemy2 = pBoss;
虽然听起来平平无奇,但是多态是非常强大的。它允许我们忘记某对象真正的类、抛弃对每个派生类的特定处理代码。例如,有个把敌人作为参数来判断是否可以被射击的函数,如下:
// Determine if the given enemy can be shot at
bool CanShootAtEnemy( Enemy& enemy );
如果没有多态,我们只能或写一个把Boss当作参数的函数,或做危险操作,比如传入空指针和一个标识符来声明这个变量的类型:
// Bad: ignore polymorphism and write a new function to
// determine
// if a boss can be shot at
bool CanShootAtBoss (Boss& boss);
// Worse: try to write a generic function that uses the
// type of
// object and a void pointer
bool CanShootAtEnemyOrBoss(void* pEnemyOrBoss, boolbIsEnemy) ;
如果敌人类数量变多,事情就会更复杂。多态辅助我们忽略对象属于哪个敌人的子类。我们在后续也会用这个机制来写插件、在避免重编译的基础上扩展功能(有利于打补丁和写mod)。回到敌人类的例子,我们使用多态简化了代码,只需一个敌人数组,就能管理所有的敌人、Boss、超级无敌Boss,一视同仁地对待所有的三个类。但有个潜在问题,还记得在每个类都被重写的RunAI()吗?像如下代码中一样使用多态会怎样?
Enemy * pEnemy = new Enemy;
pEnemy->RunAI(); // Enemy::RunAI() gets called
Enemy * pBoss = new Boss;
pBoss->RunAI(); // Which function gets called??
// Boss::RunAI() or Enemy: :RunAI()?
当指针和对象同属于敌人类时,调用的就是敌人类的RunAI(),但指针时敌人类而对象是Boss类时呢?答案是,取决于RunAI是否为虚函数。如果是虚函数,则以对象类为准(Boss),否则为指针类(Enemy)。此处,我们希望使用的是Boss类,且希望每个敌人都以对象类为准来运行AI,所以RunAI()应为虚函数,以下为重写的敌人类:
class Enemy
{
public:
void SelectAnimation();
virtual void RunAI();
// Many more functions could go here...
private:
int m_iHitPoints;
// Many more member variables could go here...
};
现在终于可以用相同代码操纵所有敌人了:
Enemy * pEnemies[256] ; // Create an array of enemies
enemies[0] = new Enemy;
enemies[1] = new Enemy;
enemies[2] = new Boss; // A Boss is an enemy,
so this works
enemies[3] = new FlyingEnemy; // Another type of enemy
also works
enemies[4] = new FlyingEnemy;
IE “CheGnes
// Inside the game loop
for ( int i=0; i < iNumEnemies; ++i )
pEnemies[i]->RunAI();
4.要不要用继承呢?
有了锤子后,一切都长得像钉子——继承太过强大所以你会像随地大小用。然而,乱用会事倍功半。确保只在没有其他简单解决方法且合适时使用它,我给出两条基础的使用规范。
规则1:包含与继承
通常,继承描述的是“判断”关系,若乙类继承于甲类,那么乙类是甲类的一种,正如Boss是一种敌人。若把敌人继承自武器类呢?这样敌人不就能做所有武器能做的事情了?比如开枪。但这不符合判断关系,敌人不是一种武器,敌人能拥有武器,这叫做包含。故敌人类或许能有个成员变量“武器”而不是继承它。第一个规则就是:继承应服从“判断”关系。
即使敌人继承了武器类,编译也不会报错,短期内也或许省时省力,但如果敌人能在多个武器间切换呢?如果有些敌人压根没武器呢?好的设计让改动如微风般轻盈优雅。错误的继承则让改动伤筋动骨。在指定情况中,如果陷入是否继承的两难境地,那么避免继承,尤其是当你仍在学习如何在大型项目中培养长远目光时。被继承毁掉的项目远多于保守使用继承的项目。
规则2:行为和数据
一位年轻气盛的程序员读了上文,于是从敌人类建立了强敌类,有两倍的攻击力。显然符合上个规则,但哪里是不是不对?这个类里几乎是空的,唯一的新内容在构造函数里,即更多的攻击力,有点大材小用了。其实它根本不该是新的类,因为强敌和敌人的区别仅在于数据而不是行为。第二条规则就是:新类应当用于更改行为而不是数据。
除开大材小用以外,这样还会导致排列组合带来的爆炸性新类:假设还有高防御敌呢?假设还有高防高攻敌呢?要是每个都建立新类,就带来巨大的混乱。
5.何时用?何时不用?
还需注意,虚函数会带来轻微的性能惩罚,后文会详细说明。总之,仅在必要时使用虚函数。听起来显而易见,但是人们总会预先把所有函数都设为虚函数以备将来某时某人继承并覆写它。长远计划当然没错,但是开启太多潘多拉魔盒也可能后患无穷。理想状态下,编译器应当足够智慧到,把无用的虚函数优化成普通函数,但是C++语言不是这么用的。所以调用虚函数确实会带来性能惩罚。
除此之外,谁也不能预知未来,等你正写的类要生小类时候,可能项目已经大变样,你的类也已经面目全非了,所以想得太长远反而使你束手束脚,固步自封。也就是说,除非你当下确信需要此虚函数,否则不要乱建虚函数。
有时,在循环中被成百上千次调用的虚函数会带来极大的开销,尤其是当这个它为单行函数时(第六章会详写行内函数)。在替换掉它之前,应当确认是否是“虚拟性”损害了性能,如果真有其事,再替换为非虚函数。注意,仅仅加个判断语句不会有什么帮助。可以尝试复制出多个循环,每个调用一个函数。更好的方法是,把循环搬到某个类中,基于虚函数来调用正确的循环,这样就只调用了一次虚函数。
另一个潜在性能陷阱是程序越过虚边界。与上个情况不同,并不是同个虚函数被多次调用,而是一堆虚函数。例如,想象一个图形渲染模块中的虚接口:
class GraphicsRenderer
{
public:
virtual SetRenderState(...);
virtual SetTextureState(...);
virtual SetLight(...);
virtual DrawTriangle(...);
Wikcupec
};
不幸的是,渲染类被设置在了错误的层级,所以每当我们绘制一个三角形时都不得不调用一系列的虚函数:SetRenderState(),……再乘上每帧都要三角形总数,虚函数开销将变得巨大。解决方法是,将抽象性移到更高的层级,新的抽象接口将负责绘制网格体(即许多三角形):
class GraphicsRenderer
public:
virtual SetMaterial(...);
virtual DrawMesh(...);
};
现在只需Set Material(), and DrawMesh()两个函数即可,我们把虚函数调用次数大大降低了。接口的改动将会在所有有关代码中产生深远的影响,所以需要在初次设计时深思熟虑。
6.继承应用
为了更深入地理解继承的优劣,需要理解继承实际的运行逻辑。在左手规范的前提下,编译器作者们在如何应用继承有极大的自由,但幸运的是,几乎现代的所有编译器都在大致相同的方式下应用继承。
首先从非虚函数开始,它们简单的对应了某个代码片段,所以编译器会在编译时计算函数的地址和链接时间;运行时,编译器仅仅调用固定的地址。
虚函数,由于调用的函数取决于被调用的对象类型,则稍显复杂。通常,虚列表会解决这个问题。它是个由函数指针组成的列表。只要类中有至少一个虚函数,就会有自己的函数列表。运行时,由索引编号来确定调用的函数。它仅仅包含指向虚函数的指针;非虚函数则总是在编译时被计算,调用时直接使用地址。这意味着每个虚函数仅让我们付出几个字节的内存和微小的性能惩罚,且不影响非虚函数的性能。这就是C++最重要的设计原则之一:你不需要为未使用的东西付出性能。
另外也需要记住,一个虚列表仅和一个类对应,而非和对象对应。每个类都可能有成百上千的实例化对象,用地形切块举例,一个256*256的地图里会有65536个"地形切块"类的对象,但仅需要一个虚列表(假如此类中至少有一个虚函数)。
从属于同个类的对象,每个都会存一个指向该类虚列表的指针,而不是各自存一个虚列表。通常,这个指针就在类的起始位置。于是每个(至少有一个虚函数的)对象里会多存(通常是四个字节长的)一个指针。在前文的地形切块例子里,就需要多存64kB + 虚列表本身的几个字节这么多。和它的优点相比,这点存储消耗不值一提,但我们要意识到它,尤其是在内存吃紧的平台上。
需要纠正的是,并非每个对象都有指向虚列表的指针,只有属于拥有虚函数的类的对象才有。这个区别微小但重要,这意味着我们不必顾虑在小而基础的类(例如向量和矩阵)中使用继承,只要不使用虚函数。
7.开销分析
当运行时调用虚函数时会发生如下的步骤:
1. 从一个简单的函数调用开始
2. 虚列表指针被使用
3. 被调用函数在列表中的位置被找到
4. 基于该位置的地址,调用指定函数、
其中,第四步和非虚函数的调用相同,所以使用虚函数的额外开销来自步骤2和3,具体的开销额度难以计算,取决于运行平台、管线深度等。假设我们写好了一个游戏,并尝试在开发的结尾检测性能瓶颈是否由虚函数导致。首先,和循环不同,虚函数的开销不会在分析工具的热点图中被绘制,所以开销将是更细微和广泛的,即使我们确定了性能瓶颈就在虚函数,往往也来不及改动了。假设整个游戏的架构都依赖于继承和虚函数来覆写置顶行为,那么激进地改框架并移除所有虚函数时难上加难的。最好的方法是在开发中就做好虚函数调用的预期,并总是做真实的性能分析来验证我们的假设。大多数时候虚函数的开销可以被省略,但是当其还是行内函数时,事情就可能严重了。总是避免让行内函数成为虚函数。 这通常意味着我们在一个过于低的概念层设置接口,应当三思而后行。
另一个大问题是查找虚类表带来的缓存数据。如果我们有许多类,它们将会在随机顺序中被调用,所以每帧都要在不同的虚列表中寻找函数入口。这将导致缓存数据中某些有用的数据被弹出或持续的缓存丢失,此时虚函数的惩罚将大大拖慢运行速度。
虚函数的间接寻址总是增加开销吗?并不总是。编译器作者心灵手巧,总能找到捷径。在某对象上直接调用(而非使用指针或引用)虚函数、或使用与对象同类的指针或引用时,编译器会将虚列表跳跃优化掉,直接调用函数。
8. 替代方案
在C中要实现类似继承的功能,我们往往用如下的方法:
1. 单一架构和大量条件语句:创造包含了所有可能需要的数据的C架构,写大量的条件检测来基于类型执行不同的代码。抛开代码的丑陋和维护噩梦不提,内存也将被同样的大尺寸结构体浪费。性能也远低于使用虚函数的情况——大量判断语句消耗了远比几个条件跳跃更大的性能。
2. 单一架构和switch语句:代码和上个情况相比稍显简洁,但编译器仍将生成同样的if语句。极少数情况下,编译器能用跳跃表替代条件语句,即使如此,性能也与虚函数不相上下。更不幸的是,你无法胁迫编译器把switch生成为跳跃表。
3.指针表:为每个类型的对象创建函数指针表,可读性差且可维护性差。这其实就是重建了虚函数并失去了继承的优点(不同的对象尺寸、私有和保护成员等)。开销和虚函数&继承不相上下,甚至需要大量的额外工作。
结论就是,不要尝试从零开始构建它们,因为编译器总是能比你做得更好。
9.结构和继承
除了不必要的性能惩罚和内存消耗以外,过度使用继承的潜在危害也会作用于源码。类的作用是塑造一个概念,并通过最小程度地公开接口、尽可能地在私有函数和成员变量中隐藏复杂性来使用它。但是别忘记,创造一个接口干净整洁的类是个不小的挑战。我们要决定哪些东西要被隐藏或公开、怎样在接口简洁的同时保证必须的灵活性……创建继承,就像是左右逢敌,要同时考虑当前的使用和将来的可延展性。
另一个问题则是,继承会在整个程序中产生影响。继承会迅速发展壮大成继承链——整个继承树跨越成百上千个类,其中有些类有五六层深。即使每个类都精心设计、符合规范,仍会让人挠头。巨大又高深的继承树会拾可读性变差。函数调用时需在整棵树上下而求索,才能确定究竟是在何处被调用,又是在何处被覆写的。更糟的是,继承会硬化程序设计,而软件应当是灵活的。事情总在变:发行商的新需求、为保持竞争力而加新功能、或仅是让它更有趣。灵活易变的代码基础让你获益良多。最糟的事情莫过于,在游戏发售两周之后,我们想做一个理应简单却致命的修改,却做不到。
我们的备选是什么?即使继承是正确的选择,有时不妨试试“包含”。它不会硬化程序设计,保持了代码的灵活和可塑性:被包含的对象不必在乎持有它们的对象,只要执行命令就好。这并不意味着要把所有的继承都替换为包含,而是在当继承链太长时,应明智地在某处切断它为两道三条短链。如下图所示的变化,注意继承和包含关系不同的箭头样式。