由于 CRTP 允许使用派生类的函数来重写基类函数,因此实现了多态行为。关键的区别在于,这种多态发生在编译时,而不是运行时。
正如刚刚看到的,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
在这两种情况下,在第二行,调用了仅基于基类知识编写的代码:
运行时多态:有一个共同的基类,以及一些直接操作该基类类型的函数(通过基类指针或引用)。
CRTP 与静态多态:有一个共同的基类模板(B
为了使这两种多态形式之间的对称性(而非等价性!)更加完整,还需要解决另外两个特殊情况:纯虚函数和多态析构。先从前者开始。
在 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()
我们需要讨论的最后一种特殊情况 —— 多态性析构。
目前,我们一直有意回避通过 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 操作符。
第二种选择是真正地将析构函数设为虚函数。这样做确实会重新引入虚函数调用的开销,但仅限于析构函数。同时,也会因虚函数表指针而增加对象的大小。如果这两种开销都不是问题,那么可以采用这种混合的静态-动态多态方式:除了析构函数外,所有虚函数调用都在编译时绑定,且没有运行时开销。
在实现 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 的工作原理,接下来让我们看看它的实际用途。