這篇博客來講一下g++實作的C++物件模型中的虛函式的實作,包括:單一繼承體系下的虛函式,多繼承下的虛函式和虛繼承下的虛函式,其中虛繼承下的虛函式在《深度探索C++物件模型》中只是說很復雜,受限于技術力和查到的資料,這里我只是對于g++的部分實作進行觀察,
單一繼承體系下的虛函式
在前面的博客中我們已經通過對虛表的探索講了虛函式的一般實作,大體上來說就是編譯器會在適當的時候(在單一繼承體系中就是當類中第一次出現虛函式的時候)添加一個虛表指標,指向屬于該類的虛函式表,而所有虛函式的地址會出現在虛表指標的固定表項,也就是說在繼承體系下的一個虛函式會被賦予固定的虛表下標,當派生類覆寫(override)了基類的虛函式時,新的虛函式的地址會出現在基類虛函式在虛表中的位置,在多型呼叫虛函式時從虛表中取出虛函式地址來呼叫,從而實作多型,
一般而言,在單一繼承體系下每一個類都只有一個虛表,在這個虛表中存有所有active virtual functions(中文版《深度探索C++物件模型》沒有翻譯,我這里也直接使用了,在我的理解里就是派生類所有有效的、能用的虛函式)的地址,這些active virtual functions包括:
- 該類所定義的所有虛函式,包括其覆寫(override)的基類的虛函式;
- 繼承自基類的虛函式,如果派生類不覆寫這些虛函式的話;
- 一個pure_vairtual_called()函式物體,她既可以扮演pure virtual function的空間保衛者角色,也可以當作例外處理函式(有時候會用到)【《深度探索C++物件模型》原話】
// test23.cpp
class Base {
public:
Base(int i)
: m_i(i)
{}
virtual
~Base() {
m_i = 0;
}
virtual
int getInt() {
return m_i;
}
virtual
void increaseInt() {
m_i++;
}
virtual
long getLong() = 0;
private:
int m_i;
};
class Derived: public Base {
public:
Derived(int i, long l)
: Base(i),
m_l(l)
{}
virtual
~Derived() {
m_l = 0;
}
virtual
int getInt() override { // overrid Base::getInt()
return Base::getInt() + 1;
}
virtual
long getLong() override { // overrid Base::getLong(),在Base中是一個純虛函式
return m_l;
}
virtual
void increaseLong() { // new virtual function
++m_l;
}
private:
long m_l;
};
int main() {
Derived* pd = new Derived(1, 2L);
int i = pd->getInt();
pd->increaseInt();
long l = pd->getLong();
pd->increaseLong();
pd->~Derived();
delete pd;
}

另外,在這里我們可以注意到一個問題,虛表指標指向的空間,前兩個表項都顯示是Derived::~Derived(),也就是都是解構式,而且地址不一樣,這是怎么回事?我們看一下這兩處地方的匯編代碼:

可以看到,第一個解構式就是普通的解構式它先呼叫了我們自己定義的解構式,再呼叫了基類的解構式Base::~Base;而第二個虛構函式則是先呼叫了第一個解構式,再呼叫了::operator delete(_ZdlPvm使用c++filt工具查看可知其就是operator delete(void*, unsigned long)),
那是不是就是當我們自己呼叫Derived::~Derived時呼叫第一個,使用delete運算子時呼叫的就是第二個呢?我們看到反匯編:

可以看到確實是這樣的,同時,我們還有一個小發現,就是當delete運算子操作的指標是nullptr時,是不會呼叫解構式的,編譯器真是相當費心了(在我的測驗下好像是只有delete一個指向有虛解構式的物件的指標時才會檢查,否則就直接不檢查呼叫::operator delete),
關于最后一個,因為我們無法實體化抽象基類,所以使用-fdump-class-hierarchy選項查看類資訊:
Vtable for Base
Base::_ZTV4Base: 7 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI4Base)
16 0
24 0
32 (int (*)(...))Base::getInt
40 (int (*)(...))Base::increaseInt
48 (int (*)(...))__cxa_pure_virtual
Class Base
size=16 align=8
base size=12 base align=8
Base (0x0x7f24b28e7960) 0
vptr=((& Base::_ZTV4Base) + 16)
Vtable for Derived
Derived::_ZTV7Derived: 8 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI7Derived)
16 (int (*)(...))Derived::~Derived
24 (int (*)(...))Derived::~Derived
32 (int (*)(...))Derived::getInt
40 (int (*)(...))Base::increaseInt
48 (int (*)(...))Derived::getLong
56 (int (*)(...))Derived::increaseLong
Class Derived
size=24 align=8
base size=24 base align=8
Derived (0x0x7f24b277d1a0) 0
vptr=((& Derived::_ZTV7Derived) + 16)
Base (0x0x7f24b28e7de0) 0
primary-for Derived (0x0x7f24b277d1a0)
我們可以看到在Base類的48偏移處確實有一個__cxa_pure_virtual表項,應該就是所謂的pure_vairtual_called,在結合Derived類的虛表,在對應位置是Derived::getLong,說明正是使用該函式占位了Base::getLong這個虛函式,
多重繼承下的虛函式
在單一繼承體系下一切都顯得那么美好,完全不涉及到指標的調整,因為所有的指標轉化都不需要做底層的調整,始終指向類的開頭,你可能現在還不能理解,在看完這一部分后再來看上面這一句話就會感慨:啊,單一繼承是這么簡單的事!
但在多重繼承下事情開始變得復雜,看下面的例子:
// test24.cpp
class Base1 {
public:
Base1(int i)
: m_i(i)
{}
virtual
int getInt() {
return m_i;
}
virtual
Base1* clone() {
return new Base1(m_i);
}
private:
int m_i;
};
class Base2 {
public:
Base2(long l)
: m_l(l)
{}
virtual
long getLong() {
return m_l;
}
virtual
Base2* clone() {
return new Base2(m_l);
}
private:
long m_l;
};
class Derived: public Base1, public Base2 {
public:
Derived(int i, long l)
: Base1(i),
Base2(l)
{}
virtual
long getLong() { // override Base2::getLong()
return Base2::getLong() + 1L;
}
virtual
Derived* clone() { // override Base1::clone 和 Base2::clone
return new Derived(getInt(), getLong());
}
private:
};
int main() {
Derived* pd = new Derived(1, 2L);
Base2* pb2 = pd;
long l = pb2->getLong(); // (1)
Base2* p = pb2->clone(); // (2)
}
試想,在(1)這一陳述句上,我們使用pb2呼叫getLong這一虛函式,雖然pb2型別是Base2*,但它實際上指向的是類Derived的物件,由前面的知識我們知道,在指標由Dervied*轉化為Base2*時,會加上Base2在類Derived內的偏移(為0x10),那就出問題了,pd2 = (pd2 == nullptr ? nullptr : pd + 0x10),在執行pb2->getLong()時,傳入的是pd2,但實際上呼叫的是Derived::getLong(),需要的是派生類Derived的指標,怎么辦?
同時,在(2)這一陳述句上,回傳的是Derived*指標,但接收的是Base2*指標,如何在運行時知道對指標進行處理?
解決這兩個問題的方法就是一個被稱為"thunk"的技術,
所謂"thunk",就是在代碼的前面或后面添加一段小的代碼段,
比如在Derived::getLong(),為了調整指標,編譯器會生成這樣一段代碼:
// 偽碼
// thunk for Derived::getLong()
this = this - 0x10
jmp Derived::getLong(this)
而在虛函式表中Derived::getLong()應該在的位置,便由上述thunk的地址代替了,
至于在pb2的clone()函式,則被調整為:
// 偽碼
// thunk for Derived::clone()
this = this - 0x10
Derived* pd = Derived::clone()
pd = (pd == nullptr ? nullptr : pd + 0x10)
return pd
我們看一下反匯編,驗證一下:

可以看到Derived::getLong()確實是這樣的,再看一下Derived::clone():


確實是前面描述的那樣,只不過編譯器將其分為了兩部分,一部分調整this指標,一部分調整回傳值,
其實在《深度探索C++物件模型》中,還提到了一種情況,那就是基類指標呼叫派生類的虛函式,而在派生類的虛函式中又呼叫基類的虛函式,在這種情況下,在派生類的虛函式中呼叫基類的虛函式時又要調整this指標,
我覺得這種其實不是問題,因為在派生類中this指標明確是Derived*型別,既然要呼叫基類的虛函式,肯定是要將Derived*型別轉化為Base1*或者Base2*型別,自然要進行this指標的調整,這是自然而然的,不需要添加額外的東西,
總的來說,一個派生自n個基類的派生類,除了原本要生成的一個虛函式表外,還要生成n-1個額外的虛函式表,在本例中,有兩個虛函式表被編譯出來:
- 一個主要物體,與Base1(最左側的基類)共享
- 一個次要物體,與Base2(第二個基類)共享
在g++中,這兩個表是緊貼在一起的,我們使用-fdump-class-hierarchy引數看一下類資訊:
Vtable for Derived
Derived::_ZTV7Derived: 9 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI7Derived)
16 (int (*)(...))Base1::getInt // 第一個虛表指標指向的地方
24 (int (*)(...))Derived::clone
32 (int (*)(...))Derived::getLong
40 (int (*)(...))-16
48 (int (*)(...))(& _ZTI7Derived)
56 (int (*)(...))Derived::_ZThn16_N7Derived7getLongEv // 第二個虛表指標指向的地方
64 (int (*)(...))Derived::_ZTchn16_h16_N7Derived5cloneEv
Class Derived
size=32 align=8
base size=32 base align=8
Derived (0x0x7fa4a118e5b0) 0
vptr=((& Derived::_ZTV7Derived) + 16)
Base1 (0x0x7fa4a12e7ea0) 0
primary-for Derived (0x0x7fa4a118e5b0)
Base2 (0x0x7fa4a12e7f00) 16
vptr=((& Derived::_ZTV7Derived) + 56)
虛繼承下的虛函式
《深度探索C++物件模型》中對于虛函式的實作并無講解,只是說再虛繼承體系下虛函式的實作非常復雜,其建議不要在虛基類中定義非靜態的資料成員,所以下面只是我對于g++對虛繼承下虛函式的實作的觀察,并沒有形成總結,
// test25.cpp
class Point2D {
public:
Point2D(int x, int y)
: m_x(x),
m_y(y)
{}
virtual
~Point2D() {
m_x = m_y = 0;
}
virtual
void allAddOne() {
m_x += 1;
m_y += 1;
}
virtual
int z() const {
return 0;
}
private:
int m_x;
int m_y;
};
class Point3D: virtual public Point2D {
public:
Point3D(int x, int y, int z)
: Point2D(x, y),
m_z(z)
{}
virtual
~Point3D() {
m_z = 0;
}
virtual
void allAddOne() override {
Point2D::allAddOne();
m_z += 1;
}
virtual
int z() const override {
return m_z;
}
private:
int m_z;
};
int main () {
Point3D* p3d = new Point3D(1, 2, 3);
Point2D* p2d = p3d;
p2d->allAddOne();
int z = p2d->z();
}
我們先使用-fdump-class-hierarchy查看類的資訊:
Vtable for Point2D
Point2D::_ZTV7Point2D: 6 entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI7Point2D)
16 (int (*)(...))Point2D::~Point2D
24 (int (*)(...))Point2D::~Point2D
32 (int (*)(...))Point2D::allAddOne
40 (int (*)(...))Point2D::z
Class Point2D
size=16 align=8
base size=16 base align=8
Point2D (0x0x7ff517ae7960) 0
vptr=((& Point2D::_ZTV7Point2D) + 16)
Vtable for Point3D
Point3D::_ZTV7Point3D: 16 entries
0 16
8 (int (*)(...))0
16 (int (*)(...))(& _ZTI7Point3D)
24 (int (*)(...))Point3D::~Point3D
32 (int (*)(...))Point3D::~Point3D
40 (int (*)(...))Point3D::allAddOne
48 (int (*)(...))Point3D::z
56 18446744073709551600
64 18446744073709551600
72 18446744073709551600
80 (int (*)(...))-16
88 (int (*)(...))(& _ZTI7Point3D)
96 (int (*)(...))Point3D::_ZTv0_n24_N7Point3DD1Ev
104 (int (*)(...))Point3D::_ZTv0_n24_N7Point3DD0Ev
112 (int (*)(...))Point3D::_ZTv0_n32_N7Point3D9allAddOneEv
120 (int (*)(...))Point3D::_ZTv0_n40_NK7Point3D1zEv
VTT for Point3D
Point3D::_ZTT7Point3D: 2 entries
0 ((& Point3D::_ZTV7Point3D) + 24)
8 ((& Point3D::_ZTV7Point3D) + 96)
Class Point3D
size=32 align=8
base size=12 base align=8
Point3D (0x0x7ff51797d1a0) 0
vptridx=0 vptr=((& Point3D::_ZTV7Point3D) + 24)
Point2D (0x0x7ff517ae7de0) 16 virtual
vptridx=8 vbaseoffset=-24 vptr=((& Point3D::_ZTV7Point3D) + 96)
Point3D物件的結構還是比較簡單的,如下:
(gdb) x/8xw p3d
0x8414e70: 0x08201cd0 0x00000000 0x00000003 0x00000000
0x8414e80: 0x08201d18 0x00000000 0x00000001 0x00000002
很明顯0x08201cd0是Point3D新增的虛表指標,結合類資訊,我們知道其指向了((& Point3D::_ZTV7Point3D) + 24);而0x08201d18是繼承自虛基類的放虛表指標的地方,只不過這里放了Derived類自己的虛表指標,其指向了((& Point3D::_ZTV7Point3D) + 96),
我們關注的重點是虛基類的虛函式表和其中虛函式的實作:虛函式表中放的是什么的地址?不像沒有虛基類的多重繼承那樣各個物件的偏移是一定的,(在只有指標或參考的情況下)虛繼承下虛基類的偏移是運行時才能知道的,其中的虛函式又是如何調整this指標的呢?
(gdb) x/4ag 0x08201d18
0x8201d18 <_ZTV7Point3D+96>: 0x8000ba6 <_ZTv0_n24_N7Point3DD1Ev> 0x8000bdb <_ZTv0_n24_N7Point3DD0Ev>
0x8201d28 <_ZTV7Point3D+112>: 0x8000c24 <_ZTv0_n32_N7Point3D9allAddOneEv> 0x8000c3f <_ZTv0_n40_NK7Point3D1zEv>
正如類資訊中展示的那樣,虛基類的虛表中放置的正是這幾個函式名字,但這幾個函式是什么呢?我們使用c++filt看一下:
$ c++filt _ZTv0_n24_N7Point3DD1Ev
virtual thunk to Point3D::~Point3D()
$ c++filt _ZTv0_n24_N7Point3DD0Ev
virtual thunk to Point3D::~Point3D()
$ c++filt _ZTv0_n32_N7Point3D9allAddOneEv
virtual thunk to Point3D::allAddOne()
$ c++filt _ZTv0_n40_NK7Point3D1zEv
virtual thunk to Point3D::z() const
可以看到他們被稱為virtual thunk,看來是和thunk相似的技術,用來調整this指標和回傳值,我們來看看其內部是怎么運行的:

和我們前面討論的thunk非常像,都是調整this指標,只是前面的thunk里this指標調整的值是固定的,而這里this指標調整的值是動態的放在vptr[-3]處,我們再看一下這里放的是什么,我們直接看g++生成的類資訊,虛表指標是指向((& Point3D::_ZTV7Point3D) + 96),那vptr[-3]就應該是((& Point3D::_ZTV7Point3D) + 72)放的東西了,可以看到是18446744073709551600,把這個值當作一個long型別的值的話正好是-16,這不就是從Point2D*型別轉化為Point3D*型別需要減的值嘛(因為Point2D在Point3D類的物體中偏移為16),我們再檢查一下其他的virtual thunk是不是也是一樣?

嗯,沒問題,再看看下一個:

不好,出現不一樣了,這次偏移是vptr[-4]這里,也就是((& Point3D::_ZTV7Point3D) + 64)放的東西,可以看到是18446744073709551600,咦,和上面的是一樣的,考慮到虛表指標指向的前兩項其實是一個函式,只不過一個不呼叫::operator delete,一個呼叫而已,那是不是編譯器為每個虛函式都準備了一個this指標的調整量?我們繼續看最后剩下的那個virtual thunk:

果然是這樣的,這次是vptr[-5],
我們可以稍微總結一下虛繼承下虛函式的實作:就是在虛表里為每個虛函式增加了虛基類指標到override該虛函式的派生類的指標需要對this進行的偏移,
我能做的總結也就是這樣了,如果有大神知道詳細的規則可以評論一下,或者給一個鏈接,謝謝,
這一章后面還有成員函式指標的內容,就留在后面的博客里講吧,
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/528000.html
標籤:其他
上一篇:C++建構式初始化串列注意的坑
下一篇:限流 - 限流注解組件開發
