美人+鱼=美人鱼——谈C++多继承

引言

继承是C++作为面向对象语言的一大特性。继承提高了代码的复用和可扩展性。子类可以把父类的数据成员“完整”地继承下来,而对于父类的成员函数,子类继承的是它们的调用权。 根据子类继承父类的个数,继承分为单继承、多继承:

  • 单继承,子类继承一个父类
  • 多继承:子类继承多个父类 另外还有一种虚继承,虚继承其实是为了解决多继承带了的问题而引入的。后边再详述。

单继承没什么好说的,所以不单独列一节。并且下边的美人鱼例子,虽是为讲多继承而设计,但它也包含了单继承的内容。

多继承

我们以美人鱼为例,美人鱼既有美人的某些属性,又有鱼的某些属性,可以把美人和鱼看作美人鱼的父类;而美人和鱼都是动物,可以把动物看作美人和鱼的共同父类。这样我们就可以定义如下几个类:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class Animal{
public:
    int data_Animal;
};

class Beauty : public Animal{
public:
    int data_Beauty;
};

class Fish : public Animal{
public:
    int data_Fish;
};

class BeautyFish : public Beauty, public Fish{
public:
    int data_BeautyFish;
};

如果把这四个类的继承关系画成图,你就会发现,四个类组成一个菱形,这就是菱形继承

Diamond inheritance

那么这四个类的对象会占用多大的内存呢?我在64位Linux平台下,用g++编译器的-fdump-class-hierarchy选项做了测试,为忽略内存对齐影响,使用#pragma pack(1)设为1字节对齐。测试结果如下:

1
2
3
4
sizeof(Animal) == 4
sizeof(Beauty) == 8
sizeof(Fish) == 8
sizeof(BeautyFish) == 20

-fdump-class-hierarchy生成的文件内容如下(只保留以上四个类)

 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
Class Animal
   size=4 align=1
   base size=4 base align=1
Animal (0x0x7f11187c4d80) 0

Class Beauty
   size=8 align=1
   base size=8 base align=1
Beauty (0x0x7f111860e5b0) 0
  Animal (0x0x7f11187c4de0) 0

Class Fish
   size=8 align=1
   base size=8 base align=1
Fish (0x0x7f111860e618) 0
  Animal (0x0x7f11187c4e40) 0

Class BeautyFish
   size=20 align=1
   base size=20 base align=1
BeautyFish (0x0x7f111861e540) 0
  Beauty (0x0x7f111860e680) 0
    Animal (0x0x7f11187c4ea0) 0
  Fish (0x0x7f111860e6e8) 8
    Animal (0x0x7f11187c4f00) 8

这是一个简单的模型,各个类都没有虚函数,所以不难想象为什么结果是这样。用图来表示,各个类的对象的内存布局大致如下:

普通多继承

可以看到,在BeautyFish的对象内,保存了两份data_Animal,一份来自父类Beauty,另一份来自父类Fish。从功能上讲,BeautyFish的对象没有必要保存两份data_Animal,这样是非常浪费内存空间的,这就是多继承带来的数据冗余问题。

除了数据冗余,多继承还会带来二义性问题。假设有下面这样的程序:

1
2
3
4
5
6
int main(){
    BeautyFish bf;
    bf.data_Animal = 2021; //语句1
    bf.Beauty::data_Animal = 2021; //语句2
    bf.Fish::data_Animal = 2021; //语句3
}
  • 语句1将会产生二义性调用,bf内有两份data_Animal,程序不知道该去给哪一个进行赋值操作;
  • 语句2和语句3可以正常通过,因为通过作用域限定符指明了具体给哪个data_Animal进行赋值操作。

虚继承

为了解决多继承带来的数据冗余二义性问题,C++引入了虚继承机制。虚继承使子类只保留一份间接基类的成员,既节省内存空间,又避免了二义性的麻烦。

于BeautyFish类而言,Beauty和Fish是它的直接基类,Animal则是它的间接基类。

具体做起来也非常简单,只需要在Beauty和Fish继承Animal时,加一个virtual关键字。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
class Animal{
public:
    int data_Animal;
};

class Beauty : public virtual Animal{
public:
    int data_Beauty;
};

class Fish : public virtual Animal{
public:
    int data_Fish;
};

class BeautyFish : public Beauty, public Fish{
public:
    int data_BeautyFish;
};

同样的方法,我们再来测一下四个类的对象的大小,测试结果如下:

1
2
3
4
sizeof(Animal) == 4
sizeof(Beauty) == 16
sizeof(Fish) == 16
sizeof(BeautyFish) == 32

-fdump-class-hierarchy生成的文件内容如下(只保留以上四个类)

 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
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
Class Animal
   size=4 align=1
   base size=4 base align=1
Animal (0x0x7f758e638d80) 0

Vtable for Beauty
Beauty::_ZTV6Beauty: 3u entries
0     12u
8     (int (*)(...))0
16    (int (*)(...))(& _ZTI6Beauty)

VTT for Beauty
Beauty::_ZTT6Beauty: 1u entries
0     ((& Beauty::_ZTV6Beauty) + 24u)

Class Beauty
   size=16 align=1
   base size=12 base align=1
Beauty (0x0x7f758e4825b0) 0
    vptridx=0u vptr=((& Beauty::_ZTV6Beauty) + 24u)
  Animal (0x0x7f758e638de0) 12 virtual
      vbaseoffset=-24

Vtable for Fish
Fish::_ZTV4Fish: 3u entries
0     12u
8     (int (*)(...))0
16    (int (*)(...))(& _ZTI4Fish)

VTT for Fish
Fish::_ZTT4Fish: 1u entries
0     ((& Fish::_ZTV4Fish) + 24u)

Class Fish
   size=16 align=1
   base size=12 base align=1
Fish (0x0x7f758e482618) 0
    vptridx=0u vptr=((& Fish::_ZTV4Fish) + 24u)
  Animal (0x0x7f758e638e40) 12 virtual
      vbaseoffset=-24

Vtable for BeautyFish
BeautyFish::_ZTV10BeautyFish: 6u entries
0     28u
8     (int (*)(...))0
16    (int (*)(...))(& _ZTI10BeautyFish)
24    16u
32    (int (*)(...))-12
40    (int (*)(...))(& _ZTI10BeautyFish)

Construction vtable for Beauty (0x0x7f758e482680 instance) in BeautyFish
BeautyFish::_ZTC10BeautyFish0_6Beauty: 3u entries
0     28u
8     (int (*)(...))0
16    (int (*)(...))(& _ZTI6Beauty)

Construction vtable for Fish (0x0x7f758e4826e8 instance) in BeautyFish
BeautyFish::_ZTC10BeautyFish12_4Fish: 3u entries
0     16u
8     (int (*)(...))0
16    (int (*)(...))(& _ZTI4Fish)

VTT for BeautyFish
BeautyFish::_ZTT10BeautyFish: 4u entries
0     ((& BeautyFish::_ZTV10BeautyFish) + 24u)
8     ((& BeautyFish::_ZTC10BeautyFish0_6Beauty) + 24u)
16    ((& BeautyFish::_ZTC10BeautyFish12_4Fish) + 24u)
24    ((& BeautyFish::_ZTV10BeautyFish) + 48u)

Class BeautyFish
   size=32 align=1
   base size=28 base align=1
BeautyFish (0x0x7f758e492540) 0
    vptridx=0u vptr=((& BeautyFish::_ZTV10BeautyFish) + 24u)
  Beauty (0x0x7f758e482680) 0
      primary-for BeautyFish (0x0x7f758e492540)
      subvttidx=8u
    Animal (0x0x7f758e638ea0) 28 virtual
        vbaseoffset=-24
  Fish (0x0x7f758e4826e8) 12
      subvttidx=16u vptridx=24u vptr=((& BeautyFish::_ZTV10BeautyFish) + 48u)
    Animal (0x0x7f758e638ea0) alternative-path

虽然只是加了个virtual,但我们可以看到上边的文件已经比之前的复杂很多了。经过分析上边的文件,结合gdb的打印类布局的内容以及网上相关博客,我还是分析出了四个类的内存布局。

Animal、Beauty和Fish的内存模型如下图:

虚继承

Animal类对象的内存布局与之前一模一样,不再赘述。

Beauty和Fish内存模型是一样的,我们以Beauty为例讲解。

  • 虚继承的话,子类的对象里首先存的是自己的东西,最后才是虚基类的东西;
  • 非虚继承的话,子类的对象里首先存的是基类的东西,最后才是自己的东西。如果有多个基类,则按照继承的顺序,第一个基类被设为主基类。

由于是Beauty虚继承Animal,所以在Beauty对象的内存里,首先是虚指针vptr和Beauty自己的数据data_Beauty,最后才是从虚基类Animal继承来的data_Animal。

Beauty的虚表有三个内容:

  • 第一个slot是vbase_offset,其值是12。这个值的意思是,Beauty中虚基类的部分(即Animal的部分)在Beauty对象内存中的偏移量是12。我们可以看到从Beauty对象的内存首地址偏移12个字节正好是data_Animal的地址。
  • 第二个slot是offset_to_top。将对象从当前这个类型(this指针)转换为该对象的实际类型的地址偏移量;
  • 第三个slot是type_info_for_Beauty,用于RTTI。

第二和第三个slot里的内容还没有弄明白,这里就先不多说了。

BeautyFish就比较复杂了,其内存布局大致如下:

BeautyFish

BeautyFish非虚继承Beauty和Fish,所以BeautyFish对象的内存里,首先是基类的东西,即Beauty的虚指针和数据、Fish的虚指针和数据,然后是BeautyFish自己的数据,最后是虚基类Animal的数据。

BeautyFish的虚表这里不再展开。

总结

关于C++对象的内存布局,C++标准并没有作出严格的约束,只是做了一个框架性的约束,而具体的实现交由编译器自己来完成。所以就导致C++对象模型相关内容是编译器相关的,不同的编译器可能会有不同的结果。

以我所知道的,GCC和VC++对于C++对象内存模型的实现差别就很大。比如对于虚基类,GCC的做法是扩展了虚表(virtual table);而VC++则是模仿虚表,建了一个虚基类表(virtual base class table),正如虚指针vptr指向虚表一样,VC++会在C++对象里加一个虚基类指针vbptr,该指针指向虚基类表。

这些东西有些繁杂,我也不能保证写的一定正确,很多东西都是自己分析得出,只为建立自己的C++对象模型观。如有朋友发现有误,欢迎指出。

Licensed under CC BY-NC-SA 4.0
Built with Hugo
Theme Stack designed by Jimmy