本期聊一个空基类优化(EmptyBaseClassOptimization,EBCO)的问题,在C++STL中广泛使用的技术。
继承体系中的内存模型我们都知道,在C++中,不存在大小是零的类。即便是空类,也要占据一个字节,否则无法比较两个空类对象是否是同一个对象(在C/C++中,默认使用地址来判断两个变量是否是同一个)。
classBaseEmpty{public:BaseEmpty(){std::cout"Baseaddress:"thisstd::endl;}};intmain(intargc,charconst*argv[]){BaseEmptyempty_1{};BaseEmptyempty_2{};assert(empty_1!=empty_2);//两个空类对象的地址肯定不同std::coutsizeof(BaseEmpty{})std::endl;//输出1}
子类继承父类,可以等效地看作子类将父类的成员变量复制到自己内存模型中。比如在下面的demo中,Dervied_1继承了父类Base,Derived_1的内存模型等效于Derived_2。
classBase{public:Base()=default;private:intnum_{0};boolstate_{false};};classDerived_1:publicBase{public:Derived_1()=default;private:std::stringname_{"CPP"};};classDerived_2{public:Derived_2()=default;private:intnum_{0};boolstate_{false};//子类的成员变量std::stringname_{"CPP"};};空基类优化
在C++STL中经常使用空类作为基类,表达某种属性、特征,然后该空基类会被另一个空类继承。比如,在一文中,__gnu_cxx::new_allocator是个空类,它的子类std::allocator也是个空类,仅提供了几个用于构造、析构对象的成员函数。
在了解继承体系中的内存模型后,现在开始讨论我们的问题。
对于空类BaseEmpty,假设有另一个空类DerivedEmpty继承自BaseEmpty,空类DerivedDeeperEmpty继承自DerivedEmpty,那么DerivedDeeperEmpty对象的大小sizeof(DerivedDeeperEmpty{})会是几个字节???
classDerivedEmpty:publicBaseEmpty{public:DerivedEmpty(){std::cout"Derivedaddress:"thisstd::endl;}};classDerivedDeeperEmpty:publicDerivedEmpty{public:DerivedDeeperEmpty(){std::cout"deeperaddress:"thisstd::endl;}};
如果仍然像之前说的那种内存等效模型,那么编译器会每个空基类对象都分配内存,因此即便DerivedDeeperEmpty是个空类,也要占用两个字节,相当于内部分别包含了BaseEmpty、DerivedEmpty对象,即等效为类DerivedDeeperEmpty_eq:
classDerivedDeeperEmpty_eq{public:DerivedDeeperEmpty_eq()=default;private:BaseEmptybase_1_;DerivedEmptybase_2_;};sizeof(DerivedDeeperEmpty_eq);//2
如果编译器真的是这么实现,是否很不符合直觉。因为DerivedDeeperEmpty明明只是个空类啊!!!大小却是2个字节???如果情况再极端点,DerivedDeeperEmpty还有更深的空子类,那么空子类的大小会不断膨胀???
是的,这种情况太不符合直觉,严重浪费内存。因此,C++标准如下规定:在空类被用作基类时,如果不给它分配内存并不会导致它被存储到与同类型对象(包括子类对象)相同的地址上,那么就可以不给它分配内存。换句话说,BaseEmpty作为空基类时,下面两种情况,编译器不会为BaseEmpty对象在子类中分配内存:
子类单继承,比如类DerivedDeeperEmpty。
子类在多继承、选择第二个基类时,没有继续选择BaseEmpty及BaseEmpty的子类作为父类,那么BaseEmpty是不会被分配内存的。
什么意思呢?下面写个demo。
intmain(intargc,charconst*argv[]){autodeeperDerived=DerivedDeeperEmpty{};std::cout"size:"sizeof(deeperDerived)std::endl;return0;}
输出如下:
$g++ecbo.cc-oecbo./ecboBaseaddress:0x7ffffaa07Derivedaddress:0x7ffffaa07deeperaddress:0x7ffffaa07size:1
发现了什么?编译器并没有为deeperDerived的基类对象分配内存,deeperDerived中的两个基类对象和deeperDerived指向了同一个地址!!!最终sizeof(deeperDerived)输出的大小也是1,也验证了这一点。
再来看一个demo。现在,增加一个新的空类DerivedSameEmpty,同时继承了BaseEmpty、DerivedEmpty,然后查看DerivedSameEmpty对象的大小。
classDerivedSameEmpty:publicBaseEmpty,DerivedEmpty{public:DerivedSameEmpty(){std::cout"DerivedSameEmptyaddress:"thisstd::endl;}};intmain(intargc,charconst*argv[]){autoderivedSame=DerivedSameEmpty{};std::cout"size:"sizeof(derivedSame)std::endl;return0;}
编译输出:
$g++ecbo.cc-oecbo./ecboecbo.cc:32:7:warning:directbase‘BaseEmpty’inaccessiblein‘DerivedSameEmpty’duetoambiguity32
classDerivedSameEmpty:publicBaseEmpty,DerivedEmpty{
^~~~~~~~~~~~~~~~Baseaddress:0x7fffc67ecBaseaddress:0x7fffc67ecDerivedaddress:0x7fffc67ecDerivedSameaddress:0x7fffc67ecsize:2
好,问题出现了,分析就要开始了。
此时会发现DerivedSameEmpty的大小是2,符合本期开篇提出的继承体系中的内存模型,可等效为DerviedSameEmpty的内存布局:
classDerivedSameEmpty_eq{public:DerivedSameEmpty_eq()=default;private:BaseEmptybase_1_;//1字节DerivedEmptybase_2_;//1字节};
这个从上面的编译输出可以验证:BaseEmpty对象base_1_的地址是0x7fffdab6,而DerivedEmpty对象base_2_首地址是0x7fffdab7,比base_1_增加了一个字节,并且DerviedSameEmpty对象的地址和base_1_相同。
「BaseEmpty对象和DerivedEmpty对象的地址为何不同?」
问题不在于DerivedSameEmpty继承了两个空基类,在于这空基类BaseEmpty和DerivedEmpty是同一个类型。
编译器可以不为BaseEmpty对象分配内存,那么BaseEmpty对象肯定会和DerivedSameEmpty对象同一个地址。但是,如果编译器也不为DerivedEmpty对象分配内存,那么DerivedEmpty对象就会和DerviedSameEmpty对象也是同一个地址。
最终,就差不多是这个意思:
base_1_==base_2_==derivedSame;//三个对象的地址相同
base_1_、base_2_两个不同的对象,却指向了同一个地址???
即便这两个对象有相同的基类,但毕竟是不同的对象,如果指向了同一个地址,那么就无法通过地址来区分对象base_1_和base_2_是否是同一个对象。因此,这个时候就导致编译器无法为DerviedSameEmpty开启EBCO,需要为DerivedSameEmpty继承的每个空基类对象都要分配1个字节,自然,DerivedSameEmpty{}大小就是2了,此时的内存模型就是DerivedSameEmpty_eq。
因此,在空类A作为基类时,空类B继承A,空类C继承B,如果要触发编译器的EBCO机制,那么空类C不能再继承A及其子类。
如下,有空类DerivedDiffEmpty继承了两个不同的空基类,其对象的大小还是1。
classBaseAnotherEmpty{};classDerivedDiffEmpty:publicBaseEmpty,BaseAnotherEmpty{};intmain(intargc,charconst*argv[]){sizeof(DerivedDiffEmpty{});//1return0;}EBCO的应用
如下,模板类Foo中有两个类对象,当T1、T2有可能是空类时,按照下面这种写法会导致类Foo对象大小为2个字节,但是如果使用EBCO来重新设计Foo,则可以减少Foo对象大小。
templatetypenameT1,typenameT2classFoo{public://...private:T1t1_obj_;T2t2_obj_;};
怎么设计?将has-a改为私有继承T1、T2。
由于T1、T2是空基类,那么私有继承能达到和has-a一样效果,同样的可以使用T1、T2的成员函数,同时借助于EBCO节省了内存。
templatetypenameT1,typenameT2classFoo:privateT1,T2{public://...};
当然,也不是所有的空基类都能这么设计,以下三种情况无法使用EBCO:
当T1、T2是非class类型或者是union类型时,无法继承;当T1、T2是同一种类型,或者具有继承关系,此时会发生DerivdedSameEmpty中警告;当T1、T2的类型可能是final修饰时,此时T1、T2是不允许继承的类。其中,1和3是硬性限制,无法突破,但2问题是可以解决的。卖个关子,欲知详情,请见下期std::tuple的设计。
感谢你的观看,你的点赞、