1. 什么是 CRTP

CRTP 的全称是 Curiously Recurring Template Pattern,中文叫"奇异递归模板模式":派生类把自己作为模板参数传给基类

template <class D>
struct Shape {
    double area() const {
        return static_cast<const D*>(this)->area_impl();   // 编译期就知道 D 是谁
    }
};

struct Circle : Shape<Circle> {          // 把自己传给基类
    double r;
    double area_impl() const { return 3.14159 * r * r; }
};

template <class D>
double twice(const Shape<D>& s) { return 2 * s.area(); }   // 静态多态

基类在编译期就知道派生类的确切类型,可以直接 static_cast 过去调用派生类的函数。这样得到的是静态多态:没有虚函数表,没有间接调用,调用可以被内联。

虚函数(动态多态) CRTP(静态多态)
分派时机 运行时,查 vtable 编译期
开销 一次间接调用,通常无法内联;每个对象多一个 vptr 零开销,可以内联
放进同一个容器 可以,std::vector<Base*> 不行,Shape<Circle>Shape<Square> 是两个不相关的类型
类型集合 运行时可以扩展(比如加载插件) 编译期固定

常见用途:

  • 静态多态:性能敏感、类型在编译期已知的场景,比如 Eigen 的表达式模板。
  • Mixin(给派生类注入功能)
    • std::enable_shared_from_this<T>
    • std::ranges::view_interface<D>:只要实现 begin/end,就能白得 empty()front()
    • 只写 <== 就生成全部比较运算符(如 Boost.Operators)
    • 按类型统计实例数

常见坑:

  • 模板参数写错:比如 struct B : Shape<A>,这时 static_cast 是未定义行为。防法是把基类的构造函数设为 private,再加上 friend D;,写错参数的派生类就无法构造。
  • 派生类忘了实现 area_impl:如果基类里的函数名和派生类实现的函数名相同,派生类没实现时基类会调用到自己,形成无限递归。所以通常给两边起不同的名字,比如 areaarea_impl

C++23 的 deducing this(显式对象参数)让很多 CRTP 写法不再需要模板基类:

struct Shape {
    template <class Self>
    double area(this const Self& self) { return self.area_impl(); }   // Self 推导为 Circle
};
struct Circle : Shape { double r; double area_impl() const { return 3.14159 * r * r; } };

CRTP 和 PIMPL 对比

这两个模式解决的不是同一类问题,放在一起比,最有用的一点是:它们对编译器的态度正好相反。

  • CRTP:把尽可能多的信息暴露给编译器,派生类的类型在编译期就确定,换来零开销的静态分派和内联。
  • PIMPL:把尽可能多的信息对编译器藏起来,头文件里只留一个指针,换来编译隔离和 ABI 稳定。
CRTP PIMPL
全称 Curiously Recurring Template Pattern Pointer to IMPLementation
解决的问题 静态多态、复用代码而不付虚函数的开销 编译防火墙、ABI 稳定、隐藏实现细节
机制 struct D : Base<D>,基类 static_cast<D*>(this) 类里只有 std::unique_ptr<Impl>Impl 只在 .cpp 里定义
实现放在哪 全在头文件(模板) 在 .cpp 里
运行时开销 零,可以内联 每个对象一次堆分配;每次调用多一次指针间接;跨编译单元无法内联
编译依赖 头文件一改,所有使用者都要重编;模板实例化拖慢编译 改私有成员不影响使用者,只需重编 .cpp
ABI 不稳定,布局随实现变化 稳定,sizeof 始终是一个指针
对象大小 由成员决定 固定 8 字节(x86_64 上的一个指针)
典型应用 Eigen 表达式模板、enable_shared_from_thisranges::view_interface Qt 的 d-pointer、库的公开接口类、不把 <windows.h> 这类重型头文件泄漏给使用者

PIMPL 的写法和坑

// widget.h —— 使用者只看到这些
class Widget {
public:
    Widget();
    ~Widget();                                  // 必须只声明,在 .cpp 里定义
    Widget(Widget&&) noexcept;
    Widget& operator=(Widget&&) noexcept;
    void draw();
private:
    struct Impl;                                // 前向声明,不完整类型
    std::unique_ptr<Impl> p_;
};

// widget.cpp
struct Widget::Impl { std::vector<int> data; void draw(); };
Widget::Widget() : p_(std::make_unique<Impl>()) {}
Widget::~Widget() = default;                    // 在这里 Impl 已经是完整类型
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;
void Widget::draw() { p_->draw(); }
  1. 析构函数必须在 .cpp 里定义。 如果在头文件里隐式生成析构函数,unique_ptr 的删除器会遇到不完整的 Impl,编译报错。移动操作同理。
  2. 拷贝要自己写,并且是深拷贝:p_(std::make_unique<Impl>(*o.p_))
  3. const 不会传递:在 const 成员函数里,p_-> 拿到的仍是非 const 的 Impl,可以改它。需要的话可以用 std::experimental::propagate_const
  4. 被移动后的对象里 p_ 为空:再调用它的成员函数会解引用空指针。

CRTP 的坑

回顾一下:

  • 模板参数写错:比如 struct B : Base<A>,这时 static_cast 是未定义行为。防法是把基类构造函数设为 private,再加 friend D;
  • 放不进同一个容器Base<A>Base<B> 是不同的类型。
  • 编译开销:所有代码都在头文件里,每种实例化都会膨胀代码,编译错误信息也长。

各自的替代方案

想要的效果 做法
隐藏实现 PIMPL,或者抽象接口 + 工厂函数(纯虚基类,std::unique_ptr<IWidget> make_widget())。后者同样能隔离编译,代价是虚函数调用
避免 PIMPL 的堆分配 Fast PIMPL:在类里放一块对齐的字节缓冲区,用 placement new 构造 Impl,并用 static_assert 检查大小和对齐。代价是头文件里要写死大小
多态 虚函数(运行时可扩展)、CRTP(编译期固定,零开销)、std::variantstd::visit(类型集合封闭、值语义)、C++23 deducing this(取代部分 CRTP 写法)

面试里可以这样总结:CRTP 用编译期信息换运行时性能,PIMPL 用运行时的一次间接换编译隔离和 ABI 稳定。 性能敏感的内部代码适合 CRTP,对外发布的库接口适合 PIMPL。