12.1 识别类和类之间的关系(2)
对代码清单12-2进行分析后发现,编译器提供了默认构造函数与析构函数。当子类中没有构造函数或析构函数,而其父类却需要构造函数与析构函数时,编译器会为该父类的子类提供默认的构造函数与析构函数。
由于子类继承了父类,因此子类中需要拥有父类的各成员,类似于在子类中定义了父类的对象作为数据成员使用。代码清单12-1中的类关系如果转换成以下代码,则它们的内存结构等价。
- class CBase{...}; // 类定义见代码清单12-1
- class CDervie{
- public:
- CBase m_Base; // 原来的父类CBase成为成员对象
- int m_nDervie; // 原来的子类派生数据
- ;
原来的父类CBase成为了CDervie的一个成员对象,当产生CDervie类的对象时,将会先产生成员对象m_Base,这需要调用其构造函数。当CDervie类没有构造函数时,为了能够在CDervie类对象产生时调用成员对象的构造函数,编译器同样会提供默认构造函数,以实现成员构造函数的调用。
但是,如果子类含有构造函数,而父类不存在构造函数,则编译器不会为父类提供默认的构造函数。在构造子类时,由于父类中没有虚表指针,也不存在构造祖先类的问题,因此添加默认构造函数对父类没有任何意义。父类中含有虚函数的情况则不同,此时的父类需要初始化虚表工作,因此编译器会为其提供默认的构造函数,以初始化虚表指针。
当子类对象被销毁时,其父类也同时被销毁,为了可以调用父类的析构函数,编译器为子类提供了默认的析构函数。在子类的析构函数中,析构函数的调用顺序与构造函数相反,先执行自身的析构代码,再执行其父类的析构代码。
依照构造函数与析构函数的调用顺序,不仅可以顺藤摸瓜找出各类之间的关系,还可以根据调用顺序区别出构造函数与析构函数。
子类对象在内存中的数据排列为:先安排父类的数据,后安排子类新定义的数据。当类中定义了其他对象作为成员,并在初始化列表中指定了某个成员的初始化值时,构造的顺序会是怎样的呢?我们先来看下面的代码:
- //源码对照
- class CInit{
- public:
- CInit(){
- m_nNumber = 0;
- }
- int m_nNumber;
- };
-
- class CDervie : public CBase{
- public:
- CDervie():m_nDervie(1){
- printf("使用初始化列表\r\n");
- }
- CInit m_Init; // 在类中定义其他对象作为成员
- int m_nDervie;
- };
-
- // main函数实现
- void main(int argc, char* argv[]){
- CDervie Dervie;
- }
- //反汇编代码分析
- ; 函数入口代码略
- 00401068 lea ecx,[ebp-0Ch] ; 传递this指针,调用CDervie的构造函数
- 0040106B call @ILT+10(CDervie::CDervie) (0040100f)
- ; 进一步查看CDervie的构造函数,函数入口代码分析略
- 004010CF mov dword ptr [ebp-10h],ecx ; [ebp-10h]保存了this指针
- ; 传递this指针,并调用父类构造函数
- 004010D2 mov ecx,dword ptr [ebp-10h]
- 004010D5 call @ILT+25(CBase::CBase) (0040101e)
- 004010DA mov dword ptr [ebp-4],0 ; 调试版产生的对象计数代码,不必理会
- ; 根据this指针调整到类中定义的对象m_Init的首地址处,并调用其构造函数
- 004010E1 mov ecx,dword ptr [ebp-10h]
- 004010E4 add ecx,4
- 004010E7 call @ILT+30(CInit::CInit) (00401023)
- ; 执行初始化列表,this指针传递给eax后,[eax+8]是对成员数据m_nDervie进行寻址
- 004010EC mov eax,dword ptr [ebp-10h]
- 004010EF mov dword ptr [eax+8],1
- ; 最后才是执行CDervie的构造函数代码
- 004010F6 push offset string "使用初始化列表\r\n " (0042501c)
- 004010FB call printf (004012b0)
- 00401100 add esp,4
- ; 其余代码分析略
根据以上分析,在有初始化列表的情况下,将会优先执行初始化列表中的操作,其次才是自身的构造函数。构造的顺序为:先构造父类,然后按声明顺序构造成员对象和初始化列表中指定的成员,最后才是自身的构造函数。读者可自行修改类中各个成员的定义顺序,初始化列表的内容,然后按以上方法分析并验证其构造的顺序。
回到代码清单12-2的分析中,在子类对象Dervie的内存布局中,首地址处的第一个数据是父类数据成员m_nBase,向后的4字节数据为自身数据成员m_nDervie,如表12-1所示。
表12-1 Dervie对象内存结构
有了这样的内存结构,不但可以使用指向子类对象的子类指针间接寻址到父类定义的成员,而且可以使用指向子类对象的父类指针间接寻址到父类定义的成员。在使用父类成员函数时,传递的this指针也可以是子类对象首地址。因此,在父类中,可以根据以上内存结构将子类对象的首地址视为父类对象的首地址来对数据进行操作,而且不会出错。由于父类对象的长度不超过子类对象,而子类对象只要派生新的数据,其长度即可超过父类,因此子类指针的寻址范围不小于父类指针。在使用子类指针访问父类对象时,如果访问的成员数据是父类对象所定义的,那么不会出错;如果访问的是子类派生的成员数据,则会造成访问越界。