简单来说,如果你需要运行时多态(Run-time Polymorphism),就使用带虚函数的接口类;如果你需要的是编译期约束(Compile-time Constraints),则应使用 Concept。混淆这两者的适用场景,是引入技术债的常见原因。

在软件工程中,工具选择错误往往比不使用工具更为危险。本文将深入剖析两种多态方式的本质,并提供工程上的最佳实践。
一、纯虚类的本质:类型擦除与动态分派

你是否曾因为 Java 或 C# 的习惯,在 C++ 中也下意识地使用纯虚类?在 C++ 的语境下,纯虚类(即抽象基类)的真正核心价值在于处理“运行时未知具体类型”的场景。典型的应用场景可以归纳为以下三类:
- 插件系统:从动态链接库(DLL/so)中加载在编译时未知的类型。
- 异构容器:例如
std::vector<std::unique_ptr<Runnable>>,用于统一管理 TaskA、TaskB、TaskC 等不同类型。
- 稳定 ABI:对外发布 SDK 接口,用户无需重新编译即可替换具体实现。
其代码形式通常如下:
class Runnable {
public:
virtual ~Runnable() = default;
virtual void run() = 0;
};
void scheduler(std::vector<std::unique_ptr<Runnable>> & tasks) {
for (auto & task : tasks) {
task->run(); // 虚函数调用,间接跳转,无法内联优化
}
}
这种方式的代价是显著的:每次 run() 调用都是一次间接调用(indirect call),容易导致 CPU 分支预测失败,并带来额外的内存访问延迟。但它换来的是无与伦比的运行时灵活性。
因此,核心原则是:只要问题需要在运行时决定具体类型,就应选择虚函数接口。
二、Concept的本质:编译期形状约束

在 C++20 中引入的 Concept,并不是用来替代运行时接口的。它本身不产生任何运行时代码,其作用仅限于在编译期验证类型是否满足特定的“形状”或要求。
例如,我们可以定义一个 Runnable Concept:
template <typename T>
concept Runnable = requires(T t) {
t.run(); // 编译期检查:类型T是否有可调用的run()成员函数
};
template <Runnable T>
void test(T & task) {
task.run(); // 直接调用,可能被编译器内联优化
}
在这里,编译器在实例化模板时确切知道 T 的具体类型,因此能够生成最优的代码。如果 run() 方法是内联的,整个调用链条甚至可能被完全优化掉。
Concept 主要带来三方面优势:
- 零运行时开销:在性能关键的热代码路径上可以实现极致性能。
- 支持鸭子类型(Duck Typing):第三方类型无需继承特定的基类,只要拥有符合要求的方法(如
run())即可使用,侵入性为零。
- 错误提前暴露:当类型不满足约束时,编译器会给出清晰明确的错误信息(如“缺少 run() 方法”),而非难以阅读的模板实例化失败日志。
然而,Concept 也有其硬性限制:它不能直接用于创建异构集合(例如 std::vector<Runnable> 是非法语法);模板针对不同类型单独实例化可能导致代码膨胀(Binary Bloat);复杂的 Concept 可能会显著增加编译时间。因此,Concept 最适合用于内部的泛型算法、高性能核心组件等不需要在运行时替换具体实现的场景。
三、混合架构:工程实践中的稳健选择

在实际的大型工程中,将两种方式分层结合使用,往往是最稳健和灵活的架构设计:
- 内部核心层:使用 Concept,以保证极致的性能和强大的泛型表达能力。
- 对外边界层:使用虚接口或
std::function,以提供运行时多态的灵活性。
示例代码如下:
// 内部高性能路径 - 使用Concept
template <Runnable T>
void internal_schedule(T && task) {
task.run(); // 直接调用,零开销抽象
}
// 对外稳定ABI - 使用虚接口
class TaskRunner {
public:
virtual void submit(std::unique_ptr<Runnable> task) = 0;
};
当需要将受 Concept 约束的类型放入异构容器时,可以采用“类型擦除(Type Erasure)”模式进行桥接:
class AnyRunnable {
struct Concept {
virtual void run() = 0;
virtual ~Concept() = default;
};
template <typename T>
struct Model : Concept {
T impl;
Model(T t) : impl(std::move(t)) {}
void run() override { impl.run(); }
};
std::unique_ptr<Concept> ptr;
public:
template <Runnable T>
AnyRunnable(T t) : ptr(std::make_unique<Model<T>>(std::move(t))) {}
void run() { ptr->run(); }
};
这种设计实现了“用 Concept 约束输入,用虚函数统一输出”。它的一个巨大优势是技术债可控:未来若需求变更,需要增加运行时多态支持,你通常只需修改外部边界层或桥接层,而无需触及核心业务逻辑。
结论与建议
在不涉及运行时多态的场景下,Concept 通常是比虚接口更优秀的选择:它性能更高、更通用、侵入性更低,并能更早暴露错误。理解 虚函数表 实现的运行时多态与 Concept 实现的编译期多态之间的根本差异,是进行正确技术选型的关键。更多关于系统设计模式与架构的讨论,欢迎在 云栈社区 与大家交流。