一、CRTP 到底是什么?
CRTP,全称是 Curiously Recurring Template Pattern。中文叫「奇异递归模板模式」。名字听着挺唬人,对吧?
说白了,就是让一个派生类把自己的类型作为模板参数,传给基类。像这样:
template <typename Derived>
class Base {
// ...
};
class Derived : public Base<Derived> {
// ...
};
你看,Derived 继承自 Base<Derived>。基类知道派生类的类型。这就是 CRTP 的核心。
我第一次看到这种写法时,心里想的是:「这玩意儿不会死循环吗?」后来才明白,模板是在编译期实例化的,不存在运行时递归。嗯,这里要注意,CRTP 是编译期的技巧,跟运行时多态完全不是一回事。
二、静态多态:没有虚函数的「多态」
传统的运行时多态,靠的是虚函数表。每个对象里藏着一个 vptr,调用虚函数时去查表。这带来了两个开销:
- 一次间接寻址(查虚表)
- 虚函数通常不能被内联
CRTP 可以做到类似的效果,但完全不用虚函数。看个例子:
template <typename Derived>
class Shape {
public:
void draw() const {
static_cast<const Derived*>(this)->drawImpl();
}
};
class Circle : public Shape<Circle> {
public:
void drawImpl() const {
std::cout << "绘制圆形" << std::endl;
}
};
class Square : public Shape<Square> {
public:
void drawImpl() const {
std::cout << "绘制正方形" << std::endl;
}
};
template <typename T>
void render(const Shape<T>& shape) {
shape.draw(); // 编译期确定调用哪个 drawImpl
}
这里 Shape::draw() 通过 static_cast 把 this 转成派生类指针,然后调用 drawImpl()。整个过程没有虚函数,没有运行时开销。
关键区别:运行时多态是「同一个接口,多种实现,运行时决定」。CRTP 是「同一个接口,多种实现,编译期决定」。
我个人的习惯是:如果多态的类型集合在编译期就完全确定,而且对性能有要求,我会优先考虑 CRTP。比如游戏引擎中的组件系统、图形学中的几何处理,这些场景下虚函数的开销有时候真的不能忍。
三、代码复用的高级技巧
CRTP 另一个大用处是代码复用。你想想看,很多类之间会有相同的功能,比如「对象计数」、「单例模式」、「运算符重载」等等。用 CRTP 可以把这些通用逻辑抽到基类里。
3.1 对象计数
template <typename Derived>
class ObjectCounter {
public:
static size_t aliveCount() { return count; }
protected:
ObjectCounter() { ++count; }
ObjectCounter(const ObjectCounter&) { ++count; }
~ObjectCounter() { --count; }
private:
static size_t count;
};
template <typename Derived>
size_t ObjectCounter<Derived>::count = 0;
class MyClass : public ObjectCounter<MyClass> {
// 自己的逻辑
};
每个派生类都有自己的 count 静态成员。因为模板参数不同,ObjectCounter<MyClass> 和 ObjectCounter<YourClass> 是完全不同的类型。这个技巧我在做内存池调试时用过,用来追踪某个类的对象数量,排查内存泄漏。
3.2 运算符重载的自动生成
我记得有一次写一个数值类型库,要为几十种类型重载 ==、!=、<、> 等运算符。写到手软。后来用 CRTP 一劳永逸:
template <typename Derived>
class Comparable {
public:
bool operator!=(const Derived& other) const {
return !static_cast<const Derived*>(this)->operator==(other);
}
bool operator>(const Derived& other) const {
return other < static_cast<const Derived*>(*this);
}
// 其他运算符类似...
};
class Rational : public Comparable<Rational> {
public:
bool operator==(const Rational& other) const { /* ... */ }
bool operator<(const Rational& other) const { /* ... */ }
};
派生类只需要实现 == 和 <,剩下的 !=、>、>=、<= 都由基类自动生成。这就是 CRTP 的威力——把重复劳动降到最低。
四、CRTP 的避坑指南
我曾经踩过的坑:
- 派生类没有实现接口:如果
static_cast调用的函数在派生类中不存在,编译错误会非常晦涩。建议在基类中用static_assert做一些编译期检查。 - 基类析构函数不是虚函数:CRTP 基类通常不应该有虚函数。但如果你通过基类指针删除派生类对象,行为是未定义的。我的建议是:永远不要用 CRTP 基类的指针来管理派生类对象的生命周期。
- 模板代码膨胀:每个不同的模板参数都会实例化一份基类代码。如果基类很大,会导致二进制体积增加。解决办法是把公共逻辑放到非模板基类中。
五、CRTP 与运行时多态的对比
| 特性 | CRTP(静态多态) | 虚函数(运行时多态) |
|---|---|---|
| 调用开销 | 无额外开销(可内联) | 间接寻址(通常不可内联) |
| 类型集合 | 编译期固定 | 运行时可扩展 |
| 代码复用 | 强(通过基类注入行为) | 弱(通常需要手动转发) |
| 二进制体积 | 可能膨胀(每个类型实例化一份) | 较小(共享虚表) |
| 使用难度 | 中等(模板语法) | 简单(面向对象基础) |
你想想看,如果你的系统里多态的类型在写代码时就已经全部知道了,而且你特别在意性能,那 CRTP 几乎是完美的选择。反过来,如果你需要写一个插件系统,运行时才能加载新的类型,那虚函数才是正道。
六、CRTP 的核心知识体系
下面这张图总结了 CRTP 的核心逻辑和应用场景:
七、总结
CRTP 是 C++ 模板元编程里一个非常实用的工具。它让我们在编译期实现多态,避免了虚函数的运行时开销。同时,它也是代码复用的利器——把通用逻辑抽到基类里,派生类只需要关注自己的特殊行为。
我个人觉得,CRTP 是每个 C++ 开发者都应该掌握的技巧。它不像 SFINAE 那样晦涩,也不像变参模板那样复杂。但它能解决很多实际问题。你想想看,当你写了一个基类,然后十几个派生类自动获得了「对象计数」、「比较运算符」、「序列化支持」等功能,那种感觉真的很爽。
我的建议:从今天开始,在你下一个需要多态或代码复用的场景里,试试 CRTP。先从小处着手,比如给一个类加上对象计数功能。慢慢你就会发现它的妙处。
公众号:蓝海资料掘金营,微信deep3321