一个没完没了的争论

C++ 社区里最常见的站队:有人说「虚函数是万恶之源」,有人说「模板把编译时间拖死」。两边都没说错,但两边都没说全。

真相是:编译期和运行期不是对立面,是同一个工具箱里两把不同尺寸的扳手。 一个优秀的组件需要同时用上两把,而且要知道什么时候用哪一把。

这篇文章的目标:给一个决策框架,让你在设计组件时不用纠结。


先搞清楚「编译期」做了什么

std::sort 为例:

std::vector<int> v = {3, 1, 4, 1, 5};
std::sort(v.begin(), v.end());

看看编译器干了什么:

  1. 看到 std::vector<int> → 生成 vector<int> 的完整代码
  2. 看到 .begin() / .end() → 返回 int*(或等价的迭代器)
  3. 看到 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::sortqsort 快 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 支持 newvectorstring 等动态容器(在编译期上下文中使用,编译结束后释放)。这意味着很多初始化逻辑可以完全在编译期完成:

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 个值,编译期算好

一个实用的选择流程

当你设计一个组件的接口时,按这个顺序问自己:

  1. 这个行为在编译期能确定吗? → 能用就用 Policy / constexpr / CRTP
  2. 运行时需要切换吗? → 用类型擦除包一层
  3. 对外分发是否需要隐藏实现? → 用虚接口(纯虚类)提供稳定 ABI
  4. 两者都需要? → 两层设计,内编译外运行

如果拿不准,记住这个经验法则:先用模板写内层(不损失性能),觉得不方便了再加运行期外层。 反过来很难——把一个已经到处散开的虚函数调用收束回来几乎不可能。


下一篇

组件契约:concept + static_assert + 编译期测试 →


系列文章:C++ 组件化基础 | 作者:Logic