C++多态

面向对象的程序设计的三大要素之一就是多态,多态是指基类的指针指向不同的派生类,其行为不同。多态的实现主要是通过虚函数和虚表来完成,虚表保存在对象的头四个字节,要调用虚函数必须存在对象,也就是说虚函数必须作为类的成员函数来使用。

编译器为每个拥有虚函数的对象准备了一个虚函数表,表中存储了虚函数的地址,类对象在头四个字节中存储了虚函数表的指针。
下面是一个具体的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class CVirtual
{
public:
virtual void showNumber(){
printf("%d\n", nNum);
}

virtual void setNumber(int n){
nNum = n;
}
private:
int nNum;
};

int main()
{
CVirtual cv;
cv.setNumber(2);
cv.showNumber();
return 0;
}

上述这段代码定义了两个虚函数setNumber和showNumber,并在主函数中调用了他们,下面通过反汇编的方式来展示编译器是如何调用虚函数的

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
26:       CVirtual cv;
00401288 lea ecx,[ebp-8];对象中有一个整形数字占4个字节,同时又有虚函数,头四个字节用来存储虚函数表指针,总共占8个字节
0040128B call @ILT+25(CVirtual::CVirtual) (0040101e);调用构造函数
27: cv.setNumber(2);
00401290 push 2
00401292 lea ecx,[ebp-8]
00401295 call @ILT+0(CVirtual::setNumber) (00401005);调用虚函数
28: cv.showNumber();
0040129A lea ecx,[ebp-8]
0040129D call @ILT+5(CVirtual::showNumber) (0040100a);调用虚函数
29: return 0;
004012A2 xor eax,eax
;构造函数
0401389 pop ecx;还原ecx使得ecx保存对象的首地址
0040138A mov dword ptr [ebp-4],ecx
0040138D mov eax,dword ptr [ebp-4]
00401390 mov dword ptr [eax],offset CVirtual::`vftable' (0042f020);将虚函数表的首地址赋值到对象的头4个字节
00401396 mov eax,dword ptr [ebp-4]
00401399 pop edi
0040139A pop esi
0040139B pop ebx
0040139C mov esp,ebp
0040139E pop ebp
0040139F ret
;setNumber(int n)
0040134A mov dword ptr [ebp-4],ecx
18: nNum = n;
0040134D mov eax,dword ptr [ebp-4];eax = ecx
00401350 mov ecx,dword ptr [ebp+8]
00401353 mov dword ptr [eax+4],ecx

从上面的汇编代码可以看到,当类中有虚函数的时候,编译器会提供一个默认的构造函数,用于初始化对象的头4个字节,这四个字节存储的是一个指针值,我们定位到这个指针所在的内存如下图:
虚函数表的内荣
这段内存中存储了两个值,分别为0x0040100AH和0x00401005H,我们执行后面的代码发现,这两个地址给定的是虚函数所在的地址。
虚函数地址
在调用时编译器直接调用对应的虚函数,并没有通过虚表来寻址到对应的函数地址。下面看另外一个例子:

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
class CParent
{
public:
virtual void showClass()
{
printf("CParent\n");
}
};

class CChild:public CParent
{
public:
virtual void showClass()
{
printf("CChild\n");
}
};
int main()
{
CParent *pClass = NULL;
CParent cp;
CChild cc;
pClass = &cp;
pClass->showClass();
pClass = &cc;
pClass->showClass();
pClass = NULL;
return 0;
}

上述代码定义了一个基类和派生类,并且在派生类中重写了函数showClass,在调用时用分别利用基类的指针指向基类和派生类的对象来调用这个虚函数,得到的结果自然是不同的,这样构成了多态。下面是它的反汇编代码,这段代码基本展示了多态的实现原理

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
29:       CParent *pClass = NULL;
00401288 mov dword ptr [ebp-4],0
30: CParent cp;
0040128F lea ecx,[ebp-8]
00401292 call @ILT+30(CParent::CParent) (00401023);调用构造函数
31: CChild cc;
00401297 lea ecx,[ebp-0Ch]
0040129A call @ILT+0(CChild::CChild) (00401005)
32: pClass = &cp;
0040129F lea eax,[ebp-8];取对象的首地址
004012A2 mov dword ptr [ebp-4],eax;
33: pClass->showClass();
004012A5 mov ecx,dword ptr [ebp-4]
004012A8 mov edx,dword ptr [ecx];将指针值放入到edx中
004012AA mov esi,esp
004012AC mov ecx,dword ptr [ebp-4];获取虚函数表指针
004012AF call dword ptr [edx];调转到虚函数指针所对应的位置执行代码
004012B1 cmp esi,esp
004012B3 call __chkesp (00401560)
34: pClass = &cc;
004012B8 lea eax,[ebp-0Ch]
004012BB mov dword ptr [ebp-4],eax
35: pClass->showClass();
004012BE mov ecx,dword ptr [ebp-4]
004012C1 mov edx,dword ptr [ecx]
004012C3 mov esi,esp
004012C5 mov ecx,dword ptr [ebp-4]
004012C8 call dword ptr [edx]
004012CA cmp esi,esp
004012CC call __chkesp (00401560)
36: pClass = NULL;
004012D1 mov dword ptr [ebp-4],0
37: return 0;
004012D8 xor eax,eax

从上述代码来看,在调用虚函数时首先根据头四个字节的值找到对应的虚函数表,然后根据虚函数表中存储的内容来找到对应函数的地址,最后根据函数地址跳转到对应的位置,执行函数代码。由于虚函数表中的虚函数是在编译时就根据对象的不同将对应的函数装入到各自对象的虚函数表中,因此,不同的对象所拥有的虚函数表不同,最终根据虚函数表寻址到的虚函数也就不同,这样就构成了多态。
对于虚函数的调用,先后经历了几次间接寻址,比直接调用函数效率低了一些,通过虚函数间接寻址访问的情况只有利用类对象的指针或者引用来访问虚函数时才会出现,利用对象本身调用虚函数时,没有必要进行查表,因为已经明确调用的是自身的成员函数,没有构成多态,查表只会降低程序的运行效率。