CRTP 奇异递归模板模式:编译期多态与零开销抽象
更新时间:2026-08-31。本文是
languages/cpp/主题专家层第 12 篇。入门层讲了"多态让基类指针调用派生类实现",虚函数与 vtable 机制 讲了动态多态的底层与开销,模板实例化机制 讲了模板如何按类型复制代码。本文把这两者拧到一起,回答一个更锋利的问题:能不能既有"基类写通用算法、派生类提供实现"的写法,又完全不付 vtable 的代价? CRTP 就是答案。
本文要回答的问题
- CRTP 那个"派生类把自己当模板参数传给基类"的写法,到底图什么?
- 为什么说它实现的是"静态多态",和
virtual的动态多态本质区别在哪? - 怎么用
nm实打实证明 CRTP 类根本没有 vtable? - 对象计数、Policy 组合、侵入式容器这些经典 idiom,用 CRTP 写出来长什么样?
- CRTP 和虚函数,性能到底差多少?我用服务器跑了 1 亿次调用,数据在第四节。
- 它有什么代价:代码膨胀、编译错误难读、什么场景其实不该用?
一、CRTP 是什么:一个"套娃"的模板
CRTP 的全称是 Curiously Recurring Template Pattern(奇异递归模板模式)。名字吓人,结构其实就一句话:派生类把自己作为模板实参,传给它的基类模板。
template <typename Derived>
class Base {
public:
void interface() {
// 在基类里,通过静态转换"向下看"到派生类
static_cast<Derived*>(this)->implementation();
}
};
class Derived : public Base<Derived> { // 注意:Derived 出现在自己的基类里
public:
void implementation() { /* ... */ }
};这段代码最"诡异"的地方在于:Derived 还没定义完,就出现在 Base<Derived> 里了——它继承自一个"以自己为参数的模板"。这在 C++ 里完全合法,因为 Base<Derived> 的实例化只要求 Derived 是个不完整类型(incomplete type),而继承语句本身并不立刻展开 Derived 的成员。等到 interface() 被调用时,Derived 早已完整,静态转换顺理成章。
把这种"基类是模板、派生类把自己喂回去"的关系画成图:
关键认知:static_cast<Derived*>(this) 这一步完全发生在编译期。编译器在实例化 Base<Derived> 时,已经确切知道 this 指向的是 Derived,于是 interface() 里对 implementation() 的调用被直接决议成 Derived::implementation——没有任何运行时的类型查询,也没有虚表查表。这跟 virtual 函数"运行时根据对象真实类型查 vtable"是两条完全不同的路。
二、静态多态:把 vtable 彻底干掉
先看一个完整可比的例子:用虚函数和 CRTP 两种方式,实现"一组几何图形,统一算面积"。完整代码在 demos/cpp-expert/crtp/crtp_basic.cpp,这里只贴关键结构。
虚函数版(动态多态):
struct ShapeV {
virtual double area() const = 0; // 纯虚,强制派生类实现
virtual ~ShapeV() = default;
};
struct CircleV : ShapeV {
double r;
double area() const override { return 3.14159 * r * r; }
};CRTP 版(静态多态):
template <typename Derived>
struct ShapeC {
double area() const {
return static_cast<const Derived*>(this)->area_impl();
}
};
struct CircleC : ShapeC<CircleC> {
double r;
double area_impl() const { return 3.14159 * r * r; }
};两者用法表面相似——都能写 p->area() 拿到面积。但底层天差地别。
2.1 用 nm 实锤:CRTP 类没有 vtable
我把 crtp_basic.cpp 在 Linux(GCC 4.8.5,-O2)编译后,用 nm -C 看符号表。虚函数版清清楚楚躺着三张虚表:
0000000000400f20 V vtable for ShapeV
0000000000400f60 V vtable for CircleV
0000000000400fa0 V vtable for SquareV而 CRTP 版的 CircleC、SquareC?符号表里连一个 vtable 字样都没有。 这不是巧合——CRTP 基类 ShapeC<Derived> 不是多态类型(没有 virtual),派生类自然也不生成 vtable、不塞 vptr。
这个差异带来的直接后果是对象体积:虚函数版的对象里要放一个 vptr(64 位平台 8 字节),CRTP 版的对象只有自己的数据成员。对 Circle 这种只含一个 double 的类:
| 类型 | 内存布局 | sizeof |
|---|---|---|
CircleV(虚函数) | vptr(8) + double(8) | 16 字节 |
CircleC(CRTP) | double(8) | 8 字节 |
一倍的内存差距,在海量小对象(比如图形引擎里的顶点、游戏里的组件)场景会被放大成实打实的内存带宽与缓存压力。
2.2 决议时机的根本区别
- 虚函数:
p->area()在运行时,先通过vptr找到vtable,再取对应槽位的函数指针,最后一次间接跳转才进到真正的area。这中间的"查表 + 间接跳转"是不可省略的运行时代价(详见 vtable 机制 的间接跳转开销分析)。 - CRTP:
p->area()在编译期就已经被编译器展开成static_cast后直接调用CircleC::area_impl。目标函数地址在编译期确定,调用点可以内联、可以常量传播、可以被优化器彻底吃掉。
一句话总结这一节:CRTP 用"编译期已知派生类类型"换掉了"运行期查 vtable",代价是失去运行期多态能力(不能把异质对象装进同一个基类指针容器统一处理),收益是零 vtable、更小对象、可内联的调用。
三、三大经典应用 idiom
光说"静态多态"有点抽象,CRTP 真正值钱的是几个被写进无数库(Eigen、Boost.Iterator、std::enable_shared_from_this 都是它的身影)的惯用法。
3.1 编译期对象计数
想给每个类都加上"当前存活实例数"的能力,用虚函数你得在基类塞一个 static int count 再配合虚析构——但那样所有类共享一个计数器。CRTP 的解法优雅得多:
template <typename T>
struct Counter {
static int count;
Counter() { ++count; }
Counter(const Counter&) { ++count; }
~Counter() { --count; }
};
template <typename T> int Counter<T>::count = 0;
struct Widget : Counter<Widget> { int id; Widget(int i):id(i){} };
struct Gadget : Counter<Gadget> { double v; Gadget(double d):v(d){} };Widget 继承 Counter<Widget>,Gadget 继承 Counter<Gadget>——两者各自实例化出一份独立的 Counter 模板,于是各有各的 static int count。在 demos/cpp-expert/crtp/crtp_counter.cpp 里我跑了实测:
Widget count = 3 (expect 3)
Gadget count = 1 (expect 1)
in scope Widget count = 4 (expect 4)
after scope Widget count = 3 (expect 3)构造加、析构减、拷贝也加,完全符合预期。如果用单一基类 + 虚函数,这个"每类独立计数"是做不到的,因为 static 成员属于基类本身,所有派生类共享。CRTP 借助"每个 T 一份模板实例"把这个能力白送了。
3.2 Policy 组合(编译期策略拼装)
CRTP 常和模板基类搭配,让"行为"像积木一样拼到派生类上。一个经典形态是"基类提供算法骨架,派生类通过 CRTP 注入具体步骤":
template <typename Derived>
struct Logger {
void do_work() {
Derived& self = *static_cast<Derived*>(this);
self.before();
self.step(); // 由派生类实现具体步骤
self.after();
}
};
struct MyTask : Logger<MyTask> {
void before() { /* 打日志 */ }
void step() { /* 真正干活 */ }
void after() { /* 收尾 */ }
};这其实就是"编译期版本的模板方法模式(Template Method)"——把运行期靠虚函数实现的"钩子",提前到编译期用静态绑定钉死。好处是 before/step/after 全可以被内联,没有虚调用开销;代价是 MyTask 必须从 Logger<MyTask> 继承,耦合在编译期就焊死了。
3.3 侵入式容器
标准库的 std::list<T> 会在节点里额外存 prev/next 指针("非侵入式"),每个对象被装进不同容器就要重复开销。侵入式容器反过来:让对象自己携带链接指针,通过 CRTP 基类提供,这样对象能直接挂进链表/树而不用额外分配节点内存。
template <typename T>
struct IntrusiveNode {
T* next = nullptr; // 链接指针直接长在对象里
};
struct Job : IntrusiveNode<Job> {
int priority;
};Job 自身就带着 next,把它串成单链表时不需要任何额外节点分配。这在高频分配/释放、对缓存极敏感的场景(网络框架的连接池、实时调度的任务队列)里价值很大。Eigen 的 Matrix 也用类似 CRTP 手法让表达式模板在编译期拼出最优计算图,把"多个临时矩阵"优化成一次循环——那是 CRTP 性能美学的巅峰。
四、性能实测:CRTP vs 虚函数,到底差多少?
光讲机制不够,我直接上了服务器(Linux x86_64,GCC 4.8.5)跑了 benchmark。代码在 demos/cpp-expert/crtp/crtp_bench.cpp:被调用方分别用虚函数和 CRTP 实现同一个 foo(int),主循环各调 1 亿次,用 std::chrono 计时,结果如下。
4.1 -O2(真实优化级别)
virtual calls : 100000000 in 260756 us
crtp calls : 100000000 in 246592 us
speedup (crtp/virtual) = 1.05744xCRTP 快了约 5.7%。注意这个数字偏小,原因是 GCC 在 -O2 下已经对虚调用做了 devirtualization(去虚化)——当它能在编译期确定这个 BaseV* 实际指向 DerivV 时,会直接把虚调用改写成直接调用,于是两边都内联了,差距自然被抹平。这恰好印证了 vtable 文档 里说的"现代编译器能去掉很多虚化"。
4.2 -O0(关闭优化,看"裸"成本)
virtual calls : 100000000 in 271927 us
crtp calls : 100000000 in 443128 us
speedup (crtp/virtual) = 0.613653xCRTP 反而慢了约 39%! 这个反直觉的结果很有教学意义:在 -O0 下,编译器不内联、不优化,static_cast<Derived*> + 模板实例化产生的额外层次反而成了负担;而虚调用在 -O0 下只是一次简单的间接跳转,实现得相当朴素。结论很明确——CRTP 的性能优势不是"无条件快",而是"给优化器更多可乘之机"。一旦优化器被允许工作(-O2/-O3),它要么把两边都治好,要么在能去虚化时让虚函数也飞起来。
4.3 那么 CRTP 的性能价值到底在哪?
把两个数据点合起来看,真相是:在 -O2 这种生产级编译下,单看一个 foo() 调用的微基准,CRTP 跟虚函数差距极小(本例 5%)。CRTP 真正的性能收益不在"调用快一点",而在三件事:
- 零 vtable 内存:每个对象少一个指针,海量小对象场景省的是实打实的内存与缓存行;
- 可内联的调用链:CRTP 的
interface()→impl()能被整体内联,编译器得以做跨函数优化(常量传播、死代码消除),而虚调用即使去虚化也常因为跨翻译单元而受限; - 编译期组合零开销:像 Eigen 那样把一堆操作在编译期折叠成一条循环,这种"整体优化"只有静态多态做得到。
所以微基准里 5% 的差距是小看了它——CRTP 的战场从来不是"一次调用省几个纳秒",而是"让整个算法在编译期被重组"。
五、代价与陷阱:什么时候别用 CRTP
任何把活从运行期挪到编译期的技术,都要还债。CRTP 的账主要有这几笔:
5.1 代码膨胀(Code Bloat)
Counter<Widget> 和 Counter<Gadget> 是两份不同的类,各自的成员函数各生成一份机器码。如果 Widget/Gadget/... 有几十个派生类,基类的算法代码就会被复制几十份。这跟 模板实例化机制 讲的"按类型组合复制代码"是同一笔账——CRTP 尤其容易触发,因为每个派生类都是一个新的模板实参组合。
5.2 错误信息地狱
模板一错,编译器报的错能吓退初学者。比如你忘了在派生类里提供 area_impl,GCC 报的不会是"派生类缺少 area_impl",而是从 ShapeC<Derived>::area 内部抛出一堆关于 static_cast 和未找到成员的嵌套错误,定位很痛苦。
5.3 失去运行期多态
这是最本质的限制:CRTP 对象不能放进"基类指针"容器统一处理。 std::vector<ShapeC*> 不存在,因为 ShapeC<CircleC> 和 ShapeC<SquareC> 是两个不同的类型,没有共同基类。如果你的场景是"运行时才知道有哪些具体类型、需要异构集合 + 统一遍历",那就只能乖乖用虚函数——CRTP 替代不了动态多态,它是另一条路。
5.4 耦合与可读性
派生类必须 : public Base<自己>,这种"自己出现在自己基类里"的写法对新人极不友好,且一旦继承链变深(CRTP 基类又继承别的 CRTP 基类),心智负担陡增。它适合写在库的内部,不适合写在业务代码里到处飞。
六、与 C++20 concepts 的协同
CRTP 有个老毛病:基类在 interface() 里直接 static_cast<Derived*> 调 implementation(),如果派生类压根没实现 implementation,错误要到实例化点才爆发(见 5.2)。C++20 的 concepts 能在基类模板层面就约束 Derived 必须长什么样:
template <typename D>
concept HasArea = requires(const D& d) {
{ d.area_impl() } -> std::convertible_to<double>;
};
template <typename Derived>
requires HasArea<Derived>
struct ShapeC {
double area() const {
return static_cast<const Derived*>(this)->area_impl();
}
};这样一旦 Derived 漏了 area_impl,编译器在 ShapeC<Derived> 实例化时就能给出清晰得多的诊断:"Derived 不满足 HasArea 约束",而不是甩出一长串 static_cast 内部错误。关于 concepts 的底层机制,可延伸阅读 C++20 concepts 深入。
七、一句话总结
CRTP 用一个"派生类把自己喂给基类模板"的套娃写法,把多态的决议从运行期查 vtable 提前到了编译期静态绑定:它消灭了 vtable 与 vptr、缩小了对象、放开了内联,代价是代码膨胀、错误难读、且再也装不进异构基类容器。它的性能价值不在"单次调用快 5%",而在"让整个算法在编译期被重组"——Eigen、Boost 那些以性能著称的库,骨架里都立着 CRTP 的影子。
相关阅读
- C++ 虚函数、虚表与多态机制 —— 动态多态的底层与间接跳转开销(CRTP 的对照面)
- C++ 模板实例化机制 —— 理解 CRTP 为何按类型复制代码(代码膨胀根源)
- C++20 concepts 深入 —— 用 concepts 给 CRTP 基类加编译期约束,改善错误诊断
- DPA 技术报告:Runtime 工程师实操 SOP —— 同属本站专家级深度文档范式参考
实验代码获取
本文所有示例均在公开仓库 derekzhuo/geek-doc.cn 的 demos/cpp-expert/crtp/ 目录:
crtp_basic.cpp—— 虚函数 vs CRTP 静态多态,验证计算结果一致crtp_counter.cpp—— 编译期对象计数 idiom,含运行输出crtp_bench.cpp—— CRTP vs 虚函数 1 亿次调用性能 benchmarkMakefile——make run一键构建并跑全部示例
编译命令:g++ -std=c++11 -O2 xxx.cpp -o xxx(服务器 GCC 4.8.5 实测通过;本地建议 -std=c++17 以获得更清晰报错)。