12.2 多重继承

12.1 节讲解了类与类之间的关系,但所接触的派生类都只有一个父类。当子类拥有多个父类(如类C继承自类A同时也继承自类B)时,便构成了多重继承关系。在多重继承的情况下,子类所继承的父类变为多个,但其结构与单一继承相似。

分析多重继承的第一步是了解派生类中各数据成员在内存中的布局情况。在12.1节中,子类继承自一个父类,其内存中首先存放的是父类的数据成员。当子类产生多重继承时,其父类数据成员在内存中又该如何存放呢?我们通过代码清单12-8来看看多重继承类的定义。

代码清单12-8 多重继承类的定义—C++源码


//定义沙发类

class CSofa{

public:

CSofa(){

m_nColor=2;

}

virtual~CSofa(){//沙发类虚析构函数

printf("virtual~CSofa()\r\n");

}

virtual int GetColor(){//获取沙发颜色

return m_nColor;

}

virtual int SitDown(){//沙发可以坐下休息

return printf("Sit down and rest your legs\r\n");

}

protected:

int m_nColor;//沙发类成员变量

};

//定义床类

class CBed{

public:

CBed(){

m_nLength=4;

m_nWidth=5;

}

virtual~CBed(){//床类虚析构函数

printf("virtual~CBed()\r\n");

}

virtual int GetArea(){//获取床面积

return m_nLength*m_nWidth;

}

virtual int Sleep(){//床可以用来睡觉

return printf("go to sleep\r\n");

}

protected:

int m_nLength;//床类成员变量

int m_nWidth;

};

//子类沙发床定义,派生自CSofa类和CBed类

class CSofaBed:public CSofa, public CBed{

public:

CSofaBed(){

m_nHeight=6;

}

virtual~CSofaBed(){//沙发床类的虚析构函数

printf("virtual~CSofaBed()\r\n");

}

virtual int SitDown(){//沙发可以坐下休息

return printf("Sit down on the sofa bed\r\n");

}

virtual int Sleep(){//床可以用来睡觉

return printf("go to sleep on the sofa bed\r\n");

}

virtual int GetHeight(){

return m_nHeight;

}

protected:

int m_nHeight;//沙发类的成员变量

};


代码清单12-8中定义了两个父类:沙发类和床类,通过多重继承,以它们为父类派生出沙发类,它们都拥有各自的属性以及方法。main函数中定义了子类SofaBed的对象,其中包含两个父类的数据成员,此时SofaBed在内存中占多少字节呢?如图12-7所示为对象SofaBed占用内存空间的大小。

图 12-7 对象SofaBed占用内存空间的大小

根据图12-7所示,对象SofaBed占用的内存空间大小为0x18字节。这些数据的内容是什么?它们又是如何存放在内存中的?具体如图12-8所示。

图 12-8 对象SofaBed的内存信息

如图12-8所示,对象SofaBed的首地址在0x0012FF5C处,在图中可看到子类的数据成员和两个父类中的数据成员。数据成员的排列顺序由继承父类的先后顺序所决定,从左向右依次排列。除此之外,还剩余两个地址值,分别为0x00426198与0x0042501C,这两个地址处的数据如图12-9所示。

图 12-9 子类对象的虚表指针对应的虚表信息

图12-9中显示了Debug下两个虚表指针所指向的虚表信息。查看图12-9中的两个虚表信息后会发现,这两个虚表中保存了子类的虚函数与父类的虚函数,父类的这些虚函数都是在子类中没有实现的。由此可见,编译器将子类CSofaBed的虚函数制作了两份。为什么会产生两份虚函数呢?我们先从对象SofaBed的构造入手,循序渐进地进行分析,过程如代码清单12-9所示。

代码清单12-9 对象SofaBed的构造过程—Debug版


//源码参考见代码清单12-7

CSofaBed SofaBed;//定义对象

0040F72D lea ecx,[ebp-24h];传递this指针

0040F730 call@ILT+10(CSofaBed:CSofaBed)(0040100f);调用构造函数

//分析构造函数CSofaBed

CSofaBed(){

;部分代码分析略

004011FE pop ecx;还原this指针

004011FF mov dword ptr[ebp-10h],ecx

00401202 mov ecx, dword ptr[ebp-10h];以对象首地址作为this指针

00401205 call@ILT+110(CSofa:CSofa)(00401073);调用沙发父类的构造函数

0040120A mov dword ptr[ebp-4],0

00401211 mov ecx, dword ptr[ebp-10h]

00401214 add ecx,8;将this指针调整到第二个虚表指针地址处

00401217 call@ILT+130(CBed:CBed)(00401087);调用床父类的构造函数

0040121C mov eax, dword ptr[ebp-10h];获取第二个虚表指针地址

;设置虚表指针

0040121F mov dword ptr[eax],offset CSofaBed:'vftable'(00426198)

00401225 mov ecx, dword ptr[ebp-10h];获取对象的首地址

;设置虚表指针

00401228 mov dword ptr[ecx+8],offset CSofaBed:'vftable'(0042501c)

;部分代码分析略

0040125D ret


在代码清单12-9的子类构造中,根据继承关系的顺序,首先调用了父类CSofa的构造函数。在调用另一个父类CBed时,并不是直接将对象的首地址作为this指针传递,而是向后调整了父类CSofa的大小,以调整后的地址值作为this指针,最后再调用父类CBed的构造函数。

由于有了两个父类,因此子类在继承时也将它们的虚表指针一起继承了过来,也就有了两个虚表指针。可见,在多重继承中,子类虚表指针的个数取决于所继承的父类的个数,有几个父类便会出现对应个数的虚表指针(虚基类除外,详见12.3节的讲解)。

这些虚表指针在将子类对象转换成父类指针时使用,每个虚表指针对应着一个父类,如代码清单12-10所示。

代码清单12-10 多重继承子类对象转换为父类指针


CSofaBed SofaBed;

CSofa*pSofa=&SofaBed;

0040F73C lea eax,[ebp-24h];直接将首地址转换为父类指针

0040F73F mov dword ptr[ebp-28h],eax

CBed*pBed=&SofaBed;

0040F742 lea ecx,[ebp-24h]

0040F745 test ecx, ecx;检查对象首地址

0040F747 je main+51h(0040f751)

0040F749 lea edx,[ebp-1Ch];即lea edx,[ebp-24h+8h],调整为CBed的指针

0040F74C mov dword ptr[ebp-30h],edx

0040F74F jmp main+58h(0040f758)

0040F751 mov dword ptr[ebp-30h],0

0040F758 mov eax, dword ptr[ebp-30h]

0040F75B mov dword ptr[ebp-2Ch],eax;保存调整后的this指针


在代码清单12-10中,在转换CBed指针时,会调整首地址并跳过第一个父类所占用的空间。这样一来,当使用父类CBed的指针访问CBed中实现的虚函数时,就不会错误地寻址到继承自CSofa类的成员变量。

了解了多重继承中子类的构造函数,以及父类指针的转换过程后,接下来通过分析代码清单12-11来学习多重继承中子类对象的析构过程。

代码清单12-11 多重继承的类对象析构函数—Debug版


;子类析构函数的实现过程

virtual~CSofaBed(){//沙发床类的虚析构函数

;部分代码略

0040170E pop ecx;还原this指针

0040170F mov dword ptr[ebp-10h],ecx

00401712 mov eax, dword ptr[ebp-10h]

;将两个虚表指针设置为各个父类的虚表首地址

00401715 mov dword ptr[eax],offset CSofaBed:'vftable'(00426198)

0040171B mov ecx, dword ptr[ebp-10h]

0040171E mov dword ptr[ecx+8],offset CSofaBed:'vftable'(0042501c)

00401725 mov dword ptr[ebp-4],0

;执行子类虚函数内的代码

printf("virtual~CSofaBed()\r\n");

}

;比较对象地址,与子类对象转为父类指针相似

00401739 cmp dword ptr[ebp-10h],0;当this==NULL时不需调整

0040173D je CSofaBed:~CSofaBed+6Ah(0040174a)

0040173F mov edx, dword ptr[ebp-10h]

00401742 add edx,8

00401745 mov dword ptr[ebp-14h],edx;将调整后的this指针保存到[ebp-14h]

00401748 jmp CSofaBed:~CSofaBed+71h(00401751)

0040174A mov dword ptr[ebp-14h],0

00401751 mov ecx, dword ptr[ebp-14h]

;调用父类CBed的析构函数

00401754 call@ILT+75(CBed:~CBed)(00401050)

00401759 mov dword ptr[ebp-4],0FFFFFFFFh

00401760 mov ecx, dword ptr[ebp-10h]

;无需转换this指针,直接调用父类CSofa的析构函数

00401763 call@ILT+125(CSofa:~CSofa)(00401082)

00401768 mov ecx, dword ptr[ebp-0Ch]

0040176B mov dword ptr fs:[0],ecx

;部分代码略

00401782 ret


代码清单12-11演示了对象SofaBed的析构过程。由于具有多个同级父类(多个同时继承的父类),因此在子类中产生了多个虚表指针。在对父类进行析构时,需要设置this指针,用于调用父类的析构函数。由于具有多个父类,当在析构的过程中调用各个父类的析构函数时,传递的首地址将有所不同,编译器会根据每个父类在对象中占用的空间位置,对应地传入各个父类部分的首地址作为this指针。

在Debug版下,由于侧重调试功能,因此使用了两个临时变量来分别保存两个this指针,它们对应的地址分别为两个虚表指针的首地址。在Release版下,虽然会进行优化,但原理不变,子类析构函数调用父类的析构函数时,仍然会传入在对象中父类对应的地址,当做this指针。

前面讲解了多重继承中子类对象的生成与销毁过程,以及在内存中的分布情况,对比单继承类,两者特征总结如下:

单继承类

在类对象占用的内存空间中,只保存一份虚表指针。

由于只有一个虚表指针,对应的也只有一个虚表。

虚表中各项保存了类中各虚函数的首地址。

构造时先构造父类,再构造自身,并且只调用一次父类构造函数。

析构时先析构自身,再析构父类,并且只调用一次父类析构函数。

多重继承类

在类对象所占用的内存空间中,根据继承父类的个数保存对应的虚表指针。

根据所保存的虚表指针的个数,对应产生相应个数的虚表。

转换父类指针时,需要跳转到对象的首地址。

构造时需要调用多个父类构造函数。

构造时先构造继承列表中第一个父类,然后依次调用到最后一个继承的父类构造函数。

析构时先析构自身,然后以与构造函数相反的顺序调用所有父类的析构函数。

当对象作为成员时,整个类对象的内存结构和多重继承很相似。当类中无虚函数时,整个类对象内存结构和多重继承完全一样,可酌情还原;当父类或成员对象存在虚函数时,通过观察虚表指针的位置和构造函数、析构函数中填写虚表指针的数目及目标地址,来还原继承或成员关系。

在对象模型的还原过程中,可根据以上特性识别出继承关系。对于有虚函数的情况,可利用虚表的初始化,使用IDA中的引用参考进行识别还原。引用参考的使用请回顾第11章的相关内容。