学习过C++的童鞋都知道C++类成员函数可以分为虚函数和非虚函数,(java程序员就请绕过这个问题,因为java类全是虚函数),那么这两类函数有什么区别呢,这篇文章主要介绍C++虚函数的运行原理。
我们还是通过一个例子,来说明两者的差异,然后讨论引发差异的原因:C++程序如下:
#include
#include
class P {
public:
void foo1() { printf("in P::foo1()\n"); }
virtual void foo2() { printf("in P::foo2()\n"); }
};
class C: public P {
public:
void foo1() { printf("in C::foo1()\n"); }
virtual void foo2() { printf("in C::foo2()\n"); }
};
void sub(P * o) {
printf("in sub(P *)\n");
o->foo1();
o->foo2();
}
void sub(C * o) {
printf("in sub(C *)\n");
o->foo1();
o->foo2();
}
int main(int argc, char * argv[]) {
P * p = new P();
C * c = new C();
sub(p);
sub(c);
sub(dynamic_cast(c));
return 0;
}
例子程序定义了两个类,父类P和子类C,分别定义了非虚函数foo1()和虚函数foo2();然后定义了两个全局函数sub(P *)和sub(C *),这两个函数的功能是一样的,都是调用参数o的foo1()和foo2()函数,不同点是参数类型,一个是P类,另一个是C类。
再看main()函数,分别对p和c调用sub()函数,然后把c对象强制转换成P类型对象,调用sub()函数。看运行结果:
in sub(P *)
in P::foo1()
in P::foo2()
in sub(C *)
in C::foo1()
in C::foo2()
in sub(P *)
in P::foo1()
in C::foo2()
- 第一个调用 sub(p),因为p就是一个P类型实例,没有疑问会调用 sub(P *)函数,然后在sub内再调用P的foo1()和foo2()函数。
- 第二个调用 sub(c),因为c就是一个C类型实例,没有疑问会调用 sub(C *)函数,然后在sub内再调用C的foo1()和foo2()函数。
- 第三个调用 sub(dynamic_cast
(c)),有点特殊,首先c是一个C类,但是这里把c对象cast成了一个P对象,那么编译器会按照P类型调用sub(P *)这个函数,再看foo1()和foo2()的调用,这里就不一样了,我们看到foo1()调用的是P类的,而foo2()调用的又是C类的。
这才是C++虚函数的本质,即尽管把c对象强制转换成了P类型对象,最终还是能够掉到C类的函数,因为毕竟c是一个C类的实例。
下面我们分析虚函数和非虚函数的实现差异,我们打开sub(P *)和 sub(C *)的汇编代码以查看:
void sub(P *) + void sub(C *)
------------------------------+----------------------------------
_Z3subP1P: + _Z3subP1C:
pushq %rbp + pushq %rbp
movq %rsp, %rbp + movq %rsp, %rbp
subq $16, %rsp + subq $16, %rsp
movq %rdi, -8(%rbp) + movq %rdi, -8(%rbp)
movl $.LC4, %edi + movl $.LC5, %edi
call puts + call puts
movq -8(%rbp), %rax + movq -8(%rbp), %rax
movq %rax, %rdi + movq %rax, %rdi
call _ZN1P4foo1Ev + call _ZN1C4foo1Ev
movq -8(%rbp), %rax + movq -8(%rbp), %rax
movq (%rax), %rax + movq (%rax), %rax
movq (%rax), %rdx + movq (%rax), %rdx
movq -8(%rbp), %rax + movq -8(%rbp), %rax
movq %rax, %rdi + movq %rax, %rdi
call *%rdx + call *%rdx
leave + leave
ret + ret
我们可以看到这两个函数的代码结构是一样的,我们还可以看到不管是sub(P *)还是sub(C *),他们调用foo1()和foo2()的代码都是不一样的:
- 调用foo1()是通过call _ZN1P4foo1Ev和_ZN1C4foo1Ev来完成的。
- 调用foo2()是通过call *%rdx来完成的。
为什么两者的调用方式不一样呢,答案foo2()是虚函数,而foo1()不是,这正好体现了两者的差异,也就是函数声明成virtual和没有virtual的关键差异。
- foo1()不是虚函数,那么调用foo1()的时候直接使用了符号表地址,也就是说不
管参数p是一个P类实例,还是P的子类C的实例,都是调用P::foo1(),这个函数调用是在编译时刻就确定了的。 - foo2()是虚函数,调用foo2()的时候没有直接使用foo2()的符号表,而是使用一个存储在%rdx寄存器里的间接地址,这个地址是通过如下三条汇编指令得到的:
movq -8(%rbp), %rax
movq (%rax), %rax
movq (%rax), %rdx
可以看到,这个地址的获取和参数p相关(假定-8(%rbp)是参数p在函数栈中的地址),也就是说参数P *p中带有了foo2()的地址信息;仔细分析这三条指令:
》1. 第一条执行把p的值(即类实例的地址)加载到寄存器 rax。
》2. 第二条指令把类实例内存的前8个字节加载到寄存器rax,
》3.. 第三条指令把当前rax寄存器对应地址的头8个字节加载到rax寄存器。
怎么理解这三条指令,其实很简单,目的就是要加载foo2()的函数地址到寄存器rdx,给紧接着下面的call指令使用,这三条指令先加载实例地址,然后从实例对象中找到类的虚函数表,再从虚函数表中找到foo2()的地址。
这三条load指令就是调用一个虚函数额外多出来的指令,而这就是以前很多人觉得虚函数性能差的原因。
通过前面分析我们可以看出,一个对象内存空间的头8个字节存储的是虚函数表指针,即如果一个类有虚函数,那么这个类实例的大小会增加额外的8字节,指向对象类的虚函数表,并且这个指针存储在类实例的起始地址,其他对象实例变量依次往后排。另外不管一个类有多少个虚函数,对象内存空间的大小只会增加一个地址长度,虚函数越多,对应虚函数表的大小就会增大,但是对象实例大小不会增大了,举例子来看:
#include
#include
class P1 {
private:
long l;
public:
void foo1() {}
};
class P2 {
private:
long l;
public:
void foo1() {}
virtual void foo2() {}
};
class P3 {
private:
long l;
public:
void foo1() {}
virtual void foo2() {}
virtual void foo3() {}
};
int main(int argc, char * argv[]) {
printf("%d,%d,%d\n", sizeof(P1), sizeof(P2), sizeof(P3));
return 0;
}
运行输出结果为: 8,16,16
因为P1没有虚函数,成员变量l占用8字节,P2和P3虚函数表指针占用8字节,实例变量l占用8字节,所以总长度为16。
注意虚函数表是属于类的,而不是属于对象的,即一个类有一个虚函数表,所有这个类的实例对象都指向同一个虚函数表,用代码说明:
include
#include
class P {
private:
long l;
public:
void foo1() {}
virtual void foo2() {}
};
int main(int argc, char * argv[]) {
P * p1 = new P();
P * p2 = new P();
P * p3 = new P();
printf("p1:0x%x,0x%x\n", p1, *((long *)p1));
printf("p2:0x%x,0x%x\n", p2, *((long *)p2));
printf("p3:0x%x,0x%x\n", p3, *((long *)p3));
return 0;
}
'''
运行结果为
p1:0xd94010,0x4008b0
p2:0xd94030,0x4008b0
p3:0xd94050,0x4008b0
看到类P的三个实例p1,p2,p3他们的实例指针是不相同的,而虚函数表指针是同一个。
***
总结一下,如果C++类中定义过虚函数,不管是只定义了一个还是多个,那么这个类就会生出一个虚函数表,里面包含所有虚函数的地址指针列表,而类的每一个对象实例在内存的开头位置,都额外分配一个指针变量,指向类的虚函数表。
当call一个虚函数的时候,首先从this指针位置读出虚函数表,然后从虚函数表里面拿出虚函数的正确地址,在call到这个地址。
把虚函数表指针放在对象内存的开始位置是linux环境下的结果,不同的编译运行环境可能会不同,比如windows系统下就把虚函数表指针放在对象内存的结尾部。
最后,这篇文章主要介绍虚函数的原理,通过最普通的场景来描述虚函数的实现原理,对于复杂的场景,例如多继续,虚继承等会导致类实例结构发生变化,而虚函数表的处理也会发生变化,不在此描述。