一个C++多继承带来的游戏开发陷阱

-潘宏

-2012年12月

-本人水平有限,疏忽错误在所难免,还请各位高手不吝赐教

-email: popyy@netease.com

-weibo.com/panhong101


故事

事情的经过是这样的:


当时我正在给游戏增加一个AI模块,我们采用了A*算法进行寻路计算。该算法是一个计算密集型过程,在没有任何优化的情况下,我们的引擎必须至少保证几十个单位同时进行寻路,从而能够在快速迭代的情况下进行正常的设计测试。这可不是件容易的事情——我们的地图不小,而且在开发初期,我们还没有积累起成熟的策略性寻路方案——我们的寻路技术是白手起家,这样的直接同步计算量会拖垮整个游戏。


在这样的前提下,经过短暂地思考,我选择了一个临时方案——把A*计算放到后台进行。我很清楚,这将极大地增加编程难度。我将不得不处理烦人的同步问题和状态转换,这可能引入潜在的安全隐患。但在当时的情况下,这个方案很直接,和AI本身没有关系,可以保证我们很快就能有一个可以跑起来的原型。我着手开始编程了。


我们的游戏对象是一个叫做Actor的类——它代表一个地图上的战斗单位,它可以寻路。请注意,这样的描述给了我自己一个暗示:我可以把寻路做成一个单独的类,以应付将来可能让除了Actor之外其他什么东西也“可以寻路”的需求,从而复用这些代码。然后,这个类就产生了——叫做Path_Finder——它处理所有和寻路相关的操作,这当然包括费时的A*计算。最后,我把Actor设计成了类似这样的一个类:


class Actor : public EventedSprite, public Path_Finder { ... };


其中EventedSprite是引擎所提供的精灵类,它是游戏中可见对象的一个抽象类,用来处理一个游戏对象的动画和位置以及和地图的关系。Path_Finder就是我们的寻路类,我采用了继承关系来复用代码——这很合理,因为这个体系不复杂,功能组合也不多。我不打算给Path_Finder增加任何更深层的子类体系,它就是它——只处理寻路。(后来我感觉如果不需要后台计算,用对象组合的方式可能更合理)


很自然地,我开始把寻路类和后台计算联系起来。在我们的游戏平台上,系统提供了任务类(Task)对线程进行了封装,从而让后台计算拥有更好的抽象性。于是我设计了这些类:

    class ConcurrentJob  
    {  
    public:  
        virtual void run() = 0;   
    };  
      
    class Path_Finder : public ConcurrentJob  
    {  
    public:  
        //...other declarations  
      
        virtual void run()  
        {  
            // perform pathfinding...  
        }  
      
    };  
      
    class My_Task : public Task  
    {  
    public:  
        void setJob( void* job ) { m_job = job; }  
        virtual void run()  
        {  
            ConcurrentJob* job = static_cast<ConcurrentJob*>( m_job );  
            job->run();  
        }  
    private:  
        void*       m_job;  
    };  

ConcurrentJob是一个游戏的并发作业类,作为可进行后台计算类的一个基类。Path_Finder就是我们的寻路类,它继承自ConcurrentJob并实现了run方法,该方法会进行寻路,并在后台被调用。My_Task类继承自系统类Task,用来和ConcurrentJob配合使用,设置一个计算作业,然后在run中将它转换成一个ConcurrentJob并进行相应计算。My_Task作为Task的子类会被交给系统的后台运行队列。


你可能会问My_Task::m_job为什么是void*,而不是直接写成ConcurrentJob*,这样不就可以免去了My_Task::run中的casting了吗?是的,你说的没错。但有两点需要我这么做:


1) 如果这么写,也就不会引出由于使用了多继承(MI)而带来的错误,这正是错误的直接来源。

2) 实际上,在我们的系统中,My_Task和Task类并不是C++编写的,我使用了两种语言进行交叉编程,而那种语言在声明的时候无法识别C++写的类,因此只能使用void*。而这里的My_Task::run的实现其实也不应该写在声明这里,因为这也是该语言无法识别的。现在这么做(全部用C++写出来)只是为了方便阅读。


于是在进行寻路计算时,我编写了类似下面的启动代码:

    My_Task* myTask = new My_Task;  
    Actor* actor = new Actor;  
    myTask->setJob( actor );  
    System_Task_Manager_Or_Something->addToOperationQueue( myTask );  

我建立了一个My_Task实例,然后将一个Actor作为一个Path_Finder,也就是一个concurrent job加入到了task里面,然后交给系统在后台运行。这看起来没什么问题,直到我运行程序...


程序崩溃了!来自非主线程!断点在My_Task::run中,对调式器的提示进行了紧张分析后,我得到了结论:


ConcurrentJob* job = static_cast<ConcurrentJob*>( m_job );


的casting失败了,得到了非法的job指针,而后面使用了该非法指针。


为什么casting会失败呢?根据继承体系,一个Actor就是一个Path_Finder,而一个Path_Finder不就是一个ConcurrentJob吗?为什么把一个Actor*转换成一个ConcurrentJob*会失败呢?



追寻真相


经过对代码仔细地调试和研究一番之后,罪魁祸首终于被抓到——C++多继承(MI)机制带来的指针隐式调整!这种转换是编译器暗中完成的,转换本身对程序开发者是透明的,在一般情况下也不会影响程序。但我们的程序存在一个特殊性,从而导致了这个严重的错误。接下来,请先将上面的问题压入堆栈,让我慢慢地把这个问题的前因后果都告诉你。这需要一些C++对象模型的基本知识,不过请放心,需要知道的知识我都会包含进来。



单继承(Single Inheritance,SI)对象模型


考虑下面的这个类:

    class A  
    {  
    public:  
        int m_a;  
        int m_b;  
    };  
      
    A a;  

class object a在内存中的布局如下所示:


两个member data按照顺序排列,class object a的地址就是该内存空间的首地址。现在我们增加一个类:

    class B : public A  
    {  
    public:  
        int m_c;  
    };  
      
    B b;  
    A* a = &b; // upcast  
    B* b2 = static_cast<B*>(a); // downcast  

class object b在内存中的布局如下图:


先是A类的subobject内存布局,然后是B类的data member。C++标准保证“出现在派生类中的基类subobject保留其原样性不变”。因此无论class A的布局如何,都会完整地存在于class B的内存模型中,这主要考虑和C的兼容性。但有以下几点需要注意(请不要被下面3条所述细节困扰,如果实在不太清楚,可以略过,我们的重点在于SI的基础知识):


1)class A的因内存alignment而产生的padding bytes也必须出现在B的class A subobject中,这确保了基类subobject的严格原样性。

2)对于具有virtual function的类体系,vptr的放置根据不同编译器会有两种方式:头部和尾部。对于放在头部的编译器,如果这里给B类增加一个virtual destructor,从而让A无virtual机制而让B有virtual机制,class object b的头部就是vptr而不是class A subobject了。但这不会影响指针的相同性。

3)如果B是virtual继承于A,则事情另有变数。用Stanley B. Lippman的话说“任何规则一旦遇到virtual base class,就没辙了”。这里我们不讨论这个题外话。


&b、a和b2所指向的都是b的首地址。因此,在SI模型下,对象内存采用重叠的模型,基类和任何的派生类的指针,都指向该对象的首地址,因此这些指针的地址值都是一样的——所有基类subobject都共享相同首地址。


也就是说,在一个继承体系内,不论你用什么样的方式对一个指向了某对象的指针进行downcast或upcast,指针的地址值都是一样的。再加一层体系如下:

    class C : public B  
    {  
    public:  
        int m_d;  
    };  



A、B、C这3个在同一体系下的类的指针无论怎样进行相互casting,得到的地址都一样。



多继承(Multiple Inheritance,MI)对象模型


MI机制是C++这门语言的特性之一,同时,也是复杂度的罪魁祸首之一!因为,在这里,编译器又背着我们做了一些事情,这也是C++饱受批评的主要原因。


请考虑下面的程序:

    class A  
    {  
    public:  
        int m_a;  
    };  
      
    class B  
    {  
    public:  
        int m_b;  
    };  
      
    class C  
    {  
    public:  
        int m_c;  
    };  
      
    class D : public A, public B, public C  
    {  
    public:  
        int m_d;  
    };  
      
    A* a;  
    B* b;  
    C* c;  
    D d;  
    a = &d;  
    b = &d;  
    c = &d;  

类关系如图所示:




这是一个最简单的MI体系,D继承自三个base class。我们再来看看它的内存模型:




可以看到,和SI不同的是,MI采用了非重叠模型——每个base class subobject都有自己的首地址。这里,A、B和C subobject各自占据它们自己的首地址,唯一的例外就是D object——也就是这个模型的拥有者,它的首地址和class A subobject是相同的。因此,我们说:


assert( a == &d );

assert( b != &d );

assert( c != &d );


“哎!等等!”,我听到了你在打断我,“我们在上面的程序中已经写明


b = &d;

c = &d;


这里为什么你会这么写:


assert( b != &d );

assert( c != &d );


你确定断言不会crash吗?”。如果你这么问我,我很高兴,这表明你在跟着我。下面是我通过试验得到的数据:




这就是问题的关键所在——编译器背着我们做了一件事情:this指针调整!在MI的世界里,this指针调整非常频繁,而这种调整,主要发生在 派生类对象 和“第二个以及后续的基类对象”(像咒语一样)之间的转换。在上面的例子里,“第二个以及后续的基类”就是类B和C。这个转换就是


b = &d; // upcast

c = &d; // upcast


this指针就是在这个时候被compiler调整的。b和c分别指向了正确的,属于它们各自的subobject的地址。同理,当我们将b和c转换成d指针的时候,this指针也会调整


D* d2 = static_cast<D*>(b); // downcast

D* d3 = static_cast<D*>(c); // downcast


结果是:


assert( d2 == &d );

assert( d3 == &d );


指针又被调整了回来。而这在SI的世界中是不会发生的(重叠模型)。


为什么要调整this指针呢?this指针调整的原因在于MI采用了非重叠的内存模型,而之所以采用这种模型,是为了保证各基类体系的完整性和独立性,保证virtual机制能够得以在MI的不同体系之间顺利运行(这通过每个subobject各自的vptr进行)。关于MI以及它的this指针调整,可以说的东西足够写成一本书(本文只是冰山一角),这里当然不行!关于MI的任何理论问题,你都可以在《Inside The C++ Object Model》一书中找到。


但是,如果你把上面我们讨论的理论都弄明白了,就足够理解下面的部分,以及一般的MI问题了。


问题分析


在掌握了SI和MI各自的基本知识之后,我们现在可以把之前的问题弹出堆栈!我们暂时离开实验室,来分析一下这个现实生活中的问题。Actor的继承体系如下所示:




老办法,我们分析一下它的内存模型:




该体系是一个SI和MI的混合体。可以把Actor看成是左右两个体系的MI类。Super hierarchy和EventedSprite这个SI作为第一个base class,ConcurrentJob和Path_Finder的这个SI看做是第二个base class。因此,


class Actor : public EventedSprite, public Path_Finder {...}


有关系:


Actor actor;

EventedSprite* spr = &actor; // 1

Path_Finder* path = &actor; // 2


assert( spr == &actor );

assert( path != &actor );


因为步骤2进行了this指针调整——这很清楚。好了,我们来看我们出问题的程序:

    My_Task* myTask = new My_Task;  
    Actor* actor = new Actor;  
    myTask->setJob( actor );  
    System_Task_Manager_Or_Something->addToOperationQueue( myTask );  

我们将actor交给了My_Task::setJob这个方法,该方法的形参是类型void*——它可以接受任何指针类型,这没有什么问题——我们只需要存储这个地址,在需要使用的时候用就是了。我们再看My_Task::run:


    virtual void run()  
    {  
        ConcurrentJob* job = static_cast<ConcurrentJob*>( m_job );  
        job->run();  
    }  

m_job就是刚才被存储的Actor*——这个地址没问题。但,m_job的类型是void*——没有任何类型信息!我们应该对

ConcurrentJob* job = static_cast<ConcurrentJob*>( m_job );

有什么期待呢?我们期待compiler会为我们调整this指针!因为ConcurrentJob是第二个基类体系,还记得 “第二个以及后续的基类”咒语吗?一个void*的指针,我们用编译期casting  operator


static_cast


进行转换,是不会有任何地址上的变化的!Actor*的地址就这么直接赋予了ConcurrentJob*。this指针没有被调整!这个指针没有指向正确的subobject!导致了后面的严重错误!

一个快速的解决方案就是把m_job先变成Actor*,然后再转换。不过无论如何,只要能够给compiler足够的类型信息量,它就能做对事情——但前提是,你要先做对事情。


总结

经过了细心的修改,我们的AI模块终于有了一个简单原型,并且能够比较流畅的跑起来了。设计师们都很高兴,开始以此为基础着手设计更聪明的AI了...

前面我们发现并解决了一个日常游戏开发中的问题,原理其实很简单,但需要开发者有足够的耐心。C++作为一门强大的开发语言,有它自己的哲学。它提供了多种编程范式,带来方便的同时,也给很多开发者带来了烦恼——语言机制过于复杂多样。这需要开发者不断地实践,形成对C++更深刻的理解。

原文地址:http://blog.csdn.net/popy007/article/details/8256502

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值