http://bigasp.com/archives/486

如何正確使用C++多重繼承

2011年06月17日 — Asp J

原創文章,轉載請註明:轉載自Soul Apogee
本文連結地址:如何正確使用C++多重繼承

C++多重繼承一直是一個讓人搞不太清楚的一個問題,但是有時候為了實現多個介面,多重繼承是基本不可避免,當然在Windows下我們有強大的COM來幫我們搞定這個事情,不過如果你想自己實現一套類似於COM的東西出來的時候,麻煩事情就來了。

在COM裡面,有兩個很基礎的,而且我們都會用到的特性:
1. 純虛介面:一般使用一個只有純虛擬函式的類來作為介面
2. 引用計數:在C++中一般都使用引用計數來管理物件的生命週期

這兩個功能在一般設計C++介面的時候也經常用到。其實說到底,上面這兩個特性牽扯到的是多重繼承的二個表現:
1. 多重繼承中的資料儲存
2. 多重繼承中的虛擬函式

在COM中,純虛介面是使用的interface來定義的,引用計數是通過IUnknown介面來實現的,所有的介面都是從IUnknown這種介面中派生出來的。當我們需要某一個類實現多個介面的時候,就經常會遇見這樣的多重繼承:
multi-inheritance-com:

哦?!是不是很眼熟,ios,istream,ostream,iostream。。各種C++書籍最喜歡用的一個示例。好吧,現在我們先自己實現一個吧,看看到底要怎麼使用多重繼承。

多重繼承中物件的的資料儲存

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <stdio.h>
 
class IBase
{
public:
    IBase() : n(0) {}
    virtual ~IBase() {}
    void show() { printf("%dn", n); }
    int inc() { return ++n; }
    int dec() { return --n; }
 
protected:
    int n;
};
 
class IA : public IBase
{
public:
    virtual ~IA() {}
};
 
class IB : public IBase
{
public:
    virtual ~IB() {}
};
 
class CImpl : public IA, public IB
{
public:
    virtual ~CImpl() {}
};
 
int main(int argc, char* argv[])
{
    CImpl o;
    IA *pA = &o;
    IB *pB = &o;
 
    pA->inc();
    pA->show();
 
    pB->dec();
    pB->show();
 
    return 0;
}

編譯,OK,成功了!好,執行試一試。
run-result-1:

為什麼是1和-1呢?明明n只在繼承的一個類IBase裡面有,一次加1,一次減一,結果不是應該是1和0麼?是不是很奇怪?

這便是使用多重繼承的時候經常產生的第一個問題:多副本的資料儲存。
當然這個問題很好解決,只需要使用虛繼承即可解決。只需要在IA和IB的定義中,在public IBase前加入virtual關鍵字即可。

1
2
class IA : virtual public IBase
class IB : virtual public IBase

現在再讓我們來看一看執行結果:
run-result-2:

結果已經正確了,為什麼會發生這種情況呢?虛繼承到底幹了些什麼呢?

我們先來看看在沒有使用虛繼承的情況下,CImpl的在記憶體中是怎麼樣的:
cimpl-memory-normal:

對於普通的public繼承(非虛繼承),C++會將基類和派生類的內容放在一塊,合起來組成一個完整的派生類,在記憶體上看,它們是連在一起的。按
照這樣的規則,在這裡,IA和IB中就會各包含一個IBase,而IA,IB和CImpl中的部分內容又共同組成了CImpl。在將CImpl物件o的指
針轉型成IA的指標pA過程中,指標將被移動到IA所在的部分割槽域,同樣在轉型成IB的過程中,指標將被移動到IB所在的部分割槽域(也就是說,轉型之後,
指標的值都不一樣)。在之後的操作中,pA操作的便是IA這個部分中的IBase,而pB操作的便是IB這個部分中的IBase,最後IA部分中的
IBase變成了1,而IB部分中的IBase變成了-1。所以輸出的結果也就變成了1和-1了。

之後我們修改成了虛繼承,看看到底發生了什麼?
cimpl-memory-virtual:

原來的IA和IB中的IBase部分變成了一個指向基類的指標,而基類也變成了一個單獨的部分。這樣一旦對基類做任何的修改,都會通過這個指標重定向到這
個獨立的基類上去,於是,就不存在多副本的資料儲存了,這個詭異的問題也就解決了。但是當然從這個圖上我們也可以看到,使用虛繼承後,訪問資料多了一次跳
轉,這多出的一次跳轉將導致效率的下降一倍甚至更多,所以如果一個類使用的非常頻繁,很明顯應該儘量避免使用虛繼承。

二義性

當然資料的儲存只是使用多重繼承中遇到的一個問題,現在我們來看另外一個問題,函式的二義性。
首先我們先把資料的儲存拋開,單純的來看一個只有函式的繼承關係。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class IBase
{
public:
    virtual ~IBase() {}
    void foo() { }
};
 
class IA : public IBase
{
public:
    virtual ~IA() {}
};
 
class IB : public IBase
{
public:
    virtual ~IB() {}
};
 
class CImpl : public IA, public IB
{
public:
    virtual ~CImpl() {}
};
 
int main(int argc, char* argv[])
{
    CImpl o;
    o.foo();    // 直接呼叫CImpl的foo函式
 
    return 0;
}

編譯一下,試試。
error C2385: ambiguous access of ‘foo’
could be the ‘foo’ in base ‘IBase’
or could be the ‘foo’ in base ‘IBase’
error C3861: ‘foo’: identifier not found

出錯了!杯具。。為什麼?錯誤還這麼奇怪,神馬叫做可以是IBase中的foo又可以是IBase中的foo呢?

這就是使用多重繼承的時候經常產生的第二個問題:二義性。
在使用多重繼承時,如果有兩個被繼承的類擁有共同的基類,那麼就很容易出現這種情況。那什麼是二義性呢?
我們先來看一個更簡單的繼承關係:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class A
{
public:
    void foo();
};
 
class B : public A
{
public:
    void foo();
};
 
class C : public B
{
public:
    void foo();
};

我們可以把繼承關係中,兩個類之間沿著基類方向的相隔的繼承級數看成一個距離,那麼C到A的距離是2,B到A的距離就是1。當然距離不能為負。
當我們對ABC中某個物件呼叫foo函式的時候,編譯器會優先選擇離當前指標型別的距離最短的一個函式實現去呼叫,也就是說,foo函式的查詢路徑是C->B->A,找到一個最近的去呼叫。
而對於我們當前這個繼承關係來說,IA和IB還是各包含一份IBase的例項,雖然在記憶體裡這裡僅僅是包含一份資料,但是在編譯的過程中,IA和IB中還
包含了一份從IBase中繼承下來的函式列表。所以有兩個包含有foo函式類與CImpl類的距離是一樣的,所以在對CImpl呼叫foo函式,就產生了
所謂的二義性,除非我們指定使用IA::foo或者IB::foo,否則編譯器將無法決定使用哪一個基類的foo函式。

1
o.IA::foo();    // 指定呼叫CImpl從IA部分繼承過來的foo函式,這樣就可以編譯通過了。

當然如果我們這樣寫程式碼也是不行的:

1
2
IBase *pBase = &o;    // 指標轉義時的二義性,不知道是使用IA中的IBase部分,還是IB中的IBase部分
o.inc();                    // 資料訪問時的二義性,不知道是訪問IA中IBase部分的n,還是IB中IBase部分的n

多重繼承中的虛擬函式

既然直接使用多重繼承會有如此多的問題,那麼我們能不能通過虛擬函式來解決這個問題呢?

這裡小小的提一下,剛剛二義性裡面說到兩個類的距離,對於編譯器來說,一般是找離當前的類距離最近的函式實現來呼叫(或者資料來訪問),而虛擬函式則是讓編譯器做相反的事情:找一個離當前類反向距離最遠的函式實現來呼叫。

好,我們先把上面的程式做一點點小改變,把foo()函式變成一個虛擬函式,看看有什麼變化。

1
2
3
4
5
6
class IBase
{
public:
    virtual ~IBase() {}
    virtual void foo() {}    // 變成虛函數了
};

編譯,結果還是失敗。
error C2385: ambiguous access of ‘foo’
could be the ‘foo’ in base ‘IBase’
or could be the ‘foo’ in base ‘IBase’
error C3861: ‘foo’: identifier not found

產生問題的原因依然是二義性。即便換成virtual函式,也不能改變二義性這個問題。為什麼呢?
因為我們是用的.運算子來訪問的,而不是用指標,所以這裡虛擬函式和普通函式沒有任何區別。=.=。。。
好,我們再來小小的修改一下,把他變成指標,讓他通過虛表去訪問,看看行不行。

1
2
CImpl *p = &o;
p->foo();

編譯,結果。。。還是一樣失敗。。。
好吧,我們可以把呼叫foo()的幾句話都去掉,來看看CImpl中生成的虛表到底是個什麼樣子。
debug-result-vptr-1:

在這個例項中,IA和CImpl部分公用一個虛表,而IB則使用另外的一個虛表(兩個虛表這個特性主要是在指標型別轉換的時候有用,這裡就不說了)。
在這IA的虛表中存在一個指向IBase::foo()的指標,在IB的虛表中也存在一個指向IBase::foo()的指標,所以在CImpl中,可以
找到兩個IBase::foo()函式的指標,這樣,編譯器就無法確定到底應該使用哪一個IBase::foo()函式作為他自己的foo()函數了。二
義性也就產生了。

既然如此,那解決起來就沒有什麼別的辦法了,只能把foo函式的最終在CImpl中實現一次了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class CImpl : public IA, public IB
{
public:
    virtual ~CImpl() {}
    virtual void foo() { }
};
 
int main(int argc, char* argv[])
{
    CImpl o;
    o.foo();
 
    CImpl *p = &o;
    p->foo();
 
    return 0;
}

編譯一下,通過了!對於o.foo()來說,這當然是意料之中,離CImpl距離最近的foo函式實現,就是CImpl自己嘛,當然沒有問題。
對於後面這個p->foo()的呼叫,編譯器現在也已經可以決定對於CImpl這個類來說,離他最遠的foo函式呼叫是誰了——也是他自己。
所以這裡就不會產生二義性的問題了。

在多重繼承中編譯器對this指標的修正
這裡再讓我們來看看這次編譯出來的虛表,看看還有什麼發現。
debug-result-vptr-2:

0x004112a3 [thunk]:CImpl::foo`adjustor{4}’ (void) *
這個看上去很怪的函式是什麼呢?我們反彙編一下他看看。
virtual-function-wrapper:

這裡可以看到有一句彙編指令:sub ecx, 4。這條指令的左右其實是在修正this指標。
因為從IB的虛表來的請求,this指標都是指向CImpl中IB的部分,而當呼叫CImpl中的foo函式時,如果還使用IB的this指標,那麼程式就會出錯,所以這裡需要先將this指標修正為CImpl所在的地址,才能呼叫CImpl的foo函式。

在程式執行的時候,this指標一般被儲存在ecx暫存器中,或者當前函式的第一個引數傳遞進去,不過不同的語言或者不同的編譯器編譯出來的程式碼可能會不一樣。

我們這裡的解構函式都是虛擬函式,所以我們還可以在截圖中看到,編譯器會對解構函式做同樣的處理。

如何同時解決資料訪問和二義性問題呢

貌似到現在都只提到最簡單的一種多重繼承的情況,但是實際上我們已經遇到了很多的問題了,既然多重繼承中會有這麼多問題,那我們有沒有什麼比較通用的方法能把他們一起解決了呢?
方法肯定是有的:
1. 使用虛繼承
這算是一種確實可行的方法,只是說會帶來額外的時間和空間的開銷,訪問任何一個數據,都需要通過虛繼承表進行跳轉,不過一般來說夠用了。

2. 虛擬函式當介面,繼承多個介面,統一實現
這個思想就類似於COM了,只是說COM用的是純虛擬函式,對於那些會產生二義性的類,我們在最後都實現一邊,這樣就不會有問題了。這樣帶來的時間開銷也僅
僅是呼叫時查詢一次虛表。但是麻煩的地方就是,有時候繼承一下,你可能就要實現一下了,比如引用計數神馬的,當然你也可以通過模版來簡化你的程式碼。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class IBase
{
public:
    virtual ~IBase() {}
    virtual void show() = 0;
};
 
class IA : public IBase
{
public:
    virtual ~IA() {}
    virtual int inc() = 0;
};
 
class IB : public IBase
{
public:
    virtual ~IB() {}
    virtual int dec() = 0;
};
 
class CImpl : public IA, public IB
{
public:
    CImpl() : n(0) {}
    virtual ~CImpl() {}
    int inc() { return ++n; }
    int dec() { return --n; }
    void show() { printf("%dn", n); }
 
private:
    int n;
};

3. 通過純虛擬函式實現模版方法,將函式轉移
這種實現比較複雜,wtl中用的比較多,一般是用在引用計數上,好處很明顯,就是可以繼承,不用每個類都實現一個引用計數,而只用將新的基類的引用計數轉移至原本存在的類上就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class IBase
{
public:
    virtual ~IBase() {}
    void foo() {}
};
 
class IA : public IBase
{
public:
    virtual ~IA() {}
};
 
class IShifter
{
public:
    virtual ~IShifter() {}
    void foo() { do_foo(); }
 
protected:
    virtual void do_foo() = 0;
};
 
class IB : public IShifter
{
public:
    virtual ~IB() {}
};
 
class CImpl : public IA, public IB
{
public:
    virtual ~CImpl() {}
    void foo() { IA::foo(); }
 
protected:
    virtual void do_foo() { IA::foo(); }
};