【C++】《给游戏开发者的C++》笔记 第二章 多重继承

多重继承是C++中另一个新的概念,它扩展了单例继承到把两到三个类作为基类,虽然不如单例那样被广泛使用,也和前者共同有着某些隐患,但是使用正确时它将是非常有用的工具。尤其是它之中的特定表达,如抽象接口,也很有用。

1.使用

想象一个这样的情境:需要设计基础的游戏实体类,所有的游戏对象都将继承自此类,敌人、物品、触发器、摄影机等;所有游戏对象都必须实现两个要求:能够接受消息和被连接为树的一部分。忽略游戏实体类的所有其他内容,我们该怎么实现这两个要求呢?

1.一体法

最显而易见的方法就是在游戏实体类之中实现。然而,把所需东西全加到一个类里并非是最好的方法,即使它简单粗暴。

类的简洁性是把双刃剑。一方面,加功能时、改继承链、该结构时省去了建新类的麻烦。另一方面类会变得臃肿庞杂,使诸如游戏实体这种基础类膨胀得难以管理、错综复杂。长远来看,让一切变得复杂了。

另一个问题是代码的重复。游戏实体类是唯一的能够接收消息的类吗?或许玩家类需要在不属于游戏实体类的同时也能接受消息。游戏实体类是唯一能成为树的一部分的类吗?可能其他对象(诸如场景节点和动画骨骼)也能用类似方法管理。无法重用代码令人遗憾,更象征了失败的软件设计。把需要的代码随处粘贴,会随着架构得进化带来惊人的维护难题。

2.包含法

显然,前文所说的每个对象都该有个自己的类,所以或许可以有“接收消息”类和“树节点”类。游戏实体类将包含这两种对象并调用它们接口来使用它们。包含是一种极佳的解决方法,不增加复杂性的同时大量重用了代码。唯一的缺点是,许多接口函数的唯一作用是调用其它对象的函数,它们难以维护,尤其是接口被反复迭代时。使用包含法的游戏实体类如下: 

class GameEntity
{
    public:
    // MessageReceiver functions
    bool ReceiveMessage(const Message & msQg);
    // TreeNode functions
    GameEntity * GetParent();
    GameEntity * GetFirstChild();
    //...
    private:
    MessageReceiver m MsgReceiver;
    TreeNode m_TreeNode;
};
inline bool GameEntity: :ReceiveMessage(const Message & msg)
{
    return m_MsgReceiver.ReceiveMessage(msq);
}
inline GameEntity * GameEntity: :GetParent()
{
    return m_TreeNode.GetParent();
}
inline GameEntity * GameEntity: :GetFirstChild()
{
    return m_TreeNode.GetFirstChild(); 
}

 与其在游戏实体中控制对象的交互,不如直接暴露对象本身。这将显著减少接口函数维护成本,但也暴露了不必要的信息。如果后续需要删除其中某一对象来优化游戏实体类,所有引用此类的代码都将受影响。由于这种可能性,我们先暂时使这两个对象私有。

3. 单例继承法

使用第一章的方法,我们让游戏实体类是消息接收类的子类。那树节点类怎么办呢?这个类也是个树节点。显然单例继承无法简单的表现它们之间的关系。建立了如下图的继承链后,表面上程序没有一场,但是这丑陋的设计后续将引发大麻烦。树节点都是消息接收器吗?既然不一定是,为什么要继承它?反转关系也不对。而且这样也阻止了我们重用非消息接收器的树节点。所以想象更好的办法吧。

b4b5c0de62794d71b5bb058e374ce8a0.png

4. 多例继承来救急

游戏实体类可以同时继承树节点类和消息接收类,于是自动有个它们的接口、行为、变量等。

Class GameEntity:public MessageReceiver, public TreeNode 
public:
    // Game entity functions...
};

多例继承的对象也能像单例继承的对象一样使用。

GameEntity entity;
//...
GameEntity * pParent = entity.GetParent(); 

2. 问题

新功能往往带来复杂性和隐患。甚至有人认为多例继承的创造的麻烦多于它解决的问题。主要有以下几点:

  • 模糊性

如果两个基类中包含了名称和参数完全相同的函数呢?想象前文的两个类中都有IsValid()函数,用于检测对象是否状态正常,那在子类中调用时会发生什么?答案是编译器报错,不明确的调用。要解决模糊性,必须在函数调用前使用类名作为前缀和作用域标识符。

void GameEntity: :SomeFunction()
{
if (MessageReceiver::IsValid() && TreeNode::IsValid())
{
    //...
}
} 

从游戏实体类外部调用基类的函数会更麻烦,前缀还要加上当前类的名称。

bValid = entity.MessageReceiver::IsValid() &&
entity.TreeNode.IsValid(); 
  • 形态

多例继承也会导致继承树的形态出现问题。假设我们有AI类负责处理不同实体的移动方式:地面AI类、飞行AI类,设计师刚拍脑袋又想到了混合角色,在地上和空中都能移动,于是我们要给它创个类。自然可以同时继承地面AI和飞行AI,但问题在于它们的基类是同一个。这就是死亡钻石继承树,如下图所示。

dd2ce1be35ea4de4bd911926c9cb9bb1.png

 它带来了两个意外的问题:

1. 移动AI的内容在混合AI中出现了两次,后者中出现了惊人的两个m_iCounter变量;

2.在后者中尝试使用前者的成员变量是不明确的,我们要指定访问时的继承路径。由于殊途同归,听起来有点多余。既然前文中有重复的变量,那么这显然是有必要的。

混合AI类中结合基类内容的方法,如下图所见。335935e01f93460d94431c392cc36c2b.png

 为了解决这个问题,C++引入了虚继承,它与虚函数完全不同。它允许基类在子类对象中仅出现一次,即使是钻石型的继承结构里。但虚继承会带来运行和存储的开销。大量隐蔽的指针修正和表的解引用维持了表象的正确。最好的办法是,尽一切可能避免钻石继承。如果你仍确信钻石继承是最好的方案,那么确保你和团队都明白每个细节和虚继承的副作用。本书后文也会极力避免虚继承和钻石结构。

  • 程序架构

还没完呢,最后的致命问题是:正确但粗心地使用它会带来恐怖的程序架构。过度依赖多例继承而非单例继承,会导致过多的继承层级、巨大的对象、臃肿的接口还有类之间过密的耦合。换句话说,你无法简单的在各式语境下轻易的重用单个类、维护和扩展已有代码时困难重重、编译和链接时间大大增长。多例继承复杂而难用,务必在其他可选方案中三思而后行。后续我们会分析一些应当使用多例继承的特殊案例。

3. 多态

与单例继承一样,可以用基类的指针来引用对象,不同处在于,存储和使用这些指针时需要更小心。可以使用老派C式和新派C++转换法,将对象在继承树上向上转化

GameEntity * pEntity = GetEntity();
MessageReceiver * pRec;
pRec = (MessageReceiver *)(pEntity); // C cast
pRec2 = static_cast<MessageReceiver *>(pEntity); // C++ cast 

向下转化则不尽相同。单例继承时,我们只需确认转化类型正确就可以;多例继承中, 这个方法不好用,原因在于虚列表。单例继承中,继承树中所有类共享同一个虚列表的开头,但派生类的的入口要更靠后。多例继承中,不同的基类有不同的虚列表入口,转类型时就需要返回一个不同的指针。后文会详解这其中的细节。

那应当如何做多例继承的向下转化?答案是时动态转化,它的特别之处在于引入了能够做指针算数和调整虚列表偏移的运行代码。当转化非法时,动态转化返回空值。

GameEntity * pEntity = GetEntity();
// Normal dynamic cast. Works fine.
MessageReceiver * pRec;
pRec = dynamic_cast<MessageReceiver*>(pEntity) ; 

// Also works fine, but pNode will have a different value
// than pEntity .
TreeNode * pNode;
pNode = dynamic_cast<TreeNode*>(pEntity) ;

// This is not a valid cast because the entity we have is not
// actually a player object. It will fail and return NULL.
Player * pPlayer;
pPlayer = dynamic_cast<Player*>(pEntity) ; 

不幸的是,动态转化不仅有性能惩罚,也需要编译器设置中开启了“运行时类型信息”(RTTI)功能。这个功能意味着,运行时编译器能建立并存储多到动态转化准确无误的有关C++类的信息。即使每个类的所需内容不多,但所有类的总和就不一定了,况且我们并不需要一些轻量类(比如向量和矩阵)的信息。在15章我们会详解默认的RTTI功能并介绍更合适的自制替代方案。

4. 何时用?何时不用?

到此为止多态继承都听起来不甚有用,然而它也可以是有力的工具。最重要的是,不要不假思索地用多例继承,总是尝试用包含法作为备选。如果包含法也不合适,也不要武断地使用单例继承。扭转单例继承链条来避免多例继承,则会创造无用的临时类,使代码可读性和维护性降低。

之后的12章会讲到,抽象接口是多例继承的好伙伴,通过增加一些可被继承类的限制条件,可以避免前文所述的大部分隐患。抽象接口是运行时更新、游戏发售后扩展、写插件等的基础。

总是避免在复杂的继承层中使用多例继承。即使在单例继承中,找寻代码和程序流就已经令人头秃了,多例继承只会火上浇油,甚至会不小心搞出死亡钻石。

多例继承的优秀范例往往是对简单、通用的类的扩展。一个多例继承实现例子:“引用计数”类包含了“加引用”和“解引用”两个函数用来计算子类中某个函数被引用的次数。此类并不继承其他类,而且简洁明了,所以是绝佳的多例继承基类。

5. 实现

纵使有诸多共同点,多例继承远比单例继承更复杂,而后者比前者更简洁高效。单例继承的极优雅处在于,子类总是对基类向后兼容。又由于仅需一个虚列表,所以基类很容易忽略子类末尾处的额外内容。于是向上或向下的类型转化轻而易举。而在多例继承中,由于多态性,派生类在正确转换后应该能够像其任何父类一样进行寻址。仅仅附加成员变量并保留一个虚列表(就像单继承一样)是行不通的,因为无论我们如何努力,我们都无法使派生类看起来像它的所有父类。

c9042fd104334d0b82954ce537df5bac.png

单例继承的类关系

 

22aa4b8bb05a48c99d45fb020dfe0473.png

多例继承的类关系

 它们的区别在于,多例继承中,每个基类各自的虚列表都被附加到了子类的虚列表。这样,子类才能通过修改虚列表指针位置的方式,转化为指定基类。

从存储的视角看,多例继承使每个对象的每个基类都多了一个额外指针,在今天计算机的RAM中这点数据不算什么,但是要意识到它的存在,尤其是对象被成千上万次生成的时候。单例继承中,仅当基类中存在虚函数时,才需要虚列表指针,否则只需要附加成员变量。

最后,多态继承时基类的附加顺序是依赖于实现方式的,大多数编译器按照继承声明中的顺序决定,而一些编译器则为了优化性能而调换顺序。

6.开销分析

1.转化

单例继承中,指针类型可以在继承层级中自由的上下转化,使编译器将某对象识别为指定类。编译器会保证对这个指针所有的操作都是合法的、虚函数也在使用正确的虚列表。然而在多例继承中,由于子对象由多个基对象构成、每个基对象又有各自的虚列表,所以类转化时的指针调整就不可避免。尤其是将子类对象转化为第二(或其他后续基类)基类(或反之亦然)时,虚列表指针需要小小移动,来指向对象的正确部分。子类和第一基类的相互转化则不影响指针,和单例继承如出一辙。

Parenti * pParent1 = new Child;
Child * pChild = dynamic_cast<Child*>(pParent1) ;
assert (pParent1 == pChild) ; // Unchanged 

 从非首基类转化到子类会改变指针,此处增加的是负偏移,来指向“真正的”对象起始位置。

Parent2 * pParent2 = new Child;
Child * pChild = dynamic_cast<Child*>(pParent2) ;
assert (pParent2 != pChild) ; // Not the same! 

最后,从子类转化到第二基类也会改变指针。

Child * pChild = new Child;
Parent2 * pParent2 = dynamic_cast<Parent2*>(pChild) ;
assert (pChild != pParent2) ; // Not the same! 

1037e29cea7a4a1289c13da7f1a2bf5d.png

额外的指针偏移操作不会消耗太多性能,编译时就能确定偏移量,所以甚至不需要访问其他的数据(避免了数据缓存抖动问题)。访问序列表和最终函数调用的开销掩盖了偏移指针的开销。严重的性能损耗反而来源于动态转化自身。运行时,程序不得不确定转化是否可行,于是需要考虑原有指针类型、指针引用的对象类型、目标类型。基于具体实现,继承树越复杂,此操作就越慢。15章中将深入讨论动态转化和RTTI并实现性能更佳的自制版本。

2. 虚函数和第二基类

调用非首基类中虚函数也会带来额外的性能开销。如前文所述,多例继承的子类有多个虚列表,其中任何一个虚函数被调用之前,指针都必须被移动到对应的位置,正如类转化时发生的事情。幸运的是,这次不需要动态转化,所以实际上开销可以忽略不计。

第二章 完

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值