空類(empty class)

空類就是沒有靜態成員變量的類,卻通常帶有 typedef 和成員函數。


空類運行時占用的空間

為保證不同的對象的地址是不同的,C++ 要求空類的大小不能為零。

class Empty { };

int main()
{
  std::cout << "sizeof(Empty): " << sizeof(Empty) << '\n';

  Empty arr[10];
  std::cout << "sizeof(arr): " << sizeof(arr) << '\n';

  Empty a, b;
  if (&a != &b) {
    std::cout << "the size of class Empty is not zero" << '\n';
  }
}

上述代碼結果如下(本文的測試環境為 Ubuntu-16.04-64bit GCC-5.4.0):

sizeof(Empty): 1
sizeof(arr): 10
the size of class Empty is not zero

如果 Empty 的大小為0,則無法區別 arr 中的十個元素。對于多數平臺,Empty 的大小都是1,但是部分平臺在對齊上有著較為嚴格的要求,結果可能會是一個字的大?。ū热?)。
對于帶有虛函數的空類:

class EmptyWithVirtualFunc
{
public:
  virtual void VirtualFunc() { }
};

int main()
{
  std::cout << "sizeof(EmptyWithVirtualFunc): " << sizeof(EmptyWithVirtualFunc) << '\n';
  std::cout << "sizeof(void*): " << sizeof(void*) << '\n';
}

結果如下:

sizeof(EmptyWithVirtualFunc): 8
sizeof(void*): 8

帶有虛函數的空類,編譯器會在該空類對象的起始位置(所有非靜態成員變量之前)放置一個虛指針,所以該類的大小不是1而是一個指針的大小。


空基類優化

在 C++ 中有一個現象與上述相悖:在空類作為基類的情況下,子類的空間中可能不會出現多出來的那一個字節。 由于帶有虛函數的空類實質上還是有一個隱藏的虛指針成員,不算是嚴格意義上的空類,所以不參與空基類優化。

單繼承

class Derived1 : public Empty { };

class Derived2 : public Empty
{
public:
  std::int32_t i32;
};

int main()
{
  std::cout << "sizeof(Derived1): " << sizeof(Devired1) << '\n';
  std::cout << "sizeof(Derived2): " << sizeof(Devired2) << '\n';
}

結果如下:

sizeof(Derived1): 1
sizeof(Derived2): 4

從結果可以看出,Empty 在沒有繼承情況下多出來的一個字節在子類中并沒有體現,這一個字節被“優化”了。
當有子類繼承空類 Derived1,既多層繼承時:

class Derived3 : public Derived1 { };

class Derived4 : public Derived1
{
public:
  std::int32_t i32;
};

int main()
{
  std::cout << "sizeof(Derived3): " << sizeof(Devired3) << '\n';
  std::cout << "sizeof(Derived4): " << sizeof(Devired4) << '\n';
}

結果如下:

sizeof(Derived3): 1
sizeof(Derived4): 4

從多層繼承的結果可以看出,多出的那一個字節是否被優化與空類的繼承層數無關。
但是在部分情況下,優化效果會消失:

class Derived5 : public Empty
{
public:
  Empty e;
};

class Derived6 : public Empty
{
public:
  static Empty se;
};
Empty Derived6::se { };

class Derived7 : public Empty
{
public:
  std::int32_t i32;
  Empty e;
};

int main()
{
  std::cout << "sizeof(Derived5): " << sizeof(Devired5) << '\n';
  std::cout << "sizeof(Derived6): " << sizeof(Devired6) << '\n';
  std::cout << "sizeof(Derived7): " << sizeof(Devired7) << '\n';
}

結果如下:

sizeof(Derived5): 2
sizeof(Derived6): 1
sizeof(Derived7): 8

我們分析一下這三個子類的內存布局,
Derived5:

此時空基類優化失去了效果。如果依然進行優化,則無法區分基類 Empty 和子類中的成員 Empty(注意子類 Derived5 中的 Empty 不是基類,所以不參與優化,一定會占用一個字節)。
Derived6:

依然進行空基類優化。因為靜態成員變量不屬于某個具體的類實例,不占用類實例的空間,所以此時基類 Empty 不會與靜態成員變量發生沖突,但是由于 Derived6 是空類,所以還是要占用一個字節空間。
Derived7:

依然進行了空基類優化。因為基類 Empty 與子類中的成員 Empty 的地址空間不是相連的,不發生沖突(注意此時優化掉了基類 Empty 的一個字節,并沒有優化子類成員變量 Empty)。在子類成員 Empty 后補齊三個字節,所以整體占用的空間是八個字節。

多重繼承

如果不同的空類同時作為一個類的基類時,

class Empty1 { };

class MultiDerived : public Empty, public Empty1 { };

int main()
{
  std::cout << "sizeof(MultiDerived): " << sizeof(MultiDerived) << '\n';
}

結果如下:

sizeof(MultiDerived): 1

編譯器認為不同的空類在子類的內存空間是不會發生沖突的。
再考慮如下的情況,

class MultiDerived1 : public Empty { };
class MultiDerived2 : public Empty { };
class MultiDerived3 : public MultiDerived1, public MultiDerived2 { };

int main()
{
  std::cout << "sizeof(MultiDerived3): " << sizeof(MultiDerived3) << '\n';
}

結果如下(暫不考慮虛繼承):

sizeof(MultiDerived3): 2

MultiDerived3的內存布局如下:

沒有進行空基類優化。由于 MultiDerived1 是一個(is-a)Empty,而且 MultiDerived2 也是一個 Empty,又由于 MultiDerived1 和 MultiDerived2 在子類的內存空間中是連續的,此時如果進行了空基類優化,則兩個 Empty 就無法區分。
再考慮如下的情況,

class NotEmpty
{
public:
  std::int32_t i32;
};

class MultiDerived4 : public MultiDerived1, public NotEmpty
{
public:
  Empty e;
};

class MultiDerived5 : public NotEmpty, public MultiDerived1
{
public:
  Empty e;
};

class MultiDerived6 : public NotEmpty, public MultiDerived1 { };

int main()
{
  std::cout << "sizeof(MultiDerived4): " << sizeof(MultiDerived4) << '\n';
  std::cout << "sizeof(MultiDerived5): " << sizeof(MultiDerived5) << '\n';
  std::cout << "sizeof(MultiDerived6): " << sizeof(MultiDerived6) << '\n';
}

結果如下:

sizeof(MultiDerived4): 8
sizeof(MultiDerived5): 8
sizeof(MultiDerived6): 4

我們分析一下這三個子類的內存布局,
MultiDerived4:

進行了空基類優化。由于 MultiDerived1 的 Empty 與子類成員 Empty 中間隔了 NotEmpty,所以不發生沖突,因此可以進行優化。
MultiDerived5:

沒有發生空基類優化。因為 MultiDerived1 的 Empty 與子類成員 Empty 是連續的,進行優化會發生沖突。
MultiDerived6:

進行了空基類優化。因為 MultiDerived1 的 Empty 不會與其他 Empty 發生沖突。

特殊的情況

再來看看比較特殊的情況,

class Foo
{
public:
  Empty e[4];
  Derived2 d;
};

class Foo1Helper : public Empty
{
public:
  std::int8_t i8[3];
};

class Foo1 : public Empty
{
public:
  Foo1Helper d;
};

class Foo2 : public Empty
{
public:
  Foo f;
};

int main()
{
  std::cout << "sizeof(Foo): " << sizeof(Foo) << '\n';
  std::cout << "sizeof(Foo1): " << sizeof(Foo1) << '\n';
  std::cout << "sizeof(Foo2): " << sizeof(Foo2) << '\n';
}

結果如下:

sizeof(Foo): 8
sizeof(Foo1): 4
sizeof(Foo2): 12

Foo 中的 Derived2 仍然進行了空基類優化,并沒有因為 Foo 中的成員 Empty 與 Derived2 的基類 Empty 相鄰而影響優化,從“空基類優化”這個名字也表明了該優化只與繼承體系有關系,而不考慮被優化的類之外的干擾。
Foo1 也進行了空基類優化,但是比較特別,編譯器首先考慮的是將子類成員變量 Foo1Helper 進行優化(理由同 Foo1 中的 Derived2),此時 Foo1Helper 內存空間中已不存在 Empty,所以也對 Foo1 進行了優化。
Foo2 沒有發生空基類優化,因為第一個成員 Foo 的第一個成員變量是 Empty,與基類中 Empty 發生了沖突。

結論

當空類作為一個類的基類的時候,該空類占用的額外一個字節的內存空間在子類中將會被優化掉,除了一種情況外:在子類的內存空間中有連續的相同類型的空類出現時(無論該空類是作為基類,超基類,子類的第一個非靜態成員變量,子類的第一個非靜態成員變量的基類,子類的第一個非靜態成員變量的成員,所有的這些都可以歸納為子類的內存空間中基類的空間與接下來的第一個內存塊),為了區分連續的空類,將不進行空基類優化。
此外,在 C++11 中,空基類優化是強制性的,不再是可選的。


空類的應用

std::vector

在標準庫中,使用到分配器(allocator-aware)的類大多利用到了空基類優化,進而避免無狀態(stateless)的分配器成員占用額外的空間。

template <typename _Tp, typename _Alloc>
struct _Vector_base
{
  typedef _Alloc<_Tp> _Tp_alloc_type; // 分配器的具體類型
  typedef _Tp* pointer; // 存儲類型

  // 數據存儲的具體實現
  struct _Vector_impl : public _Tp_alloc_type
  {
    pointer _M_start; // 存儲的開始
    pointer _M_finish; // 存儲的結束
    pointer _M_end_of_storage; // 已經分配的空間,即capacity
  };
};

template <typename _Tp, typename _Alloc = std::allocator<_Tp>>
class vector : protected _Vector_base<_Tp, _Alloc>
{
};

以上是經過簡化的 std::vector 的代碼。我們需要關注的是 _Vector_base 中的 _Vector_impl。
對于 _Tp_alloc_type,我們也可以不讓 _Vector_impl 繼承于 _Tp_alloc_type,單獨設置一個成員變量,

template <typename _Tp, typename _Alloc>
struct _Vector_base
{
  typedef _Alloc<_Tp> _Tp_alloc_type; // 分配器的具體類型
  typedef _Tp* pointer; // 存儲類型

  // _Vector_impl利用該變量進行內存的分配
  _Tp_alloc_type _alloc;
    
  // 數據存儲的具體實現
  struct _Vector_impl
  {
    pointer _M_start; // 存儲的開始
    pointer _M_finish; // 存儲的結束
    pointer _M_end_of_storage; // 已經分配的空間,即capacity
  };
};

由于無狀態的分配器是空類,沒有任何成員變量,這樣處理的話會白白浪費了一個字節的存儲空間,像std::vector 這樣的使用率非常高的類來說,代價非常高。
所以標準庫采用了空基類優化,將分配器額外的存儲空間優化掉。

std::enable_if

template <bool _Cond, typename _Tp = void>
struct enable_if { };

template <typename _Tp>
struct enable_if<true, _Tp>
{
  typedef _Tp type;
};

以上是 GCC 中關于 std::enable_if 完整的代碼。
enable_if 是空類,但是這里與空基類優化無關。當 _Cond 為 true 時,enable_if 進行了部分模板特化,其中的 typedef 是關鍵。
下面是 enable_if 的實例,

template <typename T>
typename std::enable_if<std::is_integral<T>::value,bool>::type is_odd(T i)
{
  return (i%2) == 1;
}

template <typename T, typename = typename std::enable_if<std::is_integral<T>::value>::type>
bool is_even(T i)
{
  return (i%2) == 0;
}

int main()
{
  int i { 2 }; // i是整型值
    
  std::cout << std::boolalpha; // bool值會展示成"true", "false"而不是"0", "1"
  std::cout << "i is odd: " << is_odd(i) << '\n';
  std::cout << "i is even: " << is_even(i) << '\n';

  double d { 2.0 }; // d是雙精度浮點數

  std::cout << "i is odd: " << is_odd(i) << '\n'; // ERROR, 編譯失敗
  std::cout << "i is even: " << is_even(i) << '\n'; // ERROR, 編譯失敗
}

結果如下:

i is odd: false
i is even: true

在上述的兩個例子中,_Cond 為 true 的模板特化中的 type 成為了關鍵,如果 _Cond 為 false,則使用type 會發生編譯錯誤,因為在原型中沒有 type。is_odd 利用的 type 作為返回值;is_even 則純粹是利用 type 作為編譯時的驗證工具。

利用空類替代friend

關鍵字 friend 是一種強耦合,甚至強于繼承,所以我們應當小心地使用 friend 或者盡量避免。
friend 的常見用途是訪問另一個類的私有構造函數,

class Secret
{
  friend class SecretFactory;

private:
  // SecretFactory可以訪問該構造函數
  explicit Secret(std::string str) : _data{std::move(str)} {}

  // SecretFactory同時也可以訪問該函數,但是這可能會給我們造成麻煩
  void addData(const std::string& moreData) { _data.append(moreData); }

private:
  // SecretFactory無論如何也不應該訪問該數據
  std::string _data;
};

在上述例子中,SecretFactory 可以訪問不該訪問的 _data,這會添加很多麻煩。
我們可以通過空類來限制 SecretFactory 可以訪問的函數,

class Secret
{
public:
  class ConstructorKey {
    // 如果其他的類想要訪問Secret的構造函數,可以在這里添加友元
    friend class SecretFactory;
  private:
    // 構造函數為private很關鍵
    ConstructorKey() {}; // ①
    ConstructorKey(const ConstructorKey&) = default; // ②
  };

  // 設置為public是為了讓SecretFactory訪問
  explicit Secret(std::string str, ConstructorKey) : _data{std::move(str)} {}

private:
  void addData(const std::string& moreData) { _data.append(moreData); }

  std::string _data;
};

class SecretFactory
{
public:
  Secret getSecret(std::string str) {
    // RVO
    return Secret { std::move(str), Secret::ConstructorKey{} };
  }

  void modify(Secret& secret, const std::string& additionalData) {
    // secret.addData(additionalData); // ERROR, addData是私有的,此時空類已經限制了SecretFactory訪問Secret的函數
  }
};

int main()
{
  // Secret s { "Secret Class", ConstructorKey{} }; // ERROR, 無法訪問ConstructorKey的構造函數

  SecretFactory sf;
  Secret s = sf.getSecret("Secret Class");
}

上例有兩點需要解釋,
對于①,ConstructorKey 的構造函數的訪問權限是 private,只有對其為 friend 的類才能訪問構造函數;不能將構造函數設置為 default,即 ConstructorKey() = default;,對于沒有非靜態成員的類(空類)來講,即使默認構造為 private,依然可以通過統一初始化方式(uniform initialization)對其進行初始化,

class EmptyUniIni
{
  EmptyUniIni() = default;
};

int main()
{
  EmptyUniIni empty; // ERROE, 無法訪問構造函數
  EmptyUniIni empty1 {}; // OK, uniform initialization
}

對于②,需要將復制構造函數設置為 private,否則的話可以通過下面的代碼進行構造 Secret,

Secret::ConstructorKey* pk = nullptr;
Secret s { "Secret class", *pk };

這樣的話,我們前邊所做的努力就白費了。

std::input_iterator_tag, std::output_iterator_tag

// 用來標記input iterator
struct input_iterator_tag { };

// 用來標記output iterator
struct output_iterator_tag { };

// _Category即上述兩個標簽
template <typename _Category, typename... _Others>
struct iterator { };

// 簡略寫其他的template參數
template <typename... _Others>
class istream_iterator : public iterator<input_iterator_tag, _Others...>
{
};

template <typename... _Others>
class ostream_iterator : public iterator<output_iterator_tag, _Others...>
{
};

上述是簡化的 GCC 代碼。
由于 C++ 是強類型語言,input_iterator_tag 和 output_iterator_tag 雖然什么都沒有,只有名字不同,他們也是不同的類型,所以 istream_iterator 的父類和 ostream_iterator 的父類是不同的,他們在繼承層次上沒有任何關系,即 input_iterator_tag 標記了 istream_iterator,output_iterator_tag 標記了ostream_iterator。
而且上述代碼不會有任何性能上的缺陷,因為編譯器會檢查模板中的參數是否被使用,如果沒有使用,則將該模板參數省略掉,進而不會影響性能。


總結

通過上述講解,我們了解了空類的一些特性與應用場景,利用空基類優化或者與模板結合起來,會有奇妙的效果。


參考

[1] C++ Templates: The Complete Guide
[2] classes-and-objects
[3] Passkey Idiom: More Useful Empty Classes
[4] The "Empty Member" C++ Optimization
[5] Empty base optimization

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,362評論 6 537
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,013評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事?!?“怎么了?”我有些...
    開封第一講書人閱讀 177,346評論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,421評論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,146評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,534評論 1 325
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,585評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,767評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,318評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,074評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,258評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,828評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,486評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,916評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,156評論 1 290
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,993評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,234評論 2 375