1. C++ 对象模型
class 类对象的内存分布与 struct 结构体对象的内存分布相同,即遵循内存对齐原则。同时,类对象的内存中只保存成员变量,而成员函数保存在代码段。
那么程序中是怎么通过成员对象来调用对应的成员函数呢?实际上,在调用成员函数时,编译器会把对象的地址隐式传递给成员函数,成员函数中用隐式形参指针 this 来接收对象地址,这个传递过程是隐式完成的,这也是 this 指针指向调用函数对象的原因。
当发生类继承时,对象模型实际为子类的成员变量在父类的成员变量上进行叠加。即使子类继承后发生成员变量冲突,也仅是逻辑上的成员变量覆盖而非内存上的变量覆盖,通过作用域限定符可以访问父类的成员变量也说明了这一点。
- 实验:
class Parent
{
private:
int miVar;
char mcVar;
public:
void Print()
{
cout << "in Parent" << endl;
}
};
class Child : public Parent
{
private:
int miVar;
float mfVar;
public:
void Print()
{
cout << "in Child" << endl;
}
};
int main(int argc, char *argv[])
{
cout << "size(Parent) = " << sizeof(Parent) << endl; // size(Parent) = 8
cout << "size(Child) = " << sizeof(Child) << endl; // sizeof(Child) = 16
}
以上子类 Child 对象的内存分布如下:
| |||
| |||
| |||
|
2. 多态对象模型
当定义了虚函数时,对象模型也发生了变化。当类中存在虚函数时,编译器会为类自动添加一个指针,该指针指向一个名为虚函数表的结构,虚函数表中记录的是类中所有显式实现的虚函数函数地址。因此,含有虚函数的类比无虚函数的类多占用 4 字节(由指针所占的内存大小决定)。
虚函数表指针在类对象模型的最前面,且每个含虚函数的类都有一个唯一对应的虚函数表。虚函数表存放在只读存储区(.rodata 段),在编译期形成,由编译器创建与维护。例如:
class Parent_novir
{
public:
int miVar;
void fun () {}
};
class Parent
{
public:
int miVar;
virtual void fun () {}
};
class Child : public Parent
{
public:
int miVar;
virtual void fun () {}
};
int main(int argc, char *argv[])
{
/* 虚函数表指针占用类对象大小 */
cout << "size(Parent_novir) = " << sizeof(Parent_novir) << endl; // size(Parent_novir) = 4
cout << "size(Parent) = " << sizeof(Parent) << endl; // size(Parent) = 8
cout << "size(Child) = " << sizeof(Child) << endl; // sizeof(Child) = 12
/* 虚函数表位于类对象模型头部 */
Parent Par_obj1;
cout << "&Par_obj1 = " << &Par_obj1 << endl; // &Par_obj1 = 0x7ffdd8c0
cout << "&Par_obj1.miVar = " << &Par_obj1.miVar << endl; // &Par_obj1.miVar = 0x7ffdd8c4
/* 每个类对应唯一的虚函数表 */
cout << "Par_obj1 virtual-table is " << *(void**)&Par_obj1 << endl; // Par_obj1 virtual-table is 0x400df8
Parent Par_obj2;
cout << "Par_obj2 virtual-table is " << *(void**)&Par_obj2 << endl; // Par_obj2 virtual-table is 0x400df8
Child Ch_obj;
cout << "Ch_obj virtual-table is " << *(void**)&Ch_obj << endl; // Ch_obj virtual-table is 0x400df0
}
虚函数表的创建过程:编译器编译到类的实现时,编译发现类中存在虚函数,于是把在类模型前添加一个虚函数表指针,然后在只读存储区创造一个虚函数表,每编译到一个虚函数时,就把虚函数的函数地址记录到表中。
假设有如下类定义:
class Parent
{
public:
virtual void fun1 () {}
virtual void fun2 () {}
};
class Child : public Parent
{
public:
virtual void fun1 () {}
virtual void fun3 () {}
};
编译后,类 Parent 和类 Child 对应的虚函数表分别如下所示:
Parent |
---|
Parent::fun1() |
Parent::fun2() |
Child |
---|
Child::fun1() |
Child::fun3() |
虚函数表的访问过程:创建类对象时,在构造函数执行完后,虚函数表指针指向该类对应的虚函数表。当使用父类指针(或引用)访问父类或子类对象的成员函数时,编译器会检查该成员函数是否被记录在虚函数表中。如果不在,则直接调用成员函数,这里的函数调用在编译期已确定;如果在,则再访问类对象对应的虚函数表中记录的虚函数地址,这里的函数调用在程序运行期确定。
因此,调用虚函数时会比调用普通的成员函数多一次地址访问,使用虚函数程序效率会有所下降,所以不能盲目定义虚函数。
怎么理解函数调用在程序运行期确定?
一般地,在程序编译时,通过函数调用表达式可以确定具体调用的函数地址。但虚函数是特殊的函数调用,需要通过虚函数表再确定具体调用的函数地址。通过汇编指令可以看到,在编译期间,虚函数的调用的编译结果为:调用实际对象对应的虚函数表中的函数,但具体会访问哪个函数地址编译器是不知情的,因为不同的类有着各自的虚函数表。只有在程序运行时,CPU 才可以真正地通过类对象对应的虚函数表访问到真正的虚函数地址。
这就是为什么同一条函数调用表达式得到的调用结果不同,也就是为什么虚函数的调用需要二次寻址。这一行为被称为动态联编或动态绑定,是多态的核心思想!