关注公众号【高性能架构探索】,第一时间获取最新文章;公众号内回复【pdf】,免费获取经典书籍
私有继承和多继承
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
类分别编写了A
、B
、C
三个子类,分别是public
、private
和protected
继承。然后用Base *
类型的指针去分别指向a
、b
、c
。发现只有public
继承的a
对象可以用p
直接指向,而b
和c
都会报这样的错:
Cannot cast 'B' to its private base class 'Base' Cannot cast 'C' to its protected base class 'Base'
也就是说,私有继承是不支持多态的,那么也就印证了,他并不是 OOP 理论中的“继承关系”,但是,由于私有继承会继承成员变量,也就是可以通过b
和c
去使用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
指针的偏移量就永远是0
,T
类型的数组大小也将是0
,而如果它成为了一个成员的话,问题会更严重:
struct Test { T t; int a; }; // t和a首地址相同
由于T
是0
大小,那么此时Test
结构体中,t
和a
就会在同一首地址。 所以,为了避免这种 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
同时继承自Anmial
和Pet
,但从 OOP 理论上来说,Cat
和Animal
是继承关系,而和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
类中继承来的x
和y
显然表达的是圆心的坐标。不过这样写并不符合设计规范,但笔者用这个例子希望解释的是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
继承自B
和C
时是普通继承,如果用了虚拟继承,则会在 D 内部又额外添加一份虚基表指针。要虚拟继承的是B
和C
对A
的继承,这也是虚拟继承语法非常迷惑的地方,也就是说,菱形继承的分支处要用虚拟继承,而汇聚处要用普通继承。所以我们还是要明白其底层原理,以及引入这个语法的原因(针对解决的问题),才能更好的使用这个语法,避免出错。
原创文章受到原创版权保护。转载请注明出处:https://www.ccppcoding.com/archives/1266
非原创文章文中已经注明原地址,如有侵权,联系删除
关注公众号【高性能架构探索】,第一时间获取最新文章
转载文章受原作者版权保护。转载请注明原作者出处!