前言
「多态」的经典定义是「同一接口,不同实现」。教科书通常用虚函数讲这件事:基类声明virtual void draw(),派生类各自覆盖,通过基类指针调用时在运行期查虚表决定走哪个实现。这被称为动态多态(dynamic polymorphism)。但 C++ 还有另一条完全不同的路径:模板在编译期根据实参类型生成不同的代码,只要类型提供了所需的操作,同一段模板代码就能作用于多种类型。这被称为静态多态(static polymorphism),也叫编译期多态。
一个常见的误解是「模板只是泛型容器,跟多态没关系」。实际上,模板是 C++ 里最强的多态机制之一:std::sort对std::vector<int>、std::vector<std::string>、裸数组都能工作,靠的就是「类型只要支持随机访问迭代器和比较,就能实例化」这一隐式接口。另一个误解是「静态多态一定比动态多态快」——这要看你比什么:它省掉了虚表间接调用,但会带来代码膨胀和编译期开销,而且在需要「运行时才知具体类型」的场景根本用不了。
本文讲清三件事:模板为什么能在编译期完成派发、CRTP 如何把派生类「注入」基类、以及类型擦除如何在两者之间架桥。示例以 C++17 为准。
一、动态多态与静态多态的机制对比
动态多态的运行时结构是:每个含虚函数的类有一张虚函数表(vtable),每个对象头部(具体布局由实现决定,标准不规定)有一个指向该表的指针。调用p->draw()时,编译器生成「取 vptr、按固定偏移取函数指针、间接调用」的序列。它支持开放集合:库可以先编译好,用户后来新增派生类,库代码不用重编。
静态多态的机制是:编译器看到template <class T> void f(T x)被f(Circle{})调用时,用T = Circle实例化出一份独立函数。类型在编译期完全已知,调用是直接调用,可以被内联、可以做针对该类型的优化。代价是:
- 每种类型生成一份代码,二进制变大,指令缓存压力上升;
- 编译时间随实例化数量增长;
- 模板定义必须对使用点可见(通常放头文件);
- 无法把
f<int>和f<double>放进同一个容器——它们是不同的类型; - 错误信息在实例化深处爆发,可读性差(C++20 的 concepts 就是为解决这个而加的)。
| 维度 | 动态多态(虚函数) | 静态多态(模板) |
|---|
| 绑定时机 | 运行期 | 编译期 |
| 派发开销 | 间接调用,通常难以内联 | 直接调用,可内联 |
| 类型集合 | 开放,可后加 | 封闭,编译期确定 |
| 代码体积 | 一份实现 | 每个类型一份 |
| 异构容器 | 可以(基类指针) | 不可以 |
| 接口检查 | 编译期检查 override | 实例化时才检查操作是否存在 |
注意「派发开销」一栏只是机制描述,不代表任何具体倍数。现代编译器在能看到完整类型时也会对虚调用做去虚化(devirtualization),是否发生取决于优化级别和上下文。
二、模板的「隐式接口」
模板对类型的要求不是写在签名里的,而是体现在函数体用到了哪些操作。例如:
// 适用于 C++17
template <class T>
T sumThree(const T& a, const T& b, const T& c) {
T r = a; // 要求 T 可拷贝构造
r += b; // 要求 T 有 operator+=
r += c;
return r; // 要求 T 可拷贝/移动返回
}这段代码对int、double、std::string都成立,对std::vector<int>不成立(没有+=)。编译器不会在定义模板时检查,而是在实例化时把T代进去,若某个表达式不合法就报错。这种「鸭子类型」式的约束就是隐式接口。
C++20 起可以用 concepts 把隐式接口显式化:
// 需要 C++20
#include <concepts>
template <class T>
concept Addable = requires(T a, T b) {
{ a += b } -> std::same_as<T&>;
};
template <Addable T>
T sumThree(const T& a, const T& b, const T& c) {
T r = a;
r += b;
r += c;
return r;
}这样错误信息会指向「T 不满足 Addable」,而不是模板体内部某一行。concepts 需要 C++20 及以上,各编译器支持程度不同,使用前应查自己工具链的支持表。
三、CRTP:让基类认识派生类
动态多态里,基类通过虚函数「向下」调用派生实现。静态多态里有一个技巧可以达到类似效果:把派生类作为模板参数传给基类,基类在编译期就知道派生类的完整类型。这就是奇异递归模板模式(Curiously Recurring Template Pattern, CRTP)。
// 适用于 C++17
#include <iostream>
#include <string>
template <class Derived>
class Shape {
public:
void draw() const {
// 向下转型为派生类,调用其非虚函数
static_cast<const Derived*>(this)->drawImpl();
}
double area() const {
return static_cast<const Derived*>(this)->areaImpl();
}
};
class Circle : public Shape<Circle> {
public:
explicit Circle(double r) : r_(r) {}
void drawImpl() const { std::cout << "circle r=" << r_ << '\n'; }
double areaImpl() const { return 3.141592653589793 * r_ * r_; }
private:
double r_;
};
class Square : public Shape<Square> {
public:
explicit Square(double s) : s_(s) {}
void drawImpl() const { std::cout << "square s=" << s_ << '\n'; }
double areaImpl() const { return s_ * s_; }
private:
double s_;
};
template <class S>
void render(const Shape<S>& shape) {
shape.draw(); // 编译期确定调用 Circle::drawImpl 或 Square::drawImpl
}要点如下。
static_cast<const Derived*>(this)在Derived确实继承自Shape<Derived>时是合法的向下转型,不做运行期检查。如果写错,比如class Circle : public Shape<Square>,这是未定义行为——CRTP 的正确性依赖程序员保证模板实参就是派生类本身。
Shape<Circle>和Shape<Square>是两个不同的类型,没有公共基类,所以不能写Shape* p = &circle;。要统一处理,只能在模板函数里以Shape<S>的形式接收,S在编译期确定。
CRTP 适合「接口固定、实现由派生类提供、不需要运行期异构」的场景,比如静态接口检查、混入(mixin)计数、enable_shared_from_this(标准库自身就用 CRTP 实现它)。它的优点是调用可内联、无 vptr 开销;缺点同样是代码膨胀和无法放进同一容器。
补充一点:C++23 的显式对象参数(deducing this)可以让基类直接以this推导派生类型,不必再把派生类写成模板实参。该特性需要 C++23 且编译器支持情况不一,老代码里仍以 CRTP 为主。
四、类型擦除:在两者之间架桥
如果既想要模板的编写便利,又需要「同一个容器里放不同实现」,就需要类型擦除(type erasure)。std::function就是标准库里的例子:它可以存任意可调用对象,但对外表现为同一个类型。
手写一个最小版本,思路是「外部类 + 内部抽象基类 + 模板化实现类」:
// 适用于 C++17
#include <memory>
#include <type_traits>
#include <utility>
class Drawable {
public:
template <class T,
class = std::enable_if_t<
!std::is_same_v<std::decay_t<T>, Drawable>>>
Drawable(T&& obj)
: self_(std::make_unique<Model<std::decay_t<T>>>(std::forward<T>(obj))) {}
Drawable(const Drawable& other) : self_(other.self_->clone()) {}
Drawable(Drawable&&) noexcept = default;
Drawable& operator=(const Drawable& other) {
if (this != &other) self_ = other.self_->clone();
return *this;
}
Drawable& operator=(Drawable&&) noexcept = default;
void draw() const { self_->draw(); }
private:
struct Concept {
virtual ~Concept() = default;
virtual void draw() const = 0;
virtual std::unique_ptr<Concept> clone() const = 0;
};
template <class T>
struct Model final : Concept {
explicit Model(T obj) : value_(std::move(obj)) {}
void draw() const override { value_.draw(); }
std::unique_ptr<Concept> clone() const override {
return std::make_unique<Model<T>>(value_); // 要求 T 可拷贝
}
T value_;
};
std::unique_ptr<Concept> self_;
};Drawable的构造函数是模板,接受任何类型;但它把类型信息藏进Model<T>,对外只暴露Concept的虚接口。于是std::vector<Drawable>可以同时装Circle和Square,而调用draw()时走一次虚调用。这就是「用动态多态的运行时灵活性,换取模板的易用接口」。
那个enable_if不是装饰:没有它,模板构造函数会在拷贝构造Drawable时被选中(T推导为Drawable&),与拷贝构造函数产生歧义。所有「泛型构造函数 + 拷贝构造」的类都要处理这一点。
五、选择哪种多态
可以用一张表来决策。
| 需求 | 推荐机制 |
|---|
| 类型集合编译期已知,追求内联 | 模板 / CRTP |
| 需要运行期新增类型,库与用户分离编译 | 虚函数 |
| 类型集合封闭且有限 | std::variant+std::visit(C++17) |
| 需要统一类型但底层实现各异 | 类型擦除(std::function思路) |
| 需要约束模板参数、改善报错 | concepts(C++20) |
其中std::variant值得单独提一句:它是闭合的代数类型,std::visit会对每个备选类型生成分支,本质上是编译期展开加运行期标签,不需要堆分配,也不需要虚表。它适合「就这几种,不会再变」的场景。
常见坑点
- ❌ 把模板定义放在
.cpp里,只留声明在头文件,链接时报 undefined reference。
✅ 模板定义通常必须放在头文件(或显式实例化),因为使用点需要看到完整定义。
- ❌ CRTP 里写
static_cast<Derived*>(this),但派生类并没有继承对应的基类模板。
✅ 保证class D : public Base<D>形式完全匹配,必要时加static_assert(std::is_base_of_v<Base<D>, D>)。
- ❌ 以为
Shape<Circle>和Shape<Square>有公共基类,写Shape* p。
✅ 它们类型无关;需要统一处理就用模板函数或类型擦除。
- ❌ 类型擦除类的泛型构造函数不排除自身类型,拷贝时产生歧义。
✅ 用enable_if+is_same_v<decay_t<T>, Self>排除。
- ❌ 在模板里对
T做假定(比如要求T可默认构造),调用方传不支持的类型时错误信息极长。
✅ 用static_assert或 concepts 提前给出清晰约束。
- ❌ 为了「性能」把所有接口都改成 CRTP,导致二进制膨胀、编译时间失控。
✅ 只在热点路径或确实需要内联的层次使用;边界处仍用虚接口。
- ❌ 认为静态多态一定更快,忽略指令缓存和代码体积的影响。
✅ 以实际测量为准,讲清楚测的是什么场景,不要套用通用结论。
总结
| 概念 | 绑定时机 | 典型手段 | 代价 |
|---|
| 动态多态 | 运行期 | virtual/override | vptr 与间接调用,难内联 |
| 静态多态 | 编译期 | 模板、CRTP | 代码膨胀,编译变慢 |
| 类型擦除 | 运行期(内部) | 抽象基类 + 模板实现类 | 一次堆分配与虚调用 |
| 闭合联合 | 编译期展开 + 运行期标签 | std::variant+std::visit | 类型集合固定 |
模板实现多态的本质,是把「运行时才知道的类型」提前到「编译时就知道」,从而把派发成本转成代码体积和编译时间。它不是虚函数的替代品,而是另一条权衡曲线。真正成熟的 C++ 代码往往同时使用两者:模块边界用虚接口保持稳定,模块内部用模板和 CRTP 换取效率。