CRTP 与静态多态:不用虚函数也能多态
CRTP(Curiously Recurring Template Pattern) ------把派生类自身作为模板参数传给基类------是 C++ 里实现编译期多态 的经典手段。
它让你在不付出虚函数表(vtable)开销的前提下,写出「接口统一、实现各异」的代码,同时保持内联与零抽象成本。
本文从「为什么不想用虚函数」出发,讲清 CRTP 的机制、常见用法、C++23 Deducing
this对它的替代,以及真实项目里的坑。
1. 虚函数的代价在哪?
cpp
struct Shape {
virtual double area() const = 0;
virtual ~Shape() = default;
};
运行时多态的开销并不大,但在某些场景下确实有影响:
| 代价 | 说明 |
|---|---|
| vtable 间接跳转 | 每次虚调用多一次指针解引用;阻止内联 |
| 对象多一个 vptr | 8 字节(x86-64),在大量小对象时显著 |
| 编译器优化受限 | 虚调用是运行期决议,LTO 之外难以 devirtualize |
如果所有类型在编译期可确定(模板实例化、容器里只放同一类型等),可以用 CRTP 把分派提前到编译期,彻底消除上述开销。
2. CRTP 的基本形式
cpp
template <typename Derived>
class Base {
public:
void interface() {
// 编译期「向下转型」:知道 Derived 的完整类型
static_cast<Derived*>(this)->implementation();
}
};
class Concrete : public Base<Concrete> {
public:
void implementation() {
// 真正的逻辑
}
};
关键点:
Base是类模板 ,模板参数就是继承它的那个类。- 在
Base内部通过static_cast<Derived*>(this)调用派生类的方法------编译期就确定了目标函数,不走 vtable。 - 每个不同的
Derived会实例化出不同的Base<Derived>,所以不同派生类之间没有公共基类(这是与虚函数最大的区别)。
3. 完整示例:静态多态的 Shape
cpp
#include <iostream>
#include <cmath>
template <typename Derived>
struct ShapeBase {
double area() const {
return static_cast<const Derived*>(this)->area_impl();
}
void describe() const {
std::cout << "area = " << area() << '\n';
}
};
struct Circle : ShapeBase<Circle> {
double radius;
explicit Circle(double r) : radius(r) {}
double area_impl() const { return M_PI * radius * radius; }
};
struct Rectangle : ShapeBase<Rectangle> {
double w, h;
Rectangle(double w, double h) : w(w), h(h) {}
double area_impl() const { return w * h; }
};
// 泛型函数:接受任何 ShapeBase<T>
template <typename T>
void print_area(const ShapeBase<T>& shape) {
shape.describe();
}
int main() {
Circle c(3.0);
Rectangle r(4.0, 5.0);
print_area(c); // area = 28.2743
print_area(r); // area = 20
}
print_area 是模板函数,编译期为 Circle 和 Rectangle 各生成一份;调用链全部可内联,零虚开销。
4. 典型应用场景
4.1 接口强制 + 默认实现
cpp
template <typename Derived>
struct Printable {
void print(std::ostream& os) const {
// 默认行为:调用派生类的 to_string()
os << static_cast<const Derived*>(this)->to_string();
}
};
struct MyClass : Printable<MyClass> {
std::string to_string() const { return "MyClass{...}"; }
};
基类提供通用框架(print),派生类只需要实现一个小钩子(to_string)。
4.2 运算符混入(Mixin)
标准库里 std::enable_shared_from_this<T> 就是 CRTP;Boost.Operators 用 CRTP 帮你从 < 自动生成 >、<=、>=:
cpp
template <typename Derived>
struct Comparable {
friend bool operator>(const Derived& a, const Derived& b) { return b < a; }
friend bool operator<=(const Derived& a, const Derived& b) { return !(b < a); }
friend bool operator>=(const Derived& a, const Derived& b) { return !(a < b); }
};
struct Score : Comparable<Score> {
int value;
friend bool operator<(const Score& a, const Score& b) { return a.value < b.value; }
};
// Score 自动获得 >, <=, >=
4.3 计数器(统计实例数)
cpp
template <typename Derived>
struct InstanceCounter {
static inline int count = 0;
InstanceCounter() { ++count; }
~InstanceCounter() { --count; }
InstanceCounter(const InstanceCounter&) { ++count; }
};
struct Widget : InstanceCounter<Widget> {};
struct Gadget : InstanceCounter<Gadget> {};
// Widget::count 与 Gadget::count 各自独立
每个 Derived 拥有独立的 count,因为 InstanceCounter<Widget> 和 InstanceCounter<Gadget> 是不同类。
5. CRTP vs 虚函数:如何选?
| 维度 | CRTP(编译期多态) | 虚函数(运行时多态) |
|---|---|---|
| 类型何时确定 | 编译期 | 运行期 |
| 异构容器 | 不直接支持(各派生类无公共基类) | 天然支持(vector<Base*>) |
| 调用开销 | 零(可内联) | 一次间接跳转 |
| 代码膨胀 | 每个 Derived 一份实例化 | 共享一份函数体 |
| 可读性 | 模板重、报错长 | 直观 |
经验法则:
- 需要运行期在同一个容器里混放不同类型 → 虚函数。
- 类型在编译期全部已知、追求零开销、或做 mixin → CRTP。
- 两者可以混用:接口层用虚函数做 type erasure,热路径内部用 CRTP 消除开销。
6. 常见坑与注意事项
6.1 忘记 static_cast 的 const 匹配
cpp
// 错:const 成员函数里 this 是 const,需要 static_cast<const Derived*>
double area() const {
return static_cast<Derived*>(this)->area_impl(); // ❌ 编译失败
}
6.2 派生类忘记实现钩子
CRTP 基类在模板实例化时才检查 Derived 是否有 area_impl()。如果拼写错了或者漏实现,报错信息会指向基类内部,对使用者不友好。可用 Concepts 约束改善(见第 7 节)。
6.3 传错模板参数
cpp
struct A : ShapeBase<A> {};
struct B : ShapeBase<A> {}; // ❌ B 继承了 ShapeBase<A>,static_cast 到 A*,UB
这是拷贝粘贴最常见的 bug。C++23 之前没有语言级防御,只能靠 code review 或 static_assert:
cpp
template <typename Derived>
struct ShapeBase {
ShapeBase() {
static_assert(std::is_base_of_v<ShapeBase, Derived>,
"CRTP misuse: Derived must inherit from ShapeBase<Derived>");
}
};
6.4 不能放入异构容器
ShapeBase<Circle> 和 ShapeBase<Rectangle> 是两个不同的类 ,不能塞进同一个 vector。若需要异构集合,要么加一层 type erasure(std::function、std::variant、或再套一个虚接口),要么回归虚函数。
7. C++20 Concepts 加持:更好的错误提示
cpp
template <typename T>
concept HasAreaImpl = requires(const T& t) {
{ t.area_impl() } -> std::convertible_to<double>;
};
template <HasAreaImpl Derived>
struct ShapeBase {
double area() const {
return static_cast<const Derived*>(this)->area_impl();
}
};
若 Derived 缺少 area_impl(),编译器会在实例化点 直接报「不满足 HasAreaImpl 约束」,而不是在基类深处给出一堆模板替换失败的噪音。
8. C++23 Deducing this:CRTP 的终结者?
C++23 引入了 explicit object parameter (this auto&&),让成员函数通过推导得知「实际调用者的类型」,不再需要 CRTP 的模板继承绕法:
cpp
struct ShapeBase {
// this 的类型由调用者推导
double area(this const auto& self) {
return self.area_impl();
}
void describe(this const auto& self) {
std::cout << "area = " << self.area() << '\n';
}
};
struct Circle : ShapeBase {
double radius;
double area_impl() const { return 3.14159 * radius * radius; }
};
对比 CRTP:
| CRTP | Deducing this |
|
|---|---|---|
| 语法 | class D : Base<D> |
class D : Base |
| 传错参数风险 | 有 | 无 |
| 编译器支持 | C++11 起 | C++23(GCC 14+、Clang 18+、MSVC 19.37+) |
如果你的项目已经能用 C++23,优先用 deducing this------意图更清晰、不用传模板参数、无 CRTP 拼写错误风险。但在 C++11/14/17/20 代码库里,CRTP 仍然是唯一选项。
8.1 Deducing this 能解决异构容器问题吗?
不能。 虽然派生类现在共享同一个 ShapeBase(不像 CRTP 每个派生类对应不同的 Base<D>),但 deducing this 仍然是编译期多态。
你确实可以存:
cpp
std::vector<std::unique_ptr<ShapeBase>> shapes;
shapes.push_back(std::make_unique<Circle>(3.0)); // ✅ 能编译,共享基类
shapes.push_back(std::make_unique<Rectangle>(4.0, 5.0)); // ✅
但调不了多态方法:
cpp
for (auto& s : shapes) {
s->area(); // ❌ self 推导为 const ShapeBase&,找不到 area_impl()
}
原因有二:
this auto&本质是函数模板 ------self的类型由调用点的静态类型 决定。通过ShapeBase*调用时,self只能推导为const ShapeBase&,不可能是Circle&。- 虚函数不能是模板 ------你也不能给 deducing
this成员加virtual,因为 vtable 槽位数量必须编译期确定,而模板可以无限实例化。
cpp
virtual double area(this const auto& self); // ❌ 非法:虚函数不能是模板
本质矛盾:模板需要编译期确定类型来实例化;运行时多态的"类型"恰恰编译期不确定。
8.2 那异构容器到底怎么办?
需要某种形式的运行期间接跳转,三种主流方案:
方案 A:经典虚函数(开放集,堆分配)
cpp
struct Shape {
virtual double area() const = 0;
virtual ~Shape() = default;
};
struct Circle : Shape {
double radius;
explicit Circle(double r) : radius(r) {}
double area() const override { return 3.14159 * radius * radius; }
};
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(3.0));
for (auto& s : shapes) std::cout << s->area() << '\n'; // ✅ 虚分派
方案 B:std::variant + std::visit(闭集,无堆分配)
所有类型编译期已知,但需要在运行期区分是哪一个:
cpp
struct Circle {
double radius;
double area() const { return 3.14159 * radius * radius; }
};
struct Rectangle {
double w, h;
double area() const { return w * h; }
};
using AnyShape = std::variant<Circle, Rectangle>;
std::vector<AnyShape> shapes;
shapes.emplace_back(Circle{3.0});
shapes.emplace_back(Rectangle{4.0, 5.0});
for (auto& s : shapes) {
double a = std::visit([](const auto& x) { return x.area(); }, s);
std::cout << a << '\n';
}
优点:对象内联在 vector 里,无堆分配、缓存友好;缺点:类型集必须编译期封闭,新增类型要改 variant。
方案 C:类型擦除(手写或用库)
把「能算 area()」这个能力擦除到一个固定接口里,内部持有 void* + 函数指针,类似 std::function 的做法:
cpp
class AnyShape {
struct Concept {
virtual double area() const = 0;
virtual ~Concept() = default;
};
template <typename T>
struct Model : Concept {
T obj;
template <typename U>
explicit Model(U&& u) : obj(std::forward<U>(u)) {}
double area() const override { return obj.area(); }
};
std::unique_ptr<Concept> impl_;
public:
template <typename T>
AnyShape(T&& obj) : impl_(std::make_unique<Model<std::decay_t<T>>>(std::forward<T>(obj))) {}
double area() const { return impl_->area(); }
};
std::vector<AnyShape> shapes;
shapes.emplace_back(Circle{3.0});
shapes.emplace_back(Rectangle{4.0, 5.0});
for (auto& s : shapes) std::cout << s.area() << '\n'; // ✅
优点:使用者不需要继承任何基类(duck typing),类型开放;缺点:实现复杂、有堆分配。
8.3 方案选择速查
| 方案 | 类型集 | 堆分配 | 侵入性 | 适用场景 |
|---|---|---|---|---|
| 虚函数 | 开放 | 是 | 需继承 | 通用、类型未来会扩展 |
variant + visit |
封闭 | 否 | 无 | 类型有限、追求性能 |
| 类型擦除 | 开放 | 是 | 无 | 不想强制继承、duck typing |
9. 小结
- CRTP = 派生类把自己作为模板参数传给基类,基类内通过
static_cast<Derived*>(this)在编译期分派。 - 适用于编译期已知全部类型的场景:mixin 注入、运算符生成、实例计数、策略模式等。
- 零虚开销,可内联;但无法做异构容器,模板报错噪音大。
- 配合 Concepts 可改善报错体验;C++23 deducing
this语法更优但仍是编译期分派,不能解决异构容器问题。 - 需要异构容器时:虚函数(开放集)、
variant(闭集零堆分配)、类型擦除(开放集 duck typing)三选一。 - 与虚函数并非互斥:外层用虚函数做类型擦除、内层用 CRTP 做热路径分派,在真实项目中很常见。