为什么要了解C++的对象模型,我觉得第一点就是了解了C++的对象模型之后可以避免很多C++的错误写法;第二点可以加深对C++编译器的理解,了解C++编译器在编译的时候做了哪些幕后工作;第三点是可以加强对计算机底层的理解,并且可以可以增加对C语言的理解。文中的内容,是我读了《深度探索C++对象模型》这本书之后的一点心得体会。所有的例子的运行环境是VS2013,64位,Windows10系统。
如果父类有虚函数,那么子类可以覆写父类的虚函数,达到多态的效果。但问题是,在编译器编译的时候不能确定该调用哪一个函数,所以只能在运行时确定。要在运行时确定,类对象里面就必须有虚函数地址的信息。很明显,如果把每个虚函数的地址都存入对象中,是很浪费的,所以对象里面仅仅保存一个指向一个含有类中所有虚函数地址的指针,这个地址就是虚函数表。相同类对象的虚函数表是相同的,所以每个类的虚函数表在内存中只有一份。虚函数表和指针都是在编译器编译的时候加进去的,不同的编译器在实现细节上有差别,这里以VS2013为例。
虚函数指针的位置
按常理来说,虚函数指针不可能放在类对象的中间,只能放在头部和尾部。这样就比较好确定了,如下的代码就可以区分是否在头部。
#include
using namespace std;
class A{
public:
int val;
virtual void func(){
cout << "A::func()" << endl;
}
};
int main(){
A a;
char *p1 = (char *)&a;
char *p2 = (char *)&a.val;
if (p1 != p2){
cout << "start" << endl;
}
else{
cout << "end" << endl;
}
return 0;
}
在VS2013中,虚函数指针被放在了对象的头部。
通过虚函数地址调用虚函数
先看下面的代码:
#include
using namespace std;
class A{
public:
int val = 100;
virtual void f1(){
cout << "A::f1()" << endl;
}
virtual void f2(){
cout << "A.val = " << val << endl;
}
};
typedef void(*func)(A *a);
int main(){
A a;
func** pvptr = (func **)&a;
func* vptr = *pvptr;
for (int i = 0; i < 2; i++){
vptr[i](&a);
}
return 0;
}
为了简单起见,这里虚函数的参数和返回值都是一样的,值得注意的是,如果使用这种方式调用函数,第一个参数需要传入this指针,不然会出问题。虚函数表是类型为func的数组,而a的首地址存的是func*类型的,再对a取地址,得到的是func**类型。运行结果表明,在虚函数表中虚函数的排列是按照虚函数的声明顺序来排列的,这也是符合一般的习惯。
可以使用VS2013中提供的命令行提示工具来查看生成类的布局,在命令行提示工具中输入:
/* cl /d1 reportSingleClassLayout类名 源文件名 */
cl /d1 reportSingleClassLayoutA virtual_function.cpp
就可以打印出类A的相关信息。在Linux下,也有类似的命令:
g++ -fdump -class -hierarchy -fsyntax -only 源文件名
在输出中可以看到如下的内容:
class A size(16):
+---
0 | {vfptr}
8 | val
| (size=4)
+---
A::$vftable@:
| &A_meta
| 0
0 | &A::f1
1 | &A::f2
可以看出类A的布局的确如上面所说。
静态联编和动态联编
简单的说,静态联编就是在编译的时候就确定函数的调用地址,而动态联编就是在运行时在确定函数调用的地址。在C++中实现动态联编需要同时满足以下三个条件:虚函数,继承关系,基类指针或引用指向子类对象。这会导致下面的区别:
#include
using namespace std;
class A{
public:
int val = 100;
virtual void f(){
cout << "A::f()" << endl;
}
};
class B :public A{
public:
virtual void f() override{
cout << "B::f()" << endl;
}
};
typedef void(*func)();
int main(){
B b;
A a = b;
A *pa = &b;
a.f();
pa->f();
func **vpa = (func **)&b;
func *va = *vpa;
(*va)();
return 0;
}
运行程序,会发现以下的现象,使用a.f()调用的父类的方法,而直接使用虚函数调用还是调用的是父类的方法。说明虚函数指针在赋值的时候也是被修改了的。通过反汇编,可以发现,使用a.f()调用的时候是直接跳转到函数地址,而使用pa->f()调用需要的步骤更多,说明pa->f()是动态联编,在运行的是才决定调用哪个函数。
a.f();
00007FF7523B4B2D lea rcx,[a]
00007FF7523B4B32 call A::f (07FF7523B1190h)
pa->f();
00007FF7523B4B37 mov rax,qword ptr [pa]
00007FF7523B4B3C mov rax,qword ptr [rax]
00007FF7523B4B3F mov rcx,qword ptr [pa]
00007FF7523B4B44 call qword ptr [rax]
虚函数指针有关的错误
先看下面一个程序:
#include
using namespace std;
class A{
public:
int val;
virtual void f(){
cout << "A::f()" << endl;
}
A(){
memset(this, 0, sizeof(A));
}
A(const A& obj){
memcpy(this, &obj, sizeof(A));
}
};
class B :public A{
public:
virtual void f() override{
cout << "B::f()" << endl;
}
};
int main(){
A *a1 = new A();
//a1->f();
B b;
A a(b);
a.f();
A *p = &a;
p->f();
return 0;
}
这个程序有两个问题,第一个,a1->f()会出现错误,第二,a.f()调用的是A的函数,p->f()调用的是B的函数。其实是使用memset会把虚函数指针清空,而memcpy会把虚函数指针也赋值。第一个a.f()因为是静态联编,没有经过虚函数指针。
如果出现了这种奇怪的错误,不懂虚函数指针,会觉得非常奇怪。