本文主要介绍 C++虚函数的底层实现,虚析构函数的作用,虚函数表的数据结构及存储方式,以及__declspec(novtable)的作用。

一. 虚函数介绍

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
// 基类
//
class Base {
public:
Base() {
printf("Call Base::Base()\n");
}

~Base() {

}

void Name() {
printf("Call Base::Name()\n");
}
};

// 派生类
//
class Derive : public Base {
public:
Derive() {
printf("Call Derive::Derive()\n");
}

~Derive() {

}

void Name() {
printf("Call Derive::Name()\n");
}
};

int main()
{
Base* pBase = new Derive();
pBase->Name();

delete pBase;
return 0;
}

程序输出:

1
2
3
Call Base::Base()
Call Derive::Derive()
Call Base::Name()

输出内容的第 3 行为:Call Base::Name(),并不是期望的Call Derive::Name()
因为void Name()函数不是虚函数,所以pBase->Name()调用的是基类的 Name()函数,并不是我们所期望的派生类 Derive 的 Name()函数。 如果将基类中void Name()改成虚函数virtual void Name(),程序输出就会和我们期望的一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 基类
//
class Base {
public:
Base() {
printf("Call Base::Base()\n");
}

~Base() {
printf("Call Base::~Base()\n");
}

virtual void Name() {
printf("Call Base::Name()\n");
}
};

二、 虚析构函数

virtual不仅可以修饰成员函数,也可以用来修饰析构函数,也就是我们常说的虚析构函数。 下面的例子中的基类的析构函数没有使用 virtual 修饰,我们先执行程序,观察运行结果(类似上面的程序,只是在析构函数中多加入了输出打印语句)。

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
// 基类
//
class Base {
public:
Base() {
printf("Call Base::Base()\n");
}

~Base() {
printf("Call Base::~Base()\n");
}

virtual void Name() {
printf("Call Base::Name()\n");
}
};

// 派生类
//
class Derive : public Base {
public:
Derive() {
printf("Call Derive::Derive()\n");
}

~Derive() {
printf("Call Derive::~Derive()\n");
}

void Name() {
printf("Call Derive::Name()\n");
}
};

int main()
{
Base* pBase = new Derive();
pBase->Name();

delete pBase;
return 0;
}

程序输出:

1
2
3
4
Call Base::Base()
Call Derive::Derive()
Call Derive::Name()
Call Base::~Base()

从输出内容的第 4 行可以看到,执行delete pBase语句只有基类 Base 类的析构函数被调用了,而派生类 Derive 的析构函数却没有被调用。如果此时派生类 Derive 中有需要在析构函数执行的代码(如内存释放,句柄关闭等),这些代码将不会执行,有可能就会造成内存泄漏、句柄泄漏、逻辑错误等问题。

正确的做法是:使用 virtual 修饰基类的析构函数,即虚析构函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 基类
//
class Base {
public:
Base() {
printf("Call Base::Base()\n");
}

virtual ~Base() {
printf("Call Base::~Base()\n");
}

virtual void Name() {
printf("Call Base::Name()\n");
}
};

此时程序输出为:

1
2
3
4
5
Call Base::Base()
Call Derive::Derive()
Call Derive::Name()
Call Derive::~Derive()
Call Base::~Base()

调用delete pBase之后,先执行了派生类 Derive 的析构函数,然后执行基类 Base 的析构函数。

在《C++API 设计》一书中有明确的说到:“如果希望一个类可以被继承,那么就应该将它的析构函数使用 virtual 修饰;反过来可以理解为,如果一个类的析构函数不是虚的,那么这个类是被设计为不可继承的。”

三、虚函数的实现原理

3.1 实现原理

C++的虚函数是使用虚函数表(即指针数组,也就是指针的指针)来实现的。 只要在类中声明了虚函数,编译器就会在类的对象中自动生成一个虚函数表,但一个对象最多只有一个虚函数表,不管这个类声明了多少个虚函数。虚函数表是针对于类的对象的。

3.2 虚函数表(指针)存储位置

不同的编译器将自动生成的虚函数表指针存放的位置不同,有的存放在类对象所占内存的起始位置,有的存放在类对象所占内存的末尾。 可以通过如下代码来判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 返回值: ture  - 虚函数指针存放在对象内存起始位置
// false - 虚函数指针存放在对象内存末尾位置
//
bool VirtualTableAtFirst() {
class _C {
public:
char _i;
virtual void _f() {
}
};

_C c;
char * p1 = reinterpret_cast<char*>(&c);
char * p2 = reinterpret_cast<char*>(&c._i);

return p1 != p2;
}

通过 MSVC2015 编译运行,返回true, 说明 MSVC 编译器是将虚函数表指针放置在类对象内存的起始位置处。

3.3 虚函数表存储方式

既然知道了 C++是使用虚函数表的形式来实现虚函数的,那个虚函数表中的数据是以何种形式来存储的了? 现在我们根据类的继承方式的不同来分别说明。

3.3.1 单继承无重载

类结构如图,Derive 继承于 Base,但 Derive 没有重载 Base 类中的任何函数。

需要说明的是,函数 f(), g(), h(), f1(), g1(), h1() 均为虚函数,这个在图上没有明确的写出来,后面的图也是一样。

这时Base b; 对象 b 的虚函数表为:

Derive d; 对象 d 的虚函数表为:

3.3.2 单继承有重载

Derive 重载 Base 类中的 f()函数:

这时Base b; 对象 b 的虚函数表不变,无论继承于它派生类如何重载,都不会影响基类的虚函数:

Derive d; 对象 d 的虚函数表为:

派生类中重载基类的 f()函数指针替换了原来基类中虚函数 Base::f()的指针; 派生类中其他的虚函数存放在基类虚函数之后。

3.3.3 多继承无重载

此时,Derive d; 对象 d 的虚函数表为:

派生类自己的虚函数存放在第一个基类的虚函数表的最后面。

3.3.4 多继承有重载

此时,Derive d; 对象 d 的虚函数表为:

四、虚函数表的应用

我们知道在 C++中要调用类的私有方法,我们可以使用友员(friend)的方式。但其实如果我们知道类的定义,完全可以根据该类对象的内存布局来直接调用它的私有函数。

下面介绍如何使用虚函数表的方式来调用该类的私有虚函数

现有如下Test类,提供了一个私有虚函数virtual void Func()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Test {
public:
Test() {

}

virtual~ Test() {

}

private:
virtual void Func() {
printf("Private Function\n");
}
};

现在我们使用虚函数表来调用Func成员函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef void(*PFN_Func)();

int main()
{
Test t;
unsigned long **VirtualTable = (unsigned long **)(&t);
unsigned long FuncAddr = VirtualTable[0][1];

PFN_Func pfnFunc = (PFN_Func)FuncAddr;
if (pfnFunc) {
pfnFunc();
}

return 0;
}

我们知道虚函数表其实就是一个二维数组。因为示例中的Test类没有继承于其他类,所以第一维只有一个元素;又因Test类有 2 个虚函数,故第二维有 2 个元素,且Func排在第二个,所以用VirtualTable[0][1]来取Func函数地址。

因为该示例运行在 MSVC 编译器环境,所以默认认为虚函数表位于类对象内存布局的起始位置,故直接使用了unsigned long **VirtualTable = (unsigned long **)(&t);
严谨的做法应该是先判断虚函数表是否位于对象的内存布局起始位置。

五、__declspec(novtable)

Mircrosoft C++中提供了__declspec(novtable)来修饰类,__declspec(novtable)会阻止编译器为该类生成虚函数表,这样可以减少编译生成代码的大小,同时也约束该类无法被实例化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// novtable.cpp
#include <stdio.h>

struct __declspec(novtable) X {
virtual void mf();
};

struct Y : public X {
void mf() {
printf_s("In Y\n");
}
};

int main() {
// X *pX = new X();
// pX->mf(); // 导致运行时访问冲突

Y *pY = new Y();
pY->mf();
}

文章图片带有“CSDN”水印的说明:
由于该文章和图片最初发表在我的CSDN 博客中,因此图片被 CSDN 自动添加了水印。