首頁 > 軟體

C++私有繼承與EBO深入分析講解

2022-08-15 18:06:28

Hello!大家好呀,近期逗比老師的一個學生問了我這樣一個問題:“C++裡的私有繼承到底有什麼意義?”

不知道你有沒有跟他一樣的困惑。的確,我們在編寫C++專案中,幾乎是沒有用過私有繼承(這裡包括protected繼承和private繼承),都是清一色的public繼承。有的老師乾脆直接告訴學生,你見到繼承就是public,其他那倆是歷史原因,當它不存在就好了。

這種說法呢,其實也有一定道理,但也不全對。對的部分在於:C++中,確實只有public繼承才表示的OOP理論中的“繼承”,而私有繼承其實對應的是OOP理論中的“組合”關係,所以說“見到繼承就寫public”這話其實沒毛病。然而不對的部分在於:私有繼承是為了解決某些效能問題而存在的,我們知道通常表示組合的做法是成員物件,但在某些極端情況下,成員物件會出現一些效能問題,這時我們不得不用私有繼承來代替。

私有繼承本質不是繼承

在此強調,這個標題中,第一個“繼承”指的是一種C++語法,也就是class A : B {};這種寫法。而第二個“繼承”指的是OOP(物件導向程式設計)的理論,也就是A is a B的抽象關係,類似於“狗”繼承自“動物”的這種關係。

所以我們說,私有繼承本質是表示組合的,而不是繼承關係,要驗證這個說法,只需要做一個小實驗即可。我們知道最能體現繼承關係的應該就是多型了,如果父類別指標能夠指向子類物件,那麼即可實現多型效應。

請看下面的例程:

class Base {};
class A : public Base {};
class B : private Base {};
class C : protected Base {};
void Demo() {
  A a;
  B b;
  C c;
  Base *p = &a; // OK
  p = &b; // ERR
  p = &c; // ERR
}

這裡我們給Base類分別編寫了A、B、C三個子類,分別是public、private個protected繼承。然後用Base *型別的指標去分別指向a、b、c。發現只有public繼承的a物件可以用p直接指向,而b和c都會報這樣的錯:

Cannot cast 'B' to its private base class 'Base'
Cannot cast 'C' to its protected base class 'Base'

也就是說,私有繼承是不支援多型的,那麼也就印證了,他並不是OOP理論中的“繼承關係”,但是,由於私有繼承會繼承成員變數,也就是可以通過b和c去使用a的成員,那麼其實這是一種組合關係。或者,大家可以理解為,把b.a.member改寫成了b.A::member而已。

那麼私有繼承既然是用來表示組合關係的,那我們為什麼不直接用成員物件呢?為什麼要使用私有繼承?這是因為用成員物件在某種情況下是有缺陷的。

空類大小

在解釋私有繼承的意義之前,我們先來看一個問題,請看下面例程

class T {};
// sizeof(T) = ?

T是一個空類,裡面什麼都沒有,那麼這時T的大小是多少?有的同學可能不假思索就會回答0。照理說,空類的大小就是應該是0,但如果真的設定為0的話,會有很嚴重的副作用,請看例程:

class T {};
void Demo() {
  T arr[10];
  sizeof(arr); // 0
  T *p = arr + 5;
  // 此時p==arr
  p++; // ++其實無效
}

發現了嗎?假如T的大小是0,那麼T指標的偏移量就永遠是0,T型別的陣列大小也將是0,而如果它成為了一個成員的話,問題會更嚴重:

struct Test {
  T t;
  int a;
};
// t和a首地址相同

由於T是0大小,那麼此時Test結構體中,t和a就會在同一首地址。

所以,為了避免這種0長的問題,編譯器會針對於空類自動補一個位元組的大小,也就是說其實sizeof(T)是1,而不是0。

這裡需要注意的是,不僅是絕對的空類會有這樣的問題,只要是不含有非靜態成員變數的類都有同樣的問題,例如下面例程中的幾個類都可以認為是空類:

class A {};
class B {
  static int m1;
  static int f();
};
class C {
public:
  C();
  ~C();
  void f1();
  double f2(int arg) const;
};

有了自動補1位元組,T的長度變成了1,那麼T*的偏移量也會變成1,就不會出現0長的問題。但是,這麼做就會引入另一個問題,請看例程:

class Empty {};
class Test {
  Empty m1;
  long m2;
};
// sizeof(Test)==16

由於Empty是空類,編譯器補了1位元組,所以此時m1是1位元組,而m2是8位元組,m1之後要進行位元組對齊,因此Test變成了16位元組。如果Test中出現了很多空類成員,這種問題就會被繼續放大。

這就是用成員物件來表示組合關係時,可能會出現的問題,而私有繼承就是為了解決這個問題的。

空基礎類別成員壓縮

(EBO,Empty Base Class Optimization)

在上一節最後的歷程中,為了讓m1不再佔用空間,但又能讓Test中繼承Empty類的其他內容(例如函數、型別重定義等),我們考慮將其改為繼承來實現,EBO就是說,當父類別為空類的時候,子類中不會再去分配父類別的空間,也就是說這種情況下編譯器不會再去補那1位元組了,節省了空間。

但如果使用public繼承會怎麼樣?

class Empty {};
class Test : public Empty {
  long m2;
};
// 假如這裡有一個函數讓傳Empty類物件
void f(const Empty &obj) {}
// 那麼下面的呼叫將會合法
void Demo() {
  Test t;
  f(t); // OK
}

Test由於是Empty的子類,所以會觸發多型性,t會當做Empty型別傳入f中。這顯然問題很大呀!如果用這個例子看不出問題的話,我們換一個例子:

class Alloc {
public:
  void *Create();
  void Destroy();
};
class Vector : public Alloc {
};
// 這個函數用來建立buffer
void CreateBuffer(const Alloc &alloc) {
  void *buffer = alloc.Create(); // 呼叫分配器的Create方法建立空間
}
void Demo() {
  Vector ve; // 這是一個容器
  CreateBuffer(ve); // 語法上是可以通過的,但是顯然不合理
}

記憶體分配器往往就是個空類,因為它只提供一些方法,不提供具體成員。Vector是一個容器,如果這裡用public繼承,那麼容器將成為分配器的一種,然後呼叫CreateBuffer的時候可以傳一個容器進去,這顯然很不合理呀!

那麼此時,用私有繼承就可以完美解決這個問題了

class Alloc {
public:
  void *Create();
  void Destroy();
};
class Vector : private Alloc {
private:
  void *buffer;
  size_t size;
  // ...
};
// 這個函數用來建立buffer
void CreateBuffer(const Alloc &alloc) {
  void *buffer = alloc.Create(); // 呼叫分配器的Create方法建立空間
}
void Demo() {
  Vector ve; // 這是一個容器
  CreateBuffer(ve); // ERR,會報錯,私有繼承關係不可觸發多型
}

此時,由於私有繼承不可觸發多型,那麼Vector就並不是Alloc的一種,也就是說,從OOP理論上來說,他們並不是繼承關係。而由於有了私有繼承,在Vector中可以呼叫Alloc裡的方法以及型別重新命名,所以這其實是一種組合關係。

而又因為EBO,所以也不用擔心Alloc佔用Vector的成員空間的問題。

總結

總結下來,私有繼承其實是表示組合關係的,它是當組合類為空類時,為了增強效能而提供的一種成員物件的代替方案。

好啦!相信大家已經明白私有繼承的存在意義了,這裡建議大家閱讀一下STL原始碼,會看到絕大多數容器和分配器之間都是使用私有繼承方式的。如果還有什麼疑問歡迎評論區丟擲!

到此這篇關於C++私有繼承與EBO深入分析講解的文章就介紹到這了,更多相關C++私有繼承 內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com