c++的std::variant和多态继承在设计上如何取舍? (访问者模式)

该用 std::variant 而不是虚函数多态时:类型集合固定且编译期可知,强调“它是什么”而非“它能做什么”,需值语义、无堆分配、类型数少(≤10)且不频繁增删。

什么时候该用 std::variant 而不是虚函数多态?

当类型集合固定、编译期可知,且你**不希望引入运行时多态开销或对象生命周期管理复杂度**时,std::variant 更直接。比如解析 JSON 值、状态机枚举值、AST 节点(若确定只有几种结构),std::variant

比定义基类 + 四个派生类更轻量。

关键判断点:std::variant 适合“数据变体”,虚函数多态适合“行为变体”。前者强调“它是什么”,后者强调“它能做什么”。

  • 类型数量少(通常 ≤ 10),且不会频繁增删 —— std::variant 可维护
  • 需要值语义(拷贝/移动安全)、无指针/堆分配需求 —— std::variant 天然支持
  • 所有类型都满足 std::is_trivially_copyable 或你明确接受非平凡开销 —— 否则注意构造/析构成本

访问者模式 + std::variant 怎么写才不别扭?

标准库没提供 std::visit 的“自动分发到成员函数”机制,所以硬套传统访问者模式(双分派)会显得冗余。更自然的做法是:用 std::visit 配合 lambda 或重载的 struct,把“访问逻辑”内联或局部化。

避免为每个操作都写一个独立访问者类;多数场景下,一次 std::visit 调用配一个 lambda 就够了。

auto result = std::visit([](const auto& v) -> int {
    using T = std::decay_t;
    if constexpr (std::is_same_v) return v * 2;
    else if constexpr (std::is_same_v) return static_cast(v.size());
    else return -1;
}, my_variant);
  • if constexpr + auto 参数实现编译期分发,比手写一堆 visit_int/visit_string 方法干净
  • 若逻辑复杂,可封装成具名 struct 并重载 operator(),但不要强行模仿经典访问者接口(如 visit(Int&)
  • std::visit 要求所有分支返回相同类型,否则编译失败 —— 这是常见报错点:error: inconsistent deduction for auto return type

虚函数多态在哪些地方不可被 std::variant 替代?

当你需要**动态扩展类型集**(比如插件系统加载新类型的节点)、或已有大量基于基类指针/引用的旧代码、或必须支持**不相关的类型继承同一接口**(如 Drawable 接口被 GUI 控件和游戏实体同时实现),这时 std::variant 就力不从心了。

另外,如果子类有显著不同的内存布局、需要多态销毁(delete base_ptr)、或依赖 RTTI(dynamic_cast),也意味着你已经处在虚函数多态的领域里。

  • std::variant 不支持运行时新增类型 —— 所有类型必须出现在模板参数列表中
  • 无法持有“外部定义”的类型(比如第三方库的类),除非你修改 variant 定义并重新编译
  • 没有虚析构函数,不能用基类指针统一管理 —— 这是内存安全红线

混合使用时最容易踩的坑

有人试图用 std::variant<:unique_ptr>, ...> 把两种范式缝在一起,结果既失去 variant 的栈存储优势,又没解决虚函数的指针间接成本,还增加了 std::unique_ptr 的移动开销和空指针检查负担。

真正需要混合的场景极少。更常见的合理组合是:std::variant 管理“核心数据形态”,再用少量虚函数处理“外部交互行为”(如序列化接口)。

  • 不要在 variant 里存裸指针(T*)—— 丢失所有权语义,极易悬垂
  • 避免在 std::visit 中捕获大对象 by-reference 到 lambda,尤其当 lambda 存活时间超过 visit 调用 —— 生命周期隐患
  • 如果 variant 包含不可默认构造的类型(如 std::mutex),初始化和赋值要格外小心,否则触发未定义行为

访问者模式本身不是银弹;用在 std::variant 上时,重点不是复刻设计模式教科书,而是利用 std::visit 的类型安全分发能力,把分支逻辑写得清晰、可测、不重复。真正麻烦的永远不是选 variant 还是虚函数,而是类型边界模糊、后期被迫加运行时类型检查、或者在两者之间反复桥接。