跳转至

编译期多态 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
}
查看完整可编译示例
// 运行时多态示例:使用虚函数

#include <iostream>
#include <memory>

// 模拟硬件 ADC 读取
int read_adc_hw() {
    return 42;  // 模拟值
}

// 传感器接口(基类)
struct ISensor {
    virtual ~ISensor() = default;
    virtual int read() = 0;
};

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

// 温度传感器实现
struct TempSensor : ISensor {
    int read() override {
        // 模拟温度读取
        return 25;
    }
};

// 传感器轮询函数
void poll(ISensor* s) {
    int v = s->read();  // 虚函数调用 - 运行时决定
    std::cout << "传感器值: " << v << std::endl;
    // ...处理 v
}

// ==================== 演示主函数 ====================
int main() {
    std::cout << "=== 运行时多态示例 ===" << std::endl;

    ADCSensor adc;
    TempSensor temp;

    std::cout << "\n--- 通过基类指针调用 ---" << std::endl;
    poll(&adc);
    poll(&temp);

    // 使用容器存储不同传感器
    std::unique_ptr<ISensor> sensors[] = {
        std::make_unique<ADCSensor>(),
        std::make_unique<TempSensor>()
    };

    std::cout << "\n--- 使用容器遍历 ---" << std::endl;
    for (auto& s : sensors) {
        poll(s.get());
    }

    std::cout << "\n关键点:" << std::endl;
    std::cout << "1. 虚函数调用需要通过 vtable 间接查找" << std::endl;
    std::cout << "2. 每个对象携带 vptr(占用 RAM)" << std::endl;
    std::cout << "3. 可以在容器中存储不同类型的传感器" << std::endl;
    std::cout << "4. 适合需要运行时动态替换的场景" << std::endl;

    return 0;
}

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

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

struct ADCSensor {
    int read() { return read_adc_hw(); }
};
查看完整可编译示例
// 编译时多态示例:使用模板

#include <iostream>
#include <array>

// 模拟硬件 ADC 读取
int read_adc_hw() {
    return 42;  // 模拟值
}

// ADC 传感器(不需要基类)
struct ADCSensor {
    int read() {
        return read_adc_hw();
    }
};

// 温度传感器
struct TempSensor {
    int read() {
        return 25;
    }
};

// 编译期多态的轮询函数
template<typename Sensor>
void poll(Sensor& s) {
    int v = s.read();  // 非虚函数,编译期解析,可内联
    std::cout << "传感器值: " << v << std::endl;
    // ...处理 v
}

// ==================== 演示主函数 ====================
int main() {
    std::cout << "=== 编译时多态示例 ===" << std::endl;

    ADCSensor adc;
    TempSensor temp;

    std::cout << "\n--- 模板函数调用 ---" << std::endl;
    poll(adc);  // 实例化 poll<ADCSensor>
    poll(temp); // 实例化 poll<TempSensor>

    std::cout << "\n关键点:" << std::endl;
    std::cout << "1. read() 调用可以被编译器内联" << std::endl;
    std::cout << "2. 不需要 vtable 和 vptr,节省 RAM" << std::endl;
    std::cout << "3. 每个模板参数生成独立的函数实例" << std::endl;
    std::cout << "4. 适合性能敏感的嵌入式场景" << std::endl;

    return 0;
}

差异立竿见影:模板版本在 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(奇异递归模板模式)示例

#include <iostream>

// 模拟硬件 ADC 读取
int read_adc_hw() {
    return 42;
}

// CRTP 基类
template<typename Derived>
struct SensorBase {
    // 基类中调用派生类的方法
    int read_and_scale() {
        // static_cast 转换为派生类类型
        int v = static_cast<Derived*>(this)->read();
        return scale(v);
    }

    // 基类提供的通用实现
    int scale(int value) {
        return value * 2;  // 示例缩放
    }

    void calibrate() {
        std::cout << "执行校准..." << std::endl;
    }
};

// ADC 传感器,继承 CRTP 基类
struct ADCSensor : SensorBase<ADCSensor> {
    int read() {
        return read_adc_hw();
    }

    // 可以覆盖基类的实现
    int scale(int value) {
        return value * 3;  // ADC 特定的缩放
    }
};

// 温度传感器,继承 CRTP 基类
struct TempSensor : SensorBase<TempSensor> {
    int read() {
        return 25;
    }

    // 使用基类的默认缩放 (value * 2)
};

// ==================== 演示主函数 ====================
int main() {
    std::cout << "=== CRTP 示例 ===" << std::endl;

    ADCSensor adc;
    TempSensor temp;

    std::cout << "\n--- ADC 传感器 ---" << std::endl;
    std::cout << "原始值: " << adc.read() << std::endl;
    std::cout << "缩放后: " << adc.read_and_scale() << std::endl;
    adc.calibrate();

    std::cout << "\n--- 温度传感器 ---" << std::endl;
    std::cout << "原始值: " << temp.read() << std::endl;
    std::cout << "缩放后: " << temp.read_and_scale() << std::endl;
    temp.calibrate();

    std::cout << "\n关键点:" << std::endl;
    std::cout << "1. CRTP 让基类可以调用派生类的方法" << std::endl;
    std::cout << "2. 编译期确定类型,无虚函数开销" << std::endl;
    std::cout << "3. 可以在基类中实现代码复用" << std::endl;
    std::cout << "4. 派生类可以选择覆盖或使用默认实现" << std::endl;

    return 0;
}

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

std::variant / std::visit

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

// 定义不同的消息类型
struct StartEvent { int priority; };
struct StopEvent { int reason_code; };

using Event = std::variant<StartEvent, StopEvent>;

// 使用 std::visit 处理事件
std::visit([](auto&& e) {
    // 处理不同类型
}, event);
查看完整可编译示例
// std::variant 和 std::visit 示例

#include <iostream>
#include <variant>
#include <string>
#include <vector>

// 定义不同的消息类型
struct StartEvent {
    int priority;
};

struct StopEvent {
    int reason_code;
};

struct ConfigEvent {
    std::string key;
    int value;
};

// 使用 std::variant 存储不同类型的事件
using Event = std::variant<StartEvent, StopEvent, ConfigEvent>;

// 访问者:处理不同类型的事件
struct EventVisitor {
    void operator()(const StartEvent& e) {
        std::cout << "启动事件,优先级: " << e.priority << std::endl;
    }

    void operator()(const StopEvent& e) {
        std::cout << "停止事件,原因码: " << e.reason_code << std::endl;
    }

    void operator()(const ConfigEvent& e) {
        std::cout << "配置事件: " << e.key << " = " << e.value << std::endl;
    }
};

// 通用的处理函数
void process_event(const Event& e) {
    std::visit(EventVisitor{}, e);
}

// 使用 lambda 的版本(C++20 的更简洁写法)
void process_event_modern(const Event& e) {
    std::visit([](const auto& event) {
        using T = std::decay_t<decltype(event)>;
        if constexpr (std::is_same_v<T, StartEvent>) {
            std::cout << "[modern] 启动事件,优先级: " << event.priority << std::endl;
        } else if constexpr (std::is_same_v<T, StopEvent>) {
            std::cout << "[modern] 停止事件,原因码: " << event.reason_code << std::endl;
        } else if constexpr (std::is_same_v<T, ConfigEvent>) {
            std::cout << "[modern] 配置事件: " << event.key << " = " << event.value << std::endl;
        }
    }, e);
}

// ==================== 演示主函数 ====================
int main() {
    std::cout << "=== std::variant 和 std::visit 示例 ===" << std::endl;

    // 创建不同类型的事件
    Event e1 = StartEvent{10};
    Event e2 = StopEvent{5};
    Event e3 = ConfigEvent{"timeout", 1000};

    std::cout << "\n--- 使用访问者模式 ---" << std::endl;
    process_event(e1);
    process_event(e2);
    process_event(e3);

    std::cout << "\n--- 使用 modern lambda ---" << std::endl;
    process_event_modern(e1);
    process_event_modern(e2);
    process_event_modern(e3);

    // 存储在容器中
    std::cout << "\n--- 存储在容器中 ---" << std::endl;
    std::vector<Event> events = {
        StartEvent{1},
        ConfigEvent{"mode", 2},
        StopEvent{0}
    };

    for (const auto& e : events) {
        process_event(e);
    }

    std::cout << "\n关键点:" << std::endl;
    std::cout << "1. std::variant 可以存储不同类型的值" << std::endl;
    std::cout << "2. std::visit 在编译期为所有类型生成分发逻辑" << std::endl;
    std::cout << "3. 类型安全,编译期检查所有变体都被处理" << std::endl;
    std::cout << "4. 无 vtable 开销,内联优化友好" << std::endl;

    return 0;
}

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

类型擦除(type erasure)

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


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

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

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