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