何为多态?我们简单的说一下,在面向对象语言中,接口的多种不同的实现方式即为多态。在C++中,多态分为静态多态和动态多态。怎么理解这两种多态呢?下面,我们通过实践,来理解一下。
静态多态
静态多态是通过函数的重载来实现的。
首先,我给出一段代码:
1 |
|
执行一下:
1 | g++ test.cpp |
结果:
OK,在这里,我们可以看到,我们调用了两次函数。这两次调用的函数都是同一个名字的,只是参数的类型有所不同(第一个是浮点型的,第二个是整型的)。但是,却调用了不同的函数。可以说,这操作还是挺骚的。
好的,现在让我们来看看为什么可以实现这种骚操作。
怎么看呢?我们来看看生成的那个可执行文件里面有什么蹊跷吧:
1 | cat a.exe |
为什么看不了呢?可能因为a.exe
是二进制文件的缘故吧,cat
命令看不了,会乱码。
OK,我们换一种方式,使用nm
命令看看:
1 | nm a.exe |
nice,可以看了,我们接下来去锁定一下那两个同名的函数,使用grep
命令:
1 | nm a.exe | grep foo |
看到了没,在符号表中(图片中的那个T表示位于代码区的符号),那两个foo
的表示形式是有所不同的,前面的_Z3foo
是一样的,但是后面的一个字母是不同的,一个是d
(表示的含义是double),一个是i
(表示的含义是int)。也就是说,实际上我们的这两个函数是可以这样写的:
1 |
|
结果:
奶思,效果是一样的。
细心的小伙伴们可能发现了,返回值的类型char
并没有在_Z3food
和_Z3fooi
这两个符号中体现出来。所以,这就是为什么重载只能是通过函数的形参列表的不同加以区分而不能通过返回值的类型加以区分的原因了。
综上所述,静态多态是在编译阶段就确定了要执行哪个函数。
动态多态
动态多态是通过虚函数来实现的。
首先,我给出一段代码:
1 |
|
结果:
又是骚操作,指针类型是基类的,执行的却是子类的函数。
好的,现在让我们来看看为什么可以实现这种骚操作。
我们这次从汇编的代码来看看为什么可以有这样的骚操作:
1 | g++ -S test.cpp |
结果:
我们现在就针对这个汇编代码来研究一番。
虚函数表
首先,我们来先看一看文件中一个叫做_ZTV4Base
的东西:
这是什么呢?我们可以通过c++filt
命令来转换:
1 | c++filt _ZTV4Base |
结果:
翻译过来就是Base的虚函数表。
OK,那这个虚函数表里面有什么东西呢?从图片中我们可以看到有:
1 | .quad 0 |
其中,它的第一项是0,第二项_ZIT4Base是关于Base的类型信息,这与typeid有关。我们不讨论它们。现在让我们来看看后面的几项。和刚才的方法一样,我们使用c++filt
命令来分别查看它们:
1 | c++filt _ZN4Base5sleepEv |
1 | c++filt _ZN4Base3eatEv |
1 | c++filt _ZN4Base3runEv |
可以看出,在这个虚函数表中,前三项刚好是按照类中定义顺序的那些三个虚函数。
我们继续看后面的:
1 | c++filt _ZTS6Animal |
这个和_ZTI4Base那项一样是个数据符号,我们不讨论这一项。
类似的,我们来查看一下Animal的虚函数表:
OK,这张图片和上面的差不多,我们类推出在Animal虚函数表中,有Animal::sleep
、Animal::eat
、Animal::run
这三个函数。
那么,还是没有解决“为什么指针类型是基类的,执行的却是子类的函数”这个问题。
类对象中,有指向虚函数表的指针
我们继续看汇编代码:
我们来看看,_ZN4BaseC2Ev
是啥:
1 | c++filt _ZN4BaseC2Ev |
发现它是Base
类的构造函数。
我们来看看构造函数所做的工作是什么。我们在图片中可以看到这个:
这个不是上面出现的那个虚函数表吗?
实际上16+$_ZTV4Base
就是Base类的虚函数表在内存中的地址。这也就是说,构造函数把虚表的地址给了一个变量,而这个变量,用来指向内存中Base类的虚函数表。因此,我们可以大胆的猜测一下,对应的C++代码是:
1 | this->vtable = &Base_vtable; |
类似的,我们来看看Animal类的构造函数做了什么:
我们在这张图片里面可以看到这个:
第一句是:call _ZN4BaseC2Ev
。了解汇编的同学一定知道这句话的意思是子程序调用指令。 也就是说,在执行Animal类的构造函数的时候,还执行了Base类的构造函数。
OK,继续看,后面的两句和Base类的构造函数所做的工作差不多,即把Animal类的虚函数表的地址给一个变量。因此,我们可以大胆的猜测一下,对应的C++代码是:
1 | this->vtable = &Animal_vtable; |
OK,也就是说,因为虚函数的存在,使得类中多了一个指针变量来保存类的虚函数表的地址。
我们来测试一下:
1 |
|
结果:
为什么这里会打印出1而不是保存sleep函数地址所需要的内存大小呢?
因为,函数的地址只与类型相关,而与类型的实例无关,编译器不会因为这个sleep函数而在实例内添加任何额外的信息。
那么为什么会打印出1呢?明明在类中没有定义任何变量呀。这是因为当我们声明该类型的实例的时候,它必须在内存中占有一定的空间,否则无法使用这些实例。(至于需要多少内存空间,由编译器决定。我这个编译器是分配1个字节的内存单元)
OK,现在,我们把sleep声明为虚函数:
1 |
|
结果:
奶思,符合我们的预期。说明编译器为我们增加了一个指针变量。
虽然知道了这些,但是还是没有解决为什么执行的是子类的函数。
我们继续……
动态绑定
我们继续来看汇编代码。以下是主函数部分:
我们来回顾一下,进入主函数以后,执行的第一条C++语句:
1 | Animal dargon; |
对应的汇编代码是:
前面两句:
1 | leaq -32(%rbp), %rax |
其实是先移动栈指针,给变量dargon在栈上分配内存。变量dargon的首地址是 rbp - 32。
第三句是:
1 | call _ZN6AnimalC1Ev |
也就是执行了Animal类的构造函数。这个很容易理解,当使用静态方法:
1 | classA object; |
定义了一个类对象的时候,会调用构造函数。
OK,接下来看看C++的第二条语句:
1 | Animal dog; |
我们来看看对应的汇编代码:
和上面类似的,栈指针移动,为变量dog分配内存,然后,再调用Animal类的构造函数。变量dog的首地址是 rbp - 48。
OK,我们接下来看看C++的第三条语句:
1 | Base* pBase = &dargon; |
对应的汇编代码为:
因为我们什么分析了,变量dargon的首地址是 rbp - 32 ,所以,两句汇编的含义就是把:dargon变量的地址指针变量pBase。
我们继续看C++的第四条语句:
1 | Base& pRe = dog; |
对应的汇编代码为:
这句话和上面的类似。
我们继续看C++的第5和第6条语句:
1 | pBase->sleep(); |
实现了动态多态的效果。
同理,后面的汇编代码也实现了动态多态:
再次测试
通过什么的分析,我们大致可以得出如下关于虚函数表的图:
验证
1 |
|
OK,和图片的猜想一致。
happy ending……