CRTP 与静态多态:不用虚函数也能多态

CRTP 与静态多态:不用虚函数也能多态

CRTP(Curiously Recurring Template Pattern) ------把派生类自身作为模板参数传给基类------是 C++ 里实现编译期多态 的经典手段。

它让你在不付出虚函数表(vtable)开销的前提下,写出「接口统一、实现各异」的代码,同时保持内联与零抽象成本。

本文从「为什么不想用虚函数」出发,讲清 CRTP 的机制、常见用法、C++23 Deducing this 对它的替代,以及真实项目里的坑。


1. 虚函数的代价在哪?

cpp 复制代码
struct Shape {
    virtual double area() const = 0;
    virtual ~Shape() = default;
};

运行时多态的开销并不大,但在某些场景下确实有影响

代价 说明
vtable 间接跳转 每次虚调用多一次指针解引用;阻止内联
对象多一个 vptr 8 字节(x86-64),在大量小对象时显著
编译器优化受限 虚调用是运行期决议,LTO 之外难以 devirtualize

如果所有类型在编译期可确定(模板实例化、容器里只放同一类型等),可以用 CRTP 把分派提前到编译期,彻底消除上述开销。


2. CRTP 的基本形式

cpp 复制代码
template <typename Derived>
class Base {
public:
    void interface() {
        // 编译期「向下转型」:知道 Derived 的完整类型
        static_cast<Derived*>(this)->implementation();
    }
};

class Concrete : public Base<Concrete> {
public:
    void implementation() {
        // 真正的逻辑
    }
};

关键点:

  1. Base类模板 ,模板参数就是继承它的那个类
  2. Base 内部通过 static_cast<Derived*>(this) 调用派生类的方法------编译期就确定了目标函数,不走 vtable。
  3. 每个不同的 Derived 会实例化出不同的 Base<Derived> ,所以不同派生类之间没有公共基类(这是与虚函数最大的区别)。

3. 完整示例:静态多态的 Shape

cpp 复制代码
#include <iostream>
#include <cmath>

template <typename Derived>
struct ShapeBase {
    double area() const {
        return static_cast<const Derived*>(this)->area_impl();
    }

    void describe() const {
        std::cout << "area = " << area() << '\n';
    }
};

struct Circle : ShapeBase<Circle> {
    double radius;
    explicit Circle(double r) : radius(r) {}
    double area_impl() const { return M_PI * radius * radius; }
};

struct Rectangle : ShapeBase<Rectangle> {
    double w, h;
    Rectangle(double w, double h) : w(w), h(h) {}
    double area_impl() const { return w * h; }
};

// 泛型函数:接受任何 ShapeBase<T>
template <typename T>
void print_area(const ShapeBase<T>& shape) {
    shape.describe();
}

int main() {
    Circle c(3.0);
    Rectangle r(4.0, 5.0);
    print_area(c);  // area = 28.2743
    print_area(r);  // area = 20
}

print_area 是模板函数,编译期为 CircleRectangle 各生成一份;调用链全部可内联,零虚开销


4. 典型应用场景

4.1 接口强制 + 默认实现

cpp 复制代码
template <typename Derived>
struct Printable {
    void print(std::ostream& os) const {
        // 默认行为:调用派生类的 to_string()
        os << static_cast<const Derived*>(this)->to_string();
    }
};

struct MyClass : Printable<MyClass> {
    std::string to_string() const { return "MyClass{...}"; }
};

基类提供通用框架(print),派生类只需要实现一个小钩子(to_string)。

4.2 运算符混入(Mixin)

标准库里 std::enable_shared_from_this<T> 就是 CRTP;Boost.Operators 用 CRTP 帮你从 < 自动生成 ><=>=

cpp 复制代码
template <typename Derived>
struct Comparable {
    friend bool operator>(const Derived& a, const Derived& b) { return b < a; }
    friend bool operator<=(const Derived& a, const Derived& b) { return !(b < a); }
    friend bool operator>=(const Derived& a, const Derived& b) { return !(a < b); }
};

struct Score : Comparable<Score> {
    int value;
    friend bool operator<(const Score& a, const Score& b) { return a.value < b.value; }
};
// Score 自动获得 >, <=, >=

4.3 计数器(统计实例数)

cpp 复制代码
template <typename Derived>
struct InstanceCounter {
    static inline int count = 0;
    InstanceCounter() { ++count; }
    ~InstanceCounter() { --count; }
    InstanceCounter(const InstanceCounter&) { ++count; }
};

struct Widget : InstanceCounter<Widget> {};
struct Gadget : InstanceCounter<Gadget> {};
// Widget::count 与 Gadget::count 各自独立

每个 Derived 拥有独立的 count,因为 InstanceCounter<Widget>InstanceCounter<Gadget> 是不同类。


5. CRTP vs 虚函数:如何选?

维度 CRTP(编译期多态) 虚函数(运行时多态)
类型何时确定 编译期 运行期
异构容器 不直接支持(各派生类无公共基类) 天然支持(vector<Base*>
调用开销 零(可内联) 一次间接跳转
代码膨胀 每个 Derived 一份实例化 共享一份函数体
可读性 模板重、报错长 直观

经验法则

  • 需要运行期在同一个容器里混放不同类型 → 虚函数
  • 类型在编译期全部已知、追求零开销、或做 mixin → CRTP
  • 两者可以混用:接口层用虚函数做 type erasure,热路径内部用 CRTP 消除开销。

6. 常见坑与注意事项

6.1 忘记 static_castconst 匹配

cpp 复制代码
// 错:const 成员函数里 this 是 const,需要 static_cast<const Derived*>
double area() const {
    return static_cast<Derived*>(this)->area_impl(); // ❌ 编译失败
}

6.2 派生类忘记实现钩子

CRTP 基类在模板实例化时才检查 Derived 是否有 area_impl()。如果拼写错了或者漏实现,报错信息会指向基类内部,对使用者不友好。可用 Concepts 约束改善(见第 7 节)。

6.3 传错模板参数

cpp 复制代码
struct A : ShapeBase<A> {};
struct B : ShapeBase<A> {};  // ❌ B 继承了 ShapeBase<A>,static_cast 到 A*,UB

这是拷贝粘贴最常见的 bug。C++23 之前没有语言级防御,只能靠 code review 或 static_assert

cpp 复制代码
template <typename Derived>
struct ShapeBase {
    ShapeBase() {
        static_assert(std::is_base_of_v<ShapeBase, Derived>,
                      "CRTP misuse: Derived must inherit from ShapeBase<Derived>");
    }
};

6.4 不能放入异构容器

ShapeBase<Circle>ShapeBase<Rectangle>两个不同的类 ,不能塞进同一个 vector。若需要异构集合,要么加一层 type erasure(std::functionstd::variant、或再套一个虚接口),要么回归虚函数。


7. C++20 Concepts 加持:更好的错误提示

cpp 复制代码
template <typename T>
concept HasAreaImpl = requires(const T& t) {
    { t.area_impl() } -> std::convertible_to<double>;
};

template <HasAreaImpl Derived>
struct ShapeBase {
    double area() const {
        return static_cast<const Derived*>(this)->area_impl();
    }
};

Derived 缺少 area_impl(),编译器会在实例化点 直接报「不满足 HasAreaImpl 约束」,而不是在基类深处给出一堆模板替换失败的噪音。


8. C++23 Deducing this:CRTP 的终结者?

C++23 引入了 explicit object parameterthis auto&&),让成员函数通过推导得知「实际调用者的类型」,不再需要 CRTP 的模板继承绕法:

cpp 复制代码
struct ShapeBase {
    // this 的类型由调用者推导
    double area(this const auto& self) {
        return self.area_impl();
    }

    void describe(this const auto& self) {
        std::cout << "area = " << self.area() << '\n';
    }
};

struct Circle : ShapeBase {
    double radius;
    double area_impl() const { return 3.14159 * radius * radius; }
};

对比 CRTP:

CRTP Deducing this
语法 class D : Base<D> class D : Base
传错参数风险
编译器支持 C++11 起 C++23(GCC 14+、Clang 18+、MSVC 19.37+)

如果你的项目已经能用 C++23,优先用 deducing this------意图更清晰、不用传模板参数、无 CRTP 拼写错误风险。但在 C++11/14/17/20 代码库里,CRTP 仍然是唯一选项。

8.1 Deducing this 能解决异构容器问题吗?

不能。 虽然派生类现在共享同一个 ShapeBase(不像 CRTP 每个派生类对应不同的 Base<D>),但 deducing this 仍然是编译期多态

你确实可以

cpp 复制代码
std::vector<std::unique_ptr<ShapeBase>> shapes;
shapes.push_back(std::make_unique<Circle>(3.0));       // ✅ 能编译,共享基类
shapes.push_back(std::make_unique<Rectangle>(4.0, 5.0)); // ✅

调不了多态方法:

cpp 复制代码
for (auto& s : shapes) {
    s->area();  // ❌ self 推导为 const ShapeBase&,找不到 area_impl()
}

原因有二:

  1. this auto& 本质是函数模板 ------self 的类型由调用点的静态类型 决定。通过 ShapeBase* 调用时,self 只能推导为 const ShapeBase&,不可能是 Circle&
  2. 虚函数不能是模板 ------你也不能给 deducing this 成员加 virtual,因为 vtable 槽位数量必须编译期确定,而模板可以无限实例化。
cpp 复制代码
virtual double area(this const auto& self);  // ❌ 非法:虚函数不能是模板

本质矛盾:模板需要编译期确定类型来实例化;运行时多态的"类型"恰恰编译期不确定。

8.2 那异构容器到底怎么办?

需要某种形式的运行期间接跳转,三种主流方案:

方案 A:经典虚函数(开放集,堆分配)

cpp 复制代码
struct Shape {
    virtual double area() const = 0;
    virtual ~Shape() = default;
};

struct Circle : Shape {
    double radius;
    explicit Circle(double r) : radius(r) {}
    double area() const override { return 3.14159 * radius * radius; }
};

std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Circle>(3.0));
for (auto& s : shapes) std::cout << s->area() << '\n';  // ✅ 虚分派

方案 B:std::variant + std::visit(闭集,无堆分配)

所有类型编译期已知,但需要在运行期区分是哪一个:

cpp 复制代码
struct Circle {
    double radius;
    double area() const { return 3.14159 * radius * radius; }
};

struct Rectangle {
    double w, h;
    double area() const { return w * h; }
};

using AnyShape = std::variant<Circle, Rectangle>;

std::vector<AnyShape> shapes;
shapes.emplace_back(Circle{3.0});
shapes.emplace_back(Rectangle{4.0, 5.0});

for (auto& s : shapes) {
    double a = std::visit([](const auto& x) { return x.area(); }, s);
    std::cout << a << '\n';
}

优点:对象内联在 vector 里,无堆分配、缓存友好;缺点:类型集必须编译期封闭,新增类型要改 variant

方案 C:类型擦除(手写或用库)

把「能算 area()」这个能力擦除到一个固定接口里,内部持有 void* + 函数指针,类似 std::function 的做法:

cpp 复制代码
class AnyShape {
    struct Concept {
        virtual double area() const = 0;
        virtual ~Concept() = default;
    };

    template <typename T>
    struct Model : Concept {
        T obj;
        template <typename U>
        explicit Model(U&& u) : obj(std::forward<U>(u)) {}
        double area() const override { return obj.area(); }
    };

    std::unique_ptr<Concept> impl_;
public:
    template <typename T>
    AnyShape(T&& obj) : impl_(std::make_unique<Model<std::decay_t<T>>>(std::forward<T>(obj))) {}
    double area() const { return impl_->area(); }
};

std::vector<AnyShape> shapes;
shapes.emplace_back(Circle{3.0});
shapes.emplace_back(Rectangle{4.0, 5.0});
for (auto& s : shapes) std::cout << s.area() << '\n';  // ✅

优点:使用者不需要继承任何基类(duck typing),类型开放;缺点:实现复杂、有堆分配。

8.3 方案选择速查

方案 类型集 堆分配 侵入性 适用场景
虚函数 开放 需继承 通用、类型未来会扩展
variant + visit 封闭 类型有限、追求性能
类型擦除 开放 不想强制继承、duck typing

9. 小结

  • CRTP = 派生类把自己作为模板参数传给基类,基类内通过 static_cast<Derived*>(this) 在编译期分派。
  • 适用于编译期已知全部类型的场景:mixin 注入、运算符生成、实例计数、策略模式等。
  • 零虚开销,可内联;但无法做异构容器,模板报错噪音大。
  • 配合 Concepts 可改善报错体验;C++23 deducing this 语法更优但仍是编译期分派,不能解决异构容器问题
  • 需要异构容器时:虚函数(开放集)、variant(闭集零堆分配)、类型擦除(开放集 duck typing)三选一。
  • 与虚函数并非互斥:外层用虚函数做类型擦除、内层用 CRTP 做热路径分派,在真实项目中很常见。
相关推荐
basketball6161 小时前
设计模式入门:1. 单例模式详解 C++实现
c++·单例模式·设计模式
Brilliantwxx1 小时前
【C++】 红黑树封装 STL set/map 超详细解析
开发语言·c++
程序大视界1 小时前
【C++ 从基础到项目实战】C++(八):运算符重载——让你的类用起来像内置类型
开发语言·c++·cpp
z200509301 小时前
今日算法(回溯全排列)
c++·算法·leetcode
不会C语言的男孩1 小时前
C++ Primer 第6章:函数
开发语言·c++
码上有光2 小时前
c++:多态
java·jvm·c++·多态·多态原理
Lumbrologist2 小时前
【C++】零基础入门 · 第 18 节:互斥锁与线程同步
java·开发语言·c++
tangchao340勤奋的老年?2 小时前
C++ OpenGL显示地图
c++·opengl
I Promise342 小时前
C++ 多线程编程:从入门到实战
开发语言·c++