8.3. CRTP与静态多态

由于 CRTP 允许使用派生类的函数来重写基类函数,因此实现了多态行为。关键的区别在于,这种多态发生在编译时,而不是运行时。

8.3.1 编译时多态

正如刚刚看到的,CRTP 可以用来让派生类定制基类的行为:

template <typename D> class B {
public:
  ...
  void f(int i) { static_cast<D*>(this)->f(i); }
protected:
  int i_;
};

class D : public B<D> {
public:
  void f(int i) { i_ += i; }
};

如果调用了基类的 B::f() 方法,它会将调用分派给实际派生类的对应方法,其效果与虚函数相同。当然,为了充分利用这种多态性,必须能够通过基类指针来调用基类的方法。如果没有这种能力,只是在调用一个已经知道其类型的派生类方法:

D* d = ...; // 获取一个类型为 D 的对象
d->f(5);
B<D>* b = ...; // 这也必须是一个类型为 D 的对象
b->f(5);

函数调用的形式与通过基类指针调用虚函数完全一样。实际调用的函数 f() 来自派生类 D::f(),不过这里存在一个显著的区别 —— 派生类的实际类型 D 必须在编译时就已知;这里的基类指针不是 B*,而是 B<D>*,所以派生对象的类型就是 D。如果开发者必须事先知道实际类型,那么这种多态似乎意义不大。

因为还没有完全理解编译时多态的真正含义。正如虚函数的好处在于,可以调用一个不知道其存在的类型的成员函数一样,静态多态要真正有用,也必须具备同样的能力。

那么,如何编写一个能够为未知类型的参数进行编译的函数呢?答案当然是,使用函数模板:

// Example 01
template <typename D> void apply(B<D>* b, int& i) {
  b->f(++i);
}

这是一个模板函数,可以被基类指针调用,并能自动推导出派生类 D 的类型。现在,可以写出看似普通的多态代码:

B<D>* b = new D; // 1
apply(b); // 2

在第一行,对象的构造必须知晓其实际类型 D。对于使用虚函数的常规运行时多态也是如此:

void apply(B* b) { ... }
B* b = new D; // 1
apply(b); // 2

在这两种情况下,在第二行,调用了仅基于基类知识编写的代码:

为了使这两种多态形式之间的对称性(而非等价性!)更加完整,还需要解决另外两个特殊情况:纯虚函数和多态析构。先从前者开始。

8.3.2 编译时纯虚函数

在 CRTP 场景中,纯虚函数的等效形式是什么?纯虚函数必须在所有派生类中实现。声明了纯虚函数,或继承了纯虚函数但未进行重写的类,称为抽象类;其可以进一步派生,但不能实例化。

当思考静态多态中纯虚函数的等效形式时,会发现 CRTP 实现存在一个重大缺陷:如果在某个派生类中忘记了重写编译时的“虚函数” f(),会发生什么情况?

// Example 02
template <typename D> class B {
public:
  ...
  void f(int i) { static_cast<D*>(this)->f(i); }
};

class D : public B<D> {
  // 这里没有 f() !
};

...
B<D>* b = ...;
b->f(5); // 1

这段代码在编译时不会产生错误或警告 —— 在第一行,调用了 B::f(),而它反过来会调用 D::f()。类 D 并没有声明自己的 f() 成员函数,因此调用的是从基类继承而来的那个版本。而这个继承来的版本,正是已经见过的 B::f() 成员函数,又会再次调用 D::f(),而 D::f() 实际上就是 B::f()……于是陷入了一个无限循环。

问题的根源在于:没有机制强制我们必须在派生类中重写成员函数 f(),但如果不去重写,程序的行为就是错误的。问题的本质是我们混淆了接口和实现 —— 基类中的公共成员函数声明表明,所有派生类都必须将 void f(int) 作为其公共接口的一部分。而派生类中的同名函数则提供了实际的实现。我们将在第14章中详细讨论接口与实现的分离。但目前,我们只需知道,如果这两个函数拥有不同的名字,问题就会简单得多:

// Example 03
template <typename D> class B {
public:
  ...
  void f(int i) { static_cast<D*>(this)->f_impl(i); }
};

class D : public B<D> {
  void f_impl(int i) { i_ += i; }
};

...
B<D>* b = ...;
b->f(5);

如果现在忘记实现 D::f_impl() 会发生什么?代码将无法编译,类 D 中既没有直接定义,也没有通过继承得到名为 f_impl() 的成员函数。因此,就实现了一个编译时的纯虚函数!这里的“虚函数”实际上是 f_impl(),而不是 f()。

完成这一步后,如何实现一个带有默认实现、可选择性重写的常规虚函数呢?如果沿用分离接口与实现的模式,只需为 B::f_impl() 提供一个默认实现即可:

// Example 03
template <typename D> class B {
public:
  ...
  void f(int i) { static_cast<D*>(this)->f_impl(i); }
  void f_impl(int i) {}
};

class D1 : public B<D1> {
  void f_impl(int i) { i_ += i; }
};

class D2 : public B<D2> {
  // 这里没有f()
};

...
B<D1>* b = ...;
b->f(5); // 调用 D1::f_impl()
B<D2>* b1 = ...;
b1->f(5); // 默认调用 B::f_impl()

我们需要讨论的最后一种特殊情况 —— 多态性析构。

8.3.3 析构函数与多态删除

目前,我们一直有意回避通过 CRTP 以,某种多态方式实现的对象的删除问题。事实上,如果回顾并重读之前展示完整代码的示例,比如“引入 CRTP”一节中的基准测试装置 BM_static,会发现要么完全避免了删除对象,要么只是在栈上构造了一个派生类对象。这是因为多态性删除会带来一个其他的问题,而现在我们终于可以着手解决了。

首先,在许多情况下,多态性删除并不是一个问题。所有对象的创建都是在已知其实际类型的情况下进行的。如果构造对象的代码同时也拥有并最终负责删除这些对象,那么“删除对象的类型是什么”这个问题实际上根本不会出现。同样,如果对象存储在容器中,也不会通过基类指针或引用删除:

template <typename D> void apply(B<D>& b) {
... 对 b 进行操作 ...
}
{
  std::vector<D> v;
  v.push_back(D(...)); // 对象以 D 的类型创建
  ...
  apply(v[0]); // 对象以 B& 的类型处理
} // 对象以 D 的类型销毁

如前面的示例所示,对象的构造和删除都在已知其实际类型的情况下进行,不涉及多态性;但在这期间对它们进行操作的代码通用,编写为针对基类类型工作,所以可以作用于从该基类派生的类。

但如果确实需要通过基类指针来删除对象呢?这并不简单。首先,直接调用 delete 操作符会产生错误的结果:

B<D>* b = new D;
...
delete b;

这段代码能够编译。糟糕的是,即使是一些通常会在类含有虚函数,但析构函数非虚时发出警告的编译器,但在这种情况下也不会产生警告,因为这里根本没有虚函数,而编译器也无法识别 CRTP 多态性可能带来的问题。

此时只会调用基类 B<D> 自身的析构函数,而派生类 D 的析构函数根本不会调用!

可能会试图像处理其他编译时虚函数那样来解决这个问题:将指针转换为已知的派生类类型,然后调用派生类中预期的成员函数:

template <typename D> class B {
public:
  ~B() { static_cast<D*>(this)->~D(); }
};

与普通函数不同,这种多态尝试存在严重缺陷,原因不止一个 —— 首先,在基类的析构函数中,实际对象已不再具有派生类的类型,此时若在其上调用派生类的成员函数,都会导致未定义行为。其次,即使这种方式在某种情况下能够运行,派生类的析构函数执行后,仍会调用基类的析构函数,从而引发无限递归循环。

这个问题有两种解决方案。一种选择是像处理其他操作一样,将编译时多态扩展到删除操作本身,使用函数模板来实现:

template <typename D> void destroy(B<D>* b) {
  delete static_cast<D*>(b);
}

这是定义明确的做法。delete 操作符作用于实际类型 D 的指针上,会调用正确的析构函数。然而,必须确保始终使用这个 destroy() 函数来删除这些对象,而不是直接调用 delete 操作符。

第二种选择是真正地将析构函数设为虚函数。这样做确实会重新引入虚函数调用的开销,但仅限于析构函数。同时,也会因虚函数表指针而增加对象的大小。如果这两种开销都不是问题,那么可以采用这种混合的静态-动态多态方式:除了析构函数外,所有虚函数调用都在编译时绑定,且没有运行时开销。

8.3.4 CRTP 与访问控制

在实现 CRTP 类时,必须考虑访问权限问题 —— 想要调用的方法都必须是可访问的。该方法要么是公有的,要么调用者必须具有特殊访问权限。这与虚函数的调用方式略有不同:调用虚函数时,调用者只需对调用语句中命名的成员函数具有访问权限即可。例如,对基类函数 B::f() 的调用,要求 B::f() 公有,或者调用者有权访问非公有成员函数(例如,类 B 的另一个成员函数即使 B::f() 私有,也可以调用)。然后,如果 B::f() 是虚函数并派生类 D 重写,则在运行时实际调用的是重写的 D::f()。这里并不要求 D::f() 对原始调用点是可访问的;例如,D::f() 可以私有。

CRTP 多态调用的情况则有些不同。所有调用在代码中都是显式的,调用者必须能够访问它们所调用的函数。通常,基类必须能够访问派生类的成员函数。考虑之前章节中的一个示例,但现在加入了明确的访问控制:

template <typename D> class B {
public:
  ...
  void f(int i) { static_cast<D*>(this)->f_impl(i); }
private:
  void f_impl(int i) {}
};

class D : public B<D> {
private:
  void f_impl(int i) { i_ += i; }
  friend class B<D>;
};

函数 B::f_impl() 和 D::f_impl() 在各自的类中都是私有的。基类对派生类没有特殊访问权限,无法调用其私有成员函数。除非愿意将派生类中的成员函数 D::f_impl() 从私有改为公有,从而允许调用者访问,否则必须将基类声明为派生类的友元。

反过来操作也有一些好处。创建一个新的派生类 D1,对实现函数 f_impl() 提供了不同的重写:

class D1 : public B<D> {
private:
  void f_impl(int i) { i_ -= i; }
  friend class B<D1>;
};

这个类存在一个细微的错误 —— 实际上并非派生于 B<D1>,而是派生于旧类 B<D>;这在基于已有模板创建新类时很容易犯。如果尝试以多态方式使用该类,这个错误就会暴露:

B<D1>* b = new D1;

由于 B<D1> 并非 D1 的基类,这段代码无法通过编译。并非所有 CRTP 的使用都涉及多态调用,这类错误可能不会立即显现。理想情况下,希望在声明类 D1 时就能发现这个错误。

我们可以通过将类 B 设计成一种“静态多态意义上的抽象类”来实现。具体做法是将类 B 的构造函数设为私有,并声明派生类为其友元:

template <typename D> class B {
  int i_;
  B() : i_(0) {}
  friend D;

public:
  void f(int i) { static_cast<D*>(this)->f_impl(i); }

private:
  void f_impl(int i) {}
};

请注意这种友元声明的写法有些不同寻常 —— 是 friend D 而不是 friend class D。这正是为模板参数编写友元声明的方式。现在,唯一能够构造 B<D> 类实例的类型就是作为模板参数使用的特定派生类 D,之前错误的代码 class D1 : public B<D> 将不再能够通过编译。

现在我们已经了解了 CRTP 的工作原理,接下来让我们看看它的实际用途。