CRTP(奇异递归模板模式)
需要预先学习:
1. CRTP(奇异递归模板模式)
CRTP(Curiously Recurring Template Pattern,奇异递归模板模式)是一种继承写法:派生类把自己作为模版参数传给基类。
template <typename Derived>
class Base
{
// 基类通过模版参数 Derived "知道"派生类是谁
};
class Widget : public Base<Widget> // Widget 把自己传给 Base
{
};
这样基类在编译期就知道"谁继承了我",可以直接使用派生类的类型——由此得到一类不依赖 virtual 的多态能力,称为静态多态(static polymorphism,编译期多态)。
1.1 最小示例
#include <iostream>
template <typename Derived>
class Base
{
public:
void interface()
{
// 把 this 转换成派生类指针,调用派生类的实现
static_cast<Derived*>(this)->impl();
}
};
class Widget : public Base<Widget>
{
public:
void impl() { std::cout << "Widget::impl\n"; }
};
class Gadget : public Base<Gadget>
{
public:
void impl() { std::cout << "Gadget::impl\n"; }
};
int main()
{
Widget w;
Gadget g;
w.interface(); // 编译期绑定到 Widget::impl
g.interface(); // 编译期绑定到 Gadget::impl
}
编译并运行代码后,看到这样的输出:
Widget::impl
Gadget::impl
interface() 是基类里的代码,却能调用派生类的 impl()——效果类似虚函数,但调用在编译期就已确定:没有查表开销,还能被编译器内联(inline,把函数体直接嵌入调用处,省去调用本身的开销)。
1.2 为什么叫"奇异递归"
拆开这个名字看:
- 递归:
Widget的定义引用了Base<Widget>,而Base<Widget>的成员又引用了Widget——你中有我,我中有你,像是在"自己使用自己" - 奇异:
Widget还没定义完(正处在基类列表的位置),怎么就能当模版实参用?
答案在于模版实例化的时机。实例化 Base<Widget> 时,编译器只需要 Widget 这个名字,不需要它的完整定义,所以 Widget 此时是不完整类型也没关系;而成员函数 interface() 的函数体要等到被调用时才实例化——那时 Widget 已经定义完毕,static_cast<Derived*>(this) 完全合法。
注意:这条时机规则也划出了红线——基类的构造函数和析构函数里不能做这个转换。执行基类构造、析构时,派生类部分尚未构造或已经销毁,
static_cast<Derived*>(this)是未定义行为。
1.3 静态多态
先把同一件事用虚函数做一遍——统一的 draw 接口、多种图形:
#include <iostream>
#include <memory>
#include <vector>
class Shape
{
public:
virtual void draw() const = 0;
virtual ~Shape() = default;
};
class Circle : public Shape
{
public:
void draw() const override { std::cout << "画圆\n"; }
};
class Square : public Shape
{
public:
void draw() const override { std::cout << "画方\n"; }
};
int main()
{
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>());
shapes.push_back(std::make_unique<Square>());
for(const auto& s : shapes)
s->draw(); // 运行期查虚表,决定调用哪个 draw
}
虚函数的代价:每个对象多携带一个 vptr(virtual table pointer,指向虚函数表的指针),每次调用都要经虚表间接跳转,编译器难以内联。当具体类型在编译期已经确定时,这些代价可以完全省掉——CRTP 版本:
#include <iostream>
template <typename Derived>
class ShapeBase
{
public:
void draw() const
{
static_cast<const Derived*>(this)->draw_impl();
}
};
class Circle : public ShapeBase<Circle>
{
public:
void draw_impl() const { std::cout << "画圆\n"; }
};
class Square : public ShapeBase<Square>
{
public:
void draw_impl() const { std::cout << "画方\n"; }
};
// 接口由基类统一,实现由派生类提供,泛型代码直接可用
template <typename T>
void render(const T& shape)
{
shape.draw();
}
int main()
{
Circle c;
Square s;
render(c); // 编译期确定调用 Circle::draw_impl
render(s); // 编译期确定调用 Square::draw_impl
}
两种多态的对比:
| 虚函数多态 | CRTP 静态多态 | |
|---|---|---|
| 绑定时机 | 运行期(查虚表) | 编译期 |
| 调用开销 | 间接跳转,难内联 | 零开销,可内联 |
| 对象大小 | 多一个 vptr | 无额外开销 |
| 公共基类类型 | 有,Shape* 可指向任何派生 |
没有,ShapeBase<A> 与 ShapeBase<B> 是不同类型 |
| 异构容器 | vector<Shape*> 直接可用 |
困难,缺少统一类型 |
| 运行期决定类型 | 支持(配置、插件) | 不支持 |
一句话:虚函数用一点运行期开销换取运行期灵活性;CRTP 放弃这种灵活性,换来零开销和内联机会。
1.4 应用:把通用功能注入基类
每个类独立的实例计数器
统计"每个类当前有多少个存活实例"。用普通继承会出问题:静态成员只有一份,会被所有派生类共享。CRTP 天然解决——Counter<Widget> 与 Counter<Gadget> 是不同的类,各自持有独立的静态变量:
#include <iostream>
template <typename T>
class Counter
{
public:
Counter() { ++m_count; }
Counter(const Counter&) { ++m_count; }
~Counter() { --m_count; }
static size_t alive() { return m_count; }
private:
inline static size_t m_count = 0;
};
class Widget : public Counter<Widget> {};
class Gadget : public Counter<Gadget> {};
int main()
{
Widget w1, w2;
Gadget g1;
std::cout << Widget::alive() << '\n'; // 2
std::cout << Gadget::alive() << '\n'; // 1
}
派生类一行代码没写,就获得了"只属于自己"的计数器。这是 CRTP 最典型的用法:把通用功能注入派生类,同时为每个派生类生成一份独立的实现。
只写一个比较运算符,基类补全全套
派生类只实现 operator<,其余比较运算符由基类借它推导出来:
#include <iostream>
template <typename Derived>
class Comparable
{
public:
bool operator>(const Derived& rhs) const
{
return rhs < static_cast<const Derived&>(*this);
}
bool operator==(const Derived& rhs) const
{
const Derived& self = static_cast<const Derived&>(*this);
return !(self < rhs) && !(rhs < self);
}
};
class Money : public Comparable<Money>
{
public:
explicit Money(int cents) : m_cents(cents) {}
bool operator<(const Money& rhs) const { return m_cents < rhs.m_cents; }
private:
int m_cents;
};
int main()
{
Money a(100), b(200);
std::cout << std::boolalpha;
std::cout << (a > b) << '\n'; // false,由基类的 operator> 完成
std::cout << (a == b) << '\n'; // false,由基类的 operator== 完成
}
注:C++20 的三向比较 <=> 可以自动生成全套比较运算符,这个经典用法弱化了,但它体现的"基类借派生类之力"的思想依然是 CRTP 的核心。
链式调用不被基类截断
基类方法返回 static_cast<Derived&>(*this) 而不是基类引用,链式调用就不会被"困"在基类的方法集里:
#include <iostream>
#include <string>
template <typename Derived>
class Printer
{
public:
Derived& indent()
{
std::cout << " ";
return static_cast<Derived&>(*this); // 关键:返回派生类引用
}
};
class Report : public Printer<Report>
{
public:
Report& line(const std::string& text)
{
std::cout << text << '\n';
return *this;
}
};
int main()
{
Report r;
r.indent().line("标题").indent().line("内容");
}
如果 indent() 返回的是 Printer&,第二条链只能继续调用基类方法——line() 不在基类里,链就断了。
1.5 应用:std::enable_shared_from_this
标准库中最著名的 CRTP 是 std::enable_shared_from_this。它解决的问题是:对象在自己的成员函数里,安全地拿到管理自己的 shared_ptr。
直接拿 this 构造是错误的做法:
std::shared_ptr<Task> self(this); // 错误!为同一个对象新建了独立的引用计数块
外界的 shared_ptr 和这里的 self 各计各的数,对象会被 delete 两次。正确做法是继承 enable_shared_from_this<自己>:
#include <iostream>
#include <memory>
class Task : public std::enable_shared_from_this<Task>
{
public:
void run()
{
// 从基类提升出共享同一控制块的 shared_ptr
std::shared_ptr<Task> self = shared_from_this();
std::cout << "当前引用计数: " << self.use_count() << '\n';
}
};
int main()
{
auto task = std::make_shared<Task>();
task->run(); // 输出 2(task 和 self 各持有一个引用)
}
原理:enable_shared_from_this 基类内部藏着一个 weak_ptr,对象首次被 shared_ptr 接管时会自动登记进去,shared_from_this() 从这个 weak_ptr 提升出 shared_ptr——控制块自始至终只有一份。典型场景是异步回调:把 self 存进回调,延长对象存活到回调执行完毕。
注意:
shared_from_this()只能在对象已经被shared_ptr接管之后调用——栈对象上调用、或在构造函数里调用,都会抛出std::bad_weak_ptr。
1.6 局限与选择
CRTP 的短板都来自"编译期绑定"这枚硬币的另一面:
ShapeBase<Circle>与ShapeBase<Square>是不同类型,没有公共基类可用——两种图形的指针装不进同一个容器,运行期也无法在它们之间切换- 模版代码都在头文件里,每种派生类都实例化一份,编译时间和二进制体积随之膨胀
- 调用栈比直接写多一层基类转发,偶尔影响调试体验
选择建议:
- 虚函数:需要运行期灵活性——异构容器、按配置选实现、跨模块接口
- CRTP:类型编译期已知、在意开销(数值计算、游戏、嵌入式),或想给类注入通用功能
两者并不互斥,enable_shared_from_this 就常常用在带虚函数的类上。另外,Eigen 这类线性代数库用 CRTP 实现表达式模版(expression templates),把 a + b * c 编译成不带临时对象的循环代码——那是 CRTP 性向的极致发挥。


