一、CRTP 到底是什么?

CRTP,全称是 Curiously Recurring Template Pattern。中文叫「奇异递归模板模式」。名字听着挺唬人,对吧?

说白了,就是让一个派生类把自己的类型作为模板参数,传给基类。像这样:

template <typename Derived>
class Base {
    // ...
};

class Derived : public Base<Derived> {
    // ...
};

你看,Derived 继承自 Base<Derived>。基类知道派生类的类型。这就是 CRTP 的核心。

我第一次看到这种写法时,心里想的是:「这玩意儿不会死循环吗?」后来才明白,模板是在编译期实例化的,不存在运行时递归。嗯,这里要注意,CRTP 是编译期的技巧,跟运行时多态完全不是一回事。

二、静态多态:没有虚函数的「多态」

传统的运行时多态,靠的是虚函数表。每个对象里藏着一个 vptr,调用虚函数时去查表。这带来了两个开销:

  • 一次间接寻址(查虚表)
  • 虚函数通常不能被内联

CRTP 可以做到类似的效果,但完全不用虚函数。看个例子:

template <typename Derived>
class Shape {
public:
    void draw() const {
        static_cast<const Derived*>(this)->drawImpl();
    }
};

class Circle : public Shape<Circle> {
public:
    void drawImpl() const {
        std::cout << "绘制圆形" << std::endl;
    }
};

class Square : public Shape<Square> {
public:
    void drawImpl() const {
        std::cout << "绘制正方形" << std::endl;
    }
};

template <typename T>
void render(const Shape<T>& shape) {
    shape.draw();  // 编译期确定调用哪个 drawImpl
}

这里 Shape::draw() 通过 static_castthis 转成派生类指针,然后调用 drawImpl()。整个过程没有虚函数,没有运行时开销。

关键区别:运行时多态是「同一个接口,多种实现,运行时决定」。CRTP 是「同一个接口,多种实现,编译期决定」。

我个人的习惯是:如果多态的类型集合在编译期就完全确定,而且对性能有要求,我会优先考虑 CRTP。比如游戏引擎中的组件系统、图形学中的几何处理,这些场景下虚函数的开销有时候真的不能忍。

三、代码复用的高级技巧

CRTP 另一个大用处是代码复用。你想想看,很多类之间会有相同的功能,比如「对象计数」、「单例模式」、「运算符重载」等等。用 CRTP 可以把这些通用逻辑抽到基类里。

3.1 对象计数

template <typename Derived>
class ObjectCounter {
public:
    static size_t aliveCount() { return count; }

protected:
    ObjectCounter() { ++count; }
    ObjectCounter(const ObjectCounter&) { ++count; }
    ~ObjectCounter() { --count; }

private:
    static size_t count;
};

template <typename Derived>
size_t ObjectCounter<Derived>::count = 0;

class MyClass : public ObjectCounter<MyClass> {
    // 自己的逻辑
};

每个派生类都有自己的 count 静态成员。因为模板参数不同,ObjectCounter<MyClass>ObjectCounter<YourClass> 是完全不同的类型。这个技巧我在做内存池调试时用过,用来追踪某个类的对象数量,排查内存泄漏。

3.2 运算符重载的自动生成

我记得有一次写一个数值类型库,要为几十种类型重载 ==!=<> 等运算符。写到手软。后来用 CRTP 一劳永逸:

template <typename Derived>
class Comparable {
public:
    bool operator!=(const Derived& other) const {
        return !static_cast<const Derived*>(this)->operator==(other);
    }
    bool operator>(const Derived& other) const {
        return other < static_cast<const Derived*>(*this);
    }
    // 其他运算符类似...
};

class Rational : public Comparable<Rational> {
public:
    bool operator==(const Rational& other) const { /* ... */ }
    bool operator<(const Rational& other) const { /* ... */ }
};

派生类只需要实现 ==<,剩下的 !=>>=<= 都由基类自动生成。这就是 CRTP 的威力——把重复劳动降到最低。

四、CRTP 的避坑指南

我曾经踩过的坑:

  • 派生类没有实现接口:如果 static_cast 调用的函数在派生类中不存在,编译错误会非常晦涩。建议在基类中用 static_assert 做一些编译期检查。
  • 基类析构函数不是虚函数:CRTP 基类通常不应该有虚函数。但如果你通过基类指针删除派生类对象,行为是未定义的。我的建议是:永远不要用 CRTP 基类的指针来管理派生类对象的生命周期。
  • 模板代码膨胀:每个不同的模板参数都会实例化一份基类代码。如果基类很大,会导致二进制体积增加。解决办法是把公共逻辑放到非模板基类中。

五、CRTP 与运行时多态的对比

特性 CRTP(静态多态) 虚函数(运行时多态)
调用开销 无额外开销(可内联) 间接寻址(通常不可内联)
类型集合 编译期固定 运行时可扩展
代码复用 强(通过基类注入行为) 弱(通常需要手动转发)
二进制体积 可能膨胀(每个类型实例化一份) 较小(共享虚表)
使用难度 中等(模板语法) 简单(面向对象基础)

你想想看,如果你的系统里多态的类型在写代码时就已经全部知道了,而且你特别在意性能,那 CRTP 几乎是完美的选择。反过来,如果你需要写一个插件系统,运行时才能加载新的类型,那虚函数才是正道。

六、CRTP 的核心知识体系

下面这张图总结了 CRTP 的核心逻辑和应用场景:

CRTP 核心知识体系 CRTP 模式 静态多态 代码复用 编译期接口 无虚函数开销 编译期类型绑定 对象计数 运算符自动生成 static_cast 转换 编译期检查 适用场景:性能敏感、类型集合固定、 需要代码复用的模板库开发

七、总结

CRTP 是 C++ 模板元编程里一个非常实用的工具。它让我们在编译期实现多态,避免了虚函数的运行时开销。同时,它也是代码复用的利器——把通用逻辑抽到基类里,派生类只需要关注自己的特殊行为。

我个人觉得,CRTP 是每个 C++ 开发者都应该掌握的技巧。它不像 SFINAE 那样晦涩,也不像变参模板那样复杂。但它能解决很多实际问题。你想想看,当你写了一个基类,然后十几个派生类自动获得了「对象计数」、「比较运算符」、「序列化支持」等功能,那种感觉真的很爽。

我的建议:从今天开始,在你下一个需要多态或代码复用的场景里,试试 CRTP。先从小处着手,比如给一个类加上对象计数功能。慢慢你就会发现它的妙处。


公众号:蓝海资料掘金营,微信deep3321