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

2014-11-24 08:58:12 · 作者: · 浏览: 0

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( 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( 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(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机制是