>

Introduction


什么是 CRTP?其英文全称是 Curiously Recurring Template Pattern,最早是由 James Coplien 在 1995 年提出的一个高级 C++ 编程技术,后来在 Stanley B. Lippman 写的 Cpp Gems 一书中也介绍了它的实践。中文翻译过来是“奇特的模板递归模式”,很拗口,如果是第一次接触 C++ 的这个高级特性,肯定会被这个名称弄得云里雾里,这篇文章我就来详细讲述下 CRTP 的原理和应用。

从表现形式上来说,在 CRTP 中,派生类继承基类的时候把自身作为基类的模板参数,派生类继承这个模板基类之后,就可以用基类类型的指针指向派生类,并且能够正确调用到派生类的函数。这里注意:基类必须是一个模板类,而派生类可以是,也可以不是。
例如:

(1)基类是模板类,模板参数是普通类型;派生类是非模板类。继承时,派生类 D 作为基类的模板参数

1
2
3
4
5
template<typename T>
class Base {};

class D : public Base<D<T>> {
};

(2)基类是模板类,模板参数是普通类型;派生类是非模板类。继承时,带模板参数的派生类 D 作为基类的模板参数

1
2
3
4
5
6
template<typename T>
class Base {};

template<typename T>
class D : public Base<D<T>> {
};

(3)基类是模板类,模板参数是模板的模板,派生类是非模板类。继承时,派生类 D 作为基类的模板参数

1
2
3
4
5
template<template<typename> class T>
class Base {};

template<typename T>
class D : public Base<D> {};

当然,上述两段代码片段只是简单展示了 CRTP 模式的一些声明方式,远远没有展示 CRTP 的精髓所在,这部分在后文将继续阐述。

二、CRTP 有什么优势?

要理解为什么 CRTP 有用以及能用在什么地方,我们需要从 C++ 的本质说起。我们知道,要对原有的一个类进行功能上的扩展,在不进行修改的情况下,主要有两种方式,它们都是通过继承机制来实现。一是通过虚函数来实现多态;二是通过 模板继承( mixin 技术),实现自身扩展。

先讲虚函数实现的多态机制,多态是 C++ 实现类功能扩展的一个重大特性,大部分编译器都是通过虚函数表(vtbl)和虚函数表指针(vptr)来实现晚绑定 late binding。这样,虚函数在被调用的时候,具体执行的代码能够找到对应的对象的动态类型,从而调用正确的函数。在这里,虚表和虚表指针的使用都必然增加一部分开销,首先,内存占用上,虚表通常需要占用不超过 128 字节的内存,而一个虚指针通常也要占用 8 字节的内存,而虚指针的间接函数调用在高频率的调用中由于多增加的 cpu 指令和内存访问,也会对性能有影响。

再来看看 minix,mixin 是通过一种在调用时组合不同模板参数的方式来达到扩展的技术,比如:A<B<C<T>>> obj;
其主要是通过先定义几个非模板的基类,然后定义派生类继承一个模板参数(如:template class Derived1 : public Base {}),这样,派生类在实例化的时候把基类类型作为模板参数进行实例化即可。mixin 通过模板参数的方式把基类和派生类联系在了一起,我们可以通过定义多个不同的派生类,然后按照上面提到的层层嵌套模板参数的方式调用,可以达到在同一个基类上组合多个派生类的效果,就想层叠蛋糕一样。mixin 的缺陷也很明显,就是需要大量多层次扩展的时候,一旦嵌套过深,可读性非常不好。而且也只适合单一的堆叠增加功能,不适合动态改变功能。

mixin 和虚函数一样,对类的扩展,都是从派生类为入口的,通过派生类来实现不同的功能从而进行扩展。具体方式就是从基类开始一层一层的派生,一直到最终的派生类,层层的继承和扩展。从派生类中调用基类的函数,是很显然的,因为派生类就包含了基类的信息。

回到 CRTP,由于 CRTP 不使用虚函数,那么让基类调用派生类函数的唯一方法,就是让基类知道派生类的信息。这样,在实例化一个基类类型的对象时,就可以通过给基类不同的的模板参数的方式进行扩展基类的功能了。调用时,把基类 down cast 强制转换成派生类,就可以调用派生类的函数了。这种通过把基类定义成一个类模板,并且把派生类作为模板参数传递给基类,然后通过指针静态转换的方式来达到扩展类功能的技巧,就是 CRTP 的精妙之处所在!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
template<typename T>
class A {
public:
void foo() {
static_cast<T*>(this)->bar();
}
void bar() {
....
}
};

class B : public A<B> {
public:
void bar() {
...
}
};

T 就是派生类,在定义时只是一个模板参数,而当定义 class B 时,继承了 A ,并把 B 作为模板参数传递到了 A。
在调用 foo() 时,自然就调到了 B 的 bar() 函数。
这里,由于 A 是 B 的基类,所以 A 的指针静态转换(Down Cast)成 B 会成功。这就保证的安全性。

1
2
3
class C : public A<C> {
void bar() { ... }
};

然后,在 main 函数中我们实例化 C,并且如下方式调用。

1
2
3
4
int main() {
C c;
c.foo();
}

现在这段代码看上去有些奇怪,这里的 c.foo(),它会成功吗?如果成功那么它会调用哪个 bar?
我们知道,由于 A 是 C 的基类,在派生类 C 中,没有定义 foo 函数,那么默认就会调用基类 A 中的 foo 函数,这就有点虚函数的感觉了。当然,如果基类也没有定义该函数,那么编译会报错。简单来说,派生类定义了该函数,其就被调用,不定义,就使用基类的该函数。
在这里,基类的 foo 函数中有一个静态转换,foo() 函数中调用了派生类的 bar 函数。有两种情况:

  • 如果派生类没有定义 bar 函数,那么基类的 bar 函数会被调用
  • 如果派生类定义了 bar 函数,那么派生类的 bar 函数会被调用

看到了吗? 这几乎完完全全达到了虚函数所实现的同样的效果!最重要的是,还没有虚函数的开销!这得益于它的静态编译。
那么,现在来看看 CRTP 具体的应用场景,有了 CRTP,我们可以把一个功能分成很细的粒度,每步都做成 down cast 调用,并且每步都有默认处理(inline的),用户想在哪步修改逻辑,就把哪步抓出来覆盖。这个是虚函数不可能做到的。就像一个链条,每步都可以修改。

例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
template<typename T>
struct A {
void function() {
static_cast<T*>(this)->major();
}
void major() {
static_cast<T*>(this)->minor1();
static_cast<T*>(this)->minor2();
}
void minor1() {
static_cast<T*>(this)->step1();
static_cast<T*>(this)->step2();
static_cast<T*>(this)->step3();
}
void minor2() {
static_cast<T*>(this)->step4();
static_cast<T*>(this)->step5();
static_cast<T*>(this)->step6();
}
void step1() { ... }
void step2() { ... }
void step3() { ... }
void step4() { ... }
void step5() { ... }
void step6() { ... }
};

function() 是整个功能的入口,里面调用了 major,major 的默认处理调用了 minor1, minor2。minor1, minor2 分别调用了各个 step,
这就是整个骨架,你可以抓出某个 step 改写,也可以抓出 minor 改写,甚至抓出 major 改写。控制粒度可以从“整个”到“极细”。
而整个类 A 只是提供一套 CRTP 给你改写“皮肤”,“肌肉”,“骨架”的规则。这就是 CRTP 的神奇所在。

三、CRTP 的应用


下面再列举一个 CRTP 一个具体的应用,这个例子展示了如何记录某个类对象构造的总个数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <stddef.h>
#include <iostream>

template<typename CountedType>
class ObjectCounter{
static size_t count;
protected:
ObjectCounter(){ ++ObjectCounter<CountedType>::count; } // 声明为 protected,防止生成对象,限定只能被继承
ObjectCounter(const ObjectCounter<CountedType>&){ ++ObjectCounter<CountedType>::count; }
~ObjectCounter(){ --count; }

public:
static size_t getCount(){ return ObjectCounter<CountedType>::count; } // 作为静态函数,类方法
};

template<typename CountedType>
size_t ObjectCounter<CountedType>::count = 0;

template<typename T>
class MyString : public ObjectCounter<MyString<T> >{}; // CRTP

int main() {
MyString<char> s1, s2;
MyString<wchar_t> ws;
std::cout << "MyString<char>:"<< MyString<char>::getCount()<< std::endl; //输出2
std::cout << "MyString<wchar_t>:"<< MyString<wchar_t>::getCount() << std::endl; //输出1
}

CRTP 与 虚函数的总结比较


  • 虚函数是实现动态绑定(运行时绑定),CRTP 则是实现静态绑定(编译时绑定)

  • 虚函数的优点是基类不需要知道派生类的信息,只管调用虚函数就是,这个得益于虚函数的“晚捆绑”实现机制。

  • 虚函数的“晚捆绑”特性由于是运行时才进行,因此有一定的空间开销和运行开销。

  • 而 CRTP 正好相反,它不使用“晚捆绑”机制,因此没有这些开销,但缺点是基类需要知道派生类的信息,才能调用派生类的函数,但是派生类是不可预料的。因此它使用的场景相对来说受限。

  • 在实现多态时,需要重写虚函数,因而这是运行时绑定的操作。

  • 然而如果想在编译期确定通过基类来得到派生类的行为,CRTP 便是最佳选择,它是通过派生类覆盖基类成员函数来实现静态绑定的。


##### 参考 --- [C++ Gems][]