一个没完没了的争论
C++ 社区里最常见的站队:有人说「虚函数是万恶之源」,有人说「模板把编译时间拖死」。两边都没说错,但两边都没说全。
真相是:编译期和运行期不是对立面,是同一个工具箱里两把不同尺寸的扳手。 一个优秀的组件需要同时用上两把,而且要知道什么时候用哪一把。
这篇文章的目标:给一个决策框架,让你在设计组件时不用纠结。
先搞清楚「编译期」做了什么
以 std::sort 为例:
std::vector<int> v = {3, 1, 4, 1, 5};
std::sort(v.begin(), v.end());
看看编译器干了什么:
- 看到
std::vector<int>→ 生成vector<int>的完整代码 - 看到
.begin()/.end()→ 返回int*(或等价的迭代器) - 看到
std::sort(int*, int*)→ 由于排序用的比较算子operator<(int,int)是编译期可知的,编译器可以把整个排序算法内联展开,把所有比较调用直接替换成cmp指令
相比之下,C 的 qsort:
int arr[] = {3, 1, 4};
qsort(arr, 3, sizeof(int), [](const void* a, const void* b) {
return *(int*)a - *(int*)b;
});
qsort 每次比较都要通过函数指针回调,编译器不知道回调函数是什么,无法内联。实际测试中,std::sort 比 qsort 快 2-3 倍是常态,极端场景(小数组排序)能快到 5-10 倍。
这就是编译期的威力:编译器看到所有信息 → 可以充分优化、内联、消除间接调用。
运行期的价值:灵活性和部署
但编译期不是万能的。看这个例子:
// 用户从配置文件读取插件名,程序需要动态加载
std::string plugin_name = config.get("renderer"); // 运行期才知道
auto renderer = create_renderer(plugin_name);
renderer->draw(scene);
这里 plugin_name 的值在编译期不知道——它取决于用户写的配置文件。你必须用运行期派发(虚函数、函数指针、std::function)。
运行期手法还带来一个关键优势:改一个插件不需要重新编译整个程序。 编译期模板方案必须全部重新编译。
决策框架
把常见场景和推荐方案列成一张表:
| 场景 | 推荐 | 原因 |
|---|---|---|
| 策略在项目初期就确定了,后续不会换 | 编译期(Policy-Based) | 零开销,可内联 |
| 策略需要运行时切换(用户配置、插件) | 运行期(虚函数 / 类型擦除) | 编译期不可知 |
| 算法库的核心循环(sort、查找、hash) | 编译期(模板) | 性能关键路径 |
| 需要暴露给 Python/JS 绑定 | 运行期(虚函数/稳定 ABI) | 模板没有稳定 ABI |
| 组件要给第三方用,不想暴露头文件 | 运行期(纯虚接口 + .so) | 模板必须在头文件里 |
| 类型组合很多,不想编译产物爆炸 | 运行期 + 少量编译期 | 控制二进制体积 |
| 嵌入式 / 资源极度受限 | 编译期优先 | 可以精确控制代码生成 |
| 高频交易 / 游戏引擎热路径 | 编译期 | 每条指令都算成本 |
| 快速原型 / 迭代中的接口 | 运行期 | 改接口不用全体重编译 |
一个实用的思维模型:把编译期当成「写死但免费」,把运行期当成「灵活但有代价」。 代价不是「慢」,而是「编译器无法替你优化」。
桥接:两层的混合设计
现实中,好的组件通常用两层设计:
┌────────────────────────────┐
│ 对外接口层(类型擦除) │ ← 运行期灵活
│ std::function / 虚基类 │
├────────────────────────────┤
│ 内部实现层(Policy-Based) │ ← 编译期性能
│ 模板策略组合 │
└────────────────────────────┘
一个用两层设计写的 Logger 组件:
// ----- 内层:编译期策略 -----
struct StdoutSink {
void write(std::string_view msg) { /* 写终端 */ }
};
struct FileSink {
FILE* f_;
FileSink(const char* p) : f_(fopen(p, "a")) {}
~FileSink() { fclose(f_); }
void write(std::string_view msg) { fprintf(f_, "%s\n", msg.data()); }
};
template <typename Sink>
class FastLogger : private Sink {
std::mutex mu_;
public:
using Sink::Sink;
void log(std::string_view msg) {
std::lock_guard lock(mu_);
this->write(msg);
}
};
// ----- 外层:运行期包装 -----
class Logger {
std::function<void(std::string_view)> sink_;
public:
template <typename Sink>
explicit Logger(Sink s) : sink_(std::move(s)) {
// 内部用 FastLogger<Sink>,因为 Sink 在构造时已知
auto fast = std::make_shared<FastLogger<Sink>>(std::move(s));
sink_ = [fast](std::string_view msg) { fast->log(msg); };
}
void log(std::string_view msg) { sink_(msg); }
};
// 使用:运行时决定用哪个 Sink
Logger log = config.use_file
? Logger(FileSink("/var/log/app.log"))
: Logger(StdoutSink{});
log.log("hello"); // 一次虚函数派发,内层纯编译期优化
这一层包装的代价:一次 std::function 调用(≈ 一次虚函数查找)。相比每次调用 log 都走完整虚函数链的传统设计,这个代价微不足道。
constexpr:把计算挪到编译期
除了模板,C++ 还有另一条「编译期路径」:constexpr。
// 运行期版本
int factorial(int n) {
int result = 1;
for (int i = 2; i <= n; ++i) result *= i;
return result;
}
// constexpr 版本:相同的代码,编译器可以在编译期执行它
constexpr int factorial(int n) {
int result = 1;
for (int i = 2; i <= n; ++i) result *= i;
return result;
}
constexpr int val = factorial(10); // 编译期算出 3628800,嵌在二进制里
C++20 的 constexpr 支持 new、vector、string 等动态容器(在编译期上下文中使用,编译结束后释放)。这意味着很多初始化逻辑可以完全在编译期完成:
constexpr auto makeLookupTable() {
std::array<int, 256> table{};
for (size_t i = 0; i < 256; ++i) {
table[i] = static_cast<int>(std::sin(i * M_PI / 512.0) * 65536);
}
return table;
}
constexpr auto sin_table = makeLookupTable(); // 256 个值,编译期算好
一个实用的选择流程
当你设计一个组件的接口时,按这个顺序问自己:
- 这个行为在编译期能确定吗? → 能用就用 Policy / constexpr / CRTP
- 运行时需要切换吗? → 用类型擦除包一层
- 对外分发是否需要隐藏实现? → 用虚接口(纯虚类)提供稳定 ABI
- 两者都需要? → 两层设计,内编译外运行
如果拿不准,记住这个经验法则:先用模板写内层(不损失性能),觉得不方便了再加运行期外层。 反过来很难——把一个已经到处散开的虚函数调用收束回来几乎不可能。
下一篇
组件契约:concept + static_assert + 编译期测试 →
系列文章:C++ 组件化基础 | 作者:Logic