48、现代C++设计模式:CRTP(奇异递归模板模式),静态多态的经典实现
说到设计模式,大家第一反应可能是GoF那23种经典模式。但C++模板元编程里有个独门绝技——CRTP,全称Curiously Recurring Template Pattern。这名字挺拗口,说白了就是:派生类把自己作为模板参数传给基类。
我第一次看到这种写法时,心里直犯嘀咕:“这不循环依赖了吗?”后来在项目中用顺手了才发现,这玩意儿解决了不少运行时多态的性能痛点。今天咱们就把它掰开揉碎了聊聊。
什么是CRTP?先看个最简单的例子
CRTP的基本形式长这样:
template <typename Derived>
class Base {
public:
void interface() {
// 调用派生类的实现
static_cast<Derived*>(this)->implementation();
}
};
class Derived : public Base<Derived> {
public:
void implementation() {
std::cout << "Derived implementation" << std::endl;
}
};
你看,Derived继承Base<Derived>,把自己当模板参数传进去了。基类里通过static_cast把this转成派生类指针,然后调用派生类的方法。
这里有个关键点:没有虚函数,没有vtable,没有运行时开销。所有调用都在编译期就确定了。
静态多态 vs 动态多态
咱们对比一下两种多态的实现方式:
| 特性 | 动态多态(虚函数) | 静态多态(CRTP) |
|---|---|---|
| 绑定时机 | 运行时 | 编译期 |
| 性能开销 | 虚函数表查找(间接跳转) | 无额外开销(直接调用) |
| 灵活性 | 运行时多态,可动态替换 | 编译期多态,类型固定 |
| 代码体积 | 较小(共享一份代码) | 较大(每个派生类生成一份) |
| 适用场景 | 需要运行时多态、接口稳定 | 性能敏感、类型在编译期已知 |
我个人习惯是:能用静态多态解决的问题,尽量不用虚函数。尤其是嵌入式开发、游戏引擎、高频交易这些场景,虚函数那点开销有时候真受不了。
CRTP的经典应用场景
1. 实现代码复用(Mix-in)
我记得有个项目需要给多个类添加“比较”功能。传统做法是写个基类,然后每个派生类重载operator==。用CRTP可以更优雅:
template <typename Derived>
class Comparable {
public:
bool operator!=(const Derived& other) const {
return !(static_cast<const Derived*>(this)->operator==(other));
}
};
class Point : public Comparable<Point> {
public:
Point(int x, int y) : x_(x), y_(y) {}
bool operator==(const Point& other) const {
return x_ == other.x_ && y_ == other.y_;
}
private:
int x_, y_;
};
你看,Comparable自动提供了!=的实现,派生类只需要实现==。这就是CRTP的“模板方法”模式——基类定义骨架,派生类填充细节。
2. 单例模式(Singleton)
用CRTP实现单例,比传统写法简洁多了:
template <typename T>
class Singleton {
public:
static T& instance() {
static T inst;
return inst;
}
protected:
Singleton() = default;
~Singleton() = default;
Singleton(const Singleton&) = delete;
Singleton& operator=(const Singleton&) = delete;
};
class Logger : public Singleton<Logger> {
friend class Singleton<Logger>;
public:
void log(const std::string& msg) { /* ... */ }
private:
Logger() = default;
};
这里有个坑:派生类的构造函数必须私有,并且把基类声明为友元。否则外部可以直接构造,单例就失效了。我曾经在代码审查时看到有人忘了加friend,结果单例变成了“多例”……
3. 对象计数
想知道某个类当前有多少个实例?CRTP可以轻松搞定:
template <typename T>
class ObjectCounter {
public:
static size_t count() { return count_; }
protected:
ObjectCounter() { ++count_; }
ObjectCounter(const ObjectCounter&) { ++count_; }
~ObjectCounter() { --count_; }
private:
static inline size_t count_ = 0;
};
class Widget : public ObjectCounter<Widget> {
// Widget自己的实现
};
每个Widget实例都会自动增减计数器。注意这里用了static inline(C++17特性),省去了在cpp文件中定义静态成员的麻烦。
CRTP的避坑指南
- 派生类类型不匹配:如果写
class Derived : public Base<OtherDerived>,基类里的static_cast会出大问题。编译器不会报错,但运行时行为未定义。 - 构造函数/析构函数中调用CRTP方法:在基类构造期间,派生类还没完全构造好。这时候调用
static_cast是危险的。 - 代码膨胀:每个派生类都会实例化一份基类代码。如果派生类很多,二进制体积会变大。
我曾经在重构一个大型项目时,把虚函数全部换成CRTP。结果编译出来的二进制体积膨胀了30%……后来只在热点路径上用了CRTP,其他部分保留了虚函数。嗯,没有银弹,得根据场景权衡。
CRTP与策略模式(Policy-Based Design)
CRTP经常和策略模式搭配使用。比如实现一个“可克隆”的类:
template <typename Derived>
class Cloneable {
public:
std::unique_ptr<Derived> clone() const {
return std::make_unique<Derived>(
static_cast<const Derived&>(*this)
);
}
};
class Config : public Cloneable<Config> {
public:
Config() = default;
Config(const Config&) = default;
// ...
};
这样每个派生类自动获得clone()方法,不需要手动实现。你想想看,如果有一百个类都需要克隆功能,用CRTP能省多少重复代码?
性能对比:CRTP vs 虚函数
咱们做个简单的性能测试(伪代码):
// 虚函数版本
struct VirtualBase {
virtual int compute(int x) const = 0;
};
struct VirtualDerived : VirtualBase {
int compute(int x) const override { return x * 2; }
};
// CRTP版本
template <typename T>
struct CRTPBase {
int compute(int x) const {
return static_cast<const T*>(this)->compute_impl(x);
}
};
struct CRTPDerived : CRTPBase<CRTPDerived> {
int compute_impl(int x) const { return x * 2; }
};
在循环调用100万次的情况下,CRTP版本通常比虚函数版本快10%~30%。原因很简单:虚函数调用是间接跳转,CPU分支预测容易失败;而CRTP是直接调用,编译器甚至可以内联。
如果你不确定某个场景该用CRTP还是虚函数,可以问自己两个问题:
- 这个多态行为在编译期能确定吗?
- 性能是不是关键指标?
如果两个答案都是“是”,那就用CRTP。否则,虚函数更省心。
CRTP的进阶用法:奇异递归模板的“奇异”之处
CRTP的名字里有个“奇异”,是因为它打破了我们对继承的直觉——派生类在定义时还没完全定义好,却已经被基类使用了。这其实利用了C++的两阶段查找(Two-Phase Lookup)机制。
更“奇异”的是,CRTP可以模拟虚函数的行为,但完全在编译期完成:
template <typename... Ts>
class Visitor {
public:
template <typename T>
void visit(T&& obj) {
// 展开参数包,调用每个派生类的visit方法
(static_cast<Ts*>(this)->visit_impl(std::forward<T>(obj)), ...);
}
};
class MyVisitor : public Visitor<MyVisitor, OtherVisitor> {
public:
void visit_impl(int x) { /* 处理int */ }
void visit_impl(double d) { /* 处理double */ }
};
这种写法在C++17的折叠表达式加持下,可以实现编译期的“多重继承多态”。说实话,我第一次看到这种代码时也愣了一下——但用熟了之后,确实能写出非常灵活且高性能的代码。
总结一下
CRTP是C++模板元编程的基石之一。它让我们在编译期实现多态,避免了虚函数的运行时开销。但也要注意它的局限性:代码膨胀、类型固定、调试困难。
我个人建议:在性能敏感的核心模块中使用CRTP,在业务逻辑层使用虚函数。这样既保证了性能,又保持了代码的可维护性。
最后送大家一句话:CRTP就像一把锋利的刀,用好了能切菜如飞,用不好容易伤到自己。多写、多练、多思考,你就能驾驭它。
核心要点回顾:
- CRTP:派生类把自己作为模板参数传给基类
- 静态多态:编译期绑定,无虚函数开销
- 经典应用:代码复用、单例、对象计数、策略模式
- 避坑:类型匹配、构造/析构中调用、代码膨胀
- 性能:比虚函数快10%~30%,但牺牲了灵活性