程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> C語言 >> C++ >> C++入門知識 >> 一個C++多繼承帶來的游戲開發陷阱

一個C++多繼承帶來的游戲開發陷阱

編輯:C++入門知識

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足夠的類型信息量,它就能做對事情——但前提是,你要先做對事情。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved