虛擬繼承的邪惡
這次的文章,我想來分享的不是現代 C++,而是關於傳統 C++ 虛擬繼承 (virtual inheritance) 的一個小故事。先講結論:能免則免!
前一陣子有個同事在寫某個單元測試的時候,發現了一個很奇怪的現象,於是跑來問我。結果我當下也無法解釋,最後研究了一整個下午之後,我半開玩笑地跑去找另一個同事,說我剛剛設計了一個 C++ 防破台面試題1,問他想不想試試看。題目是這樣的:請問下面這段程式碼2會印出什麼?
#include <iostream>
#include <string>
using namespace std;
class ProcessBase
{
public:
  ProcessBase(const string& id = "foo") : pid(id) {}
  const string pid;
};
template <typename T>
class Process : public virtual ProcessBase {};
class Slave : public Process<Slave>
{
public:
  Slave(const string& id) : ProcessBase(id) {}
};
class MockSlave : public Slave
{
public:
  MockSlave(const string& id) : Slave(id) {}
};
int main()
{
  cout << Slave("bar").pid << endl;
  cout << MockSlave("bar").pid << endl;
  return 0;
}
同事可能是看到了我邪惡的笑容,仔細端詳了程式碼之後,以下賊上之心的想法說這兩行輸出應該不一樣吧!的確,程式的執行結果如下:
bar
foo
咦……既然 MockSlave 的 constructor 有把 id 傳進 Slave 的 constructor, MockSlave("bar") 和 Slave("bar") 不是應該會用一樣的方式初始化各自的 Slave 物件嗎?為什麼最後會有不一樣的結果呢?難道我們遇上了某個 C++ 編譯器的 bug,或者難道上面這段程式碼有未定義的行為嗎?
事實上,上面的行為完全符合 C++ 標準的定義。簡單講,C++ 在初始化物件的時候,有下面幾個步驟:
- 按 depth-first traversal 對每一個 virtual base class 初始化一次。
 - 依序初始化所有的 direct non-virtual base classes。
 - 依序初始化所有的 non-static 成員變數。
 - 執行當前的 constructor。
 
之所以有第一步,是因為 C++ 要確保每個 virtual base class 只會被初始化一次,而問題就發生在這裡了:對 MockSlave 而言,初始化 ProcessBase 是在初始化 Slave 之前,而在初始化 Slave 的時候才會呼叫 Slave(id)。因此,在初始化 ProcessBase 的時候,使用的是它的 default constructor,因此 ProcessBase::pid 就被設成 "foo",而之後 Slave(id) 呼叫的 ProcessBase(id) 則會直接被忽略,因為 ProcessBase 已經被初始化過了。事實上,如果把 ProcessBase 的 constructor 裡面的 id 參數的預設值拿掉:
ProcessBase(const string& id) : pid(id) {}
這樣一來,ProcessBase 就沒有了 default constructor,而編譯器會直接報錯3:
error: constructor for 'MockSlave' must explicitly initialize the base class 'ProcessBase' which does not have a default constructor
所以,如果要正確地初始化 MockSlave 的 ProcessBase,就得這樣寫:
MockSlave(const string& id) : ProcessBase(id), Slave(id) {}
眾所周知,虛擬繼承是為了解決多重繼承產生的 diamond problem 而來的概念。大概是因為虛擬繼承有這些不為人知的眉角,Google C++ Style Guide 才會明定如果要多重繼承,所有的 direct base class 都得是純粹的 interface 而不能帶有成員變數,比方說:
class ProcessBase
{
public:
  virtual const string& pid() = 0;
};
...
class Slave : public Process<Slave>
{
public:
  Slave(const string& id) : pid_(id) {}
  const string& pid() override { return pid_; }
private:
  const string pid_;
};
至於 Mesos 為什麼會使用虛擬繼承呢?可能 codebase 在某一個時間點曾經出現過吧。不過我也沒仔細找,不知道現在的 codebase 裡還有沒有就是了。總之,如果可以的話,還是儘量不要使用虛擬繼承,以免有人浪費一個下午才搞清楚發生了什麼事😓。
是說,既然把這個防破台題寫在這裡,以後大概沒機會在面試裡出這麼過分的題目了😆。
- 
      
這段程式碼是從 Mesos codebase 中簡化過的程式碼。
Process代表一個 actor,Slave代表 Mesos agent,而MockSlave自然就是單元測試裡用來模擬指定的 agent 行為的 mock class 了。 ↩ - 
      
其實一共會有兩個編譯錯誤,但另一個不是本篇的重點就是了。 ↩