13.2. 构造函数为何不能为虚函数

我们已经了解了多态是如何工作的 —— 当通过指向基类的指针或引用调用虚函数时,该指针或引用用于访问类中的虚函数表指针。这个虚函数表指针用于识别对象的真实类型,即创建该对象时所使用的类型。这个类型可能是基类本身,也可能是一个派生类。然后,实际调用该对象上的成员函数。那么,为什么同样的机制不能用于构造函数呢?让我们来探讨一下。

13.2.1 对象何时获得其类型?

很容易理解为什么前面描述的过程不能用于创建虚构造函数。首先,从前面过程的描述中可以明显看出 —— 作为该过程的一部分,需要识别出对象创建时所使用的类型。这只有在对象构造完成后才能实现 —— 在构造之前,还没有这种类型的对象,只有一些未初始化的内存。另一种理解方式是 —— 在将虚函数分派到正确的类型之前,需要查找虚函数表指针。那么,是谁将正确的值放入虚函数表指针中的呢?考虑到虚函数表指针唯一标识了对象的类型,只能在构造过程中进行初始化,在构造之前它并未初始化。但如果没有初始化,就无法用于分派虚函数调用。因此,再次意识到到构造函数不能是虚函数。

对于类型层次结构中的派生类,确定类型的过程更加复杂。可以尝试观察对象在构造过程中的类型。最简单的方法是使用 typeid 操作符,其返回有关对象类型的信息,包括类型的名称:

// Example 01
#include <iostream>
#include <typeinfo>

using std::cout;
using std::endl;

template <typename T>
auto type(T&& t) { return typeid(t).name(); }

class A {
public:
  A() { cout << "A::A(): " << type(*this) << endl; }
  virtual
  ~A() { cout << "A::~A(): " << type(*this) << endl; }
};

class B : public A {
public:
  B() { cout << "B::B(): " << type(*this) << endl; }
  ~B() { cout << "B::~B(): " << type(*this) << endl; }
};

class C : public B {
public:
  C() { cout << "C::C(): " << type(*this) << endl; }
  ~C() { cout << "C::~C(): " << type(*this) << endl; }
};

int main() {
  C c;
}

运行此程序会产生以下结果:

A::A(): 1A
B::B(): 1B
C::C(): 1C
C::~C(): 1C
B::~B(): 1B
A::~A(): 1A

std::type_info::name() 调用返回的类型名称是所谓的“名称修饰”类型名称 —— 这是编译器用来标识类型的内部名称,而不是像 class A 这样便于人类阅读的名称。如果想了解未修饰的类型名称,可以使用名称还原工具,例如 GCC 自带的 c++filt 程序:

$ c++filt -t 1A
A

也可以编写一个小型 C++ 函数来还原类型名称,但具体实现方式因编译器而异(目前没有可移植的通用版本)。例如,以下代码适用于 GCC:

// Example 2
#include <cxxabi.h>
template <typename T> auto type(T&& p) {
  int r;
  std::string name;
  char* mangled_name =
    abi::__cxa_demangle(typeid(p).name(), 0, 0, &r);
  name += mangled_name;
  ::free(mangled_name);
  return name;
}

名称还原函数返回的是一个 C 风格字符串(char* 指针),必须由调用者显式释放。现在,程序会打印出如 A、B 和 C 这样的未修饰名称。这已经足够满足我们的需求,但在某些情况下,你可能会发现类型名称的输出并不完全准确:

class A {
public:
  void f() const { cout << type(*this) << endl; }
};

...
C c;
c.f();

如果调用函数 f(),报告的类型是 C,而不是预期的 const C(因为在 const 成员函数内部,对象是 const 的)。这是因为 typeid 操作符移除了类型的 const 和 volatile 限定符以及引用。要打印这些信息,必须自行判断:

// Example 03
template <typename T> auto type(T&& p) {
  std::string name;
  using TT = std::remove_reference_t<T>;

  if (std::is_const<TT>::value) name += "const ";
  if (std::is_volatile<TT>::value) name += "volatile ";

  int r;
  name += abi::__cxa_demangle(typeid(p).name(), 0, 0, &r);
  return name;
}

无论你选择如何打印类型,在这些示例中构造了多少个对象?源代码显示只有一个,即类型为 C 的对象 c:

int main() {
  C c;
}

运行时输出显示构造了三个对象,即每种类型各一个。这两个答案都是正确的 —— 当构造类型为 C 的对象时,必须首先构造其基类 A,因此会调用 A 的构造函数。接着构造中间基类 B,最后才构造 C。析构函数的执行顺序则相反。

在构造函数或析构函数内部,由 typeid 操作符报告的对象类型,与当前正在执行构造或析构的类的类型相同。看起来,在构造过程中,由虚函数表指针指示的类型似乎在不断变化!当然,前提是假设 typeid 操作符返回的是动态类型(即由虚函数表指针指示的类型),而非在编译时就能确定的静态类型。标准确实规定了。

这是否意味着,如果在每个构造函数中调用同一个虚函数,实际上会调用该函数的三个不同版本呢?很容易验证:

// Example 04
class A {
public:
  A() { whoami(); }
  virtual ~A() { whoami(); }
  virtual void whoami() const {
    std::cout << "A::whoami" << std::endl;
  }
};

class B : public A {
public:
  B() { whoami(); }
  ~B() { whoami(); }
  void whoami() const override {
    std::cout << "B::whoami" << std::endl;
  }
};

class C : public B {
public:
  C() { whoami(); }
  ~C() { whoami(); }
  void whoami() const override {
    std::cout << "C::whoami" << std::endl;
  }
};

int main() {
  C c;
  c.whoami();
}

现在,将创建一个类型为 C 的对象,创建后对 whoami() 的调用确认了 —— 对象的动态类型是 C。从构造过程一开始,就是成立的;要求编译器构造一个类型为 C 的对象,但在构造过程中,对象的动态类型发生了变化:

A::whoami
B::whoami
C::whoami
C::whoami
C::whoami
B::whoami
A::whoami

随着对象构造的推进,虚函数表指针的值也随之改变。在最开始,它将对象类型标识为 A,尽管最终的类型是 C。

这是否是因为我们在栈上创建了对象?如果对象是在堆上创建的,情况会有所不同吗?可以进行验证:

C* c = new C;
c->whoami();
delete c;

运行修改后的程序,其结果与原始程序完全相同。

另一个导致构造函数不能是虚函数的原因,或者说更普遍地,为什么在构造点必须在编译时就知道被构造对象的类型,因为编译器需要知道为该对象分配多少内存。内存大小由类型的尺寸决定,即由 sizeof 操作符决定。sizeof(C) 的结果是一个编译时常量,无论对象是在栈上还是堆上创建,为新对象分配的内存量总是在编译时已知。

归根结底是:如果程序创建了一个类型为 T 的对象,在代码中的某个地方必然存在对 T::T 构造函数的显式调用。在此之后,可以在程序的其余部分隐藏 T 类型,例如通过基类指针访问该对象,或通过类型擦除。但在构造点,代码中至少必须有一个对 T 类型的明确提及。

一方面,现在有了一个非常合理的解释,说明为什么对象的构造永远不可能为多态。另一方面,这并不能解决一个设计上的挑战,即有时需要构造一个在编译时类型未知的对象。例如设计一个游戏 —— 玩家可以招募或召唤任意数量的冒险者加入队伍,并建立定居点和城市。为每种生物种类和每种建筑类型分别设计一个独立的类是合理的,但当一个冒险者加入队伍或一座建筑建造时,就必须构造这些类型之一的对象,而在玩家选择之前,游戏无法知道要构造哪个具体对象。

像软件中的常见做法一样,解决方案涉及增加一个间接层。