终于发现以asm之角度观察和研究问题是如此有趣, 以至于开了头便罢不了手, 也罢也罢, 那就索性将快乐进行到底吧. 秉持一贯的孔乙己之风格, 这一次, 我们来看看c++虚函数的庐山真面目. 由于对文章所拓展出来的文字篇幅难有心理预期, 但估计关于虚函数的内容又会比较繁杂, 为此, 笔者暂且命此文为"虚函数(上)".
虚函数, 是C++最重要的特点之一, 它实现了所谓的动态联编, 以"基类+虚函数"的方式, 通过很方便的改变对象指针, 可以在运行期动态决定执行哪一个对象的操作函数. 那么, c++的虚函数到底是如何实现的? 它的声明与调用跟普通的成员函数有哪些不同呢? 这些, 都将是从此篇文章开始将要探究的东西.
注: 为控制首页长度,以后代码将放在googlepages上提供下载和访问, 不再在文后附原码. 本文所用c++及asm代码通过以下url访问或下载, 代码使用gcc 4.2, 在fc 4.0下编译(
为顺利理解本文, 请务必结合源码阅读): http://sodme.dev.googlepages.com/kyj_03_code.txt
为了尽可能降低学习的门槛, 我们先从最简单的虚函数实例讲起: 为MyClass类添加一个以virtual关键字声明的虚函数test1,并在main中调用test1, 我们将重点考察这两个问题:
1.在main中, 虚函数test1是如何被调用的;
2.为了调用虚函数test1, 需要事先作哪些准备工作, 这些准备工作又是如何作的.
与上文中的asm代码相比较, 我们发现, 当添加了一个 virtual test1() 之后, 在main中, 为对象申请内存空间时, 传的长度参数是12, 比原来的8字节多了4字节, 那为什么多了这4字节? 它的作用又是什么呢? ( 嘘..., 知道的朋友先别说... )
再将两份asm代码相比较, 我们又发现, MyClass构造函数内的执行逻辑也发生了变化:
A(没有test1()虚函数的版本):
movl 8(%ebp), %eax
movl $1, (%eax)
movl 8(%ebp), %eax
movl $2, 4(%eax) B(有test1()虚函数的版本):
movl $_ZTV7MyClass+8, %edx
movl 8(%ebp), %eax
movl %edx, (%eax)
movl 8(%ebp), %eax
movl $1, 4(%eax)
movl 8(%ebp), %eax
movl $2, 8(%eax) 构造函数内的语句, 只是两条赋值语句, 分别是给data1和data2赋值, 当类MyClass内没有虚函数test1()时, 类MyClass的构造函数很本份的在作着给这两个数据成员赋值的活, 但是, 有了virtual test1()后, 构造函数却暗地里加了这几条语句:
movl $_ZTV7MyClass+8, %edx
movl 8(%ebp), %eax
movl %edx, (%eax) 其中, 8(%ebp)仍如上文中说到的: 它保存的是对象的this指针, 所以, 这三条语句连起来的含义就是: 把"$_ZTV7MyClass+8"这个值放到对象的开始地址处, 也即this指针标识的地址处. 接着查下去, 我们发现标号"_ZTV7MyClass"处定义了这样的一串东西:
_ZTV7MyClass:
.long 0
.long _ZTI7MyClass
.long _ZN7MyClass5test1Ev 啊哦, "$_ZTV7MyClass+8"这个地址里, 存放的竟然是test1()的函数地址! 哈哈..., 那也就是说, 构造函数构造了半天, 实际上已经帮我们把虚函数test1()的地址放在了this指针指向的开始地址处, 可以想见, 以后对test1()的调用, 实际上也就转化成了从this指针指向的首地址取一个函数地址, 然后通过这个地址再找到test1()的地址, 最后再执行call操作, 那么, 我们来看看下面的asm是不是这样作的呢?
接着看, 在asm中, 我们找到了以下对test1函数调用的代码段:
movl -12(%ebp), %eax ;通过eax取得this指针
movl (%eax), %eax ;通过this指针指向的开始地址, 取得存放test1()地址的地址
movl (%eax), %edx ;通过上面的那个什么什么地址, 取得了真正的虚函数test1()的地址
movl -12(%ebp), %eax ;又通过eax取得this指针
movl %eax, (%esp) ;将this指针放在栈顶以供下面调用虚函数所用
call *%edx ;调用虚函数test1() 等等, 说到这里, 可能读者已经有点晕了( 或者有点醒了?! ), 为了弄清楚虚函数定义和调用这件事, 我们把前前后后的故事串起来再讲一遍:
1.编译器在编译时, 事先会为一个类的虚函数统一找个地方, 把他们的地址统一放在一起(这个就是已被无数懂或不懂的人提到的虚函数表);
2.编译时, 将形如"pMyClass->test1()"这样对虚函数进行调用的地方, 统一改成针对于当前对象(例子中是MyClass的对象)的由this牵出的间接函数调用, 所以, 虚函数的寻址方式必然为寄存器寻址(即 call 寄存器 )的形式, 而不会是形如普通成员函数那样的存储器寻址( 即call _ZN7MyClass5printEv)这样的形式. 通过这一点, 我们甚至可以从汇编出来的汇编代码推算此调用是一个虚函数调用(不过, 略显武断了点).
3.在2中, 之所以可以通过this找到这个虚函数, 是因为在对象的构造函数中, 构造函数会把虚函数表的首址放在this指针指向的开始地址处. 也正是由于this指针开始地址处包含了这个虚函数表指针, pMyClass的对象大小才由原来的8字节变成了12字节. 还是由于这this指针指向的开始地址处放的已经是虚函数表指针而不是对象的数据成员, 所以, 对data1和data2的引用, 分别变成了"this+4"和"this+8".
虽然, 通过这个小小的例子, 我们看到了虚表, 看到了构造函数的中介作用, 看到了虚函数的调用方式. 但是, 这个例子毕竟太简单了点, 它没有涉及单继承, 没有涉及多继承, 没有涉及纯虚函数, 太多太多复杂的东西都没有涉及, 所以, 这样看来, 对虚函数的研究还有点日子呢.