文章來自於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.b
和f.a[]
分別是什麼?如果sizeof(Bar)
是0,那麼這2個地址就是一樣的。如果你用地址來作為物件的標識,那麼f.b
和f.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<>::init
和Alloc::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里名稱衝突(就像我們在第一次嘗試消除膨脹的程式碼裡一樣)。
收尾
這項技術可以在當下通用的編譯器裡使用。就算編譯器沒有實現空基類優化,也沒有額外的開銷。