• 文章
  • Wade 遠離未知的 C++ 水域。第四部分
作者:
2013年7月15日(最後更新:2013年7月15日)

Wade 遠離未知的 C++ 水域。第四部分。

評分:4.3/5(21票)
*****

這一次我們將討論 C++ 中的虛繼承,並找出為什麼應該非常小心地使用它。檢視本系列的其他文章:N1, N2, N3

虛基類的初始化

首先,讓我們找出類在沒有虛繼承的情況下是如何在記憶體中分配的。看看這段程式碼片段

class Base { ... };
class X : public Base { ... };
class Y : public Base { ... };
class XY : public X, public Y { ... };

這很清楚:非虛基類 'Base' 的成員被分配為派生類的資料成員。這導致 'XY' 物件包含兩個獨立的 'Base' 子物件。下面是一個說明性的圖

圖 1. 多個非虛繼承。

當我們處理虛繼承時,虛基類的一個物件在派生類物件中只包含一次。圖 2 顯示了下面程式碼片段中 'XY' 物件的結構。

class Base { ... };
class X : public virtual Base { ... };
class Y : public virtual Base { ... };
class XY : public X, public Y { ... };

圖 2. 多個虛繼承。

記憶體中最有可能為共享子物件 'Base' 分配空間是在 'XY' 物件的末尾。類的確切實現取決於編譯器。例如,'X' 和 'Y' 類可能儲存指向共享物件 'Base' 的指標。但據我所知,這種做法現在已經過時了。對共享子物件的引用通常透過偏移量或儲存在虛擬函式表中的資訊來實現。

“最派生”的類 'XY' 自己知道虛基類 'Base' 的子物件確切地分配在哪裡。這就是為什麼最派生類負責初始化所有虛基類的子物件。

'XY' 的建構函式初始化 'Base' 子物件以及 'X' 和 'Y' 中指向它的指標。之後,'X'、'Y' 和 'XY' 類的所有其他成員都被初始化。

一旦 'XY' 建構函式初始化了 'Base' 子物件,'X' 和 'Y' 的建構函式就不允許重新初始化它。具體的實現方式取決於編譯器。例如,它可以向 'X' 和 'Y' 的建構函式傳遞一個特殊的附加引數,告訴它們不要初始化 'Base' 類。

現在是最有趣的事情,它引起了很多困惑和許多錯誤。看看下面的建構函式

X::X(int A) : Base(A) {}
Y::Y(int A) : Base(A) {}
XY::XY() : X(3), Y(6) {}

基類建構函式將接收什麼數字作為引數——3 還是 6?都不是!

建構函式 'XY' 初始化了虛子物件 'Base',但它是隱式完成的。預設情況下呼叫的是 'Base' 建構函式。

由於 'XY' 建構函式呼叫了 'X' 或 'Y' 建構函式,它不會重新初始化 'Base'。這就是為什麼 'Base' 沒有接收傳入引數而被呼叫的原因。

虛基類的問題不止於此。除了建構函式,還有賦值運算子。如果我沒記錯的話,標準告訴我們,編譯器生成的賦值運算子可能會將值賦給虛基類的子物件多次或一次。所以,你不知道 'Base' 物件會被複制多少次。

如果你自己實現賦值運算子,請確保你已經阻止了 'Base' 物件被多次複製。下面的程式碼片段是不正確的

XY &XY::operator =(const XY &src)
{
  if (this != &src)
  {
    X::operator =(*this);
    Y::operator =(*this);
    ....
  }
  return *this;
}

此程式碼會導致 'Base' 物件被複制兩次。為了避免這種情況,我們應該在 'X' 和 'Y' 類中新增特殊函式來防止複製 'Base' 類的成員。'Base' 類的內容只複製一次,就在同一個程式碼片段中。這是修復後的程式碼

XY &XY::operator =(const XY &src)
{
  if (this != &src)
  {
    Base::operator =(*this);
    X::PartialAssign(*this);
    Y::PartialAssign(*this);
    ....
  }
  return *this;
}

這段程式碼可以正常工作,但看起來仍然不夠簡潔明瞭。這就是為什麼建議程式設計師避免多重虛繼承的原因。

虛基類與型別轉換

由於虛基類在記憶體中分配的特殊性,你無法進行如下型別轉換

Base *b = Get();
XY *q = static_cast<XY *>(b); // Compilation error
XY *w = (XY *)(b); // Compilation error

然而,堅定的程式設計師會透過使用 'reinterpret_cast' 運算子來實現這一點

XY *e = reinterpret_cast<XY *>(b);

但是,結果幾乎無用。它將把 'Base' 物件開頭的地址解釋為 'XY' 物件開頭的地址,這是完全不同的。有關詳細資訊,請參見圖 3。

進行型別轉換的唯一方法是使用 dynamic_cast 運算子。但是過度使用 dynamic_cast 會讓程式碼變得糟糕。

圖 3. 型別轉換。

我們是否應該放棄虛繼承?

我同意許多作者的觀點,即應盡一切可能避免虛繼承以及普通的多重繼承。

虛繼承會導致物件初始化和複製出現問題。由於“最派生”的類負責這些操作,它必須瞭解基類結構的全部細節。因此,類之間出現了更復雜的依賴關係,這會使專案結構複雜化,並迫使你在重構過程中對所有這些類進行一些額外的修改。所有這些都會導致新的 bug,並使程式碼可讀性降低。

型別轉換問題也可能成為 bug 的來源。你可以透過使用 dynamic_cast 運算子來部分解決這些問題。但是它太慢了,如果你的程式碼中不得不頻繁使用它,那就意味著你的專案架構可能非常糟糕。專案結構幾乎總是可以在沒有多重繼承的情況下實現的。畢竟,在許多其他語言中不存在這樣的奇技淫巧,而且它並不能阻止這些語言的程式設計師編寫大型複雜專案。

我們不能堅持完全拒絕虛繼承:它有時可能是有用和方便的。但總是三思而後行,然後再堆砌複雜的類。培育一片小類組成的森林,具有淺層繼承,比處理幾棵巨大的樹要好。例如,多重繼承在大多數情況下可以用物件組合來代替。

多重繼承的好處

好的,我們現在理解並同意對多重虛繼承和多重繼承本身的批評。但是,有沒有安全且方便使用的場景?

是的,我至少可以舉出一個例子:Mix-ins。如果你不知道它是什麼,請參閱書籍《Enough Rope to Shoot Yourself in the Foot》[3]

Mix-in 類不包含任何資料。它所有的函式通常都是純虛擬函式。它沒有建構函式,即使有,它也不會做任何事情。這意味著在建立或複製這些類時不會出現任何問題。

如果基類是 mix-in 類,賦值是無害的。即使一個物件被複制了很多次,也沒關係:程式在編譯後就會擺脫它。

參考文獻

  1. Stephen C. Dewhurst. "C++ Gotchas: Avoiding Common Problems in Coding and Design". - Addison-Wesley Professional. - 352 pages; illustrations. ISBN-13: 978-0321125187. (參見 Gotchas 45 和 53)。
  2. Wikipedia. 物件組合
  3. Allen I. Holub. "Enough Rope to Shoot Yourself in the Foot". (你可以在網上輕鬆找到它。從第 101 節開始閱讀)。