【翻译】c++类中“空成员”的优化

本文来自于The “Empty Member” C++ Optimization。是我在看c++ std::string代码时遇到的一个链接,其中解释了为什么_Alloc_hider会采用inhert from Alloc的原因。

文章应该是97年的,所以里面的指针长度还是4 byte。

c++类中“空成员”的优化

C++标准库中有很多有用的模板,包括享誉盛名的SGI STL。这些模板的实现很高效,也不失灵活。在日常的编程中,可以把这些模板当作范例来进行学习,也可启发我们如何进行兼顾灵活性与效率的程序设计。

“空成员”的优化,就是这样的一个典范:一个没有类成员的class,就不应该占用内存空间。什么情况下需要一个没有类成员的class呢?这样的class一般会拥有一系列的typedef或者成员函数,而程序的调用方可以用自己定义的类似的class来完成一些特殊的功能(自定义的class可不一定没有类成员)。这个默认提供的class应该可以满足绝大多数的需求。这种情况下,优化这个空成员的class是个很有性价比的事情。

由于语言的限制(之后会解释),空成员的class通常会占据一定的内存空间。如果是一般情况也就算了,但是在stl里,不进行优化的话,还是会劝退很多潜在的使用者的。

空成员“膨胀”

以STL举例。每个STL的容器都有一个allocator的参数,当容器需要内存的时候,它会向allocator去申请。如果用户想要自己定制化内存申请过程,那么就可以在构造容器时提供自己的allocator。大多数情况下,容器用的是STL默认的allocator,这个默认的allocator直接调用new来完成分配。这是个空类,类似于下面这个定义

  template <class T>
    class allocator {   // an empty class
      . . .
      static T* allocate(size_t n)
        { return (T*) ::operator new(n * sizeof T); }
      . . .
    };

举个list的例子,class list保存了一个私有的allocator成员,这个成员在构造函数里进行赋值

  template <class T, class Alloc = allocator<T> >
    class list {
      . . .
      Alloc alloc_; 
      struct Node { . . . };
      Node* head_;      

     public:
      explicit list(Alloc const& a = Alloc())
        : alloc_(a) { . . . }
      . . .
    };

成员list<>::alloc_通常占据4 byte,尽管这个Alloc是个空类。这通常来说不太会是个问题。但万一list自身是一个巨大的数据结构的一个节点(比如vector),当vector很大的时候,这种额外的空间消耗是不可忽视的。巨大的内存占用意味着更慢的执行速度。就算在当下,相对于cpu自身的频率来说,内存访问已经非常慢了。

空对象

那么改怎么解决这个问题?解决问题之前,首先需要搞清楚为什么这里会有这一层开销。C++的语言定义是这么说的:

A class with an empty sequence of members and base class objects is an empty class. Complete objects and member subobjects of an empty class type shall have nonzero size.

空类:没有数据成员,并且没有基类。这个基类实例化出来的完整对象的大小不应该为0.

解释以下这个规定的缘由:

  struct Bar { };
  struct Foo {
    struct Bar a[2];
    struct Bar b;
  };
  Foo f;

那么f.bf.a[]分别是什么?如果sizeof(Bar)是0,那么这2个地址就是一样的。如果你用地址来作为对象的标识,那么f.bf.a[0]就是同一个对象。C++标准委员会通过禁止空类的对象大小为0来解决这个问题。

但为什么还需要占据4 byte的大小呢?虽然大部分的编译器认为sizeof(Bar) == 1,但4 byte是对象对齐的需求。比如:

  struct Baz {
    Bar b;
    int* p;
  };

结构体Baz在大多数的体系结构上大小是8 byte,编译器自己在Baz::b后面添加补齐,是为了让Baz::p不会横跨一个字(word)。

  struct Baz
  +-----------------------------------+
  | +-------+-------+-------+-------+ |
  | | Bar b | XXXXX | XXXXX | XXXXX | |
  | +-------+-------+-------+-------+ |
  | +-------------------------------+ |
  | | int* p                        | |
  | +-------------------------------+ |
  +-----------------------------------+

那该如何规避调这个额外的开销呢?C++标准也在Footnote里提了一嘴:

A base class subobject of an empty class type may have zero size. 空类作为基类时,其大小可以为0

也就是说,如果是这个结构体

  struct Baz2 : Bar {
    int* p;
  };

编译器就会认为Bar的大小为0,这样sizeof(Baz2)就是4。

  struct Baz2
  +-----------------------------------+
  | +-------------------------------+ |
  | | int* p                        | |
  | +-------------------------------+ |
  +-----------------------------------+

编译器并未要求实现成这个样子,但是你可以认为大部分标准的编译器就是这样实现的。

消除膨胀

现在你知道了消除这个开销的原理,问题是接下来怎么做?最直观的,list<>直接继承Allocator,如下:

  template <class T, class Alloc = allocator<T> >
    class list : private Alloc {
      . . .
      struct Node { . . . };
      Node* head_;      

     public:
      explicit list(Alloc const& a = Alloc())
        : Alloc(a) { . . . }
      . . .
    };

这当然是可以的。list里的成员函数可以直接调用this->allocate(),而非allco_.allocate()完成内存申请。 不过,用户提供的Alloc是允许拥有虚函数的,这可能会和子类list<>里的某些方法有冲突。(list<>::initAlloc::init())。

另一种可行的方式是,将Alloc打包到list<>的成员变量上(比如指向第一个list node的指针),这样Allocator的接口不会暴露出来。

  template <class T, class Alloc = allocator<T> >
    class list {
      . . .
      struct Node { . . . };
      struct P : public Alloc {
        P(Alloc const& a) : Alloc(a), p(0) { }
        Node* p;
      };
      P head_;
      
     public:
      explicit list(Alloc const& a = Alloc())
        : head_(a) { . . . }
      . . .
    };

采用这种方法实现的话,申请内存就用head.allocate()。没有额外的开销,list<>用起来也和以前一样。不过就像其他做的好的优化一样,实现上总是有点丑陋,但总归不会影响到接口了。

统一一点的解决方案

当然还有提升的空间。看看下面这个template

  template <class Base, class Member>
    struct BaseOpt : Base {
      Member m;
      BaseOpt(Base const& b, Member const& mem) 
        : Base(b), m(mem) { }
    };

用这个模板,那么list的接口就可以变成这样:

  template <class T, class Alloc = allocator<T> >
    class list {
      . . .
      struct Node { . . . };
      BaseOpt<Alloc,Node*> head_;
      
     public:
      explicit list(Alloc const& a = Alloc())
        : head_(a,0) { . . . }
      . . .
    };

这个实现相比与最开始的版本,看起来也没那么不堪。其他的STL容器,也可以借助BaseOpt来简化代码。只不过,成员函数申请内存的时候就会奇怪一些,这个我们现在还暂时不考虑。

现在可以在BaseOpt定义的地方加上很好的文档来描述这个优化技术了。

也可以在BaseOpt里添加一些成员,但是并不建议这样做。这可能会造成和Base里名称冲突(就像我们在第一次尝试消除膨胀的代码里一样)。

收尾

这项技术可以在当下通用的编译器里使用。就算编译器没有实现空基类优化,也没有额外的开销。