提到C++多态,绝大多数人第一反应是虚函数:基类声明virtual接口,派生类重写,运行时通过虚函数表完成动态绑定。这条路很经典,但代价也很明确——每次虚调用都要经历一次间接跳转,编译器难以内联,对象还要额外携带一个虚表指针。其实在模板体系里还有另一条路:CRTP(Curiously Recurring Template Pattern,奇异递归模板模式),它把多态的解析时机从运行期搬到了编译期,零虚表开销,调用可被完全内联。本文从原理、写法到进阶技巧,系统讲透这套设计模式。

一、先弄清楚:虚函数的开销到底出在哪里
要理解CRTP的价值,得先看清虚函数的成本结构。一个含有虚函数的类,每个对象都会多出一个隐藏的虚表指针(通常8字节,64位平台),指向该类对应的虚函数表。调用base->draw()时,CPU要先通过对象头部取出vptr,再从vtable里按槽位取出函数地址,最后间接跳转过去执行。这三次内存访问加上间接跳转,在有分支预测加持时单看一条调用不算慢,真正的问题在于它掐断了编译器的优化链路。
虚调用是运行期才能确定目标的间接调用,编译器无法内联它。而内联恰恰是现代C++优化的核心:内联展开后,常量传播、死代码消除、循环优化才能跨函数边界生效。一个在热循环里被调用千万次的虚函数,和一个被完全内联的普通函数,性能差距可能达到数倍甚至一个数量级。此外,虚函数的存在还会影响对象的内存布局和可拷贝性,比如某些序列化场景、嵌入式平台对vptr非常敏感。
当然,虚函数换来的灵活性是实打实的:同一个Shape*指针可以在运行时指向任意派生对象,容器里可以混装不同类型。CRTP做不到这一点,它牺牲了这种异构集合能力,换取编译期的静态绑定。两者是互补关系,不是替代关系。
二、CRTP的核心原理:把派生类自己传给基类当模板参数
CRTP的写法乍一看很怪:派生类继承基类时,把派生类自身作为基类的模板参数传入。形如class Derived : public Base<Derived>。基类内部通过static_cast把this指针向下转型为派生类型,从而调用派生类的实现。看一个最简版本:
#include <iostream>
// 基类模板:Derived 是“未来的”派生类类型
template <typename Derived>
class ShapeBase {
public:
void draw() const {
// 静态向下转型:编译期已知具体类型,安全且零开销
const Derived& self = static_cast<const Derived&>(*this);
self.drawImpl(); // 调用派生类的具体实现
}
// 提供通用功能,依赖派生类暴露的接口
double areaTwice() const {
const Derived& self = static_cast<const Derived&>(*this);
return self.area() * 2.0;
}
};
class Circle : public ShapeBase<Circle> {
public:
explicit Circle(double r) : radius_(r) {}
void drawImpl() const { std::cout << "画一个圆\n"; }
double area() const { return 3.14159 * radius_ * radius_; }
private:
double radius_;
};
class Square : public ShapeBase<Square> {
public:
explicit Square(double s) : side_(s) {}
void drawImpl() const { std::cout << "画一个正方形\n"; }
double area() const { return side_ * side_; }
private:
double side_;
};
int main() {
Circle c(2.0);
Square s(3.0);
c.draw(); // 编译期绑定到 Circle::drawImpl
std::cout << s.areaTwice() << "\n"; // 直接内联 Square::area
}
注意上面代码中的static_cast用&接引用即可,这里写成&>是排版转义,实际代码就是static_cast<const Derived&>(*this)。这个转型为什么是安全的?关键在实例化时机:ShapeBase<Circle>的成员函数只有在被调用时才会实例化,而调用发生时Circle已经是完整类型。编译器在编译c.draw()时就知道c的静态类型是Circle,drawImpl的解析完全在编译期完成,没有任何虚表查找,也没有任何运行时判断。
这套机制的精妙之处在于控制反转:基类不再通过virtual关键字声明接口契约,而是通过“假设派生类提供某个成员函数”来定义契约。基类是模板,编译器在实例化时会检查派生类是否真的提供了drawImpl和area,缺了就直接编译报错。契约检查从运行时推迟到了编译期,错误暴露得更早。
三、进阶用法:静态接口约束与链式调用
1. 用编译期断言约束派生类接口
裸的CRTP契约只靠“用了才知道缺什么”,报错信息往往很晦涩。可以在基类里加入概念(C++20 concepts)或static_assert,把契约显式化:
#include <concepts>
#include <type_traits>
template <typename T>
concept Drawable = requires(const T t) {
t.drawImpl();
{ t.area() } -> std::convertible_to<double>;
};
template <typename Derived>
requires Drawable<Derived>
class ShapeBase {
public:
void draw() const {
static_cast<const Derived&>(*this).drawImpl();
}
};
这样派生类若没实现约定接口,报错会直接指向概念约束,信息清晰得多。如果还在用C++17,可以退而求其次用static_assert配合std::void_t做检测,思路相同。
2. 链式调用与对象计数器
CRTP另一个高频场景是让基类提供可复用的通用能力。比如各类流式构建器都需要返回*this且返回类型必须是派生类自身,用CRTP可以一次写对:
#include <iostream>
template <typename Derived>
class BuilderBase {
public:
Derived& setWidth(int w) {
width_ = w;
return static_cast<Derived&>(*this); // 返回派生类引用,支持链式调用
}
Derived& setHeight(int h) {
height_ = h;
return static_cast<Derived&>(*this);
}
protected:
int width_ = 0;
int height_ = 0;
};
class WindowBuilder : public BuilderBase<WindowBuilder> {
public:
WindowBuilder& setTitle(const char* t) {
title_ = t;
return *this;
}
void build() const {
std::cout << "窗口: " << width_ << "x" << height_
<< " 标题: " << title_ << "\n";
}
private:
const char* title_ = "";
};
int main() {
WindowBuilder()
.setWidth(800)
.setHeight(600)
.setTitle("主窗口")
.build();
}
如果不用CRTP,基类的setWidth只能返回基类引用,链到后面调setTitle就会编译失败。CRTP让通用代码返回正确的静态类型,这也是很多流式接口库(如Eigen的表达式模板、各类ORM查询构造器)的底层手法。
3. 自动派生类计数与对象追踪
利用CRTP每个派生类实例化出独立基类的特性,还能实现“每个类型一份静态数据”,比如免侵入式的实例计数器:
#include <iostream>
template <typename Derived>
class Counter {
public:
Counter() { ++count_; }
Counter(const Counter&) { ++count_; }
~Counter() { --count_; }
static int alive() { return count_; }
private:
inline static int count_ = 0; // 每个派生类独享一份
};
class Widget : public Counter<Widget> {};
class Gadget : public Counter<Gadget> {};
int main() {
Widget w1, w2;
{
Gadget g;
std::cout << Gadget::alive() << "\n"; // 输出 1
}
std::cout << Widget::alive() << "\n"; // 输出 2
std::cout << Gadget::alive() << "\n"; // 输出 0
}
这里的关键是Counter<Widget>和Counter<Gadget>是两个完全不同的类,各自拥有独立的静态成员count_。派生类一行代码都不用写就获得了计数能力,这是普通继承给不了的。
四、CRTP的代价、局限与选型建议
CRTP不是银弹,它有三个明显短板。第一,无法实现异构容器:std::vector<ShapeBase<?>>里的问号没法填,因为Circle和Square的基类是不同类型。真要混合存储,得借助std::variant加std::visit,或者退回虚函数方案。第二,代码膨胀:每种派生类都会实例化一份基类代码,头文件实现全部暴露,编译时间和二进制体积都会增长,大型项目里需要权衡。第三,误用风险:如果某个类class A : public ShapeBase<B>传错了模板参数,编译器不会拦截,static_cast会直接导致未定义行为。可以在基类析构函数或构造函数里加一个static_assert(std::is_base_of_v<ShapeBase<Derived>, Derived>)来兜底。
从C++23开始,标准库引入了显式对象成员函数(推导this,deducing this),可以写出更简洁的静态多态,不再需要手写static_cast。但CRTP作为成熟模式,在C++17及更早的项目中依然是编译期多态的主力手段。
选型上给一个简单的判断标准:如果多态类型在编译期就能确定、且调用位于性能敏感路径,优先CRTP;如果需要运行时动态装载异构对象,老老实实用虚函数;两者混用时,可以让CRTP基类内部再挂一层虚接口,各取所长。理解CRTP的价值不在于替代虚函数,而在于让你意识到:多态本质上是一种“在恰当的时机解析调用”的能力,时机选得越早,优化空间就越大。