C++虚函数探索笔记(2)——虚函数与多继承
关注问题:
虚函数的作用
虚函数的实现原理
虚函数表在对象布局里的位置
虚函数的类的sizeof
纯虚函数的作用
多级继承时的虚函数表内容
虚函数如何执行父类代码
多继承时的虚函数表定位,以及对象布局
虚析构函数的作用
虚函数在QT的信号与槽中的应用
虚函数与inline修饰符,static修饰符
前面我们尝试了一个简单的例子,接下来尝试一个多级继承的例子,以及一个多继承的例子。主要涉及到以下问题:多级继承时虚函数表的内容是如何填写的,如何在多级继承的情况下调用某一级父类里的虚函数,以及在多继承(多个父类)的情况下的对象布局。
多级继承
在这里,多级继承指的是有3层或者多层继承关系的情形。让我们看看下面的代码:
//Source filename: Win32Con.cpp
#include <iostream>
using namespace std;
class parent1
{
public:
virtual int fun1(){cout<<"parent1::fun1()"<<endl;return 0;};
virtual int fun2()=0;
};
class child1:public parent1
{
public:
virtual int fun1()
{
cout<<"child1::fun1()"<<endl;
parent1::fun1();
return 0;
}
virtual int fun2()
{
cout<<"child1::fun2()"<<endl;
return 0;
}
};
class grandson:public child1
{
public:
virtual int fun2()
{
cout<<"grandson::fun2()"<<endl;
//parent1::fun2();
parent1::fun1();
child1::fun2();
return 0;
}
};
void test_func1(parent1 *pp)
{
pp->fun1();
pp->fun2();
}
int main(int argc, char* argv[])
{
grandson sunzi;
test_func1(&sunzi);
return 0;
这段代码展示了三个class,分别是parent1,child1,grandson.
类parent1定义了两个虚函数,其中fun2是一个纯虚函数,这个类是一个不可实例化的抽象基类。
类child1继承了parent1,并且对两个虚函数fun1和fun2都编写了实现的代码,这个类可以被实例化。
类grandson继承了child1,但是只对虚函数fun2编写了实现的代码。
此外,我们还改写了test_func1函数,它的参数为parent1类型的指针,我们可以将parent1的子孙类作为这个函数的参数传入。在这个函数里,我们将依次调用parent1类的两个虚函数。
可以先通过阅读代码预测一下程序的输出内容。
程序的输出内容将是:
child1::fun1()
parent1::fun1()
grandson::fun2()
parent1::fun1()
child1::fun2()
先看第一行输出child1::fun1(),为什么会输出它呢?我们定义的具体对象sunzi是grandson类型的,test_func1的参数类型是parent1类型。在调用这个虚函数的时候,是完成了一次怎样的调用过程呢?
让我们再次使用cl命令输出这几个类的对象布局:
class parent1 size(4):
+---
0 | {vfptr}
+---
parent1::$vftable@:
| &parent1_meta
| 0
0 | &parent1::fun1
1 | &parent1::fun2
parent1::fun1 this adjustor: 0
parent1::fun2 this adjustor: 0
class child1 size(4):
+---
| +--- (base class parent1)
0 | | {vfptr}
| +---
+---
child1::$vftable@:
| &child1_meta
| 0
0 | &child1::fun1
1 | &child1::fun2
child1::fun1 this adjustor: 0
child1::fun2 this adjustor: 0
class grandson size(4): //grandson的对象布局
+---
| +--- (base class child1)
| | +--- (base class parent1)
0 | | | {vfptr}
| | +---
| +---
+---
grandson::$vftable@: //grandson虚函数表的内容
| &grandson_meta
| 0
0 | &child1::fun1
1 | &grandson::fun2
grandson::fun2 this adjustor: 0
因为我们实例化的是一个grandson对象,让我们看看它的对象布局。正如前面的例子一样,里面只有一个vfptr指针,但是不一样的却是这个指针所指的虚函数表的内容:
第一个虚函数,填写的是child1类的fun1的地址;第二个虚函数填写的才是grandson类的fun2的地址。
很显然我们可以得出这样一个结论:在一个子对象的虚函数表里,每一个虚函数的实际运行的函数地址,将填写为在继承体系里最后实现该虚函数的函数地址。
所以当我们在test_func1里调用了传入的parent1指针的fun1函数的时候,我们实际执行的是填写在虚函数表里的child1::fun1(),而调用fun2函数的时候,是从虚函数表里得到了grandson::fun2函数的地址并调用之。在“程序输出结果”表里的第一行和第三行结果证实了上述结论。
再看一下程序代码部分的child1::fun1()的实现代码,在第18行,我们有parent1::fun1();这样的语句,这行代码输出了运行结果里的第二行,而在grandson::fun2()的实现代码第35行的parent1::fun1();以及第36行的child1::fun2();则输出了运行结果里的第四行和第五行的内容。这三行代码展示了如何调用父类以及更高的祖先类里的虚函数。——事实上,这与调用父类的普通函数没有任何区别。
在程序代码的第34行,有一行被注释了的内容//parent1::fun2();,之所以会注释掉,是因为这样的代码是无法通过编译的,因为在parent1类里,fun2是一个“纯虚函数”也就是说这个函数没有代码实体,在编译的时候,链接器将无法找到fun2的目标代码从而报错。
其实有了对虚函数的正确的认识,上面的多级继承是很自然就能明白的。然而在多继承的情况下,情况就有所不同了……
多继承下虚函数的使用
假如一个类,它由多个父类继承而来,而在不同的父类的继承体系里,都存在虚函数的时候,这个类的对象布局又会是怎样的?它又是怎样定位虚函数的呢?
让我们看看下面的代码:
//Source filename: Win32Con.cpp
#include <iostream>
using namespace std;
class parent1
{
public:
virtual int fun1(){cout<<"parent1::fun1()"<<endl;return 0;};
};
class parent2
{
public:
virtual int fun2(){cout<<"parent2::fun2()"<<endl;return 0;};
};
class child1:public parent1,public parent2
{
public:
virtual int fun1()
{
cout<<"child1::fun1()"<<endl;
return 0;
}
virtual int fun2()
{
cout<<"child1::fun2()"<<endl;
return 0;
}
};
void test_func1(parent1 *pp)
{
pp->fun1();
}
void test_func2(parent2 *pp)
{
pp->fun2();
}
int main(int argc, char* argv[])
{
child1 chobj;
test_func1(&chobj);
test_func2(&chobj);
return 0;
}
这一次,我们有两个父类,parent1和parent2,在parent1里定义了虚函数fun1,而在parent2里定义了虚函数fun2,然后我们有一个子类child1,在里面重新实现了fun1和 fun2两个虚函数。然后我们编写了test_func1函数来调用parent1类型对象的fun1函数,编写了test_func2函数调用parent2对象的fun2函数。在main函数里我们实例化了一个child1类型的对象chobj,然后分别传给test_func1和test_func2去执行。
这段代码的运行结果非常简单就能看出来:
child1::fun1()
child1::fun2()
但是,让我们看看对象布局吧:
class child1 size(8):
+---
| +--- (base class parent1)
0 | | {vfptr}
| +---
| +--- (base class parent2)
4 | | {vfptr}
| +---
+---
child1::$vftable@parent1@:
| &child1_meta
| 0
0 | &child1::fun1
child1::$vftable@parent2@:
| -4
0 | &child1::fun2
child1::fun1 this adjustor: 0
child1::fun2 this adjustor: 4
注意到没?在child1的对象布局里,出现了两个vfptr指针!
这两个虚函数表指针分别继承于parent1和parent2类,分别指向了不同的两个虚函数表。
问题来了,当我们使用test_func1调用parent1类的fun1函数的时候,调用个过程还比较好理解,可以从传入的地址参数取得继承自parent1的vfptr,从而执行正确的fun1函数代码,但是当我们调用test_func2函数的时候,为什么程序可以自动取得来自parent2的vfptr呢,从而得出正确的fun2函数的地址呢?
其实,这个工作是编译器自动根据实例的类型完成的,在编译阶段就已经确定了在调用test_func2的时候,传入的this指针需要增加一定的偏移(在这里则是第一个vfptr所占用的大小,也就是4字节)。
我们可以看看main函数里这部分代码的反汇编代码:
child1 chobj;
00F5162E 8D 4D F4 lea ecx,[chobj]
00F51631 E8 F5 FB FF FF call child1::child1 (0F5122Bh)
test_func1(&chobj);
00F51636 8D 45 F4 lea eax,[chobj]
00F51639 50 push eax
00F5163A E8 6F FB FF FF call test_func1 (0F511AEh)
00F5163F 83 C4 04 add esp,4
test_func2(&chobj);
00F51642 8D 45 F4 lea eax,[chobj]
00F51645 85 C0 test eax,eax
00F51647 74 0E je main+47h (0F51657h)
00F51649 8D 4D F4 lea ecx,[chobj]
00F5164C 83 C1 04 add ecx,4
00F5164F 89 8D 2C FF FF FF mov dword ptr [ebp-0D4h],ecx
00F51655 EB 0A jmp main+51h (0F51661h)
00F51657 C7 85 2C FF FF FF 00 00 00 00 mov dword ptr [ebp-0D4h],0
00F51661 8B 95 2C FF FF FF mov edx,dword ptr [ebp-0D4h]
00F51667 52 push edx
00F51668 E8 F6 FA FF FF call test_func2 (0F51163h)
00F5166D 83 C4 04 add esp,4
return 0;
从第4行至第5行,执行的是test_func1函数,this指针指向 chobj (第2行lea ecx,[chobj]),但是调用test_func2函数的时候,this指针被增加了4(第14行)!于是,在test_func2执行的时候,就可以从&chobj+4的地方获得vfptr指针,从而根据parent2的对象布局得到了fun2的地址并执行了。
为了证实这点,我们可以将代码做如下的修改:
1: int main(int argc, char* argv[])
2: {
3: child1 chobj;
4: test_func1(&chobj);
5: test_func2((parent2 *)(void *)&chobj);
6: return 0;
7: }
8:
请注意红色部分的变化,在讲chobj传入给test_func2之前,先用(void *)强制转换为无类型指针,再转换为parent2 指针,这样的转换,显然是可行的,因为chobj本身就是parent2的子类,然而,程序的执行效果却是:
child1::fun1()
child1::fun1()
执行test_func2函数,调用的是parent2::fun2,但是居然执行的是child1::fun1()函数!!!
这中间发生了些什么呢?我们再看看反汇编的代码:
child1 chobj;
013D162E 8D 4D F4 lea ecx,[chobj]
013D1631 E8 F5 FB FF FF call child1::child1 (13D122Bh)
test_func1(&chobj);
013D1636 8D 45 F4 lea eax,[chobj]
013D1639 50 push eax
013D163A E8 6F FB FF FF call test_func1 (13D11AEh)
013D163F 83 C4 04 add esp,4
test_func2((parent2*)(void *)&chobj);
013D1642 8D 45 F4 lea eax,[chobj]
013D1645 50 push eax
013D1646 E8 18 FB FF FF call test_func2 (13D1163h)
013D164B 83 C4 04 add esp,4
return 0;
从调用test_func2的反汇编代码可以看到,这一次ecx寄存器的值没有做改变!所以在执行test_func2的时候,将取得parent1对象布局里的vfptr,而这个vfptr所指的虚函数表里的第一项就是fun1,并且被填写为child1::fun1的地址了。所以才出现了child::fun1的输出内容!显然这里有一个隐藏的致命问题,加入parent1和parent2的第一个虚函数的参数列表不一致,这样的调用显然就会导致堆栈被破坏掉,程序99%会立即崩溃。之前的程序没有崩溃并且成功输出内容,不过是因为parent1::fun1()和parent2::fun2()的参数列表一致的关系而已。
所以,千万不要在使用一个多继承对象的时候,将其类型信息丢弃,编译器还需要依靠正确的类型信息,在使用虚函数的时候来得到正确的汇编代码!