CRTP 最初由 James Coplien 在 1995 年发表于《C++ Report》的一篇文章中提出,并以此名称为人所知。它是更普遍的有界多态的一种特殊形式(Peter S. Canning 等人,《面向对象编程中的 F-有界多态》,函数式编程语言与计算机架构会议,1989 年)。尽管 CRTP 并不能完全取代虚函数,但它为 C++ 开发者提供了一种类似的工具,在特定情况下具有多项优势。
在讨论虚函数的更好替代方案之前,应该先思考一下,为何我们需要替代方案。虚函数有什么不好之处呢?
问题在于其性能开销。一次虚函数调用可能比一次普通函数调用昂贵数倍,对于那些本来可以内联的简单函数而言,这种开销尤其明显(虚函数无法内联)。可以通过微基准测试来测量这种差异,微基准测试正是测量小段代码性能的理想工具。市面上有许多微基准测试库和工具;在本书中,我们将使用谷歌基准测试库。为了跟随本章的示例操作,必须先下载并安装该库(详细说明请参见第5章)。然后,就可以编译并运行这些示例了。
现在我们已经准备好了微基准测试库,接下来就可以测量虚函数调用的开销了。将一个实现最简单功能、代码量最少的虚函数,与一个完成相同功能的非虚函数进行比较。以下是虚函数的示例:
// Example 01
class B {
public:
B() : i_(0) {}
virtual ~B() {}
virtual void f(int i) = 0;
int get() const { return i_; }
protected:
int i_;
};
class D : public B {
public:
void f(int i) override { i_ += i; }
};
对应的非虚函数版本:
// Example 01
class A {
public:
A() : i_(0) {}
void f(int i) { i_ += i; }
int get() const { return i_; }
protected:
int i_;
};
现在,可以在一个微基准测试装置中调用这两个函数,并测量每次调用所需的时间:
void BM_none(benchmark::State& state) {
A* a = new A;
int i = 0;
for (auto _ : state) a->f(++i);
benchmark::DoNotOptimize(a->get());
delete a;
}
void BM_dynamic(benchmark::State& state) {
B* b = new D;
int i = 0;
for (auto _ : state) b->f(++i);
benchmark::DoNotOptimize(b->get());
delete b;
}
benchmark::DoNotOptimize 包装器可防止编译器将未使用的对象优化掉,从而避免将整组函数调用作为不必要的代码移除。在测量虚函数调用时间时存在一个细微之处;一种更简单的代码写法是避免使用 new 和 delete 操作符,而直接在栈上构造派生类对象:
void BM_dynamic(benchmark::State& state) {
D d;
int i = 0;
for (auto _ : state) d.f(++i);
benchmark::DoNotOptimize(b->get());
}
然而,这个基准测试很可能会得出与非虚函数调用相同的时间。原因并非虚函数调用没有开销,而是因为在此代码中,编译器能够推断出对虚函数 f() 的调用始终是对 D::f() 的调用(这里调用不是通过基类指针,而是通过派生类的引用,几乎不可能是其他函数)。一个良好的优化编译器会对此类调用进行去虚拟化,例如:生成对 D::f() 的直接调用,从而避免了间接寻址和对虚函数表的引用。这样的调用甚至可以内联。
另一个可能的问题是,这两个微基准测试(尤其是非虚函数调用)可能都太快了 —— 基准测试循环体的执行时间可能小于循环本身的开销。可以通过在循环体内进行多次调用,来解决这个问题。这可以通过编辑器的复制粘贴功能,或使用 C++ 预处理器宏来实现:
#define REPEAT2(x) x x
#define REPEAT4(x) REPEAT2(x) REPEAT2(x)
#define REPEAT8(x) REPEAT4(x) REPEAT4(x)
#define REPEAT16(x) REPEAT8(x) REPEAT8(x)
#define REPEAT32(x) REPEAT16(x) REPEAT16(x)
#define REPEAT(x) REPEAT32(x)
在基准测试循环内部,可以这样写:
REPEAT(b->f(++i);)
基准测试报告的每次迭代时间现在对应于 32 次函数调用。虽然这对于比较两个调用没有影响,但为了方便,可以在循环结束后,在基准测试装置末尾添加以下代码行,使基准测试本身报告每秒真实的调用次数:
state.SetItemsProcessed(32*state.iterations());
现在可以比较两个基准测试的结果:
Benchmark Time UserCounters...
BM_none 1.60 ns items_per_second=19.9878G/s
BM_dynamic 9.04 ns items_per_second=3.54089G/s
虚函数调用的开销几乎是非虚函数调用的 10 倍。这并不完全是一个公平的比较;虚函数调用提供了其他功能。然而,其中一些功能可以通过其他方式实现,而无需付出这样的性能代价。
现在,将介绍 CRTP,它彻底颠覆了传统的继承模式:
template <typename D> class B {
...
};
class D : public B<D> {
...
};
最引人注目的变化是,基类现在变成了一个类模板。派生类仍然继承自基类,但这次继承的是基类模板的一个特定实例化 —— 而这个实例化正是以派生类自身作为模板参数的!类 B 以类 D 作为模板参数进行实例化,而类 D 又继承自这个以 D 实例化的 B 类,D 继承自 B,B ... 这就是递归在发挥作用。
这种令人费解的模式背后的动机是什么?现在基类在编译时就拥有了关于派生类的信息。因此,过去需要通过虚函数调用才能实现的功能,现在可以在编译时就绑定到正确的函数上:
// Example 01
template <typename D> class B {
public:
B() : i_(0) {}
void f(int i) { static_cast<D*>(this)->f(i); }
int get() const { return i_; }
protected:
int i_;
};
class D : public B<D> {
public:
void f(int i) { i_ += i; }
};
调用本身仍然可以通过基类指针进行:
B<D>* b = ...;
b->f(5);
这里没有间接寻址,也没有虚函数调用的开销。编译器可以在编译时追踪调用,直接定位到实际调用的函数,甚至可以将其内联:
void BM_static(benchmark::State& state) {
B<D>* b = new D;
int i = 0;
for (auto _ : state) {
REPEAT(b->f(++i);)
}
benchmark::DoNotOptimize(b->get());
state.SetItemsProcessed(32*state.iterations());
}
基准测试表明,通过 CRTP 进行的函数调用所花费的时间与普通函数调用完全相同:
Benchmark Time UserCounters...
BM_none 1.60 ns items_per_second=19.9878G/s
BM_dynamic 9.04 ns items_per_second=3.54089G/s
BM_static 1.55 ns items_per_second=20.646G/s
CRTP 的主要限制是,基类 B 的大小不能依赖于其模板参数 D。更一般地说,类 B 的模板必须能够在类型 D 为不完整类型的情况下完成实例化。例如,以下代码将无法编译:
template <typename D> class B {
using T = typename D::T;
T* p_;
};
class D : public B<D> {
using T = int;
};
考虑到这段代码与许多广泛使用的、引用其模板参数嵌套类型的模板非常相似,意识到它无法编译可能会让人有些意外。考虑以下模板,将具有 push_back() 和 pop_back() 函数的序列容器转换为栈:
template <typename C> class stack {
C c_;
public:
using value_type = typename C::value_type;
void push(const valuetype& v) { c.push_back(v); }
value_type pop() {
value_type v = c.back();
c.pop_back();
return v;
}
};
stack<std::vector<int>> s;
value_type 的 using 类型别名,与我们之前尝试在类 B 中声明的别名看起来完全相同。那么,类 B 中的这个别名有什么问题呢?实际上,类 B 本身并没有错。在类似于栈类的上下文中,完全可以正常编译:
class A {
public:
using T = int;
T x_;
};
B<A> b; // Compiles with no problems
问题不在于类 B 本身,而在于对它的预期使用方式:
class D : public B<D> ...
在需要知道 B<D> 的具体定义时,类型 D 尚未声明。它不可能声明 —— 因为类 D 的声明要求我们确切地知道基类 B<D> 是什么。如果类 D 还没有声明,编译器又如何知道标识符 D 指的是一个类型呢?毕竟,不能在一个完全未知的类型上实例化模板。答案介于两者之间 —— 类 D 是前向声明的,就像写了这样的代码一样:
class A;
B<A> b; // 现在无法进行编译
有些模板可以在前向声明的类型上实例化,而有些则不能。具体的规则可以从标准中费力地整理出来,但其要点是 —— 可能影响类大小的东西都必须完全声明。对不完整类型内部声明的类型的引用,比如 using T = typename D::T;,相当于对嵌套类的前向声明,而这种做法也不允许。
另一方面,类模板的成员函数体直到调用时才会实例化。事实上,对于给定的模板参数,只要成员函数不调用,甚至不需要能够编译通过。在基类的成员函数内部引用派生类、其嵌套类型及其成员函数是完全可行的。此外,由于在基类内部派生类类型为前向声明,因此可以声明指向其指针和引用。下面是一种非常常见的 CRTP 基类重构方式,将 static_cast 的使用集中在一个地方:
template <typename D> class B {
...
void f(int i) { derived()->f(i); }
D* derived() { return static_cast<D*>(this); }
};
class D : public B<D> {
...
void f(int i) { i_ += i; }
};
基类的声明中包含了一个指向不完整(前向声明)类型 D 的指针,就像其他指向不完整类型的指针一样工作;当指针解引用时,该类型必须完整。在我们的例子中,这发生在成员函数体内部 —— B::f(),该函数体直到使用端代码调用时才会编译。
那么,如果需要在编写基类时使用派生类的嵌套类型该怎么办呢?在函数体内使用是没有问题的。如果需要在基类本身中使用嵌套类型,通常出于两个原因。第一个原因是用来声明成员函数的返回类型:
// Example 01a
template <typename D> class B {
typename D::value_type get() const {
return static_cast<const D*>(this)->get();
}
...
};
D : public B<D> {
using value_type = int;
value_type get() const { ... };
...
};
这将无法编译,不过这个问题很容易解决,只需让编译器推导出返回类型即可:
// Example 01a
template <typename D> class B {
auto get() const {
return static_cast<const D*>(this)->get();
}
...
};
第二种情况则更为困难:需要嵌套类型来声明数据成员或参数。这时,只剩下一个选择:将该类型作为其他的模板参数传递给基类。这会在代码中引入一些冗余,但这不可避免:
// Example 01a
template <typename T, typename value_type> class B {
value_type value_;
...
};
class D : public B<D, int> {
using value_type = int;
value_type get() const { ... }
...
};
现在,已经了解了什么是 CRTP 以及如何编写它,接下来看看它能解决哪些设计问题。