这里从编译器的角度, 着重讨论对象的内存布局, 分析单继承, 多继承, 虚继承对内存布局带来的影响.
对象模型
简单对象模型(A Simple Object Model): 对象直接保存成员的值. C++中采用的就是这种方法.
表格驱动对象模型(A Table-driven Object Model): 对象中的每个成员对应一个指针, 指针指向成员实际的值. C++虚表采用这种方法实现.
C++对象模型
编译器会为每个有虚函数的类会生成一个 virtual table, 在其中保存所有虚函数的指针, 并在类对象内存开始位置保存指向vtable的指针vptr. 通过对象的引用或指针调用虚函数时, 编译器可以就可以通过对象的vptr将调用定向到对应的函数, 实现动态绑定. 在调用类的构造函数, 复制构造函数等时这些函数会自动处理类的vptr. 比如下面的代码中, 假设类Base是Derived的父类, 二者都定义了虚函数f(). 在第二行中, 调用了Base的复制构造函数, 对象d会被截断, 并且b的vptr会在复制构造函数中设置, 因此第三行中b调用的是Base中的f(). 另外一个值得注意的是, 运行时多态只能通过指针和引用来实现, 而不能通过对象实现. 因为对象的类型在编译器键可以确定.
1 | Derived d; |
单继承且无虚继承时, 每个对象只有一个vptr. 而当存在虚继承时, 虚继承的直接子类还会产生一个附加的vptr, 指向自身的virtual table. 当存在多继承时, 会为每个父类产生一个vptr. 下面针对这些情况详细举例说明.
单继承, 无虚继承时的对象模型
这是最简单的情况, 在对象的开始处保存一个vptr指针, 指向一个虚函数指针数组, 非静态数据成员按继承, 声明的顺序排列.
单继承, 有虚继承时的对象模型
采用虚继承的类会在产生多个vptr, 对象开始处是父类的vptr, 父类成员之后, 子类成员之前保存子类的vptr. 如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36// 这里省略了类中虚函数的定义
class BB
{
int64_t m_bb;
};
class B1 : public virtual BB
{
int64_t m_b1;
};
class DD : public B1
{
int64_t m_dd;
};
class D1 : public DD
{
int64_t m_d1;
};
// D1对象的结构, gcc 8.2.0, GNU gdb 8.1.1
// 注意, 这是在gdb中查看的结果, 并不代表真正的对象内存布局. 比如多继承, 有虚继承的情况.
{
<DD> =
{
<B1> =
{
<BB> =
{
_vptr.BB = <vtable for D1+56>, // 父类的vptr
m_bb
},
_vptr.B1 = <vtable for D1+24>, // 虚继承子类B1的vptr
m_b1
},
m_dd
},
m_d1 // 没有采用虚继承, 因此与基类BB共用vptr.
}多继承, 无虚继承时的对象模型
保留多个父类的vptr.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43// 这里省略了类中虚函数的定义
class BB
{
int64_t m_bb;
};
class B1 : public BB
{
int64_t m_b1;
};
class B2 : public BB
{
int64_t m_b2;
};
class DD : public B1, public B2
{
int64_t m_dd;
};
// DD对象的结构, gcc 8.2.0, GNU gdb 8.1.1
{
<B1> =
{
<BB> =
{
_vptr.BB = <vtable for DD+16>, // B1的vptr
m_bb
},
m_b1
},
<B2> =
{
<BB> =
{
_vptr.BB = <vtable for DD+40>, // B2的vptr
m_bb
},
m_b2
},
m_dd // 与基类B1共用vptr.
}
BB *bp = new DD; // 错误, 有歧义
BB *bp1 = dynamic_cast<B1*>(new DD); // 正确, bp1指向DD中的B1部分.
BB *bp2 = dynamic_cast<B2*>(new DD); // 正确, bp2指向DD中的B2部分.很自然的, 当用BB类型的指针/引用保存DD对象时, 就会出现歧义, 编译器无法确定采用B1中的BB还是B2中BB. 可以使用 dynamic_cast 进行干预, 以达到预期目的.
多继承, 有虚继承时的对象模型
有了上面的结论, 就不难推测这种情况下的对象模型了.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55// 这里省略了类中虚函数的定义
class BB
{
int64_t m_bb;
};
class B1 : public virtual BB
{
int64_t m_b1;
};
class B2 : public virtual BB
{
int64_t m_b2;
};
class DD : public B1, public B2
{
int64_t m_dd;
};
// DD对象的结构, gcc 8.2.0, GNU gdb 8.1.1
{
<B1> =
{
<BB> =
{
_vptr.BB = <vtable for DD+88>,
m_bb = 1
},
_vptr.B1 = <vtable for DD+24>,
m_b1
},
<B2> =
{
_vptr.B2 = <vtable for DD+56>,
m_b2
},
m_dd
}
// 实际内存布局可能是:
{
<B1> =
{
vptr.B1,
m_b1
},
<B2> =
{
vptr.B2,
m_b2
}
m_dd,
<BB> =
{
vptr.BB,
m_bb
}
}
关键字class和struct的区别
二者在绝大多数情况下是完全相同的, 可以互换, 只有几点不同.
class可以用于模板声明, struct不可以. C++引入class关键字, 保留struct的一个原因是为了体现OO, 并且兼容C, 而C中不需要模板, 也就不需要保证struct可以用于模板.
另外, 当用于声明类类型时二者略有差别:
用class声明的类的成员的默认访问级别是private, 用struct声明的类的成员的默认访问级别是public.
有继承时, 用class声明的类的默认继承方式是private, 用struct声明的类的默认继承方式是public. 这里的class, struct是指用于子类, 父类的声明方式不影响默认方式. 如下代码:
1
2
3class BB {};
class D1 : BB {}; // private继承
struct D2 : BB {}; // public继承
运行时多态必须通过public继承实现
这个设计是符合逻辑的. 可以设想, 如果使用其他继承方式, 那么从逻辑上说, 在类外不应该能访问父类成员. 但是要实现运行时多态, 正常做法是将子类指针/引用赋值给一个父类类型的指针/引用(设为bp), 之后通过bp访问父类的public成员. 也就是说, 对象中不可以在类外访问的成员, 通过将对象指针赋值给基类指针, 再通过基类指针就可以访问. 这违反了类中的访问权限控制. 所以, 在C++中, 前面的"赋值"是违法的, 无法通过编译. 而没有这个"赋值"操作, 也就无法实现运行时多态, 因此必须通过public继承实现运行时多态.