|
|
谈 CRTP(Curiously Recurring Template Pattern)的时候,我更愿意把它当作“把接口的控制权交回给编译器”的一种设计思路。C++ 的动态多态用虚函数,运行期开销和对象布局都比较稳定;而 CRTP 的“静态多态”则把分发搬到编译期,通过模板实例化和内联换来零开销抽象。这不是银弹,但在性能敏感、对象数量巨大或需要跨内核边界内联的场景,确实好用。
CRTP 的基本写法是 Base 模板拿到派生类 Derived 的类型参数,Base 里把操作转发给 static_cast(*this)。最常见的例子是用它做策略混入:比如一个通用的数值迭代器、几何库里的向量算子、或状态机的事件处理。优点很直接:没有虚表,没有间接跳转,编译器能看到最终实现,进行跨函数内联和常量传播。像 Eigen、range-v3、fmt 的一些内部机制,都用到了类似思路;想系统看,可以翻 Bjarne 的“Zero-overhead principle”理念,网上也有不少实践文章,像 https://foonathan.net/blog/crtp/ 讲得通透。
实际落地我踩过几个点。第一是“接口稳定性”问题:CRTP 把多态绑定在模板参数上,接口改动会引发模板重编译和错误瀑布。解决思路是给 Base 设计尽量小的“期待接口”(expected interface),只依赖 Derived 的少数成员,其他功能放自由函数或独立策略模板;同时用 concepts/requires 对期望接口加约束,错误信息可读很多。第二是“可替换性”问题:动态多态可以用基类指针装不同实现,CRTP 不能直接这么玩。工程里常见做法是“外静内动”或“外动内静”:要么外面放 std::variant/std::function 做一次性态分发,内部每个分支用 CRTP 压榨性能;要么定义一个小的 type-erasure 包装(类似 llvm::Any),把静态实现擦除给动态端使用。
再说可测试性和编译时间。CRTP 模板一多,二次编译成本上升、链接体积变大是常态。我的取舍是:把热路径做成 CRTP(数据结构的热操作、 tight loop 的算子),冷路径保留虚接口或简单组合;同时把模板实现放到最小可见单元,减少包含链条。另外,配合 LTO/ThinLTO 能放大静态多态的收益,特别是跨模块内联。
一个常被忽略的优势是“静态协议检查”。CRTP 让“接口不满足就编译不过”,相比运行时断言更早暴露错误。C++20 后可以写成 template concept X = requires(D d){ d.step(); d.done(); }; 然后 Base 处加约束,Derived 一旦漏实现,错误信息位置更友好。配合 SFINAE 或 if constexpr,还能在 Base 里根据 Derived 能力启用/禁用特性,生成不同的最优代码路径,避免虚拟机式的状态开销。
当然,也有不该用 CRTP 的时候。比如需要 ABI 稳定插件、跨语言边界调用、二进制分发库且要隐藏实现细节,这些都更适合虚接口或 pImpl。还有团队熟练度问题:CRTP 抽象层多了之后,可读性会迅速下降,新同事跟栈轨迹时会怀疑人生。这里我会坚持两条纪律:为 Base 写清楚“派生类需要提供哪些成员”的注释或文档测试;为每个混入模板写独立示例,避免把多种职责塞进一个 CRTP 基类。
最后给一个判断题:当你需要“像模板一样快、像继承一样组织代码”,并且实现集合是封闭的(不会在第三方动态扩展),CRTP 值得优先考虑;当你更看重可替换性、二进制稳定和团队可读性,虚函数老老实实更省心。工程在变,策略别僵化,热路径用静态多态抠性能,边界处留一点动态弹性,这往往是最稳妥的组合拳。 |
|