当前位置:   article > 正文

11. C++空基类优化

11. C++空基类优化
1. 空基类优化(EBCO)
1.1 继承体系中的内存模型

我们都知道,在C++中,不存在大小是零的类。即便是空类,也要占据一个字节,否则无法比较两个空类对象是否是同一个对象(在C/C++中,默认使用地址来判断两个变量是否是同一个)。

class BaseEmpty { 
public:
  	BaseEmpty() { std::cout<<"Base address:    "<< this << std::endl;}
};

int main(int argc, char const *argv[]) {

  	BaseEmpty empty_1{};
  	BaseEmpty empty_2{};
 
  	assert(&empty_1 != &empty_2); // 两个空类对象的地址肯定不同

  	std::cout<<sizeof(BaseEmpty{})<<std::endl; // 输出 1
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

子类继承父类,可以等效地看作子类将父类的成员变量复制到自己内存模型中。比如在下面的demo中,Dervied_1继承了父类BaseDerived_1的内存模型等效于Derived_2

class Base { 
public:
  	Base() = default;
private:
  	int num_{0};
  	bool state_{false};
};

class Derived_1 : public Base { 
public:
  	Derived_1() = default;
private:
  	std::string name_{"CPP"};
};

class Derived_2 { 
public:
  	Derived_2() = default;
private:
  	int num_{0};
  	bool state_{false};
  	// 子类的成员变量
  	std::string name_{"CPP"};
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
1.2 空基类优化

对于空类BaseEmpty ,假设有另一个空类DerivedEmpty继承自BaseEmpty,空类DerivedDeeperEmpty继承自DerivedEmpty,那么DerivedDeeperEmpty对象的大小sizeof(DerivedDeeperEmpty{})会是几个字节???

class DerivedEmpty: public BaseEmpty { 
public:
  DerivedEmpty() { 
    std::cout<<"Derived address: "<< this << std::endl;
  }
};

class DerivedDeeperEmpty: public DerivedEmpty {
public:
  DerivedDeeperEmpty() { 
    std::cout<<"deeper address:  "<< this << std::endl;
  }
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

如果仍然像之前说的那种内存等效模型,那么编译器会每个空基类对象都分配内存,因此即便DerivedDeeperEmpty是个空类,也要占用两个字节,相当于内部分别包含了BaseEmptyDerivedEmpty对象,即等效为类DerivedDeeperEmpty_eq:

class DerivedDeeperEmpty_eq {
public:
  	DerivedDeeperEmpty_eq() = default;
private:
  	BaseEmpty    base_1_;
  	DerivedEmpty base_2_;
};

sizeof(DerivedDeeperEmpty_eq); // 2
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

如果编译器真的是这么实现,是否很不符合直觉。因为DerivedDeeperEmpty明明只是个空类啊!!!大小却是2个字节???如果情况再极端点,DerivedDeeperEmpty还有更深的空子类,那么空子类的大小会不断膨胀???

是的,这种情况太不符合直觉,严重浪费内存。因此,C++标准如下规定:在空类被用作基类时,如果不给它分配内存并不会导致它被存储到与同类型对象(包括子类对象)相同的地址上,那么就可以不给它分配内存。换句话说,BaseEmpty作为空基类时,下面两种情况,编译器不会为Basement对象在子类中分配内存:

  • 子类单继承,比如类DerivedDeeperEmpty。
  • 子类在多继承、选择第二个基类时,没有继续选择BaseEmptyBaseEmpty的子类作为父类,那么BaseEmpty是不会被分配内存的。

什么意思呢?下面写个demo。

int main(int argc, char const *argv[]) {

  auto deeperDerived =  DerivedDeeperEmpty{};
  std::cout<<"size: "<<sizeof(deeperDerived) << std::endl;
  return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6

输出如下:

$ g++ ecbo.cc -o ecbo && ./ecbo 
Base address:    0x7ffff124aa07
Derived address: 0x7ffff124aa07
deeper address:  0x7ffff124aa07
size: 1
  • 1
  • 2
  • 3
  • 4
  • 5

发现了什么?编译器并没有为deeperDerived的基类对象分配内存,deeperDerived中的两个基类对象和deeperDerived指向了同一个地址!!!最终sizeof(deeperDerived) 输出的大小也是1,也验证了这一点。

再来看一个demo。现在,增加一个新的空类DerivedSameEmpty同时继承了BaseEmptyDerivedEmpty,然后查看DerivedSameEmpty对象的大小。

class DerivedSameEmpty : public BaseEmpty, DerivedEmpty { 
public:
     DerivedSameEmpty() { 
        	std::cout<<"DerivedSameEmpty address: "<< this << std::endl;
     }
};

int main(int argc, char const *argv[]) {

  	auto derivedSame = DerivedSameEmpty{};
  	std::cout<<"size: "<<sizeof(derivedSame)<<std::endl;
  	return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13

编译输出:

$ g++ ecbo.cc -o ecbo && ./ecbo 
ecbo.cc:32:7: warning: direct base ‘BaseEmpty’ inaccessible in ‘DerivedSameEmpty’ due to ambiguity
   32 | class DerivedSameEmpty : public BaseEmpty, DerivedEmpty {
      |       ^~~~~~~~~~~~~~~~
Base address:      0x7fffc67ec656
Base address:      0x7fffc67ec657
Derived address:   0x7fffc67ec657
DerivedSame address: 0x7fffc67ec656
size: 2
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

好,问题出现了,分析就要开始了。

此时会发现DerivedSameEmpty的大小是2,符合本期开篇提出的继承体系中的内存模型,可等效为DerviedSameEmpty的内存布局:

class DerivedSameEmpty_eq { 
public:
  	DerivedSameEmpty_eq() = default;

private:
  	BaseEmpty     base_1_; // 1字节
  	DerivedEmpty  base_2_; // 1 字节
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

这个从上面的编译输出可以验证:BaseEmpty对象base_1_的地址是0x7fffda4735b6,而DerivedEmpty对象base_2_首地址是0x7fffda4735b7,比base_1_增加了一个字节,并且DerviedSameEmpty对象的地址和base_1_相同。

「BaseEmpty对象和DerivedEmpty对象的地址为何不同?」

问题不在于DerivedSameEmpty 继承了两个空基类,在于这空基类BaseEmptyDerivedEmpty是同一个类型。

编译器可以不为BaseEmpty对象分配内存,那么BaseEmpty对象肯定会和DerivedSameEmpty对象同一个地址。但是,如果编译器也不为DerivedEmpty对象分配内存,那么DerivedEmpty对象就会和DerviedSameEmpty对象也是同一个地址。

最终,就差不多是这个意思:

&base_1_ == &base_2_ == &derivedSame; // 三个对象的地址相同
  • 1

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。

  class BaseAnotherEmpty { };
  class DerivedDiffEmpty : public BaseEmpty, BaseAnotherEmpty { };
  
  int main(int argc, char const *argv[]) {
    sizeof(DerivedDiffEmpty{});   // 1
    return 0;
  }
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7

EBCO的应用

如下,模板类Foo中有两个类对象,当T1T2有可能是空类时,按照下面这种写法会导致类Foo对象大小为2个字节,但是如果使用EBCO来重新设计Foo,则可以减少Foo对象大小。

template<typename T1, typename T2>
class Foo { 
public:
  // ...
private:
  T1 t1_obj_;
  T2 t2_obj_;
};
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

怎么设计?将has-a改为私有继承T1T2

由于T1T2是空基类,那么私有继承能达到和has-a 一样效果,同样的可以使用T1T2的成员函数,同时借助于EBCO节省了内存。

template<typename T1, typename T2>
class Foo : private T1, T2 { 
public:
  // ...
};
  • 1
  • 2
  • 3
  • 4
  • 5

当然,也不是所有的空基类都能这么设计,以下三种情况无法使用EBCO:

  1. T1T2 是非class 类型或者是union 类型时,无法继承;
  2. T1T2是同一种类型,或者具有继承关系,此时会发生DerivdedSameEmpty中警告;
  3. T1T2 的类型可能是final 修饰时,此时T1T2是不允许继承的类。

其中,1和3是硬性限制,无法突破,但2问题是可以解决的。卖个关子,欲知详情,请见下期std::tuple的设计。

声明:本文内容由网友自发贡献,不代表【wpsshop博客】立场,版权归原作者所有,本站不承担相应法律责任。如您发现有侵权的内容,请联系我们。转载请注明出处:https://www.wpsshop.cn/w/weixin_40725706/article/detail/262318
推荐阅读
相关标签
  

闽ICP备14008679号