C++避坑指南(十)

私有继承和多继承

C++是多范式语言

在讲解私有继承和多继承之前,笔者要先澄清一件事:C++不是单纯的面相对象的语言。同样地,它也不是单纯的面向过程的语言,也不是函数式语言,也不是接口型语言……

真的要说,C++是一个多范式语言,也就是说它并不是为了某种编程范式来创建的。C++的语法体系完整且庞大,很多范式都可以用 C++来展现。因此,不要试图用任一一种语言范式来解释 C++语法,不然你总能找到各种漏洞和奇怪的地方。

举例来说,C++中的“继承”指的是一种语法现象,而面向对象理论中的“继承”指的是一种类之间的关系。这二者是有本质区别的,请读者一定一定要区分清楚。

以面向对象为例,C++当然可以面向对象编程(OOP),但由于 C++并不是专为 OOP 创建的语言,自然就有 OOP 理论解释不了的语法现象。比如说多继承,比如说私有继承。

C++与 java 不同,java 是完全按照 OOP 理论来创建的,因此所谓“抽象类”,“接口(协议)类”的语义是明确可以和 OOP 对应上的,并且,在 OOP 理论中,“继承”关系应当是"A is a B"的关系,所以不会存在 A 既是 B 又是 C 的这种情况,自然也就不会出现“多继承”这样的语法。

但是在 C++中,考虑的是对象的布局,而不是 OOP 的理论,所以出现私有继承、多继承等这样的语法也就不奇怪了。

笔者曾经听有人持有下面这样类似的观点:

  • 虚函数都应该是纯虚的

  • 含有虚函数的类不应当支持实例化(创建对象)

  • 能实例化的类不应当被继承,有子类的类不应当被实例化

  • 一个类至多有一个“属性父类”,但可以有多个“协议父类”

等等这些观点,它们其实都有一个共同的前提,那就是“我要用 C++来支持 OOP 范式”。如果我们用 OOP 范式来约束 C++,那么上面这些观点都是非常正确的,否则将不符合 OOP 的理论,例如:

class Pet {}; class Cat : public Pet {}; class Dog : public Pet {}; void Demo() {   Pet pet; // 一个不属于猫、狗等具体类型,仅仅属于“宠物”的实例,显然不合理 }

Pet既然作为一个抽象概念存在,自然就不应当有实体。同理,如果一个类含有未完全实现的虚函数,就证明这个类属于某种抽象,它就不应该允许创建实例。而可以创建实例的类,一定就是最“具象”的定义了,它就不应当再被继承。

在 OOP 的理论下,多继承也是不合理的:

class Cat {}; class Dog {}; class SomeProperty : public Cat, public Dog {}; // 啥玩意会既是猫也是狗?

但如果是“协议父类”的多继承就是合理的:

class Pet { // 协议类  public:   virtual void Feed() = 0; // 定义了喂养方式就可以成为宠物 }; class Animal {}; class Cat : public Animal, public Pet { // 遵守协议,实现其需方法  public:   void Feed() override; // 实现协议方法 };

上面例子中,Cat虽然有 2 个父类,但Animal才是真正意义上的父类,也就是Cat is a (kind of) Animal的关系,而Pet是协议父类,也就是Cat could be a Pet,只要一个类型可以完成某些行为,那么它就可以“作为”这样一种类型。

在 java 中,这两种类型是被严格区分开的:

interface Pet { // 接口类   public void Feed(); } abstract class Animal {} // 抽象类,不可创建实例 class Cat extends Animal implements Pet {   public void Feed() {} }

子类与父类的关系叫“继承”,与协议(或者叫接口)的关系叫“实现”。

与 C++同源的 Objective-C 同样是 C 的超集,但从名称上就可看出,这是“面向对象的 C”,语法自然也是针对 OOP 理论的,所以 OC 仍然只支持单继承链,但可以定义协议类(类似于 java 中的接口类),“继承”和“遵守(类似于 java 中的实现语义)”仍然是两个分离的概念:

@protocol Pet <NSObject> // 定义协议 - (void)Feed; @end @interface Animal : NSObject @end @interface Cat : Animal<Pet> // 继承自Animal类,遵守Pet协议 - (void)Feed; @end @implementation Cat - (void)Feed {   // 实现协议接口 } @end

相比,C++只能说“可以”用做 OOP 编程,但 OOP 并不是其唯一范式,也就不会针对于 OOP 理论来限制其语法。这一点,希望读者一定要明白。

私有继承与 EBO

私有继承本质不是「继承」

在此强调,这个标题中,第一个“继承”指的是一种 C++语法,也就是class A : B {};这种写法。而第二个“继承”指的是 OOP(面向对象编程)的理论,也就是 A is a B 的抽象关系,类似于“狗”继承自“动物”的这种关系。

所以我们说,私有继承本质是表示组合的,而不是继承关系,要验证这个说法,只需要做一个小实验即可。我们知道最能体现继承关系的应该就是多态了,如果父类指针能够指向子类对象,那么即可实现多态效应。请看下面的例程:

class Base {}; class A : public Base {}; class B : private Base {}; class C : protected Base {}; void Demo() {   A a;   B b;   C c;   Base *p = &a; // OK   p = &b; // ERR   p = &c; // ERR }

这里我们给Base类分别编写了ABC三个子类,分别是publicprivateprotected继承。然后用Base *类型的指针去分别指向abc。发现只有public继承的a对象可以用p直接指向,而bc都会报这样的错:

Cannot cast 'B' to its private base class 'Base' Cannot cast 'C' to its protected base class 'Base'

也就是说,私有继承是不支持多态的,那么也就印证了,他并不是 OOP 理论中的“继承关系”,但是,由于私有继承会继承成员变量,也就是可以通过bc去使用a的成员,那么其实这是一种组合关系。或者,大家可以理解为,把b.a.member改写成了b.A::member而已。

那么私有继承既然是用来表示组合关系的,那我们为什么不直接用成员对象呢?为什么要使用私有继承?这是因为用成员对象在某种情况下是有缺陷的。

空类大小

在解释私有继承的意义之前,我们先来看一个问题,请看下面例程

class T {}; // sizeof(T) = ?

T是一个空类,里面什么都没有,那么这时T的大小是多少?照理说,空类的大小就是应该是0,但如果真的设置为0的话,会有很严重的副作用,请看例程:

class T {}; void Demo() {   T arr[10];   sizeof(arr); // 0   T *p = arr + 5;   // 此时p==arr   p++; // ++其实无效 }

发现了吗?假如T的大小是0,那么T指针的偏移量就永远是0T类型的数组大小也将是0,而如果它成为了一个成员的话,问题会更严重:

struct Test {   T t;   int a; }; // t和a首地址相同

由于T0大小,那么此时Test结构体中,ta就会在同一首地址。 所以,为了避免这种 0 长的问题,编译器会针对于空类自动补一个字节的大小,也就是说其实sizeof(T)是 1,而不是 0

这里需要注意的是,不仅是绝对的空类会有这样的问题,只要是不含有非静态成员变量的类都有同样的问题,例如下面例程中的几个类都可以认为是空类:

class A {}; class B {   static int m1;   static int f(); }; class C { public:   C();   ~C();   void f1();   double f2(int arg) const; };

有了自动补 1 字节,T的长度变成了 1,那么T*的偏移量也会变成 1,就不会出现 0 长的问题。但是,这么做就会引入另一个问题,请看例程:

class Empty {}; class Test {   Empty m1;   long m2; }; // sizeof(Test)==16

由于Empty是空类,编译器补了 1 字节,所以此时m1是 1 字节,而m2是 8 字节,m1之后要进行字节对齐,因此Test变成了 16 字节。如果Test中出现了很多空类成员,这种问题就会被继续放大。

这就是用成员对象来表示组合关系时,可能会出现的问题,而私有继承就是为了解决这个问题的。

空基类成员压缩(EBO,Empty Base Class Optimization)

在上一节最后的历程中,为了让m1不再占用空间,但又能让Test中继承Empty类的其他内容(例如函数、类型重定义等),我们考虑将其改为继承来实现,EBO 就是说,当父类为空类的时候,子类中不会再去分配父类的空间,也就是说这种情况下编译器不会再去补那 1 字节了,节省了空间。

但如果使用public继承会怎么样?

class Empty {}; class Test : public Empty {   long m2; }; // 假如这里有一个函数让传Empty类对象 void f(const Empty &obj) {} // 那么下面的调用将会合法 void Demo() {   Test t;   f(t); // OK }

Test由于是Empty的子类,所以会触发多态性,t会当做Empty类型传入f中。这显然问题很大呀!如果用这个例子看不出问题的话,我们换一个例子:

class Alloc { public:   void *Create();   void Destroy(); }; class Vector : public Alloc { }; // 这个函数用来创建buffer void CreateBuffer(const Alloc &alloc) {   void *buffer = alloc.Create(); // 调用分配器的Create方法创建空间 } void Demo() {   Vector ve; // 这是一个容器   CreateBuffer(ve); // 语法上是可以通过的,但是显然不合理 }

内存分配器往往就是个空类,因为它只提供一些方法,不提供具体成员。Vector是一个容器,如果这里用public继承,那么容器将成为分配器的一种,然后调用CreateBuffer的时候可以传一个容器进去,这显然很不合理呀!

那么此时,用私有继承就可以完美解决这个问题了

class Alloc { public:   void *Create();   void Destroy(); }; class Vector : private Alloc { private:   void *buffer;   size_t size;   // ... }; // 这个函数用来创建buffer void CreateBuffer(const Alloc &alloc) {   void *buffer = alloc.Create(); // 调用分配器的Create方法创建空间 } void Demo() {   Vector ve; // 这是一个容器   CreateBuffer(ve); // ERR,会报错,私有继承关系不可触发多态 }

此时,由于私有继承不可触发多态,那么Vector就并不是Alloc的一种,也就是说,从 OOP 理论上来说,他们并不是继承关系。而由于有了私有继承,在Vector中可以调用Alloc里的方法以及类型重命名,所以这其实是一种组合关系。 而又因为 EBO,所以也不用担心Alloc占用Vector的成员空间的问题。

谷歌规范中规定了继承必须是public的,这主要还是在贴近 OOP 理论。另一方面就是说,虽然使用私有继承是为了压缩空间,但一定程度上也是牺牲了代码的可读性,让我们不太容易看得出两种类型之间的关系,因此在绝大多数情况下,还是应当使用public继承。不过笔者仍然持有“万事皆不可一棒子打死”的观点,如果我们确实需要 EBO 的特性否则会大幅度牺牲性能的话,那么还是应当允许使用私有继承。

多继承

与私有继承类似,C++的多继承同样是“语法上”的继承,而实际意义上可能并不是 OOP 中的“继承”关系。再以前面章节的 Pet 为例:

class Pet {  public:   virtual void Feed() = 0; }; class Animal {}; class Cat : public Animal, public Pet {  public:   void Feed() override; };

从形式上来说,Cat同时继承自AnmialPet,但从 OOP 理论上来说,CatAnimal是继承关系,而和Pet是实现关系,前面章节已经介绍得很详细了,这里不再赘述。

但由于 C++并不是完全针对 OOP 的,因此支持真正意义上的多继承,也就是说,即便父类不是这种纯虚类,也同样支持集成,从语义上来说,类似于“交叉分类”。请看示例:

class Organic { // 有机物 }; class Inorganic { // 无机物 }; class Acid { // 酸 }; class Salt { // 盐 }; class AceticAcid : public Organic, public Acid { // 乙酸 }; class HydrochloricAcid : public Inorganic, public Acid { // 盐酸 }; class SodiumCarbonate : public Inorganic, public Salt { // 碳酸钠 };

上面就是一个交叉分类法的例子,使用多继承语法合情合理。如果换做其他 OOP 语言,可能会强行把“酸”或者“有机物”定义为协议类,然后用继承+实现的方式来完成。但如果从化学分类上来看,无论是“酸碱盐”还是“有机物无机物”,都是一种强分类,比如说“碳酸钠”,它就是一种“无机物”,也是一种“盐”,你并不能用类似于“猫是一种动物,可以作为宠物”的理论来解释,不能说“碳酸钠是一种盐,可以作为一种无机物”。

因此 C++中的多继承是哪种具体意义,取决于父类本身是什么。如果父类是个协议类,那这里就是“实现”语义,而如果父类本身就是个实际类,那这里就是“继承”语义。当然了,像私有继承的话表示是“组合”语义。不过 C++本身并不在意这种语义,有时为了方便,我们也可能用公有继承来表示组合语义,比如说:

class Point {  public:   double x, y; }; class Circle : public Point {  public:   double r; // 半径 };

这里Circle继承了Point,但显然不是说“圆是一个点”,这里想表达的就是圆类“包含了”点类的成员,所以只是为了复用。从意义上来说,Circle类中继承来的xy显然表达的是圆心的坐标。不过这样写并不符合设计规范,但笔者用这个例子希望解释的是C++并不在意类之间实际是什么关系,它在意的是数据复用,因此我们更需要了解一下多继承体系中的内存布局。

对于一个普通的类来说,内存布局就是按照成员的声明顺序来布局的,与 C 语言中结构体布局相同,例如:

class Test1 {  public:   char a;   int b;   short c; };

那么Test1的内存布局就是

字节编号

内容

0

a

1~3

内存对齐保留字节

4~7

b

8~9

c

9~11

内存对齐保留字节

但如果类中含有虚函数,那么还会在末尾添加虚函数表的指针,例如:

class Test1 {  public:   char a;   int b;   short c;   virtual void f() {} };

字节编号

内容

0

a

1~3

内存对齐保留字节

4~7

b

8~9

c

9~15

内存对齐保留字节

16~23

虚函数表指针

多继承时,第一父类的虚函数表会与本类合并,其他父类的虚函数表单独存在,并排列在本类成员的后面。

菱形继承与虚拟继承

C++由于支持“普适意义上的多继承”,那么就会有一种特殊情况——菱形继承,请看例程:

struct A {   int a1, a2; }; struct B : A {   int b1, b2; }; struct C : A {   int c1, c2; }; struct D : B, C {   int d1, d2; };

根据内存布局原则,D类首先是B类的元素,然后D类自己的元素,最后是C类元素:

字节序号

意义

0~15

B 类元素

16~19

d1

20~23

d2

24~31

C 类元素

如果再展开,会变成这样:

字节序号

意义

0~3

a1(B 类继承自 A 类的)

4~7

a2(B 类继承自 A 类的)

8~11

b1

12~15

b2

16~19

d1

20~23

d2

24~27

a1(C 类继承自 A 类的)

28~31

a1(C 类继承自 A 类的)

32~35

c1

36~39

c2

可以发现,A 类的成员出现了 2 份,这就是所谓“菱形继承”产生的副作用。这也是 C++的内存布局当中的一种缺陷,多继承时第一个父类作为主父类合并,而其余父类则是直接向后扩写,这个过程中没有去重的逻辑(详情参考上一节)。这样的话不仅浪费空间,还会出现二义性问题,例如d.a1到底是指从B继承来的a1还是从C里继承来的呢?

C++引入虚拟继承的概念就是为了解决这一问题。但怎么说呢,C++的复杂性往往都是因为为了解决一种缺陷而引入了另一种缺陷,虚拟继承就是非常典型的例子,如果你直接去解释虚拟继承(比如说和普通继承的区别)你一定会觉得莫名其妙,为什么要引入一种这样奇怪的继承方式。所以这里需要我们了解到,它是为了解决菱形继承时空间爆炸的问题而不得不引入的。

首先我们来看一下普通的继承和虚拟继承的区别: 普通继承:

struct A {   int a1, a2; }; struct B : A {   int b1, b2; };

B的对象模型应该是这样的:

而如果使用虚拟继承:

struct A {   int a1, a2; }; struct B : virtual A {   int b1, b2; };

对象模型是这样的:

虚拟继承的排布方式就类似于虚函数的排布,子类对象会自动生成一个虚基表来指向虚基类成员的首地址。

就像刚才说的那样,单纯的虚拟继承看上去很离谱,因为完全没有必要强行更换这样的内存布局,所以绝大多数情况下我们是不会用虚拟继承的。但是菱形继承的情况,就不一样了,普通的菱形继承会这样:

struct A {   int a1, a2; }; struct B : A {   int b1, b2; }; struct C : A {   int c1, c2; }; struct D : B, C {   int d1, d2; };

D的对象模型:

但如果使用虚拟继承,则可以把每个类单独的东西抽出来,重复的内容则用指针来指向:

struct A {   int a1, a2; }; struct B : virtual A {   int b1, b2; }; struct C : virtual A {   int c1, c2; }; struct D : B, C {   int d1, d2; };

D的对象模型将会变成:

也就是说此时,共有的虚基类只会保存一份,这样就不会有二义性,同时也节省了空间。

但需要注意的是,D继承自BC时是普通继承,如果用了虚拟继承,则会在 D 内部又额外添加一份虚基表指针。要虚拟继承的是BCA的继承,这也是虚拟继承语法非常迷惑的地方,也就是说,菱形继承的分支处要用虚拟继承,而汇聚处要用普通继承。所以我们还是要明白其底层原理,以及引入这个语法的原因(针对解决的问题),才能更好的使用这个语法,避免出错。

原创文章受到原创版权保护。转载请注明出处:https://www.ccppcoding.com/archives/1266

转载文章受原作者版权保护。转载请注明原作者出处!

(0)
上一篇 2022年11月2日 下午12:22
下一篇 2022年11月2日 下午12:25

相关推荐