Q1:具体继承(单一继承)且无多态的情况
• 具体继承(对应于虚拟继承)并不会增加空间或存取时间上的额外负担
• 在这种情况下,类对象的内存存储情况如下:
Eg:
class X
{
int x;
char c;
};
class Y : public X
{
int y;
};
X对象内存布局为:
• 把两个原本不相干的类凑成一个基类与继承类对,会产生以下两个易犯错误:
1) 重复设计相同操作的函数,如:
class A
{
public:
A(int i = 0) :vala(i){}
void operator +=(const A & a)
{
vala += a.vala;
}
int vala;
};
class B : public A
{
public:
B(int i = 0, int j = 0) :A(i), valb(j){}
void operator+=(const B & b)
{
A::operator+=(b); //调用子对象A部分的加法操作符作为 B 加法操作符的一部分
valb += b.valb;
}
int valb;
};
2) 为了表现“class体系的抽象化”而膨胀所需的空间,C++语言保证“出现在派生类中的基类子对象具有其完成原样性”。如:
class X
{
int val;
char ca;
char cb;
};
此时类 X 的大小为8字节,假设为了逻辑性,将其进行扩展更为合适,即扩展为 A、B两个类,则:
class A
{
int val;
char ca;
};
class B : public A
{
char cb;
};
此时,类 A 的大小为8字节,类 B 的大小为12字节,比原来多了50%,由于抽象体系的需求膨胀了所需空间。
*备注:“出现在派生类中的基类子对象的完整原样性”的含义:(以上述类A,类B为例子)
• 类A 的大小为 8 字节,其中,4字节是 val 所占空间,1 字节是 ca所占空间,剩下三字节为对齐填补字节
• 对B的大小按以下两种情况分类:
1) 基类子对象具有完整原样性
• B 类的大小为 12字节,其中,8字节为 A 类子对象的大小(包含三字节填充字节),1字节为 cb 所占空间,剩余3字节为对其填补字节。
• 此时,B类的子对象 A 是一个完整的A 类,即sizeof(B::A) = sizeof(A),包含了 A 的填充字节,保证了基类子对象的完整原样性(VS中如此)
2) 基类子对象不具有完成原样性
• B 类大小为8字节,其中5字节为 A 类子对象的大小(取掉 A 的填充字节),1字节为 cb 所占空间,剩余2字节为填补字节
• 此时,B类子对象是一个不完整的 A类,取掉了A 中的填充字节,并将派生类的成员 cb与基类子对象A 捆绑在一起,不具有基类子对象的完整原样性(G++中如此)
• 这种情况有可能存在缺陷,当将一个 A 类对象强制复制给一个B 类对象时,将会覆盖B类对象中与 基类子对象A 捆绑在一起的部分成员。如图所示:
Q2:具体继承(单一继承)与多态的情况
• 即在基类中提供虚函数接口,供继承层次中类对象实现多态性
• 在这种情况下,基类与派生类类对象的布局情况如下:
Eg:
class X
{
int x;
char c;
virtual void func();
};
class Y : public X
{
int y;
void func();
};
• 多态的加入带来的空间与存储时间上的额外负担
1) 导入一个与基类有关的 virtual table,用来存放申明的每一个虚函数的地址, 再增加一行或两行,用来支持运行时类型识别
2) 在每个类对象中导入 vptr,使每个对象找到相应的 virtual table
3) 加强构造函数,使它能够为 vptr 设置初值,让其指向类对应的虚函数表
4) 加强析构函数,使它能析构“与类相关的 virtual table”的 vptr
• 关于 vptr 放在类对象的哪里?
1) 类对象的尾部
优点:这样就可以保留 base class C struct 的对象布局,因为允许在C 代码中也可使用
缺点:在多重继承的情况下,则,“从类对象起始点开始量起”的 offset 必须在执行期准备,甚至class vptr之间的offset也必须知道
2) 类对象的头部
优点:在多重继承的情况下,通过指向类成员的指针就可调用虚函数
缺点:丧失了C语言的兼容性
Q3:多重继承的情况
• 对多重继承的基类与派生类之间的内存布局情况如下:
Eg:
class A
{
char a;
virtual void func1();
};
class B : public A
{
char b;
void func1();
};
class C
{
char c;
virtual void func2();
}
class D : public B, public C
{
char d;
}
继承关系如下图所示:
其中各个类的内存布局如下所示 :
• 对于一个多重派生对象,将其地址指定给“最左端”(即继承层次中最先声明的)的基类的指针,情况与单一继承时相同,因为二者将指向相同的地址;而第二个或再后继的基类的地址则需要将地址进行修改,加上中间基类子对象的大小。如:
- 对于上述例子中,对D 类对象而言,其第一个声明的基类为类B(而类B第一个基类对象为类A),第二个声明的基类为类C,进行以下操作:
Eg:
D dobj;
A * pa = &dobjl; //A类子对象的地址与D类对象地址相同,因为A类子对象是D类对象声明的第一个基类
B * pb = &dobj; //B类子对象的地址与D类对象地址相同,原因如上
C * pc = &dobj; //C 类子对象的地址与D类对象不同,需要进行内部转换
D * pd = &dobj;
//①:
//对 C * pc = &dobj 进行如下转换
pc = (C*)((char*)&dobj + sizeof(B)); //加上中间基类子对象B的sizeof
//②:
//对指针复制,有时候不能直接进行拷贝或转换,可能需要先判断指针是否为0
pa = pd;
pb = pd; //这两种情况只需要简单拷贝地址即可
pc = pd;
//不能直接进行如下转换:
pc = (C*)((char*)pd + sizeof(B)); //因为当 pd = 0 时,直接转换会使得 pc = sizeof(B),这是错误的行为,因此转换前需要判断 pd 是否为0
//以下为正确操作:
pc = pd ? (C*)((char*)pd + sizeof(B)) : 0;
• 对于多重继承情况下,存取第二个或后继的基类中的一个数据成员时,并不需要付出额外的成本,因为成员的位置在编译时已经固定了,存取操作只是一个简单的offset运算,就像单一继承一样,无论是经过一个指针,一个引用或是一个对象来存取。
Q4:虚拟继承的情况
• 实现虚拟继承的难点在于:
• 以 iostream 类型为例,需要找到一个有效的方法,将 istream 与 ostream 各自维护的一个 ios子对象抽取出来,折叠为一个由 iostream 维护的单一的 ios 子对象
• 并要保存基类与派生类指针(引用)之间的多态指定操作
• 一般实现方法为:类中如果含有一个或多个虚基类子对象,则将被分割为两部分:一个不变区域,一个共享区域
1) 不变区域中,不管后继如何衍化,总有固定的offset,这部分的数据可以直接存取
2) 共享区域中,数据的位置可能会因为每次派生操作而有所变化,此时只能使用简介存取(如变成共享基类时,基类子对象的位置发生变化)
• 如何存取类的共享部分?间接存取的三种策略:
1) cfront编译器会在每一个派生类对象中安插一些指针,每个指针指向一个虚基类,要存取继承得来的虚基类成员,可通过相关指针间接完成。存在两种缺点:
• 每个对象必须针对每个虚基类增加一个额外的指针。希望类对象有用固定的负担。本缺陷通过下述两种办法可以进行克服
• 由于虚拟继承串链的加长,导致间接存取层次的增加。希望有固定的存取时间。本缺陷通过拷贝操作获得所有局部虚基类指针,放到派生对象中,牺牲空间获取了时间。如下图
2) 微软引入 virtual base class table,每个类对象如果有一个以上的虚基类,编译器就会安插一个指针指向虚基类表,真正的虚基类指针则放在表中。提供了固定负担。如下图
3) 在virtual function table中放置虚基类的offset(而不是地址),将虚基类偏移与虚函数地址混杂在一起,使虚函数表可以通过正负值分别索引虚函数地址与虚基类偏移。提供固定负担。如下图
Eg: 针对下述类继承关系讨论不同访问策略时类对象的内存布局:
class A
{
char a;
//虚函数
};
class B : public virtual A
{
char b;
};
class C : public virtual A
{
char c;
};
class D : public B , public C
{
char d;
};
继承关系如下图所示:
①.指针简介访问策略:
②.虚基类表访问策略:
③.混合虚函数访问策略: