跳转至

编译期多态 vs 运行时多态

在工程实践里说“多态”,大家第一反应往往是 virtual 与接口——也就是运行时多态。

但现代 C++ 给了我们另一套同样强大的工具:模板、CRTP、std::variant、类型擦除(type erasure)等,这些构成了编译期多态的世界。两者看似只是在“什么时候决定行为”的差异,实际上牵涉到性能、闪存与 RAM 占用、可测试性、ABI 稳定性、编译时间、调试体验等多维权衡。对嵌入式系统来说,这些权衡往往不是学术性的,而是现实的工程约束。

先统一一下概念

我们C++最开始最原生支持的多态,是运行时多态(dynamic polymorphism),这种最常见的多态通常指通过基类指针/引用调用虚函数:基类含有 virtual 函数,派生类重写,运行时通过对象的实际类型去索引 vtable 执行对应实现。关键点在于:调用点在编译时只知道基类,真正的绑定在运行时完成。其实现依赖 vtable(每个有虚表的类)+ 对象中的 vptr(指向 vtable 的指针)。

所以您就能看到,运行时的多态,有函数转发操作。

编译期多态(static polymorphism)则是通过模板、重载、constexpr、CRTP(Curiously Recurring Template Pattern)以及代数数据类型(std::variant/std::visit)等,在编译阶段就把不同实现分派、内联、优化掉。函数调用在编译期能被决定并展开为直接调用或内联,从而消除了运行时间接调用的代价。

从实现角度看,运行时多态会产生一张或多张 vtable、每个对象携带 vptr(占用 RAM),每次虚函数调用是一次间接跳转(可能影响分支预测),而编译期多态通常会生成多个具体函数实例(模板实例化),这些可以被内联与优化,调用开销可接近普通函数调用,甚至为零开销抽象。


典型代码对比:设备驱动接口

想象一个简单场景:抽象一个 Sensor,有读取值的操作。先看运行时多态版本:

struct ISensor {
    virtual ~ISensor() = default;
    virtual int read() = 0;
};

struct ADCSensor : ISensor {
    int read() override {
        // 直接访问 ADC 寄存器
        return read_adc_hw();
    }
};

void poll(ISensor* s) {
    int v = s->read(); // 虚函数调用
    // ...处理 v
}

再看编译期多态(模板)版本:

template<typename Sensor>
void poll(Sensor& s) {
    int v = s.read(); // 非虚,编译期解析
    // ...处理 v
}

struct ADCSensor {
    int read() { return read_adc_hw(); }
};

差异立竿见影:模板版本在 poll<ADCSensor> 处可以把 read() 内联,消除间接调用;运行时多态版本在二进制里则保留了虚表/间接跳转与对象的 vptr。


性能与空间(嵌入式常关心的两大资源)

执行速度

编译期多态胜在“零运行时开销抽象”——电子系统中的热点(例如 ISR 中的驱动调用、实时路径)极其适合模板化,以便内联与优化。运行时多态每次调用都会多一次内存读(读取 vptr 指向 vtable)并做一次间接跳转,且这样跳转的目标对分支预测不友好,带来的延迟在实时场景下不容忽视。

RAM 与 Flash

运行时多态:每个对象通常携带一个指向 vtable 的指针(vptr),这会占用对象的 RAM(通常一个指针大小)。vtable 本身放在只读区(Flash),但对象的 vptr 会占用可观的 RAM,尤其是在有大量对象时。另一方面,运行时多态可以通过一个 vtable 共用多个对象的函数实现,从而 Flash 占用较小(函数体只生成一份实现)。

编译期多态:模板实例化会为每个不同模板参数生成代码(函数/类实例),这可能导致二进制增长(code bloat),即 Flash 占用上升。但对象本身不必保留 vptr(节省 RAM)。在 Flash 空间充足但 RAM 紧张的嵌入式设备上,这通常是一个值得做的交换:把运行时开销和 RAM 占用换成 Flash 的增长。

启动时间与可预测性

模板实例化产生的静态初始化可以很明确,且没有动态构造的隐患(除非使用复杂全局对象)。虚表机制可能间接依赖静态构造/动态初始化顺序(尤其当与非 constexpr 的静态对象结合时),会复杂化启动流程。在需要极其可预测的启动行为的系统里,编译期多态更容易推理与验证。

CRTP(静态多态的一种)

CRTP 把具体实现的接口强制在编译期检查,并允许在基类中实现复用代码而调用派生类的实现:

template<typename Derived>
struct SensorBase {
    int read_and_scale() {
        int v = static_cast<Derived*>(this)->read();
        return scale(v);
    }
    // ...
};
struct ADCSensor : SensorBase<ADCSensor> {
    int read() { return read_adc_hw(); }
};

CRTP 的优点是既有静态分派又能复用代码,常用于驱动框架、状态机实现等。

std::variant / std::visit

当你需要封闭型多态(不是任意扩展,而是有限、多种已知变体)时,std::variant + std::visit 是很好的选择:它在编译期把所有变体列举清楚,visit 会在编译期产生分支表或内联化逻辑,既可以避免 vtable 的开销,又比模板参数传递更灵活(可在容器中保存不同类型的对象)。

std::variant 在嵌入式里需要注意其内存占用(会分配为最宽变体的大小)——但它把类型信息放在对象内部,不需要外部 vptr。

类型擦除(type erasure)

通过 std::function、自写的 type-erased wrapper(通常带有 small-buffer-optimization),我们可以在不暴露模板参数的情况下获得“近编译期效率”的接口,同时保持运行时可替换性。代价是实现复杂度和可能的内存开销(small buffer + virtual-like calls)。这种方式常被用于库层或 API 层,隐藏实现细节。


小结:没有绝对的“更好”,只有“更合适”

编译期多态与运行时多态并非对立的神学命题,而是工具箱里的两把刀。嵌入式工程师的任务是根据目标平台的约束与工程流程,选择并混合使用它们。我的建议是:

  • 先用最清晰易懂的实现(通常是运行时多态或简单函数),把功能、接口、测试先做透;
  • 在性能或资源成为瓶颈时,识别热点并用编译期多态(模板/CRTP/constexpr)进行局部优化;
  • 启用 LTO 与链接级去重来缓解模板带来的二进制膨胀;
  • 对跨模块、插件式架构保留运行时多态接口以保证 ABI 与替换能力;
  • 在设计层面,把“可变点”与“稳定点”明确区分:把不变逻辑放到编译期,把需要灵活替换的逻辑留给运行时。