CRTP和PIMPL
1. 什么是 CRTP
CRTP 的全称是 Curiously Recurring Template Pattern,中文叫"奇异递归模板模式":派生类把自己作为模板参数传给基类。
;
;
double // 静态多态
基类在编译期就知道派生类的确切类型,可以直接 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:如果基类里的函数名和派生类实现的函数名相同,派生类没实现时基类会调用到自己,形成无限递归。所以通常给两边起不同的名字,比如area和area_impl。
C++23 的 deducing this(显式对象参数)让很多 CRTP 写法不再需要模板基类:
;
;
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_this、ranges::view_interface |
Qt 的 d-pointer、库的公开接口类、不把 <windows.h> 这类重型头文件泄漏给使用者 |
PIMPL 的写法和坑
// widget.h —— 使用者只看到这些
;
// widget.cpp
;
:
; // 在这里 Impl 已经是完整类型
;
Widget& ;
void
- 析构函数必须在 .cpp 里定义。 如果在头文件里隐式生成析构函数,
unique_ptr的删除器会遇到不完整的Impl,编译报错。移动操作同理。 - 拷贝要自己写,并且是深拷贝:
p_(std::make_unique<Impl>(*o.p_))。 - const 不会传递:在
const成员函数里,p_->拿到的仍是非 const 的Impl,可以改它。需要的话可以用std::experimental::propagate_const。 - 被移动后的对象里
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::variant 加 std::visit(类型集合封闭、值语义)、C++23 deducing this(取代部分 CRTP 写法) |
面试里可以这样总结:CRTP 用编译期信息换运行时性能,PIMPL 用运行时的一次间接换编译隔离和 ABI 稳定。 性能敏感的内部代码适合 CRTP,对外发布的库接口适合 PIMPL。
暂无评论,欢迎留下第一条评论。