C++ 有多继承、虚继承、虚函数,但为了支持这些特性,代价是有的。比如
- 多继承中,空基类之间需要 1 byte(历史原因)
- 虚继承需要虚基类指针
- 虚函数需要虚函数表指针
但是这些需求又是存在的,如何解决?
只要不使用多继承和虚继承就能解决前两个问题,第三个问题可以考虑 CRTP 的静态分派功能,虽然不完全等同。
本文介绍自创的一项技术,在不使用多继承和虚继承的情况下,依然能保持相应能力,同时支持 CRTP。这可视为新的 C++ 编程范式。
根本思路就是将继承关系转换为单继承,因此我将该技术命名为基于模板的单继承化,简称为单继承化(single-inheritize, SI)
项目地址
[ GitHub ] Ubpa | UTemplategithub.com前文
Ubp.a:C++ | TemplateListzhuanlan.zhihu.com1. 手动单继承化
多个类之间的继承关系可分为虚继承(virtual inheritance)和非虚继承(non-virtual inheritance)。如果将类视为一个顶点,继承关系为边,则对应的图为有向无环图。示例如下(圆圈代表类,其中字母为类名,虚线代表虚继承,实线代表非虚继承,箭头起点为父类,终点为子类)
有向无环图可拓扑排序,如下
因此单继承化是可行的,相应代码为
2. 模板参数继承
第 1 节最后给出的代码没有灵活性,因为这些类可能又被其他类所用,如下
简单拓扑排序后代码为
可见 C
与第一节不同了,说明新需求下原有的拓扑关系而得到的代码无法适应新需求。
为解决这个问题,使用基类模板即可,上边两例相关代码可改为
3. 依赖性
第 2 节我们将类设计成了基类模板类,但类是有依赖性的,这意味着不把依赖类写上会有问题,如下就有问题,因为 B
依赖于 A
。
因此作为类设计者,应该将其所依赖的类写明,这样调用者才不会缺漏
这样类使用者可以根据他所要用的类,递归地获取信息从而得到类继承关系图,然后拓扑排序,然后根据排序结果依次套接各类。
4. 区分虚继承与非虚继承
之前我们忽略了虚继承和非虚继承的区别,但他们是不同的。
考虑下例
一种拓扑排序为 HJBCAA
,此时两个 A
在一起,我们无法得知 J
下的 A
到底是哪个。
另一种拓扑排序为 HBCAJA
,此时我们可通过 H::J::A
访问到 J
的 A
,通过 H::A
或 H::C::A
访问到虚基类 A
。
还有好几种拓扑排序,显然我们需要设计一种固定的策略。根据实践,应该选择的方案是将实继承的两者连续放置。并且构建拓扑排序时采取深度优先的策略,上边的 HBCAJA
是该策略的一种结果,搜索路径为 H->J->A->J->H->B->A->B->H->C->A->C->H
。这样我们能在 J
中通过 J::A
(注意这里的 A
不是模板,而是类) 访问到 J
的 A
。 也就是说所有非虚继承的类,能直接通过自身的类名和实基类的类名访问到其实基类。
然而该如何访问虚基类呢?我们可以引入辅助类,在套接类的过程中同时收集好所有的虚基类。假设我们通过辅助类得到了所有的虚基类 AllVBs
(里边存储的是实例类,而非模板),这样我们如果想在 B
中访问虚基类 A
,则可通过 VBList
中得到。
这里涉及了很多很多模板操作,比如
- 判断一个类型是否为特定模板的实例:
is_instance_of<typename, template<typename...>class>
- 辅助类的设计
以上不详细说明,第一点标准库没有,但有方法;第二点比较繁杂
5. 套接操作简化
假设我们得到的拓扑排序结果为 HBCAJA
,那么就需要套接 6 个模板,我们不必要一个一个套接,可以稍微简化一下语法
这也就是上节所说的辅助类,我们会一起处理虚基类和非虚基类,实际的模板为
其中要完成 AllVBs
的生成,另外检查套接过程中基类是否含有当前类所需的虚基类,过程比较反复,设计改版过很多,这里不说明,但是确实可以做到。
这时我们的参数模板类实际为
6. 静态拓扑排序
我们每次在使用类的时候,我们需要把所有虚基类找出来,并手动进行拓扑排序,并且这很麻烦,因此我们考虑是否有办法只提供直接基类(对 G
来说就是 E
和 F
)就可以得到拓扑有序的所有类,即
这样我们就能轻松地写
算法很简单,就是深度优先后序遍历
用模板写的话,如下
7. 多模板参数
上边所有的参数模板类的模板参数只有 1 个,就是 Base
,实际上是可以支持多个参数的。
为了支持整数,简单套一层模板即可
我们这套编程范式是支持奇异递归模板模式(curiously recurring template pattern,CRTP)的,如下
8. 演示
8.1 菱形继承
8.2 空基类优化
测试用例来源于 vs 开发团队[1],其所用优化方式为 __declspec(empty_bases)
,不跨平台。而单继承化编程范式也可以对此进行优化。
可以看到 naive 的方法有如下问题
- 第 6 行的
Derived3
本应为 1 byte,但由于空基类多继承会有 1 byte 的padding,所以变成了 2 byte - 第 7 行的
Derived4
本应为 4 byte,但由于int
的 alignment 是 4 byte,所以空基类多继承的开销也因为 alignment 变成了 4 byte。 - 第 8 行的
Struct2
是 1 byte 的,但看其空基类Empty1
的地址却是后 1 byte,也就是说对于一个该类的连续数组,某一位置的对象的Empty1
成员的地址是下一个位置的对象。十分诡异
而通过单继承化编程范式,以上问题通通解决。而且比 vs2015 引入的__declspec(empty_bases)
更好的地方在于,不用管哪个类上该写这个,同时也跨平台。
8.3 CRTP
Vecf3
大小为12 == 3 * sizeof(float)
,正常- 通过查汇编可知,最后两行的加法指令数相同,说明运行时这些空基类辅助类都能优化掉
IIn
和IOut
组合得到IInOut
,IAdd
和IInOut
组合得到IVal
,灵活度极大,代码可复用性达到了单个函数的程度,通过简单写上所需接口即可,都是静态的,没有运行时开销,而且也没有类大小的开销friend operator<<
有些特殊,因为它依赖于Impl
而不是依赖于模板类,导致如果用不同的模板参数时会导致重定义的问题。这种情况只发生于拓扑排序算法中,因为其中需要实例化模板,其中把Base
设成了NI_Nil
,其他情况下不会引发问题。所以就简单偏特化了该情况避免了重定义问题。 这种问题只在friend operator<<
和friend operator>>
中发生,后续可能可以更好解决。
9. 结语
该编程范式个人认为极好,使得我们可以将一切函数拆成单函数接口,然后用组合方式进行组装,表达能力上更强。原本 C++ 就有这种能力,但由于空基类多继承,虚继承会引发极大量的 padding,因此只能是一个接口包含大量内容,从而使得多接口继承的额外开销忽略不计。
本套编程范式直接优化了 C++ 底层,从而增强了语言机制,应用有待开发。本人近期将以此重写数学库。
感谢大家的阅读。
参考
- ^Optimizing the Layout of Empty Base Classes in VS2015 Update 2 https://devblogs.microsoft.com/cppblog/optimizing-the-layout-of-empty-base-classes-in-vs2015-update-2-3/