48、现代C++设计模式:CRTP(奇异递归模板模式),静态多态的经典实现

说到设计模式,大家第一反应可能是GoF那23种经典模式。但C++模板元编程里有个独门绝技——CRTP,全称Curiously Recurring Template Pattern。这名字挺拗口,说白了就是:派生类把自己作为模板参数传给基类

我第一次看到这种写法时,心里直犯嘀咕:“这不循环依赖了吗?”后来在项目中用顺手了才发现,这玩意儿解决了不少运行时多态的性能痛点。今天咱们就把它掰开揉碎了聊聊。

什么是CRTP?先看个最简单的例子

CRTP的基本形式长这样:

template <typename Derived>
class Base {
public:
    void interface() {
        // 调用派生类的实现
        static_cast<Derived*>(this)->implementation();
    }
};

class Derived : public Base<Derived> {
public:
    void implementation() {
        std::cout << "Derived implementation" << std::endl;
    }
};

你看,Derived继承Base<Derived>,把自己当模板参数传进去了。基类里通过static_castthis转成派生类指针,然后调用派生类的方法。

这里有个关键点:没有虚函数,没有vtable,没有运行时开销。所有调用都在编译期就确定了。

静态多态 vs 动态多态

咱们对比一下两种多态的实现方式:

特性 动态多态(虚函数) 静态多态(CRTP)
绑定时机 运行时 编译期
性能开销 虚函数表查找(间接跳转) 无额外开销(直接调用)
灵活性 运行时多态,可动态替换 编译期多态,类型固定
代码体积 较小(共享一份代码) 较大(每个派生类生成一份)
适用场景 需要运行时多态、接口稳定 性能敏感、类型在编译期已知

我个人习惯是:能用静态多态解决的问题,尽量不用虚函数。尤其是嵌入式开发、游戏引擎、高频交易这些场景,虚函数那点开销有时候真受不了。

CRTP的经典应用场景

1. 实现代码复用(Mix-in)

我记得有个项目需要给多个类添加“比较”功能。传统做法是写个基类,然后每个派生类重载operator==。用CRTP可以更优雅:

template <typename Derived>
class Comparable {
public:
    bool operator!=(const Derived& other) const {
        return !(static_cast<const Derived*>(this)->operator==(other));
    }
};

class Point : public Comparable<Point> {
public:
    Point(int x, int y) : x_(x), y_(y) {}
    bool operator==(const Point& other) const {
        return x_ == other.x_ && y_ == other.y_;
    }
private:
    int x_, y_;
};

你看,Comparable自动提供了!=的实现,派生类只需要实现==。这就是CRTP的“模板方法”模式——基类定义骨架,派生类填充细节。

2. 单例模式(Singleton)

用CRTP实现单例,比传统写法简洁多了:

template <typename T>
class Singleton {
public:
    static T& instance() {
        static T inst;
        return inst;
    }
protected:
    Singleton() = default;
    ~Singleton() = default;
    Singleton(const Singleton&) = delete;
    Singleton& operator=(const Singleton&) = delete;
};

class Logger : public Singleton<Logger> {
    friend class Singleton<Logger>;
public:
    void log(const std::string& msg) { /* ... */ }
private:
    Logger() = default;
};

这里有个坑:派生类的构造函数必须私有,并且把基类声明为友元。否则外部可以直接构造,单例就失效了。我曾经在代码审查时看到有人忘了加friend,结果单例变成了“多例”……

3. 对象计数

想知道某个类当前有多少个实例?CRTP可以轻松搞定:

template <typename T>
class ObjectCounter {
public:
    static size_t count() { return count_; }
protected:
    ObjectCounter() { ++count_; }
    ObjectCounter(const ObjectCounter&) { ++count_; }
    ~ObjectCounter() { --count_; }
private:
    static inline size_t count_ = 0;
};

class Widget : public ObjectCounter<Widget> {
    // Widget自己的实现
};

每个Widget实例都会自动增减计数器。注意这里用了static inline(C++17特性),省去了在cpp文件中定义静态成员的麻烦。

CRTP的避坑指南

⚠️ 常见陷阱:
  • 派生类类型不匹配:如果写class Derived : public Base<OtherDerived>,基类里的static_cast会出大问题。编译器不会报错,但运行时行为未定义。
  • 构造函数/析构函数中调用CRTP方法:在基类构造期间,派生类还没完全构造好。这时候调用static_cast是危险的。
  • 代码膨胀:每个派生类都会实例化一份基类代码。如果派生类很多,二进制体积会变大。

我曾经在重构一个大型项目时,把虚函数全部换成CRTP。结果编译出来的二进制体积膨胀了30%……后来只在热点路径上用了CRTP,其他部分保留了虚函数。嗯,没有银弹,得根据场景权衡。

CRTP与策略模式(Policy-Based Design)

CRTP经常和策略模式搭配使用。比如实现一个“可克隆”的类:

template <typename Derived>
class Cloneable {
public:
    std::unique_ptr<Derived> clone() const {
        return std::make_unique<Derived>(
            static_cast<const Derived&>(*this)
        );
    }
};

class Config : public Cloneable<Config> {
public:
    Config() = default;
    Config(const Config&) = default;
    // ...
};

这样每个派生类自动获得clone()方法,不需要手动实现。你想想看,如果有一百个类都需要克隆功能,用CRTP能省多少重复代码?

性能对比:CRTP vs 虚函数

咱们做个简单的性能测试(伪代码):

// 虚函数版本
struct VirtualBase {
    virtual int compute(int x) const = 0;
};
struct VirtualDerived : VirtualBase {
    int compute(int x) const override { return x * 2; }
};

// CRTP版本
template <typename T>
struct CRTPBase {
    int compute(int x) const {
        return static_cast<const T*>(this)->compute_impl(x);
    }
};
struct CRTPDerived : CRTPBase<CRTPDerived> {
    int compute_impl(int x) const { return x * 2; }
};

在循环调用100万次的情况下,CRTP版本通常比虚函数版本快10%~30%。原因很简单:虚函数调用是间接跳转,CPU分支预测容易失败;而CRTP是直接调用,编译器甚至可以内联。

💡 小技巧:

如果你不确定某个场景该用CRTP还是虚函数,可以问自己两个问题:

  1. 这个多态行为在编译期能确定吗?
  2. 性能是不是关键指标?

如果两个答案都是“是”,那就用CRTP。否则,虚函数更省心。

CRTP的进阶用法:奇异递归模板的“奇异”之处

CRTP的名字里有个“奇异”,是因为它打破了我们对继承的直觉——派生类在定义时还没完全定义好,却已经被基类使用了。这其实利用了C++的两阶段查找(Two-Phase Lookup)机制。

更“奇异”的是,CRTP可以模拟虚函数的行为,但完全在编译期完成:

template <typename... Ts>
class Visitor {
public:
    template <typename T>
    void visit(T&& obj) {
        // 展开参数包,调用每个派生类的visit方法
        (static_cast<Ts*>(this)->visit_impl(std::forward<T>(obj)), ...);
    }
};

class MyVisitor : public Visitor<MyVisitor, OtherVisitor> {
public:
    void visit_impl(int x) { /* 处理int */ }
    void visit_impl(double d) { /* 处理double */ }
};

这种写法在C++17的折叠表达式加持下,可以实现编译期的“多重继承多态”。说实话,我第一次看到这种代码时也愣了一下——但用熟了之后,确实能写出非常灵活且高性能的代码。

总结一下

CRTP是C++模板元编程的基石之一。它让我们在编译期实现多态,避免了虚函数的运行时开销。但也要注意它的局限性:代码膨胀、类型固定、调试困难。

我个人建议:在性能敏感的核心模块中使用CRTP,在业务逻辑层使用虚函数。这样既保证了性能,又保持了代码的可维护性。

最后送大家一句话:CRTP就像一把锋利的刀,用好了能切菜如飞,用不好容易伤到自己。多写、多练、多思考,你就能驾驭它。

核心要点回顾:

  • CRTP:派生类把自己作为模板参数传给基类
  • 静态多态:编译期绑定,无虚函数开销
  • 经典应用:代码复用、单例、对象计数、策略模式
  • 避坑:类型匹配、构造/析构中调用、代码膨胀
  • 性能:比虚函数快10%~30%,但牺牲了灵活性
CRTP(奇异递归模板模式)知识体系 CRTP 核心 派生类作为模板参数传给基类 静态多态(编译期绑定) 经典应用场景 代码复用(Mix-in) 单例模式 对象计数 对比:动态多态(虚函数) 运行时绑定,灵活 有虚函数表开销 代码体积小

专注资料整理