對於繼承概念中的虛函數,請參閱虛函數

虛繼承面向對象編程中的一種技術,是指一個指定的基類,在繼承體系結構中,將其成員數據實例共享給也從這個基類型直接或間接派生的其它類。

舉例來說:假如類A和類B各自從類X派生(非虛繼承且假設類X包含一些數據成員),且類C同時多繼承自類AB,那麼C的對象就會擁有兩套X的實例數據(可分別獨立訪問,一般要用適當的消歧義限定符)。但是如果類AB各自虛繼承了類X,那麼C的對象就只包含一套類X的實例數據。對於這一概念典型實現的編程語言是C++

這一特性在多重繼承應用中非常有用,可以使得虛基類對於由它直接或間接派生的類來說,擁有一個共同的基類對象實例。避免由於帶有歧義的組合而產生的問題(如「菱形繼承問題」)。其原理是,間接派生類(C)穿透了其父類(上面例子中的AB),實質上直接繼承了虛基類X[1][2]

這一概念一般用於「繼承」在表現為一個整體,而非幾個部分的組合時。在C++中,基類可以通過使用關鍵字virtual來聲明虛繼承關係。

問題的產生

考慮下面的類的層次和關係。

class Animal {
 public:
  virtual void eat();
};

class Mammal : public Animal {
 public:
  virtual void breathe();
};

class WingedAnimal : public Animal {
 public:
  virtual void flap();
};

// A bat is a winged mammal
class Bat : public Mammal, public WingedAnimal {
};

Bat bat;

按照上面的定義,調用bat.eat()是有歧義的,因為在Bat中有兩個Animal基類(間接的),所以所有的Bat對象都有兩個不同的Animal基類的子對象。因此,嘗試直接引用Bat對象的Animal子對象會導致錯誤,因為該繼承是有歧義的:

Bat b;
Animal &a = b; // error: which Animal subobject should a Bat cast into, 
               // a Mammal::Animal or a WingedAnimal::Animal?

要消除歧義,需要顯式的將bat轉換為每一個基類子對象:

 
菱形類繼承圖示。
Bat b;
Animal &mammal = static_cast<Mammal&> (b); 
Animal &winged = static_cast<WingedAnimal&> (b);


為了正確的調用eat(),還需要相同的可以消歧義的語句:static_cast<Mammal&>(bat).eat()static_cast<WingedAnimal&>(bat).eat().

在這個例子中,我們可能並不需要Animal被繼承兩次,我們只想建立一個模型來說明這層關係(Bat 屬於 Animal);BatMammal也是WingedAnimal並不意味着它是兩個AnimalAnimal定義的功能由Bat來實現(上面「」的屬性實際上是「實現需求」的含義),且一個Bat只實現一次。「只一個」的真正含義是Bat只有一種實現eat()的方法,無論是從Mammal的角度還是從WingedAnimal的角度來看。(在上面的第一段代碼示例中我們看到eat()並沒有在MammalWingedAnimal中被重載,所以這兩個Animal子對象實際上是以相同的方式運作,但這只是一個不完善的例子,從C++的角度來看二者之間正好沒有實際的區別。)

若將上面的關係以圖形方式表示看起來類似菱形,所以這一情況也被稱為菱形繼承。虛繼承可以解決這一問題。

解決方法

我們可以按如下方式重新聲明上面的類:

class Animal {
 public:
  virtual void eat();
};

// Two classes virtually inheriting Animal:
class Mammal : public virtual Animal {
 public:
  virtual void breathe();
};

class WingedAnimal : public virtual Animal {
 public:
  virtual void flap();
};

// A bat is still a winged mammal
class Bat : public Mammal, public WingedAnimal {
};

Bat::WingedAnimal中的Animal部分現在和Bat::Mammal中的Animal部分是相同的了,這也就是說Bat現在有且只有一個共享的Animal部分,所以對於Bat::eat()的調用就不再有歧義了。另外,直接將Bat實例分派給Animal實例的過程也不會產生歧義了,因為現在只存在一種可以轉換為AnimalBat實體了。

因為Mammal實例的起始地址和其Animal部分的內存偏移量直到程序運行分配內存時才會明確,所以虛繼承應用給MammalWingedAnimal建立了虛表(vtable)指針(「vpointer」)。因此「Bat」包含vpointer, Mammal, vpointer, WingedAnimal, Bat, Animal。這裡共有兩個虛表指針,其中最派生類的對象地址所指向的虛表指針,指向了最派生類的虛表;另一個虛表指針指向了WingedAnimal的類的虛表。Animal虛繼承而來。在上面的例子裡,一個分配給Mammal,另一個分配給WingedAnimal。因此每個對象占用的內存增加了兩個指針的大小,但卻解決了Animal的歧義問題。所有Bat類的對象都包含這兩個虛指針,但是每一個對象都包含唯一的Animal對象。假設一個類Squirrel聲明繼承了Mammal,那麼Squirrel中的Mammal對象的虛指針和Bat中的Mammal對象的虛指針是不同的,儘管他們占用的內存空間大小是相同的。這是因為在內存中MammalAnimal的距離是相同的。虛表不同而實際上占用的空間相同。

虛基類的初始化

由於虛基類是多個派生類共享的基類,因此由誰來初始化虛基類必須明確。C++標準規定,由最派生類直接初始化虛基類。因此,對間接繼承了虛基類的類,也必須能直接訪問其虛繼承來的祖先類,也即應知道其虛繼承來的祖先類的地址偏移值。

例如,常見的「菱形」虛繼承例子中,兩個派生類、一個最派生類的構造函數的初始化列表中都可以給出虛基類的初始化;但只由最派生類的構造函數實際執行虛基類的初始化。

g++與虛繼承

g++編譯器生成的C++類實例,虛函數與虛基類地址偏移值共用一個虛表(vtable)。類實例的開始處即為指向所屬類的虛指針(vptr)。實際上,一個類與它的若干祖先類(父類、祖父類、...)組成部分共用一個虛表,但各自使用的虛表部分依次相接、不相重疊。

g++編譯下,一個類實例的虛指針指向該類虛表中的第一個虛函數的地址。如果該類沒有虛函數(或者虛函數都寫入了祖先類的虛表,覆蓋了祖先類的對應虛函數),因而該類自身虛表中沒有虛函數需要填入,但該類有虛繼承的祖先類,則仍然必須要訪問虛表中的虛基類地址偏移值。這種情況下,該類仍然需要有虛表,該類實例的虛指針指向類虛表中一個值為0的條目。

該類其它的虛函數的地址依次填在虛表中第一個虛函數條目之後(內存地址自低向高方向)。虛表中第一個虛函數條目之前(內存地址自高向低方向),依次填入了typeinfo(用於RTTI)、虛指針到整個對象開始處的偏移值、虛基類地址偏移值。因此,如果一個類虛繼承了兩個類,那麼對於32位程序,虛繼承的左父類地址偏移值位於vptr-0x0c,虛繼承的右父類地址偏移值位於vptr-0x10.

一個類的祖先類有複雜的虛繼承關係,則該類的各個虛基類偏移值在虛表中的存儲順序尊重自該類到祖先的深度優先遍歷次序。

Microsoft Visual C++與虛繼承

Microsoft Visual C++與g++不同,把類的虛函數與虛基類地址偏移值分別放入了兩個虛表中,前者稱為虛函數表vftbl,後者稱虛基類表vbtbl。因此一個類實例可能有兩個虛指針分別指向類的虛函數表與虛基類表,這兩個虛指針分別稱為虛函數表指針vftbl與虛基類表指針vbtbl。當然,類實例也可以只有一個虛指針,或者沒有虛指針。虛指針總是放在類實例的數據成員之前,且虛函數表指針總是在虛基類表指針之前。因而,對於某個類實例來說,如果它有虛基類指針,那麼虛基類指針可能在類實例的0字節偏移處,也可能在類實例的4字節偏移處(對於32位程序來說),這給類成員函數指針的實現帶來了很大麻煩。

一個類的虛基類指針指向的虛基類表的首個條目,該條目的值是虛基類表指針所在的地址到該類的實例的內存首地址的偏移值。即&(obj.vbtbl) - &obj。虛基類第2、第3、... 個條目依次為該類的最左虛繼承父類、次左虛繼承父類、...的內存地址相對於虛基類表指針自身地址,即 &(obj.vbtbl)的偏移值。

如果一個類同時有虛繼承的父類與祖父類,則虛祖父類放在虛父類前面。

另外需要注意的是,類的虛函數表的第一項之前的項(即*(obj.vftbl-1))為最派生類實例的內存首地址到當前虛函數表指針的偏移值,即mostDerivedObj-obj.vftbl。派生類的虛函數覆蓋基類的虛函數時,在基類的虛函數表的對應條目寫入的是一個「樁」(thunk)函數的入口地址,以調整this指針指向到派生類實例的地址,再調用派生類的對應的虛函數。例如:this -= offset; call DerivedClass:virtFunc;

虛繼承的應用:不可派生的finally類

一個類如果不希望被繼承,類似於Java中的具有finally性質的類,這在C++中可以用虛繼承來實現:

template<typename T> class MakeFinally{
   private:
       MakeFinally(){};//只有MakeFinally的友类才可以构造MakeFinally
       ~MakeFinally(){};
   friend T;
};

class MyClass:public virtual  MakeFinally<MyClass>{};//MyClass是不可派生类

//由于虚继承,所以D要直接负责构造MakeFinally类,从而导致编译报错,所以D作为派生类是不合法的。
class D: public MyClass{};
//另外,如果D类没有实例化对象,即没有被使用,实际上D类是被编译器忽略掉而不报错


int main()
{
MyClass var1;
// D var2;  //这一行编译将导致错误,因为D类的默认构造函数不合法
}

參見

多態 (計算機科學)

引用

  1. ^ Andrei Milea. Solving the Diamond Problem with Virtual Inheritance. http://www.cprogramming.com/: Cprogramming.com. [2010-03-08]. (原始內容存檔於2021-03-04). One of the problems that arises due to multiple inheritance is the diamond problem. A classical illustration of this is given by Bjarne Stroustrup (the creator of C++) in the following example: 
  2. ^ Ralph McArdell. C++/What is virtual inheritance?. http://en.allexperts.com/: All Experts. 2004-02-14 [2010-03-08]. (原始內容存檔於2010-01-10). This is something you find may be required if you are using multiple inheritance. In that case it is possible for a class to be derived from other classes which have the same base class. In such cases, without virtual inheritance, your objects will contain more than one subobject of the base type the base classes share. Whether this is what is the required effect depends on the circumstances. If it is not then you can use virtual inheritance by specifying virtual base classes for those base types for which a whole object should only contain one such base class subobject.