CRTP 原理:基类怎么“知道“它的子类 CRTP 原理基类怎么“知道“它的子类CRTPCuriously Recurring Template Pattern奇异递归模板模式是 C 用模板实现编译期多态的经典手法。它让基类在编译期就知道子类是谁从而直接调用子类方法、直接内联不查虚表、不留 vptr代价从运行期搬到了编译期。一个真实落地是嵌入式框架 coactgithub.com/DeguiLiu/coact第五节以它的状态机为例。它解决了什么普通继承里基类不知道子类是谁。想让基类调用子类方法只能写虚函数运行期查 vtable 跳转。CRTP 换一条路把子类名当模板参数传给基类让基类在编译期认领自己的子类。虚函数和 CRTP 解决的是同一个问题基类代码里要调用由子类决定的那部分逻辑。差别只在于这一步发生在运行期还是编译期。两者都能让基类定义骨架、子类填充细节但对运行期的影响完全不同。维度虚函数运行期多态CRTP编译期多态分发机制运行期查 vtable编译期static_cast每对象开销一个 vptr零内联通常不能能类型错误运行期暴露编译期暴露最小示例与三条原理先看一个最小例子后面三条原理都围绕它展开。templatetypenameDstructBase{voidrun(){static_castD*(this)-do_work();}};structWorker:BaseWorker{voiddo_work(){/* 具体逻辑 */}};Worker : BaseWorker是奇异的地方子类继承一个以自己为参数的基类看起来像循环引用。基类里的static_castD*(this)在编译期就定死了目标类型。奇异递归四个字描述的正是这个写法本身模板类BaseD出现在自己的继承列表里Worker : BaseWorkerWorker的定义又依赖Base两者互相引用形成一层看似循环的递归。1995 年 Coplien 提出这个名字指的就是这种子类继承以自己为参数的基类的反常形态。它并不是真的递归模板实例化在编译期展开一次就结束不会无限套娃。模板按需实例化写Worker : BaseWorker时编译器把BaseWorker当成具体类型实例化模板参数D被替换成Worker。于是基类代码里的D就是Worker基类知道了子类。这是最关键的一步模板在编译期生成代码不走运行期的类型擦除。每换一个子类编译器就生成一份针对它的基类代码。成员函数延迟实例化BaseD里用到D的代码static_castD*(this)-do_work()要等到真正调用run()时才实例化定义基类时并不检查。那时Worker已完整定义、do_work()存在所以能编译通过。C 模板的成员函数是用到才实例化的。正因为run()在Worker定义之后才被调用编译器那时已经看得到Worker::do_work这一步才不会因为类型未完整而报错。静态向下转型this的类型是BaseWorker*但它实际指向的一定是Worker因为Worker继承了BaseWorker。static_castD*(this)就是static_castWorker*(this)是编译期就确定的合法转换不需要运行期类型检查。static_cast在这里是安全的下转编译器能证明this一定指向Worker所以它敢直接转换不必像dynamic_cast那样运行期查 RTTI。和虚函数的差别调用 run()虚函数查 vtable间接跳转CRTPstatic_cast直接内联每个对象一个 vptr编译器看不到目标零 vptr零间接编译期定死目标虚函数在每个对象里存一个 vptr运行期通过它查表跳转编译器看不到目标、无法内联。CRTP 在编译期就把调谁写死编译器直接内联Worker::do_work不查表、不留 vptr。两者的本质差别是类型信息放在哪虚函数把类型信息放在对象里vptr运行期读CRTP 把类型信息放在类型系统里模板参数编译期用。放在类型系统里编译器就能在生成代码时把一切定死。误区、代价与边界CRTP 不是万能的多态替代有几处用起来容易踩坑。不能装进统一容器。BaseWorkerA和BaseWorkerB是两个不同的类型没有共同基类所以写不了std::vectorBase*这样的异构容器。要运行期混装不同子类还是得回到虚函数。构造与析构期间不能调派生方法。基类构造函数执行时派生部分还没构造完此时static_castD*(this)后调用派生方法读到的是未初始化状态。派生析构完成后基类析构里再调派生方法同样是悬空。基类不能持有派生专属状态。状态应该归派生类基类只放与骨架流程相关的公共部分否则基类和派生耦合过死换一个子类就要动基类。收益和代价并存零运行期开销、可内联、类型安全、错误在编译期暴露换来的成本是编译变慢、报错变长每个子类还会实例化出一份基类代码二进制可能变大。所以它适合需要多态但付不起虚函数开销的场景比如 MCU、热路径不适合运行期才知道具体类型的场景也就是上面第一条不能装进统一容器的情况。落地形态与选型coact 是一个面向 RT-Thread 单核 MCU 的 header-first C17 事件框架代码在 github.com/DeguiLiu/coact。它的状态机HsmContext是骨架加钩子在嵌入式里的真实落地。coact 用的绑定方式与上面BaseDerived的经典写法不同它把Context当模板参数钩子用函数指针承载签名在编译期与Context绑定。目的和 CRTP 一样编译期绑定、零虚表但绑定媒介是函数指针表而非static_castDerived*(this)templatetypenameContextstructStateDef{int8_tparent;// 父状态索引void(*entry)(Context);// 钩子进入动作void(*exit)(Context);// 钩子退出动作constchar*name;int8_tinitial_child;};templatetypenameContextstructTransitionDef{int8_tsource;uint16_tsignal;int8_ttarget;TransitionKind kind;// Internal / Self / Externalbool(*guard)(constContext,constEvent);// 纯函数守卫void(*action)(Context,constEvent);// 转移动作};templatetypenameContextclassHsm{public:booldispatch(Contextctx,constEventevt)noexcept;};为什么嵌入式要这样写MCU 上-fno-rtti、业务零堆、Cortex-M 单核虚函数的 vptr 和间接跳转都付不起。HsmContext把状态机派发从活动叶子向上找转移、父状态继承、退出与进入路径做成固定骨架把 entry/exit/guard/action 四个钩子交给Context编译期就把调用目标定死、直接内联。同一套框架里这套手法还和另外两种编译期手段配合无状态策略EventPoolProfile选单核或 SMP 后端和 Traits 注入Ao的优先级与预算属性细节见同目录的编译期多态一文。CRTP 家族在工程里有三种常见形态按差异点粒度递减形态写法适合coact 落点经典 CRTPBaseDerived加static_cast基类持状态多钩子-模板参数绑定HsmContext函数指针钩子骨架加少量钩子HsmContext无状态策略struct Policy { static apply(); }单点算法替换EventPoolProfile三者差别在差异点粒度经典 CRTP 让基类持有派生状态和钩子适合流程里的多个钩子模板参数绑定把钩子做成函数指针表、基类不持派生状态适合状态机骨架无状态策略只剩一个静态算法函数适合单点算法。三者服务同一件事把差异钉在编译期避免虚表和堆。coact 的完整实现和选择理由都在 github.com/DeguiLiu/coact。