一、多态定义
多态是每种面向对象语言的重要概念。它理解起立就是父类指针指向了子类的实例,然后通过父类指针调用实际成员函数的过程.我们知道虚函数是实现多态的重要机制。假如一个类中有虚函数,那么在类实例的首部会保存该类虚函数表的指针。注意这里针对g++编译时,虚表和虚函数表还不太一样。从前面的2篇博文大家知道,在存在虚继承的情况下,虚表开始处会记录基类数据成员的偏移。虚表是类所有,虚指针是实例所有,同一类所有实例公用该类虚表。
二、源代码调试分析过程
1)子父类都不包含虚函数
示例1:中我定义了2个类,father和son类,它们都不包含虚函数,我分别定义了父类的对象b,引用对象b2,指针b3,然后分别让他们指向子类对象er。如代码中的main函数部分。我们很清楚能看到打印结果居然都是父类的Test方法。也许你知道本来就是这样的啊,下面我们反汇编看看 ,编译器到底干了什么。
#include <stdio.h>
#include <iostream>
using namespace std;
class father
{
public:
int a;
father():a(88){
}
void Test()
{
cout << "father:test" <<endl;
}
};
class son:public father
{
public:
int b;
son():b(22){}
void Test()
{
cout << "son:test" <<endl;
}
};
int main()
{
son er;
er.a = 44;
er.b = 55;
father b1 = er;
b1.Test();
father &b2 = er;
b2.Test();
father *b3 = &er;
b3->Test();
return 1;
}
运行结果:
father:test
father:test
father:test
分析:这里我就不像之前博文那样写出调试步骤了,只列出一些关键的数据。下面是这个main含函数经过反汇编得到一段代码。这里在代码中我们直接使用了类定义了一个子类对象er,而没有使用new关键子,所以er对象也是放在在栈空间而不是在堆空间(对堆栈不理解可看这里)。这里所以er安放到了rbp-0x20处,大小是16字节。下面可以看到我们每次通过我们定义的b1,b2,b3,去访问Test方法,它都会取子类对象的地址,然后就直接就调用了子类的Test()函数了。所有就有了,在没有虚函数的情况下,指针类型是什么类型,它就调用对应类的成员方法。
father b1 = er;
400884: 8b 45 e0 mov -0x20(%rbp),%eax
400887: 89 45 f0 mov %eax,-0x10(%rbp)
b1.Test();
40088a: 48 8d 45 f0 lea -0x10(%rbp),%rax "取出保存到0x10偏移处子类对象地址
40088e: 48 89 c7 mov %rax,%rdi
400891: e8 98 00 00 00 callq 40092e <_ZN6father4TestEv> "这之前编译器,也没做具体运算,就这样直接就调了基类test方法。
father &b2 = er;
400896: 48 8d 45 e0 lea -0x20(%rbp),%rax
40089a: 48 89 45 d8 mov %rax,-0x28(%rbp)
b2.Test();
40089e: 48 8b 45 d8 mov -0x28(%rbp),%rax "取出保存到0x28偏移处子类对象地址
4008a2: 48 89 c7 mov %rax,%rdi
4008a5: e8 84 00 00 00 callq 40092e <_ZN6father4TestEv> “如上同理
father *b3 = &er;
4008aa: 48 8d 45 e0 lea -0x20(%rbp),%rax
4008ae: 48 89 45 d0 mov %rax,-0x30(%rbp)
2)父类有虚函数子类没有虚函数
这种情况我们在之前说过,一旦有虚函数的话就会给该类生成一个虚函数表。该表的虚函数表指针存放到每个该类实例对象的内存首地址处,针对g++编译情况,实例首地址存放不是虚表首地址,而是虚函数表首地址(也就是虚表首地址+偏移)。下面的例子同示例1的区别有两个地方。
1.将father类的test方法改成添加virtual修饰符
2.main函数中采用new方式为son对象er分配空间(可以和之前示例1对比一下,这里是er分配到堆中)
#include <stdio.h>
#include <iostream>
using namespace std;
class father
{
public:
int a;
father():a(88){
}
virtual void Test()
{
cout << "father:test" <<endl;
}
};
class son:public father
{
public:
int b;
son():b(22){}
void Test()
{
cout << "son:test" <<endl;
}
};
int main()
{
son *er=new son();
er->a = 44;
er->b = 55;
father b1 = *er;
b1.Test();
father &b2 = *er;
b2.Test();
father *b3 = er;
b3->Test();
return 1;
}
运行结果:
father:test
son:test
son:test
总结:经过上面两个2修改,打印结果发生了很大变化。这里由于父类中存在虚函数,那么子类和父类都会对应一张虚表,第一个输出的结果是父类的test函数,原因是这里重新给父类对象b1分配了栈空间,不是简单的指针赋值了,而是将子类中对应父类那部分数据域拷贝到父类当中,而虚函数表使用的确是父类的自己的,除此之外在用father b1定义父类对象时,也没调用父类构造函数(如果采用new的方式分配的话会调用),其实在new子类对象的时候已经调用过了,下面的调试环节我们可以看到父类的数据域a的值,已经赋成子类的数据了吧。下面我有分析父类的拷贝函数,可以很清晰的看到复制过程和虚函数表赋值。下面是主要的main函数反汇编代码。
son *er=new son();
40096b: bf 10 00 00 00 mov $0x10,%edi
400970: e8 1b ff ff ff callq 400890 <_Znwm@plt>
400975: 48 89 c3 mov %rax,%rbx
400978: 48 89 d8 mov %rbx,%rax
40097b: 48 89 c7 mov %rax,%rdi
40097e: e8 1b 01 00 00 callq 400a9e <_ZN3sonC1Ev>
400983: 48 89 5d e8 mov %rbx,-0x18(%rbp) ”将er对象保存到rbp-0x18处
er->a = 44;
400987: 48 8b 45 e8 mov -0x18(%rbp),%rax
40098b: c7 40 08 2c 00 00 00 movl $0x2c,0x8(%rax) “注意这里在示例1处时是直接放到栈中,这里是放到堆里面。
er->b = 55;
400992: 48 8b 45 e8 mov -0x18(%rbp),%rax
400996: c7 40 0c 37 00 00 00 movl $0x37,0xc(%rax)
father b1 = *er;
40099d: 48 8b 55 e8 mov -0x18(%rbp),%rdx “取到er对象地址
4009a1: 48 8d 45 c0 lea -0x40(%rbp),%rax "这里取到rbp-0x40处的地址,它想干嘛,奇怪下面也没调用father构造函数,怪
4009a5: 48 89 d6 mov %rdx,%rsi ”将er对象首地址放到源寄存器中
4009a8: 48 89 c7 mov %rax,%rdi ”将b1对象首地址放到目的寄存器中
4009ab: e8 48 01 00 00 callq 400af8 <_ZN6fatherC1ERKS_> “这里不是构造函数,而是一个拷贝函数。我们下面进去看看。
b1.Test();
4009b0: 48 8d 45 c0 lea -0x40(%rbp),%rax ”下面都是简单的将er指针赋值给对应指针的操作,虚指针还是子类的。
4009b4: 48 89 c7 mov %rax,%rdi
4009b7: e8 b8 00 00 00 callq 400a74 <_ZN6father4TestEv>
father &b2 = *er;
4009bc: 48 8b 45 e8 mov -0x18(%rbp),%rax
4009c0: 48 89 45 e0 mov %rax,-0x20(%rbp) “b2入栈
b2.Test();
4009c4: 48 8b 45 e0 mov -0x20(%rbp),%rax
4009c8: 48 8b 00 mov (%rax),%rax
4009cb: 48 8b 10 mov (%rax),%rdx
4009ce: 48 8b 45 e0 mov -0x20(%rbp),%rax
4009d2: 48 89 c7 mov %rax,%rdi
4009d5: ff d2 callq *%rdx
father *b3 = er;
4009d7: 48 8b 45 e8 mov -0x18(%rbp),%rax
4009db: 48 89 45 d8 mov %rax,-0x28(%rbp) ”<span style="font-family: Arial, Helvetica, sans-serif;">b3入栈</span>
father类的拷贝函数:
这个拷贝函数,先将父类的虚指针设置为自己的,然后从子类中拷贝了属于父类的那部分数据,下面反汇编代码中有详细介绍,这里就不打字了。
0000000000400af8 <_ZN6fatherC1ERKS_>:
#include <stdio.h>
#include <iostream>
using namespace std;
class father
{
400af8: 55 push %rbp
400af9: 48 89 e5 mov %rsp,%rbp
400afc: 48 89 7d f8 mov %rdi,-0x8(%rbp) ”从上面可以看到这里将b1对象地址放到当前栈的0x08处
400b00: 48 89 75 f0 mov %rsi,-0x10(%rbp) “将er对象的地址放到0x10偏移处
400b04: 48 8b 45 f8 mov -0x8(%rbp),%rax ”取出b1对象地址
400b08: 48 c7 00 40 0c 40 00 movq $0x400c40,(%rax) “将0x400c40(父类虚表)放到b1对象所在的首地址上。
400b0f: 48 8b 45 f0 mov -0x10(%rbp),%rax ”取出er对象地址
400b13: 8b 50 08 mov 0x8(%rax),%edx “取出er对象首地址偏移8的内容,即er->a的数据
400b16: 48 8b 45 f8 mov -0x8(%rbp),%rax "再次取出b1地址放到rax通用寄存器中
400b1a: 89 50 08 mov %edx,0x8(%rax) “这里将er->a的数据拷贝到b1对象所在内存偏移0x08地上。
400b1d: c9 leaveq
400b1e: c3 retq
400b1f: 90 nop
下图就是简单的拷贝过程,只要记住这里vptr使用的是父类自己的就行了。
下面是一些调试结果,可以清晰的看到er对象中vptr.father 保存的是子类的虚指针0x400c20.在打印b1时 vptr.father=0x400c40,而a=44 不是初始化时的88,所以这里可以看出子类将a赋值过去了。其它的指针类型除了b1 vptr使用父类,其它都是子类,所以他们会调用子类的方法。
(gdb) p *er
$1 = {<father> = {_vptr.father = 0x400c20 <vtable for son+16>, a = 44}, b = 55}
(gdb) p b1
$2 = {_vptr.father = 0x400c40 <vtable for father+16>, a = 44} 注意父类vptr值
(gdb) p b2
$3 = (father &) @0x603010: {_vptr.father = 0x400c20 <vtable for son+16>, a = 44}
(gdb) p *b3
$5 = {_vptr.father = 0x400c20 <vtable for son+16>, a = 44}
下面是虚表展示区,由于基类中含有虚函数,所以子类中被迫有了虚函数,而且都有一张虚表。这里在强调一下,放在对象首地址的是虚函数表指针(它是虚表的一个偏移)而不是虚表指针,虚表指针的开始处,被g++放了其它数据。在菱形继承,虚继承的情况下,开始处会存放公用基类数据的偏移。
Vtable for father
father::_ZTV6father: 3u entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI6father)
16 father::Test
Class father
size=16 align=8
base size=12 base align=8
father (0x7f2e30aad8c0) 0
vptr=((& father::_ZTV6father) + 16u)
Vtable for son
son::_ZTV3son: 3u entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI3son)
16 son::Test
Class son
size=16 align=8
base size=16 base align=8
son (0x7f2e30af60e0) 0
vptr=((& son::_ZTV3son) + 16u)
father (0x7f2e30af6150) 0
primary-for son (0x7f2e30af60e0)
3)父类无虚函数子类有虚函数
这种情况和示例2的区别无非就是去掉了father类 test方法前面的修饰符virtual,所以子类会有一张虚表,而父类就没有了。那么这样情况下,又会表现成什么情况呢?会是和上面的过程一样吗?带着疑问我们开始吧,代码如下。
#include <stdio.h>
#include <iostream>
using namespace std;
class father
{
public:
int a;
father():a(88){
}
void Test()
{
cout << "father:test" <<endl;
}
};
class son:public father
{
public:
int b;
son():b(22){}
virtual void Test()
{
cout << "son:test" <<endl;
}
};
int main()
{
son *er=new son();
er->a = 44;
er->b = 55;
father b1 = *er;
b1.Test();
father &b2 = *er;
b2.Test();
father *b3 = er;
b3->Test();
return 1;
}
运行结果:
father:test
father:test
father:test
总结:打印结果都是父类的test方法,过程和示例2肯定不一样,我们来看看汇编代码压压惊。
father b1 = *er;
40097f: 48 8b 45 d8 mov -0x28(%rbp),%rax “取出子类对象er的内存地址
400983: 8b 40 08 mov 0x8(%rax),%eax ”取出偏移8的数据,我去,这里取的就是er->a的数据啊
400986: 89 45 e0 mov %eax,-0x20(%rbp) “将a的数据放到栈中,也就是放到b1所在地,这里father没有虚表,不用偏移^_^
b1.Test();
400989: 48 8d 45 e0 lea -0x20(%rbp),%rax
40098d: 48 89 c7 mov %rax,%rdi
400990: e8 c1 00 00 00 callq 400a56 <_ZN6father4TestEv>
father &b2 = *er;
400995: 48 83 7d d8 00 cmpq $0x0,-0x28(%rbp) "比较er对象是否是空,这么操蛋
40099a: 74 0a je 4009a6 <main+0x62> “如果是0就跳到4009a6处执行,也就是左下方第4行
40099c: 48 8b 45 d8 mov -0x28(%rbp),%rax ”取出er对象内存地址
4009a0: 48 83 c0 08 add $0x8,%rax "rax指向er->a,这想干嘛
4009a4: eb 05 jmp 4009ab <main+0x67> ”跳到下面执行
4009a6: b8 00 00 00 00 mov $0x0,%eax
4009ab: 48 89 45 d0 mov %rax,-0x30(%rbp) “这里直接将er->a,赋值给了b2
b2.Test();
4009af: 48 8b 45 d0 mov -0x30(%rbp),%rax
4009b3: 48 89 c7 mov %rax,%rdi
4009b6: e8 9b 00 00 00 callq 400a56 <_ZN6father4TestEv>
father *b3 = er;
4009bb: 48 83 7d d8 00 cmpq $0x0,-0x28(%rbp) ”这个过程和上面的一样,就这样吧
4009c0: 74 0a je 4009cc <main+0x88>
4009c2: 48 8b 45 d8 mov -0x28(%rbp),%rax
4009c6: 48 83 c0 08 add $0x8,%rax
4009ca: eb 05 jmp 4009d1 <main+0x8d>
4009cc: b8 00 00 00 00 mov $0x0,%eax
4009d1: 48 89 45 c8 mov %rax,-0x38(%rbp)
b3->Test();
4009d5: 48 8b 45 c8 mov -0x38(%rbp),%rax
4009d9: 48 89 c7 mov %rax,%rdi
4009dc: e8 75 00 00 00 callq 400a56 <_ZN6father4TestEv>
上面可以看到 执行 father b1 = *er这行代码时,直接将er->a的值赋给了父类a,这里由于父类没有虚函数,也没有加偏移8了。同样下面的b2,b3都是简单的赋值操作。这里我们可以看出了眉头了,父类没有虚函数的情况下,指针类型是什么类型,就调用对应类型的方法。这里都是将子类赋给了父类,所以调用的就是父类的方法了。
下面在上一道小菜,可以看到父类没有虚表吧,子类有虚函数(仿佛我这里说了废话)。虚表开始的不是虚函数表
Class father
size=4 align=4
base size=4 base align=4
father (0x7f63443658c0) 0
Vtable for son
son::_ZTV3son: 3u entries
0 (int (*)(...))0
8 (int (*)(...))(& _ZTI3son)
16 son::Test
Class son
size=16 align=8
base size=16 base align=8
son (0x7f63443655b0) 0
vptr=((& son::_ZTV3son) + 16u)
father (0x7f63443ae000) 8
下面是调试时的一些记录:分别打印的是b1,b2,b3的内存,可以发现子类实例中存在虚指针,而父类不存在vptr。由于是简单的拷贝操作,所以b1,b2,b3的数据域都是44,而不是初始化时的88.
(gdb) p *er
$1 = {<father> = {a = 44}, _vptr.son = 0x400c10, b = 55}
(gdb) p b1
$2 = {a = 44}
(gdb) p b2
$3 = (father &) @0x603018: {a = 44}
(gdb) p *b3
$4 = {a = 44}
三、大结局
下是的一些结论都是根据前面的几个简单例子验证得到的,针对于复杂的菱形继承,多继承的多态情况,我们后面在分析吧。
1)父子类都没有虚函数时:指针类型是什么类型,就调用对应类型的方法.
2)父类有虚函数子类没有:这时候由于父类有虚函数,所以子类也被动有了虚函数。这时候如果父类对象分配了实际的内存空间,而且父类也不是采用new的方式分配的。那么如果子类直接赋值给父类对象的话,使用基类类型访问的依然是父类方法。除非是子类指针赋值给父类指针的话,那就访问的依然是子类的方法。
小例:
-1.father b; //直接定义分配了内存
b = *er; //直接是赋值操作。
b.test(); //这种情况就是访问父类
-2. father *b=new father();
b = er;
b->test() ; //这里依然访问的是子类方法,之前的基类对象就找不到了
3)子类有虚函数父类无:这种情况下,指针是什么类型就调用什么类型的方法。