【C++】深入浅出,理解 C++ 奇异递归模板模式(CRTP)

作者:PAK向日葵日期:2026/5/30

在现代 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、也不能访问它的成员,但是,你可以定义指向它的指针或引用,也可以把它当作模板参数传进去。

编译器在处理这个地方的代码时,大致发生了如下的事情:

  1. 编译器看到了 class Cat,此时 Cat 这个类型的名字就已经注册在案了。
  2. 编译器接着看到 Animal<Cat>,于是试图去实例化 Animal<Cat> 这个类。
  3. 编译器回头去看 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++ 模板的成员函数只有在被调用时才会进行实例化。如果你仅仅是定义了AnimalCat这两个类,从来没有调用过 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 用于在不同指令类型上执行不同动作,避免用户代码里写大量 castswitch;自定义 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)》 是转载文章,点击查看原文


相关推荐


手写 Mini React:从 JSX 到虚拟 DOM 再到 render,搞懂 React 底层原理
不会敲代码12026/5/8

手写 Mini React:从 JSX 到虚拟 DOM 再到 render,搞懂 React 底层原理 引言:为什么要手写 React? 我日常写 React 组件很熟练: function App() { return ( <div style={{ background: 'salmon' }}> <h1>Hello React</h1> <h2>Hello Didact</h2> </div> ); } 但写完之后脑子里总有几个问题挥之不去


MyBatis-Plus:让数据库操作飞起来的神器
小码哥_常2026/4/28

MyBatis-Plus:让数据库操作飞起来的神器 一、MyBatis-Plus 是什么? 在 Java 开发的世界里,数据库操作是不可或缺的一部分。MyBatis 作为一款优秀的持久层框架,深受开发者喜爱,而 MyBatis-Plus 则是在 MyBatis 基础上诞生的强大增强工具。它就像是给 MyBatis 这位 “武林高手” 配备了一套超级装备,让开发变得更加高效和便捷。 MyBatis-Plus 秉持着 “只做增强不做改变” 的设计理念 ,这意味着当你引入它到项目中时,就像给原有的 M


开源 Wiki 神器 Docmost:团队协作知识库的终极解决方案
修己xj2026/4/20

在团队协作中,文档管理始终是一个让人头疼的问题。传统的文档工具要么功能单一,要么价格昂贵,要么数据不在自己手里。今天,我要向大家推荐一款开源的协作式 Wiki 软件 —— Docmost。 什么是Docmost? Docmost 是一款开源的协作式 Wiki 和文档管理软件,专为团队知识管理而设计。它提供了实时协作、权限管理、空间隔离等企业级功能,同时保持了开源软件的透明性和可控性。 github 地址: github.com/docmost/doc… 文档地址: docmost.com/do


Laravel vs ThinkPHP3.x:现代框架对决
普通网友2026/4/11

好的,我们来比较一下 Laravel 和 ThinkPHP 3.x 这两个 PHP 框架的主要特点和差异。请注意,ThinkPHP 3.x 是一个相对较老的版本(ThinkPHP 已发展至 5.x/6.x/8.x),而 Laravel 则代表了更现代的 PHP 开发框架。 核心架构与设计理念 Laravel 设计哲学: 遵循 SOLID 原则和 DRY(Don't Repeat Yourself)原则。强调优雅、简洁、表达力强的代码。架构: 采用了 MVC(Model-View-Co


如何使用edge浏览器打开控制台?
宇宙计算机2026/4/3

如何使用edge浏览器打开控制台? 我们在edge浏览器中,点击F12,打开检查页面。(或者在页面上,鼠标右键 -> 工具) 但是我们点开检查页面以后,有的时候我们的屏幕过小,上面没有中文,我们不知道哪一个才是控制台。 那看下图,下图这个图标就是控制台。 然后我们就能在控制台的右下角键入JavaScript代码了


Linux 基础指令与权限管理完全指南
凤年徐2026/3/26

Linux 基础指令与权限管理完全指南 前言 在开始Linux系统编程之前,掌握常用的Linux命令和权限管理是必不可少的一步。本文将从零开始,带你熟悉Linux环境下的基本操作,包括文件管理、目录操作、文本查看、权限设置等核心内容。无论你是刚接触Linux的新手,还是希望系统梳理知识的老手,这篇文章都能为你提供清晰的指引。 思考:为什么生产环境中很少使用Linux的图形界面?因为图形界面会占用大量系统资源,且远程管理时效率低下,命令行才是Linux的精髓。 一、环境准备与远程登录 1. 查


mcp学习笔记(三)-Mcp传输协议代码示例
Shawn_Shawn2026/3/18

一、三种传输方式详解 1. Stdio 传输 1.1 原理说明 Stdio(标准输入输出)传输是最简单的传输方式。MCP Client 将Server 作为 子进程 启动,通过进程的 stdin 和 stdout 进行双向通信。 关键规则: 每条JSON-RPC 消息以 换行符 \n 分隔 stdout 专用于协议消息,日志/调试信息必须输出到 stderr Client 与 Server 一对一绑定,生命周期同步 1.2 原理图(draw.io) 1.3 交互流程图(draw.io 时


奔图M6200-6500系列打印机拆解教程?
喝酒没肉不香2026/3/9

1.后盖 取下 1 处两个螺丝,撬开 2 的卡爪,取下后盖。 2.左盖 拆下后盖后,撬开 1 处两个卡爪,取下左盖。 3.右盖 拆下后盖后,撬开 1、2 处 6 个卡爪,取下右盖。 4.扫描台 4.1拆下左盖后,取下 1 处的螺丝,拔除 2 处的三根连接线。 4.2取下 1 处的卡环,拔除支撑杆固定销,取下扫描台。 5.引擎板 拆除右盖后,拔除 1 处,取下 2 处螺丝,取下引擎板。 7.高压电源板 7.1取下引擎板后,取下


OpenClaw安装技能的三种方式
可夫小子2026/3/1

OpenClaw安装技能的三种方式 💡 大家好,我是可夫小子,《小白玩转ChatGPT》专栏作者,关注AI编程、AI自动化和自媒体。 OpenClaw很强大,但也需要搭配很多技能才能发挥最大的作用,它的安装方式没有像Claude Code那样复杂,但也还有一些操作过程。在OpenClaw里面,可以通过WebUI的界面,很直观的管理安装的技能。 但如果安装技能呢?今天分享常用的三种方式。 通过官方openclaw命令来配置 通过openclaw config 命令,选择Local→Ski


基于 YOLOv8 的水体污染目标检测系统 [目标检测完整源码]
我是杰尼2026/2/21

基于 YOLOv8 的水体污染目标检测系统 [目标检测完整源码] 一、背景:水体监控为什么需要“计算机视觉”? 在水资源保护与环境治理领域,“看得见问题”往往是治理的第一步。然而在真实场景中,水体监控长期面临以下现实挑战: 水域面积大、人工巡检成本高 污染物种类多、形态变化大 人工判读主观性强、难以量化 传统传感器难以识别“视觉型污染” 随着无人机、高清摄像头、遥感设备的普及,水体数据获取已不再是瓶颈,真正的难点转移到了如何从海量影像中自动识别污染风险。 基于此,本文介绍一套 以 YOLOv

首页编辑器站点地图

本站内容在 CC BY-SA 4.0 协议下发布

Copyright © 2026 聚合阅读