【翻譯】c++類中“空成員”的優化

ivkus發表於2022-05-26
文章來自於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<list>),當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里名稱衝突(就像我們在第一次嘗試消除膨脹的程式碼裡一樣)。

收尾

這項技術可以在當下通用的編譯器裡使用。就算編譯器沒有實現空基類優化,也沒有額外的開銷。

相關文章