在现代 C++ 中,除了基于虚函数的运行时多态,还有一种被称为"奇异递归模板模式"(Curiously Recurring Template Pattern,CRTP)的静态多态(调用目标在编译期确定),被广泛应用于 Chromium、V8、LLVM、UE 等大型项目当中。
1. 一个具体的例子
下面先给出一个我们最熟悉不过的基于虚函数的运行时多态的例子:
1#include <iostream> 2 3class Animal { 4 public: 5 virtual void Speak() = 0; 6}; 7 8class Cat : public Animal { 9public: 10 void Speak() override { 11 std::cout << "meow" << std::endl; 12 } 13}; 14 15class Dog : public Animal { 16 public: 17 void Speak() override { 18 std::cout << "woof" << std::endl; 19 } 20}; 21 22int main() { 23 auto* dog = new Dog(); 24 auto* cat = new Cat(); 25 dog->Speak(); 26 cat->Speak(); 27} 28
很显然,在编译器无法对目标函数去虚化时,调用虚函数是需要付出查vtable等额外开销的:
下面我们再来看怎么用 CRTP 重构上面的代码:
1#include <iostream> 2 3template <typename D> 4class Animal { 5 public: 6 void Speak() { 7 // 将 this 指针显式下行转换为派生类类型, 8 // 从而调用派生类的 SpeakImpl() 方法。 9 static_cast<D*>(this)->SpeakImpl(); 10 } 11}; 12 13class Cat : public Animal<Cat> { 14 public: 15 void SpeakImpl() { 16 std::cout << "meow" << std::endl; 17 } 18}; 19 20class Dog : public Animal<Dog> { 21public: 22 void SpeakImpl() { 23 std::cout << "woof" << std::endl; 24 } 25}; 26 27int main() { 28 auto* dog = new Dog(); 29 auto* cat = new Cat(); 30 dog->Speak(); 31 cat->Speak(); 32} 33
分析这段代码的反汇编代码(debug版本),可以看到在编译阶段会为Animal<Cat>和Animal<Dog>分别生成一个 C++ 函数,而在执行 dog->Speak() 和 cat->Speak() 时,只需直接分别调用Animal<Cat>::Speak()和Animal<Dog>::Speak()即可。也就是说整个代码的行为在编译阶段是可以被完全确定下来的。
由于 CRTP 写法中代码的行为并不是在运行时才确定的,因此编译器在 release 模式下可以很容易地直接对 dog->Speak() 和 cat->Speak() 这两处操作进行内联展开:
2. 为啥 CRTP 的代码可以通过编译
初学 CRTP 时,很多同学会有一个疑问:
1class Cat : public Animal<Cat> { 2... 3}; 4
在这个地方,类Cat还没完成定义,为什么就可以让它直接依赖Animal<Cat>呢?
首先,这些同学的困惑并不是没有道理的:在编译器眼中,类在定义完成之前,是一个不完整类型(Incomplete Type)。
然而需要指出的是,对于不完整类型,你不能用它来声明变量、不能求它的 sizeof、也不能访问它的成员,但是,你可以定义指向它的指针或引用,也可以把它当作模板参数传进去。
编译器在处理这个地方的代码时,大致发生了如下的事情:
- 编译器看到了
class Cat,此时Cat这个类型的名字就已经注册在案了。 - 编译器接着看到
Animal<Cat>,于是试图去实例化Animal<Cat>这个类。 - 编译器回头去看
Animal模板的定义,发现Animal内部:- 没有定义
Cat的成员变量(不需要知道Cat的大小)。 - 没有调用
Cat的构造/析构函数。 - 唯一用到
Cat的地方是Cat*(指针)。
- 没有定义
在特定的系统架构下,任何指针的大小都是固定的(例如 64 位系统下是 8 字节)。编译器不需要知道 Derived 里面有什么,就能轻松算出来 Animal<Cat> 的大小和内存布局。
因此,编译器愉快地完成了 Animal<Cat> 的实例化,并将其作为 Cat 的基类。
另外,可能还会有同学进一步追问下面的这个问题:
1template <typename D> 2class Animal { 3 public: 4 void Speak() { 5 static_cast<D*>(this)->SpeakImpl(); 6 } 7}; 8
在编译器实例化Animal<Cat>时,Animal<Cat> 里明明用了 Cat 的方法(Cat::SpeakImpl()),但这个时候类Cat明明还未完成定义(换句话说,编译器压根不知道是否存在Cat::SpeakImpl()方法),为什么编译器不会停下来报错?
这就是 C++ 模板的 懒惰特性(Lazy) 和 两阶段查找(Two-Phase Lookup) 在起作用了
- 第一阶段(模板定义时): 编译器看到
Animal模板,由于D是一个未知的模板参数,编译器只做最基础的语法检查(比如括号有没有闭合、分号有没有漏掉)。它现在不会,也没办法去检查D内部有没有SpeakImpl()函数。 - 第二阶段(模板函数实例化时):C++ 模板的成员函数只有在被调用时才会进行实例化。如果你仅仅是定义了
Animal和Cat这两个类,从来没有调用过Animal<Cat>::Speak(),编译器甚至永远都不会去生成Animal<Cat>::Speak()内部的代码。
当你真正去调用它时:
1auto* cat = new Cat(); 2cat->Speak(); // 编译器看到有代码调用 Animal<Cat>::Speak(),此时才会被动进行这个函数的实例化 3
在这个时间点,Cat 类的整个大括号已经闭合,它的定义已经彻底完整了。编译器回头一查,发现 Cat 确实有 Speak() 函数,于是编译通过!
3. CRTP 的局限性
由于 CRTP 的类型关系在编译期确定,因此不适合需要运行时动态分派的场景。
比如下面这个例子:
1void LetAllAnimalsSpeak(const std::vector<Animal*>& animals) { 2 for (const auto& animal : animals) { 3 animal->Speak(); 4 } 5} 6 7int main() { 8 std::vector<Animal*> animals; 9 animals.emplace_back(new Dog()); 10 animals.emplace_back(new Cat()); 11 LetAllAnimalsSpeak(animals); 12} 13
由于在编译阶段我们只知道 LetAllAnimalsSpeak() 接收到的对象都是 Animal 的派生类,因此其中的 animal->Speak() 操作只能在运行时通过基于虚函数的多态动态完成派发。这种场景下 CRTP 就没有用武之地了。
4. CRTP 在工业级项目中的实践
4.1. Chromium base::RefCounted<T>:侵入式引用计数
这是 Chromium 里非常经典的 CRTP 用法。
典型写法:
1class MyFoo : public base::RefCounted<MyFoo> { 2 private: 3 friend class base::RefCounted<MyFoo>; 4 ~MyFoo(); 5}; 6
Chromium 的 RefCounted<T> 文档示例正是这种形式,并要求析构函数非 public,避免外部在仍有引用时误删对象;同时要求把 base::RefCounted<MyFoo> 声明为 friend,让引用计数归零时由基类负责销毁对象。
核心逻辑大致是:
1template <class T> 2class RefCounted : public subtle::RefCountedBase { 3 public: 4 void AddRef() const { 5 subtle::RefCountedBase::AddRef(); 6 } 7 8 void Release() const { 9 if (subtle::RefCountedBase::Release()) { 10 Traits::Destruct(static_cast<const T*>(this)); 11 } 12 } 13}; 14
关键点在这里:
1static_cast<const T*>(this) 2
Traits::Destruct(static_cast<const T*>(this))这行代码表明,通过传入的模板参数,RefCounted<MyFoo> 在引用数归零时知道真正要删除的是一个 MyFoo 类型的对象。
它解决的问题是:
把引用计数逻辑统一写在基类里,但删除对象时仍然使用派生类的真实类型。
这类 CRTP 的重点不是“多态调用”,而是 基类获得派生类类型信息。
4.2. V8 ParserBase<Impl>:Parser / PreParser 共用语法逻辑
V8 的 JavaScript 解析器里有一个非常经典的 CRTP:ParserBase<Impl>:
1template <typename Impl> 2class ParserBase { 3 public: 4 Impl* impl() { 5 return static_cast<Impl*>(this); 6 } 7 const Impl* impl() const { 8 return static_cast<const Impl*>(this); 9 } 10 ... 11}; 12 13class Parser : public ParserBase<Parser> { 14 friend class ParserBase<Parser>; 15 ... 16}; 17 18class PreParser : public ParserBase<PreParser> { 19 friend class ParserBase<PreParser>; 20 ... 21}; 22
ParserBase 中的 Impl 代表实际的 parser 或 pre-parser 类,遵循 CRTP。
很容易理解,ParserBase 负责“纯解析逻辑”,而继承自 ParserBase 的具体实现类负责 AST 生成、早期错误检测、预解析等差异行为。
这意味着 V8 可以把 ECMAScript 语法递归下降解析流程写一份,同时让 Parser 和 PreParser 在编译期替换不同的数据结构和行为。
如果用虚函数做这件事,解析器内部大量小函数调用会产生运行时分派成本,也更难内联。CRTP 让解析器主流程共享,同时让具体行为在编译期确定。
4.3. V8 ElementsAccessorBase<Subclass, Traits>:数组元素访问器优化
这是 V8 里非常“性能导向”的 CRTP。
V8 的 src/elements.cc 中有:
1// CRTP to guarantee aggressive compile time optimizations (i.e. inlining and 2// specialization of SomeElementsAccessor methods). 3template <typename Subclass, typename ElementsTraitsParam> 4class ElementsAccessorBase : public ElementsAccessor { 5 // ... 6}; 7
这里的源码注释明确说:此处的 CRTP 用来保证 aggressive compile-time optimizations,也就是更激进的编译期优化,包括内联和特化具体 SomeElementsAccessor 方法。
V8 中 JavaScript 数组有很多元素种类,例如:
- packed smi elements
- holey smi elements
- packed object elements
- double elements
- dictionary elements
- typed array elements
这些元素类型有大量共同逻辑,但又有局部差异。V8 用 CRTP 写成类似:
1class FastPackedSmiElementsAccessor 2 : public ElementsAccessorBase< 3 FastPackedSmiElementsAccessor, 4 ElementsKindTraits<PACKED_SMI_ELEMENTS>> { 5 // ... 6}; 7
基类 ElementsAccessorBase<Subclass, ElementsTraitsParam> 的方法中会这样调用子类特化逻辑:
1Subclass::HasElementImpl(...); 2Subclass::CopyElementsImpl(...); 3Subclass::GetImpl(...); 4Subclass::SetImpl(...); 5
这个应用非常典型:同一套数组操作框架,针对不同元素布局生成不同机器码,避免虚调用,并让热点路径充分内联。
这比单纯的教学例子更能体现 CRTP 的工业价值。
4.4. LLVM InstVisitor<SubClass, RetTy>
这是 LLVM IR 分析里最经典的 CRTP 之一。
典型写法:
1struct CountAllocaVisitor 2 : public llvm::InstVisitor<CountAllocaVisitor> { 3 unsigned Count = 0; 4 5 void visitAllocaInst(llvm::AllocaInst &AI) { 6 ++Count; 7 } 8}; 9
InstVisitor 用于在不同指令类型上执行不同动作,避免用户代码里写大量 cast 和 switch;自定义 visitor 时,需要让自己的类继承 InstVisitor。
它内部的分发核心类似:
1#define DELEGATE(CLASS_TO_VISIT) \ 2 return static_cast<SubClass*>(this)-> \ 3 visit##CLASS_TO_VISIT(static_cast<CLASS_TO_VISIT&>(I)) 4
也就是说,基类 InstVisitor<SubClass> 根据 LLVM IR 指令类型做统一分发,然后通过 static_cast<SubClass*>(this) 调用用户 visitor 中的 visitXXX。LLVM 源码注释还明确说,这个类设计成模板是为了避免虚函数调用开销,效率接近自己手写 opcode switch。
工程意义:
LLVM IR pass 经常要遍历大量指令,
InstVisitor让用户写出面向类型的访问逻辑,同时保持接近手写 switch 的性能。
4.5. UE TCommands<CommandContextType>:Editor 命令系统
这是 UE Editor 扩展里非常常见的 CRTP。
TCommands是“一组命令的基类”,用户通过继承它来定义自己的命令集合。它还提供静态函数 Get()、Register()、Unregister() 等。下面是一个具体的例子:
1class FMyEditorCommands 2 : public TCommands<FMyEditorCommands> { 3public: 4 FMyEditorCommands() 5 : TCommands<FMyEditorCommands>( 6 TEXT("MyEditorCommands"), 7 NSLOCTEXT("Contexts", "MyEditorCommands", "My Editor Commands"), 8 NAME_None, 9 FAppStyle::GetAppStyleSetName()) {} 10 11 virtual void RegisterCommands() override; 12 13 TSharedPtr<FUICommandInfo> MyCommand; 14}; 15
TCommands<CommandContextType> 的 CRTP 价值在于:基类 TCommands 能为每个具体命令集合维护独立的静态 singleton、注册状态和 binding context。
这类代码通常有这样的接口:
1FMyEditorCommands::Register(); 2const FMyEditorCommands& Commands = FMyEditorCommands::Get(); 3
如果不用 CRTP,而是只用普通基类,就很难让 Get() 静态返回“具体命令类”的引用。
《【C++】深入浅出,理解 C++ 奇异递归模板模式(CRTP)》 是转载文章,点击查看原文。