C++ 继承语义下构造函数中的对象指针
展示一段程序,对其做一些讨论。
1 #include <iostream> 2 #include <typeinfo> 3 using namespace std; 4 5 class X { 6 public: 7 X* ptr; 8 X() { 9 cout << typeid(*this).name() << endl; 10 ptr = this; 11 } 12 13 virtual void func() { cout << "X func" << endl; } 14 }; 15 16 class Y : public X { 17 public: 18 Y() {} 19 virtual void func() { cout << "Y func" << endl; } 20 }; 21 22 int main() { 23 X x; 24 Y y; 25 x.ptr->func(); 26 y.ptr->func(); 27 return 0; 28 }
这段是测试在继承语义下对象构造时 this 指针的程序。首先在 main 函数中声明了一个X对象x和一个Y对象y,然后x和y分别通过指针成员ptr调用func函数。看X和Y的定义我们知道Y是继承自X的,是X的子类。
X中有自定义默认构造函数,一个数据成员ptr和一个虚拟成员函数func。 在Y中除了有一个"空的"构造函数外,还对继承自X的func重写(override)。那程序的输出是什么呢?
程序的输出取决于第9行中的 *this 和第10行中的 this 。
下面基于GCC(g++)编译器实现,对这段程序做较深入的讨论。先普及一下知识:
1. C++ 中类对象在构造之前,要为其在堆(malloc new 等操纵的内存)或栈(通常指函数栈帧)上分配内存空间,并将这块内存空间的地址作为第一个参数传递给相应的构造函数,构造函数负责对这块内存初始化。示例程序中构造函数里出现的 this 即构造函数的参数。
2. 在继承语义下,子类对象在构造时需要调用父类的构造函数来初始化子类对象中属于父类的部分(以下称之子对象),调用父类构造函数时以相应子对象的地址作为参数。
3. 一个类中如果出现虚函数,那么这个类对象的第一个机器字被用作虚指针(vptr), 它指向该类的虚函数列表中第一个虚函数入口地址所在的位置。在多继承或虚拟继承等复杂情形下,一个对象中可能包含多个虚指针。(有机会专门写一篇博客来讨论GCC中多继承和虚继承的一些实现细节。)
4. 一个类的虚函数列表被放在一个叫虚表(vtable)的数据结构(由编译器生成)中。虚表中除了保存虚函数的入口地址外,还保存指向类型信息数据结构 (Runtime Type Information, RTTI)的指针和一些用于处理多继承和虚继承的偏移值(如top_offset, vbase_offset等)
5. 如果一个类中存在虚函数,编译器会在其构造函数在插入初始化 vptr 的代码,这部分代码通常在父类构造函数之后,用户代码之前。
现在开始分析 X::X(), 它所做的工作是初始化 vptr、打印类型信息和初始化 ptr。 X::X() 接受一个 X* 类型的指针,它是需要初始化的对象(或子对象)的地址。所以 *this 的类型一定是 X,第9行将打印X的类型信息。这一行中 typeid(*this).name 实际上调用 typeinfo::name(), 后者以指向X类型信息的指针(& typeinfo for X) 作为参数, 这个地址被保存在X的虚表中。
下面给出在64位机器上 X 对象的内存布局、X 类的虚表以及能够表达 X::X() 在汇编语言层面上的语义的伪代码(为了使说明更清晰借用了C/C++语言操作符, 地址偏移以字节为单位, 赋值操作是8字节操作):
X object layout Vtable for X 0 vptr ----------+ vtable for X: 3u entries 8 ptr | 0 (int (*)(...))0 // top_offset | 8 (int (*)(...))(& typeinfo for X) // ptr to typeinfo +-------> 16 (int (*)(...))X::func // slot for the first vfunc /* pseudo code * & : address-of, -> : member access through pointer */ X::X(this): // this is a pointer of type X* this->vptr = (& vtable for X) + 16 // (& this->vptr) : this + 0 print typeinfo::name(& typeinfo for X) // (& this->ptr) : this + 8 this->ptr = this
在构造 y时,先将y的地址作为参数传给Y::Y(), 由 Y::Y() 来对其初始化。 Y::Y() 将y 中 X 子对象的初始化委托给了 X::X(), 最后再在Y::Y() 中对 vptr 赋值。 我这里说"赋值"
是因为Y对象的地址与其X子对象的地址相同(这是最简单的情形,子对象地址的偏移值为 0),在X::X()中已经将vptr这个机器字初始化为X的第一个虚函数的在虚表中的位置,在 Y::Y() 中需要覆盖这个不合理的vptr值,将其设置为指向存在于Y的虚表中的第一个虚函数的位置。通过Y对象访问的ptr指针在调用Y::Y()后被初始化为其X子对象的地址,实际上也是Y对象的地址。
下面给出 Y 对象的内存布局、Y 类的虚表 和 Y::Y() 的伪代码:
1 Y object layout Vtable for Y 2 0 vptr ----------+ vtable for Y: 3u entries 3 8 ptr | 0 (int (*)(...))0 4 | 8 (int (*)(...))(& typeinfo for Y) 5 +-------> 16 (int (*)(...))Y::func 6 7 Y::Y(this): // this is a pointer of type Y* 8 X::X(this + 0) 9 this->vptr = (& vtable for Y) + 16 // (& this->vptr) : this + 0
现在很容易知道 x.ptr->func() 和 y.ptr->func() 的输出是什么,这是每个C++程序员都熟悉的动态绑定。 x.ptr 和 y.ptr 均为类型为X*的指针,x.ptr 指向 x,y.ptr 指向
y, 但他们调用 func() 时, 通过一致的表达式(在汇编语言中,是一致的命令序列)就可以实现多态, 这得益于基于虚指针和虚表的编译器实现。
体现汇编语言层面语义的伪代码:
// pseudo code // *(ptr) : extract 8 bytes from the address pointed by ptr
px = /* x.ptr or y.ptr */ vptr = *(px) // get the vptr pfunc = *(vptr) // get the function pointer pfunc(px) // call func()