如何在C++中使用std::variant实现类型安全的多态

在现代 C++(C++17 及以后)中,std::variant 提供了一种轻量级、类型安全的方式来实现多态。与传统的继承和虚函数相比,std::variant 不需要虚表开销,也不需要运行时的类型识别。本文将通过一个完整的示例来演示如何使用 std::variant 构建一个简易的表达式树,并通过访问器实现类型安全的求值与打印。

1. 需求与设计

假设我们需要表示四种基本表达式:

  • 整数常量 IntVal
  • 浮点常量 FloatVal
  • 二元加法 AddExpr
  • 二元乘法 MulExpr

传统实现可能会用一个基类 Expr,并派生出四个子类。但这样做会导致:

  1. 虚函数表占用空间
  2. 需要手动 dynamic_casttypeid 进行类型检查
  3. 难以保证所有类型都被处理(遗漏某种类型时编译器不会警告)

std::variant 可以让我们把这些类型放进一个容器,然后通过 std::visitstd::holds_alternative 进行安全访问。我们只需保证在访问时使用所有可能的类型,编译器即可检查完整性。

2. 代码实现

#include <iostream>
#include <variant>
#include <memory>
#include <vector>
#include <cmath>

// ① 定义基础类型
struct IntVal   { int value; };
struct FloatVal { double value; };

// ② 前向声明表达式类
struct AddExpr;
struct MulExpr;

// ③ 通过 std::variant 封装所有可能的表达式
using Expr = std::variant<
    IntVal,
    FloatVal,
    std::shared_ptr <AddExpr>,
    std::shared_ptr <MulExpr>
>;

// ④ 定义 AddExpr 与 MulExpr,内部存储子表达式
struct AddExpr {
    Expr left;
    Expr right;
};

struct MulExpr {
    Expr left;
    Expr right;
};

// ⑤ 访问器:求值
double eval(const Expr& e) {
    struct EvalVisitor {
        double operator()(const IntVal& iv)   const { return static_cast <double>(iv.value); }
        double operator()(const FloatVal& fv) const { return fv.value; }
        double operator()(const std::shared_ptr <AddExpr>& a) const {
            return eval(a->left) + eval(a->right);
        }
        double operator()(const std::shared_ptr <MulExpr>& m) const {
            return eval(m->left) * eval(m->right);
        }
    };
    return std::visit(EvalVisitor{}, e);
}

// ⑥ 访问器:打印
void print(const Expr& e) {
    struct PrintVisitor {
        void operator()(const IntVal& iv)   const { std::cout << iv.value; }
        void operator()(const FloatVal& fv) const { std::cout << fv.value; }
        void operator()(const std::shared_ptr <AddExpr>& a) const {
            std::cout << "(";
            print(a->left);
            std::cout << " + ";
            print(a->right);
            std::cout << ")";
        }
        void operator()(const std::shared_ptr <MulExpr>& m) const {
            std::cout << "(";
            print(m->left);
            std::cout << " * ";
            print(m->right);
            std::cout << ")";
        }
    };
    std::visit(PrintVisitor{}, e);
}

// ⑦ 工厂函数,便于构建表达式
inline Expr make_int(int v)   { return IntVal{v}; }
inline Expr make_float(double v) { return FloatVal{v}; }

inline Expr make_add(const Expr& l, const Expr& r) {
    return std::make_shared <AddExpr>(AddExpr{l, r});
}

inline Expr make_mul(const Expr& l, const Expr& r) {
    return std::make_shared <MulExpr>(MulExpr{l, r});
}

// ⑧ 主程序示例
int main() {
    // 表达式:(3 + 4.5) * (2 + 1)
    Expr expr = make_mul(
        make_add(make_int(3), make_float(4.5)),
        make_add(make_int(2), make_int(1))
    );

    std::cout << "表达式: ";
    print(expr);
    std::cout << std::endl;

    std::cout << "结果: " << eval(expr) << std::endl;
    return 0;
}

代码说明

  1. 类型定义

    • IntValFloatVal 分别封装整数和浮点常量。
    • AddExprMulExpr 存储左、右子表达式,使用 std::shared_ptr 防止递归结构导致大小不定。
  2. Expr 统一别名

    • using Expr = std::variant<...> 把所有可能的表达式类型包装进一个变体。
    • 这样 Expr 既是一个值,又可以在运行时携带任意一个子类型。
  3. 访问器

    • evalprint 使用 std::visit 与对应的访客对象实现多态行为。
    • 访客的每个 operator() 对应 Expr 里的一个成员,保证了所有情况都被处理。
  4. 工厂函数

    • make_intmake_floatmake_addmake_mul 用来构造表达式,隐藏 std::variantstd::shared_ptr 的细节。
  5. 主程序

    • 构造一个包含整数、浮点数以及二元运算的混合表达式。
    • 通过 print 打印表达式树,eval 计算结果。

3. 与传统继承比较

维度 传统继承 + 虚函数 std::variant 方案
内存占用 虚表指针(每个对象) 变体大小固定,std::shared_ptr 内部指针
运行时开销 虚函数调用 + 动态分派 std::visit 通过模板展开
类型安全 需要 dynamic_cast 或 RTTI 编译器保证访问完整性
可维护性 需要在基类添加新成员、更新子类 只需在 std::variant 列表中添加新类型

4. 进一步扩展

  • 三元运算符函数调用 等都可以通过添加新的结构体类型,并更新访问器实现。
  • 如果不想使用 std::shared_ptr,可以改用 std::unique_ptr 或直接存放对象,前提是子表达式大小已知。
  • 对于更大规模的表达式树,std::variantstd::visit 的递归调用会产生一定的函数调用开销;此时可考虑使用手写访客模式或 std::variantapply_visitor

5. 小结

std::variant 为 C++ 程序员提供了一种轻量级、类型安全的多态工具。与传统继承相比,它消除了虚表开销、消除了 RTTI 的使用,并让编译器在访问时提供完整性检查。通过本文的表达式树示例,你可以快速上手并在自己的项目中尝试这一模式。祝你编码愉快!

发表评论